admin notifications

This commit is contained in:
Aaron 2019-04-16 12:38:14 -05:00
parent 426b397b3d
commit 381722de74
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
21 changed files with 255 additions and 11 deletions

View File

@ -60,12 +60,14 @@ export default [
{
id: 'proposal_failed',
title: 'Proposal failed',
description: 'Sent to the proposal team when the deadline is reached and it didnt get fully funded',
description:
'Sent to the proposal team when the deadline is reached and it didnt get fully funded',
},
{
id: 'proposal_canceled',
title: 'Proposal canceled',
description: 'Sent to the proposal team when an admin cancels the proposal after funding',
description:
'Sent to the proposal team when an admin cancels the proposal after funding',
},
{
id: 'contribution_confirmed',
@ -85,7 +87,8 @@ export default [
{
id: 'contribution_proposal_failed',
title: 'Contribution proposal failed',
description: 'Sent to contributors when the deadline is reached and the proposal didnt get fully funded',
description:
'Sent to contributors when the deadline is reached and the proposal didnt get fully funded',
},
{
id: 'contribution_proposal_canceled',
@ -127,4 +130,19 @@ export default [
title: 'Milestone paid',
description: 'Sent when milestone is paid',
},
{
id: 'admin_approval',
title: 'Admin Approval',
description: 'Sent when proposal is ready for review',
},
{
id: 'admin_arbiter',
title: 'Admin Arbiter',
description: 'Sent when proposal is ready to have an arbiter nominated',
},
{
id: 'admin_payout',
title: 'Admin Payout',
description: 'Sent when milestone payout has been approved',
},
] as Email[];

View File

@ -2,6 +2,7 @@
FLASK_APP=app.py
FLASK_ENV=development
SITE_URL="https://zfnd.org" # No trailing slash
ADMIN_SITE_URL="https://grants-admin.zfnd.org" # No trailing slash
DATABASE_URL="sqlite:////tmp/dev.db"
REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"

View File

@ -165,5 +165,17 @@ example_email_args = {
'amount': '33',
'tx_explorer_url': 'http://someblockexplorer.com/tx/271857129857192579125',
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
}
},
'admin_approval': {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
'admin_arbiter': {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
'admin_payout': {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
}

View File

@ -278,6 +278,33 @@ def milestone_paid(email_args):
}
def admin_approval(email_args):
return {
'subject': f'Review needed for {email_args["proposal"].title}',
'title': f'Proposal Review',
'preview': f'{email_args["proposal"].title} needs review, as an admin you can help.',
'subscription': EmailSubscription.ADMIN_APPROVAL,
}
def admin_arbiter(email_args):
return {
'subject': f'Arbiter needed for {email_args["proposal"].title}',
'title': f'Arbiter Nomination',
'preview': f'{email_args["proposal"].title} needs an arbiter, as an admin you can help.',
'subscription': EmailSubscription.ADMIN_ARBITER,
}
def admin_payout(email_args):
return {
'subject': f'Payout requested for {email_args["proposal"].title}',
'title': f'Milestone Payout Requested',
'preview': f'{email_args["proposal"].title} has requested a payout, as an admin you can help.',
'subscription': EmailSubscription.ADMIN_PAYOUT,
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info,
@ -303,7 +330,10 @@ get_info_lookup = {
'milestone_request': milestone_request,
'milestone_reject': milestone_reject,
'milestone_accept': milestone_accept,
'milestone_paid': milestone_paid
'milestone_paid': milestone_paid,
'admin_approval': admin_approval,
'admin_arbiter': admin_arbiter,
'admin_payout': admin_payout
}

View File

@ -53,6 +53,18 @@ class EmailSubscription(Enum):
'bit': 11,
'key': 'arbiter'
}
ADMIN_APPROVAL = {
'bit': 12,
'key': 'admin_approval'
}
ADMIN_ARBITER = {
'bit': 13,
'key': 'admin_arbiter'
}
ADMIN_PAYOUT = {
'bit': 14,
'key': 'admin_payout'
}
def is_email_sub_key(k: str):

View File

@ -97,6 +97,7 @@ class Milestone(db.Model):
def accept_immediate(self):
if self.immediate_payout and self.index == 0:
self.proposal.send_admin_email('admin_payout')
self.date_requested = datetime.datetime.now()
self.stage = MilestoneStage.ACCEPTED
self.date_accepted = datetime.datetime.now()
@ -106,6 +107,7 @@ class Milestone(db.Model):
def accept_request(self, arbiter_id: int):
if self.stage != MilestoneStage.REQUESTED:
raise MilestoneException(f'Cannot accept payout request for milestone at {self.stage} stage')
self.proposal.send_admin_email('admin_payout')
self.stage = MilestoneStage.ACCEPTED
self.date_accepted = datetime.datetime.now()
self.accept_arbiter_id = arbiter_id

View File

@ -22,7 +22,7 @@ from grant.utils.enums import (
MilestoneStage
)
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url, gen_random_id
from grant.utils.misc import dt_to_unix, make_url, make_admin_url, gen_random_id
from grant.utils.requests import blockchain_get
from grant.utils.stubs import anonymous_user
@ -462,6 +462,16 @@ class Proposal(db.Model):
return contribution
def send_admin_email(self, type: str):
from grant.user.models import User
admins = User.get_admins()
for a in admins:
send_email(a.email_address, type, {
'user': a,
'proposal': self,
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
})
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
def submit_for_approval(self):
self.validate_publishable()
@ -485,6 +495,7 @@ class Proposal(db.Model):
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
if not self.is_staked:
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
self.send_admin_email('admin_approval')
self.status = ProposalStatus.PENDING
db.session.add(self)
db.session.flush()
@ -543,6 +554,7 @@ class Proposal(db.Model):
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
if not self.is_funded:
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
self.send_admin_email('admin_arbiter')
self.stage = ProposalStage.WIP
db.session.add(self)
db.session.flush()

View File

@ -15,6 +15,7 @@ env.read_env()
ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
SITE_URL = env.str('SITE_URL', default='https://zfnd.org')
ADMIN_SITE_URL = env.str('ADMIN_SITE_URL', default='https://grants-admin.zfnd.org')
E2E_TESTING = env.str("E2E_TESTING", default=None)
E2E_DATABASE_URL = env.str("E2E_DATABASE_URL", default=None)
SQLALCHEMY_DATABASE_URI = E2E_DATABASE_URL if E2E_TESTING else env.str("DATABASE_URL")

View File

@ -0,0 +1,32 @@
<p style="margin: 0 0 20px;">
<a href="{{ args.proposal_url }}" target="_blank">
{{ args.proposal.title }}</a
>
is awaiting approval. As an admin you can help out by reviewing it.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.proposal_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
Review Proposal
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
{{ args.proposal.title }} is awaiting approval. As an admin you can help out by reviewing it.
Visit the proposal and review: {{ args.proposal_url }}

View File

@ -0,0 +1,32 @@
<p style="margin: 0 0 20px;">
<a href="{{ args.proposal_url }}" target="_blank">
{{ args.proposal.title }}</a
>
needs an arbiter. As an admin you can help out by nominating one.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.proposal_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
Nominate Arbiter
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
{{ args.proposal.title }} needs an arbiter. As an admin you can help out by nominating one.
Visit the proposal and nominate an arbiter: {{ args.proposal_url }}

View File

@ -0,0 +1,33 @@
<p style="margin: 0 0 20px;">
<a href="{{ args.proposal_url }}" target="_blank">
{{ args.proposal.title }}</a
>
has an approved payout. As an admin you can help out by paying the milestone
and marking it paid.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.proposal_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
Pay Milestone
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,4 @@
{{ args.proposal.title }} has an approved payout. As an admin you can help out by
paying the milestone and marking it paid.
Pay the milestone: {{ args.proposal_url }}

View File

@ -198,6 +198,10 @@ class User(db.Model, UserMixin):
def get_by_email(email_address: str):
return security.datastore.get_user(email_address)
@staticmethod
def get_admins():
return User.query.filter(User.is_admin == True).all()
def check_password(self, password: str):
return verify_and_update_password(password, self)

View File

@ -4,7 +4,7 @@ import re
import string
import time
from grant.settings import SITE_URL, EXPLORER_URL
from grant.settings import SITE_URL, ADMIN_SITE_URL, EXPLORER_URL
epoch = datetime.datetime.utcfromtimestamp(0)
RANDOM_CHARS = string.ascii_letters + string.digits
@ -37,6 +37,10 @@ def make_url(path: str):
return f'{SITE_URL}{path}'
def make_admin_url(path: str):
return f'{ADMIN_SITE_URL}{path}'
def make_explore_url(txid: str):
return EXPLORER_URL.replace('<txid>', txid)

View File

@ -9,6 +9,7 @@ interface OwnProps {
emailSubscriptions: EmailSubscriptions;
loading: boolean;
onSubmit: (settings: EmailSubscriptions) => void;
isAdmin: boolean;
}
type Props = OwnProps & FormComponentProps;
@ -18,9 +19,9 @@ class EmailSubscriptionsForm extends React.Component<Props, {}> {
this.props.form.setFieldsValue(this.props.emailSubscriptions);
}
render() {
const { emailSubscriptions, loading } = this.props;
const { emailSubscriptions, loading, isAdmin } = this.props;
const { getFieldDecorator } = this.props.form;
const groupedSubs = groupEmailSubscriptionsByCategory(emailSubscriptions);
const groupedSubs = groupEmailSubscriptionsByCategory(emailSubscriptions, isAdmin);
const fields = Object.entries(this.props.form.getFieldsValue());
const numChecked = fields.map(([_, v]) => v).filter(v => v).length;

View File

@ -45,6 +45,7 @@ class EmailSubscriptions extends React.Component<Props, State> {
emailSubscriptions={emailSubscriptions}
loading={loading}
onSubmit={this.setSubscriptions}
isAdmin={!!authUser.isAdmin}
/>
</div>
);
@ -62,6 +63,15 @@ class EmailSubscriptions extends React.Component<Props, State> {
private setSubscriptions = (emailSubscriptions: IEmailSubscriptions) => {
const { authUser } = this.props;
if (!authUser) return;
if (!authUser.isAdmin) {
// fill in api-required fields which are not populated for non-admin form
emailSubscriptions = {
adminApproval: true,
adminArbiter: true,
adminPayout: true,
...emailSubscriptions,
};
}
this.setState({ loading: true });
updateUserSettings(authUser.userid, { emailSubscriptions })
.then(res => {

View File

@ -72,6 +72,23 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = {
category: EMAIL_SUBSCRIPTION_CATEGORY.PROPOSAL,
value: false,
},
// ADMIN
adminApproval: {
description: 'proposal needs review',
category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN,
value: false,
},
adminArbiter: {
description: 'proposal needs arbiter',
category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN,
value: false,
},
adminPayout: {
description: 'milestone needs payout',
category: EMAIL_SUBSCRIPTION_CATEGORY.ADMIN,
value: false,
},
};
export const EMAIL_SUBSCRIPTION_CATEGORIES: {
@ -82,10 +99,18 @@ export const EMAIL_SUBSCRIPTION_CATEGORIES: {
[EMAIL_SUBSCRIPTION_CATEGORY.FUNDED]: {
description: 'Proposals you have contributed to',
},
[EMAIL_SUBSCRIPTION_CATEGORY.ADMIN]: { description: 'Admin' },
};
export const groupEmailSubscriptionsByCategory = (es: EmailSubscriptions) => {
return Object.entries(EMAIL_SUBSCRIPTION_CATEGORIES).map(([k, v]) => {
export const groupEmailSubscriptionsByCategory = (
es: EmailSubscriptions,
withAdmin: boolean,
) => {
const catsForUser = { ...EMAIL_SUBSCRIPTION_CATEGORIES };
if (!withAdmin) {
delete catsForUser.ADMIN;
}
return Object.entries(catsForUser).map(([k, v]) => {
const subscriptionSettings = Object.entries(EMAIL_SUBSCRIPTIONS)
.filter(([_, sv]) => sv.category === k)
.map(([sk, sv]) => {

View File

@ -12,12 +12,16 @@ export interface EmailSubscriptions {
myProposalFunded: boolean;
myProposalRefund: boolean;
arbiter: boolean;
adminApproval: boolean;
adminArbiter: boolean;
adminPayout: boolean;
}
export enum EMAIL_SUBSCRIPTION_CATEGORY {
GENERAL = 'GENERAL',
PROPOSAL = 'PROPOSAL',
FUNDED = 'FUNDED',
ADMIN = 'ADMIN',
}
export interface EmailSubscriptionInfo {

View File

@ -9,6 +9,7 @@ export interface User {
title: string;
socialMedias: SocialMedia[];
avatar: { imageUrl: string } | null;
isAdmin?: boolean;
}
export interface UserSettings {