mileston payout emails + some bug fixes

This commit is contained in:
Aaron 2019-02-13 14:30:58 -06:00
parent fd9a4c5393
commit 47f827693d
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
15 changed files with 277 additions and 10 deletions

View File

@ -77,4 +77,24 @@ export default [
title: 'Arbiter assignment',
description: 'Sent if someone is made arbiter of a proposal',
},
{
id: 'milestone_request',
title: 'Milestone request',
description: 'Sent if team member has made a milestone payout request',
},
{
id: 'milestone_accept',
title: 'Milestone accept',
description: 'Sent if arbiter approves milestone payout',
},
{
id: 'milestone_reject',
title: 'Milestone reject',
description: 'Sent if arbiter rejects milestone payout',
},
{
id: 'milestone_paid',
title: 'Milestone paid',
description: 'Sent when milestone is paid',
},
] as Email[];

View File

@ -6,12 +6,19 @@ class FakeUser(object):
title = 'Email Example Dude'
class FakeMilestone(object):
id = 123
index = 0
title = 'Example Milestone'
class FakeProposal(object):
id = 123
title = 'Example proposal'
brief = 'This is an example proposal'
content = 'Example example example example'
target = "100"
current_milestone = FakeMilestone()
class FakeContribution(object):
@ -101,7 +108,27 @@ example_email_args = {
},
'proposal_arbiter': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'arbitration_url': 'http://arbitrationtab.com',
'proposal_url': 'http://zfnd.org/proposals/999',
'accept_url': 'http://zfnd.org/email/arbiter?code=blah&proposalId=999',
},
'milestone_request': {
'proposal': proposal,
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
},
'milestone_reject': {
'proposal': proposal,
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
},
'milestone_accept': {
'proposal': proposal,
'amount': '33',
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
},
'milestone_paid': {
'proposal': proposal,
'amount': '33',
'tx_explorer_url': 'http://someblockexplorer.com/tx/271857129857192579125',
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
}
}

View File

@ -21,6 +21,7 @@ from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login,
from grant.utils.misc import make_url
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, ProposalArbiterStatus, MilestoneStage
from grant.utils import pagination
from grant.settings import EXPLORER_URL
from sqlalchemy import func, or_
from .example_emails import example_email_args
@ -277,15 +278,24 @@ def paid_milestone_payout_request(id, mid, tx_id):
for ms in proposal.milestones:
if ms.id == int(mid):
ms.mark_paid(tx_id)
# TODO: email TEAM that payout request was PAID
db.session.add(ms)
db.session.flush()
# check if this is the final ms, and update proposal.stage
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
if num_paid == len(proposal.milestones):
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
db.session.add(proposal)
db.session.flush()
db.session.commit()
# email TEAM that payout request was PAID
amount = Decimal(ms.payout_percent) * Decimal(proposal.target) / 100
for member in proposal.team:
send_email(member.email_address, 'milestone_paid', {
'proposal': proposal,
'amount': amount,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{tx_id}',
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
})
return proposal_schema.dump(proposal), 200
return {"message": "No milestone matching id"}, 404

View File

@ -163,6 +163,52 @@ def proposal_arbiter(email_args):
}
def milestone_request(email_args):
p = email_args['proposal']
ms = p.current_milestone
return {
'subject': f'Payout request for {p.title} - {ms.title} has been made',
'title': f'Milestone payout requested',
'preview': f'A payout request for milestone {ms.title} has been made.',
'subscription': EmailSubscription.ARBITER,
}
def milestone_reject(email_args):
p = email_args['proposal']
ms = p.current_milestone
return {
'subject': f'Payout rejected for {p.title} - {ms.title}',
'title': f'Milestone payout rejected',
'preview': f'The payout for milestone {ms.title} has been rejected.',
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
}
def milestone_accept(email_args):
p = email_args['proposal']
a = email_args['amount']
ms = p.current_milestone
return {
'subject': f'Payout approved for {p.title} - {ms.title}!',
'title': f'Milestone payout approved',
'preview': f'The payout of {a} ZEC for milestone {ms.title} has been approved.',
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
}
def milestone_paid(email_args):
p = email_args['proposal']
a = email_args['amount']
ms = p.current_milestone
return {
'subject': f'{p.title} - {ms.title} has been paid!',
'title': f'Milestone paid',
'preview': f'The milestone {ms.title} payout of {a} ZEC has been paid!',
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info,
@ -178,7 +224,11 @@ get_info_lookup = {
'contribution_confirmed': contribution_confirmed,
'contribution_update': contribution_update,
'comment_reply': comment_reply,
'proposal_arbiter': proposal_arbiter
'proposal_arbiter': proposal_arbiter,
'milestone_request': milestone_request,
'milestone_reject': milestone_reject,
'milestone_accept': milestone_accept,
'milestone_paid': milestone_paid
}

View File

@ -1,4 +1,5 @@
from dateutil.parser import parse
from decimal import Decimal
from flask import Blueprint, g, request
from flask_yoloapi import endpoint, parameter
from grant.comment.models import Comment, comment_schema, comments_schema
@ -562,9 +563,13 @@ def request_milestone_payout(proposal_id, milestone_id):
for ms in g.current_proposal.milestones:
if ms.id == int(milestone_id):
ms.request_payout(g.current_user.id)
# TODO: email ARBITER to review payout request
db.session.add(ms)
db.session.commit()
# email ARBITER to review payout request
send_email(g.current_proposal.arbiter.user.email_address, 'milestone_request', {
'proposal': g.current_proposal,
'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'),
})
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
@ -580,9 +585,16 @@ def accept_milestone_payout_request(proposal_id, milestone_id):
for ms in g.current_proposal.milestones:
if ms.id == int(milestone_id):
ms.accept_request(g.current_user.id)
# TODO: email TEAM that payout request accepted (maybe, or wait until paid?)
db.session.add(ms)
db.session.commit()
# email TEAM that payout request accepted
amount = Decimal(ms.payout_percent) * Decimal(g.current_proposal.target) / 100
for member in g.current_proposal.team:
send_email(member.email_address, 'milestone_accept', {
'proposal': g.current_proposal,
'amount': amount,
'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'),
})
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
@ -600,9 +612,15 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason):
for ms in g.current_proposal.milestones:
if ms.id == int(milestone_id):
ms.reject_request(g.current_user.id, reason)
# TODO: email TEAM that payout request was rejected
db.session.add(ms)
db.session.commit()
# email TEAM that payout request was rejected
for member in g.current_proposal.team:
send_email(member.email_address, 'milestone_reject', {
'proposal': g.current_proposal,
'admin_note': reason,
'proposal_milestones_url': make_url(f'/proposals/{g.current_proposal.id}?tab=milestones'),
})
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404

View File

@ -0,0 +1,11 @@
<p style="margin: 0 0 20px;">
The proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
payout of <b>{{ args.amount }} ZEC</b> has been approved.
</p>
<p style="margin: 0;">
You will receive payment shortly!
</p>

View File

@ -0,0 +1,6 @@
The proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
payout of {{args.amount}} ZEC has been approved!
You will receive payment shortly!
View the milestone: {{ args.proposal_milestones_url }}

View File

@ -0,0 +1,12 @@
<p style="margin: 0 0 20px;">
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} </a
>! You can view the transaction below:
</p>
<p style="margin: 0 0 20px;">
<a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
{{ args.tx_explorer_url }}
</a>
</p>

View File

@ -0,0 +1,6 @@
Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"!
You can view the transaction below:
{{ args.tx_explorer_url }}
View the milestone: {{ args.proposal_milestones_url }}

View File

@ -0,0 +1,21 @@
<p style="margin: 0 0 20px;">
The payout request for proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
has been rejected.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
The following reason was provided:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}
<p style="margin: 0;">
Another request for payment can be made when the above concerns have been
addressed.
</p>

View File

@ -0,0 +1,12 @@
The payout request for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
has been rejected.
{% if args.admin_note %}
The following reason was provided:
> {{ args.admin_note }}
{% endif %}
Another request for payment can be made when the above concerns have been addressed.
View milestone: {{ args.proposal_milestones_url }}

View File

@ -0,0 +1,33 @@
<p style="margin: 0 0 20px;">
A payout request for the proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
has been made. As arbiter, you are responsible for reviewing this request.
</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_milestones_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 the request
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,4 @@
A payout request for the proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
has been made. As arbiter, you are responsible for reviewing this request.
Review the request: {{ args.proposal_milestones_url }}

View File

@ -7,7 +7,12 @@ import { Alert, Steps, Button, message, Modal, Input } from 'antd';
import { AlertProps } from 'antd/lib/alert';
import { StepProps } from 'antd/lib/steps';
import TextArea from 'antd/lib/input/TextArea';
import { Milestone, ProposalMilestone, MILESTONE_STAGE } from 'types';
import {
Milestone,
ProposalMilestone,
MILESTONE_STAGE,
PROPOSAL_ARBITER_STATUS,
} from 'types';
import { PROPOSAL_STAGE } from 'api/constants';
import UnitDisplay from 'components/UnitDisplay';
import Loader from 'components/Loader';
@ -16,6 +21,7 @@ import Placeholder from 'components/Placeholder';
import { proposalActions } from 'modules/proposals';
import { ProposalDetail } from 'modules/proposals/reducers';
import './index.less';
import { Link } from 'react-router-dom';
enum STEP_STATUS {
WAIT = 'wait',
@ -230,6 +236,10 @@ class ProposalMilestones extends React.Component<Props, State> {
isCurrent={activeIsCurrent}
isTeamMember={proposal.isTeamMember || false}
isArbiter={proposal.isArbiter || false}
hasArbiter={
!!proposal.arbiter.user &&
proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED
}
/>
</>
) : (
@ -301,6 +311,7 @@ interface MilestoneProps extends MSProps {
showRejectPayout: (milestoneId: number) => void;
isTeamMember: boolean;
isArbiter: boolean;
hasArbiter: boolean;
isCurrent: boolean;
proposalId: number;
isFunded: boolean;
@ -382,7 +393,11 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
if (!p.isCurrent || !p.isFunded || p.stage === MILESTONE_STAGE.PAID) {
return null;
}
if (!p.hasArbiter && !p.isTeamMember) {
return null;
}
// TEAM INFO
const team = {
[MILESTONE_STAGE.IDLE]: () => (
<>
@ -439,6 +454,7 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
[MILESTONE_STAGE.PAID]: () => <></>,
} as { [key in MILESTONE_STAGE]: () => ReactNode };
// OUTSIDERS/OTHERS INFO
const others = {
[MILESTONE_STAGE.IDLE]: () => (
<>
@ -473,6 +489,7 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
[MILESTONE_STAGE.PAID]: () => <></>,
} as { [key in MILESTONE_STAGE]: () => ReactNode };
// ARBITER INFO
const arbiter = {
[MILESTONE_STAGE.IDLE]: () => (
<>
@ -528,6 +545,26 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
content = others[p.stage]();
}
// special warning if no arbiter is set for team members
if (!p.hasArbiter && p.isTeamMember) {
content = (
<Alert
type="error"
message="Arbiter not assigned"
description={
<p>
We are sorry for the inconvenience, but in order to have milestone payouts
reviewed an arbiter must be assigned. Please{' '}
<Link target="_blank" to="/contact">
contact support
</Link>{' '}
for help.
</p>
}
/>
);
}
return (
<>
<div className="ProposalMilestones-milestone-divider" />

View File

@ -293,7 +293,7 @@ export default (state = INITIAL_STATE, action: any) => {
detail: {
...state.detail,
isAcceptingPayout: true,
acceptingPayoutError: '',
acceptPayoutError: '',
},
};
case types.PROPOSAL_PAYOUT_ACCEPT_FULFILLED:
@ -307,7 +307,7 @@ export default (state = INITIAL_STATE, action: any) => {
detail: {
...state.detail,
isAcceptingPayout: false,
acceptingPayoutError: (payload && payload.message) || payload.toString(),
acceptPayoutError: (payload && payload.message) || payload.toString(),
},
};