From 4e5c0eaea78cdd1cb5e45e705ce8d58c83c19101 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 11 Feb 2019 15:08:51 -0600 Subject: [PATCH 1/7] BE: more milestone fields --- backend/grant/milestone/models.py | 88 ++++++++++++++++---- backend/grant/proposal/models.py | 18 +++- backend/grant/proposal/views.py | 5 +- backend/grant/utils/enums.py | 11 +++ backend/grant/utils/ma_fields.py | 7 ++ backend/migrations/versions/3793d9a71e27_.py | 52 ++++++++++++ 6 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 backend/grant/utils/ma_fields.py create mode 100644 backend/migrations/versions/3793d9a71e27_.py diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index e1f62d12..4e2c6105 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -2,39 +2,55 @@ import datetime from grant.extensions import ma, db from grant.utils.exceptions import ValidationException -from grant.utils.misc import dt_to_unix +from grant.utils.ma_fields import UnixDate +from grant.utils.enums import MilestoneStage -NOT_REQUESTED = 'NOT_REQUESTED' -ONGOING_VOTE = 'ONGOING_VOTE' -PAID = 'PAID' -MILESTONE_STAGES = [NOT_REQUESTED, ONGOING_VOTE, PAID] + +class MilestoneException(Exception): + pass class Milestone(db.Model): __tablename__ = "milestone" id = db.Column(db.Integer(), primary_key=True) + index = db.Column(db.Integer(), nullable=False) date_created = db.Column(db.DateTime, nullable=False) title = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) - stage = db.Column(db.String(255), nullable=False) payout_percent = db.Column(db.String(255), nullable=False) immediate_payout = db.Column(db.Boolean) - + # TODO: change to estimated_duration (sec or ms) -- FE can calc from dates on draft date_estimated = db.Column(db.DateTime, nullable=False) + stage = db.Column(db.String(255), nullable=False) + + date_requested = db.Column(db.DateTime, nullable=True) + requested_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + date_rejected = db.Column(db.DateTime, nullable=True) + reject_reason = db.Column(db.String(255)) + reject_arbiter_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + date_accepted = db.Column(db.DateTime, nullable=True) + accept_arbiter_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + + date_paid = db.Column(db.DateTime, nullable=True) + paid_tx_id = db.Column(db.String(255)) + proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) def __init__( self, + index: int, title: str, content: str, date_estimated: datetime, payout_percent: str, immediate_payout: bool, - stage: str = NOT_REQUESTED, - proposal_id=int + stage: str = MilestoneStage.IDLE, + proposal_id=int, ): self.title = title self.content = content @@ -44,12 +60,42 @@ class Milestone(db.Model): self.immediate_payout = immediate_payout self.proposal_id = proposal_id self.date_created = datetime.datetime.now() + self.index = index @staticmethod def validate(milestone): if len(milestone.title) > 60: raise ValidationException("Milestone title must be no more than 60 chars") + def request_payout(self, user_id: int): + if self.stage not in [MilestoneStage.IDLE, MilestonStage.REJECTED]: + raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage') + self.stage = MilestoneStage.REQUESTED + self.date_requested = datetime.datetime.now() + self.requested_user_id = user_id + + def reject_request(self, arbiter_id: int, reason: str): + if self.stage != MilestoneStage.REQUESTED: + raise MilestoneException(f'Cannot reject payout request for milestone at {self.stage} stage') + self.stage = MilestoneStage.REJECTED + self.date_rejected = datetime.datetime.now() + self.reject_reason = reason + self.reject_arbiter_id = arbiter_id + + 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.stage = MilestoneStage.PAID + self.date_accepted = datetime.datetime.now() + self.accept_arbiter_id = arbiter_id + + def mark_paid(self, tx_id: str): + if self.stage != MilestoneStage.ACCEPTED: + raise MilestoneException(f'Cannot pay a milestone at {self.stage} stage') + self.stage = MilestoneStage.PAID + self.date_paid = datetime.datetime.now() + self.paid_tx_id = tx_id + class MilestoneSchema(ma.Schema): class Meta: @@ -57,22 +103,28 @@ class MilestoneSchema(ma.Schema): # Fields to expose fields = ( "title", + "index", + "id", "content", "stage", - "date_estimated", "payout_percent", "immediate_payout", + "reject_reason", + "paid_tx_id", "date_created", + "date_estimated", + "date_requested", + "date_rejected", + "date_accepted", + "date_paid", ) - date_created = ma.Method("get_date_created") - date_estimated = ma.Method("get_date_estimated") - - def get_date_created(self, obj): - return dt_to_unix(obj.date_created) - - def get_date_estimated(self, obj): - return dt_to_unix(obj.date_estimated) if obj.date_estimated else None + date_created = UnixDate(attribute='date_created') + date_estimated = UnixDate(attribute='date_estimated') + date_requested = UnixDate(attribute='date_requested') + date_rejected = UnixDate(attribute='date_rejected') + date_accepted = UnixDate(attribute='date_accepted') + date_paid = UnixDate(attribute='date_paid') milestone_schema = MilestoneSchema() diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 31336030..0914e94a 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -10,7 +10,7 @@ from grant.extensions import ma, db from grant.utils.exceptions import ValidationException from grant.utils.misc import dt_to_unix, make_url from grant.utils.requests import blockchain_get -from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus +from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus, MilestoneStage from grant.settings import PROPOSAL_STAKING_AMOUNT proposal_team = db.Table( @@ -376,6 +376,20 @@ class Proposal(db.Model): def is_staked(self): return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT + @hybrid_property + def current_milestone(self): + if self.milestones: + for ms in self.milestones: + if ms.stage != MilestoneStage.PAID: + return ms + return None + + # contributions = ProposalContribution.query \ + # .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \ + # .all() + # funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) + # return str(funded) + class ProposalSchema(ma.Schema): class Meta: @@ -399,6 +413,7 @@ class ProposalSchema(ma.Schema): "comments", "updates", "milestones", + "current_milestone", "category", "team", "payout_address", @@ -418,6 +433,7 @@ class ProposalSchema(ma.Schema): updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) milestones = ma.Nested("MilestoneSchema", many=True) + current_milestone = ma.Nested("MilestoneSchema") invites = ma.Nested("ProposalTeamInviteSchema", many=True) rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"]) arbiter = ma.Nested("UserSchema") # exclude=["arbitrated_proposals"]) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index ca11e2dc..516eff16 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -213,14 +213,15 @@ def update_proposal(milestones, proposal_id, **kwargs): # Delete & re-add milestones [db.session.delete(x) for x in g.current_proposal.milestones] if milestones: - for mdata in milestones: + for i, mdata in enumerate(milestones): m = Milestone( title=mdata["title"], content=mdata["content"], date_estimated=datetime.fromtimestamp(mdata["dateEstimated"]), payout_percent=str(mdata["payoutPercent"]), immediate_payout=mdata["immediatePayout"], - proposal_id=g.current_proposal.id + proposal_id=g.current_proposal.id, + index=i ) db.session.add(m) diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index e181ca86..1bbb6192 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -68,3 +68,14 @@ class RFPStatusEnum(CustomEnum): RFPStatus = RFPStatusEnum() + + +class MilestoneStageEnum(CustomEnum): + IDLE = 'IDLE' + REQUESTED = 'REQUESTED' + REJECTED = 'REJECTED' + ACCEPTED = 'ACCEPTED' + PAID = 'PAID' + + +MilestoneStage = MilestoneStageEnum() diff --git a/backend/grant/utils/ma_fields.py b/backend/grant/utils/ma_fields.py new file mode 100644 index 00000000..3cc19469 --- /dev/null +++ b/backend/grant/utils/ma_fields.py @@ -0,0 +1,7 @@ +from grant.extensions import ma +from .misc import dt_to_unix + + +class UnixDate(ma.Field): + def _serialize(self, value, attr, obj, **kwargs): + return dt_to_unix(value) if value else None diff --git a/backend/migrations/versions/3793d9a71e27_.py b/backend/migrations/versions/3793d9a71e27_.py new file mode 100644 index 00000000..e21d7934 --- /dev/null +++ b/backend/migrations/versions/3793d9a71e27_.py @@ -0,0 +1,52 @@ +"""milestone payment fields + +Revision ID: 3793d9a71e27 +Revises: 310dca400b81 +Create Date: 2019-02-11 11:01:44.703413 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3793d9a71e27' +down_revision = '310dca400b81' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('milestone', sa.Column('accept_arbiter_id', sa.Integer(), nullable=True)) + op.add_column('milestone', sa.Column('date_accepted', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('date_paid', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('date_rejected', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('date_requested', sa.DateTime(), nullable=True)) + op.add_column('milestone', sa.Column('index', sa.Integer(), nullable=False)) + op.add_column('milestone', sa.Column('paid_tx_id', sa.String(length=255), nullable=True)) + op.add_column('milestone', sa.Column('reject_arbiter_id', sa.Integer(), nullable=True)) + op.add_column('milestone', sa.Column('reject_reason', sa.String(length=255), nullable=True)) + op.add_column('milestone', sa.Column('requested_user_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'milestone', 'user', ['accept_arbiter_id'], ['id']) + op.create_foreign_key(None, 'milestone', 'user', ['reject_arbiter_id'], ['id']) + op.create_foreign_key(None, 'milestone', 'user', ['requested_user_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'milestone', type_='foreignkey') + op.drop_constraint(None, 'milestone', type_='foreignkey') + op.drop_constraint(None, 'milestone', type_='foreignkey') + op.drop_column('milestone', 'requested_user_id') + op.drop_column('milestone', 'reject_reason') + op.drop_column('milestone', 'reject_arbiter_id') + op.drop_column('milestone', 'paid_tx_id') + op.drop_column('milestone', 'index') + op.drop_column('milestone', 'date_requested') + op.drop_column('milestone', 'date_rejected') + op.drop_column('milestone', 'date_paid') + op.drop_column('milestone', 'date_accepted') + op.drop_column('milestone', 'accept_arbiter_id') + # ### end Alembic commands ### From ac5bef5c6fe14e1d8393ce2a2389eb1b3b496875 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 11 Feb 2019 15:22:40 -0600 Subject: [PATCH 2/7] FE: rework milestones first pass --- .../components/Proposal/Milestones/index.tsx | 229 +++++++++--------- .../components/Proposal/Milestones/style.less | 5 + frontend/client/modules/create/utils.ts | 7 +- frontend/client/modules/proposals/actions.ts | 17 +- frontend/client/utils/api.ts | 15 +- frontend/stories/ProposalMilestones.story.tsx | 42 +--- frontend/stories/props.tsx | 14 +- frontend/types/milestone.ts | 16 +- frontend/types/proposal.ts | 3 + 9 files changed, 170 insertions(+), 178 deletions(-) diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 9fbe18b4..2a40f9c3 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -2,7 +2,7 @@ import lodash from 'lodash'; import React from 'react'; import moment from 'moment'; import { Alert, Steps } from 'antd'; -import { Proposal, MILESTONE_STATE } from 'types'; +import { Proposal, Milestone, ProposalMilestone, MILESTONE_STAGE } from 'types'; import UnitDisplay from 'components/UnitDisplay'; import Loader from 'components/Loader'; import { AppState } from 'store/reducers'; @@ -10,22 +10,24 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import './style.less'; import Placeholder from 'components/Placeholder'; +import { AlertProps } from 'antd/lib/alert'; +import { StepProps } from 'antd/lib/steps'; -const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; +// const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; -enum STEP_STATUS { - WAIT = 'wait', - PROCESS = 'process', - FINISH = 'finish', - ERROR = 'error', -} +// enum STEP_STATUS { +// WAIT = 'wait', +// PROCESS = 'process', +// FINISH = 'finish', +// ERROR = 'error', +// } -const milestoneStateToStepState = { - [WAITING]: STEP_STATUS.WAIT, - [ACTIVE]: STEP_STATUS.PROCESS, - [PAID]: STEP_STATUS.FINISH, - [REJECTED]: STEP_STATUS.ERROR, -}; +// const milestoneStateToStepState = { +// [WAITING]: STEP_STATUS.WAIT, +// [ACTIVE]: STEP_STATUS.PROCESS, +// [PAID]: STEP_STATUS.FINISH, +// [REJECTED]: STEP_STATUS.ERROR, +// }; interface OwnProps { proposal: Proposal; @@ -87,107 +89,20 @@ class ProposalMilestones extends React.Component { return ; } const { milestones } = proposal; - - const isTrustee = false; // TODO: Replace with being on the team const milestoneCount = milestones.length; - const milestoneSteps = milestones.map((milestone, i) => { - const status = - this.state.activeMilestoneIdx === i && milestone.state === WAITING - ? STEP_STATUS.PROCESS - : milestoneStateToStepState[milestone.state]; - + const status: StepProps['status'] = 'wait'; + // this.state.activeMilestoneIdx === i && milestone.state === WAITING + // ? STEP_STATUS.PROCESS + // : milestoneStateToStepState[milestone.state]; const className = this.state.step === i ? 'is-active' : 'is-inactive'; - const estimatedDate = moment(milestone.dateEstimated * 1000).format('MMMM YYYY'); - const reward = ( - - ); - const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' }; - const stepProps = { title:
{milestone.title}
, status, className, onClick: () => this.setState({ step: i }), }; - - let notification; - - switch (milestone.state) { - case PAID: - notification = ( - - The team was awarded {reward}{' '} - {milestone.immediatePayout - ? 'as an initial payout' - : // TODO: Add property for payout date on milestones - `on ${moment().format('MMM Do, YYYY')}`} - . - - } - style={alertStyle} - /> - ); - break; - case ACTIVE: - notification = ( - - ); - break; - case REJECTED: - notification = ( - - Payout for this milestone was rejected on{' '} - {/* TODO: add property for payout rejection date on milestones */} - {moment().format('MMM Do, YYYY')}.{isTrustee ? ' You ' : ' The team '}{' '} - can request another review for payout at any time. - - } - style={alertStyle} - /> - ); - break; - } - - const statuses = ( -
- {!milestone.immediatePayout && ( -
- Estimate: {estimatedDate} -
- )} -
- Reward: {reward} -
-
- ); - - const content = ( -
-
-
-

{milestone.title}

- {statuses} - {notification} - {milestone.content} -
-
-
- ); - return { key: i, stepProps, content }; + return { key: i, stepProps }; }); const stepSize = milestoneCount > 5 ? 'small' : 'default'; @@ -198,7 +113,6 @@ class ProposalMilestones extends React.Component { className={classnames({ ['ProposalMilestones']: true, ['do-titles-overflow']: this.state.doTitlesOverflow, - [`is-count-${milestoneCount}`]: true, })} > {!!milestoneSteps.length ? ( @@ -208,7 +122,10 @@ class ProposalMilestones extends React.Component { ))} - {milestoneSteps[this.state.step].content} + ) : ( { } private getActiveMilestoneIdx = () => { - const { milestones } = this.props.proposal; - const activeMilestone = - milestones.find( - m => - m.state === WAITING || - m.state === ACTIVE || - (m.state === PAID && !m.isPaid) || - m.state === REJECTED, - ) || milestones[0]; - return milestones.indexOf(activeMilestone); + return 0; + // const { milestones } = this.props.proposal; + // const activeMilestone = + // milestones.find( + // m => + // m.state === WAITING || + // m.state === ACTIVE || + // (m.state === PAID && !m.isPaid) || + // m.state === REJECTED, + // ) || milestones[0]; + // return milestones.indexOf(activeMilestone); }; private updateDoTitlesOverflow = () => { @@ -268,6 +186,81 @@ class ProposalMilestones extends React.Component { }; } +const Milestone: React.SFC = p => { + const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY'); + const reward = ; + const fmtDate = (n: undefined | number) => + (n && moment(n * 1000).format('MMM Do, YYYY')) || undefined; + const getAlertProps = { + [MILESTONE_STAGE.IDLE]: () => null, + [MILESTONE_STAGE.REQUESTED]: () => ({ + type: 'info', + message: ( + <> + The team has requested a payout for this milestone. It is currently under + review. + + ), + }), + [MILESTONE_STAGE.REJECTED]: () => ({ + type: 'warning', + message: ( + + Payout for this milestone was rejected on {fmtDate(p.dateRejected)}. + {p.isTeamMember ? ' You ' : ' The team '} can request another review for payout + at any time. + + ), + }), + [MILESTONE_STAGE.ACCEPTED]: () => ({ + type: 'info', + message: ( + + Payout for this milestone was accepted on {fmtDate(p.dateAccepted)}. + {reward} will be sent to{' '} + {p.isTeamMember ? ' you ' : ' the team '} soon. + + ), + }), + [MILESTONE_STAGE.PAID]: () => ({ + type: 'success', + message: ( + + The team was awarded {reward}{' '} + {p.immediatePayout && ` as an initial payout `} on ${fmtDate(p.datePaid)} + `. + + ), + }), + } as { [key in MILESTONE_STAGE]: () => AlertProps | null }; + + const alertProps = getAlertProps[p.stage](); + + return ( +
+
+
+

{p.title}

+
+ {!p.immediatePayout && ( +
+ Estimate: {estimatedDate} +
+ )} +
+ Reward: {reward} +
+
+ {alertProps && ( + + )} + {p.content} +
+
+
+ ); +}; + const ConnectedProposalMilestones = connect((state: AppState) => { console.warn('TODO - new redux accounts/user-role-for-proposal', state); return { diff --git a/frontend/client/components/Proposal/Milestones/style.less b/frontend/client/components/Proposal/Milestones/style.less index 47e7de1b..20250426 100644 --- a/frontend/client/components/Proposal/Milestones/style.less +++ b/frontend/client/components/Proposal/Milestones/style.less @@ -39,6 +39,11 @@ margin-left: 0; } + &-alert { + width: fit-content; + margin: 0 0 1rem 0; + } + &-title { display: none; white-space: nowrap; diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 9ba03af1..348623f4 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,7 +1,7 @@ -import { ProposalDraft, CreateMilestone, STATUS } from 'types'; +import { ProposalDraft, CreateMilestone, STATUS, MILESTONE_STAGE } from 'types'; import { User } from 'types'; import { getAmountError, isValidAddress } from 'utils/validators'; -import { MILESTONE_STATE, Proposal } from 'types'; +import { Proposal } from 'types'; import { Zat, toZat } from 'utils/units'; import { ONE_DAY } from 'utils/time'; import { PROPOSAL_CATEGORY } from 'api/constants'; @@ -199,9 +199,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)), dateEstimated: m.dateEstimated, immediatePayout: m.immediatePayout, - isPaid: false, payoutPercent: m.payoutPercent.toString(), - state: MILESTONE_STATE.WAITING, + stage: MILESTONE_STAGE.IDLE, })), }; } diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index dcb91d5e..0503bc82 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -14,6 +14,19 @@ import { getProposalPageSettings } from './selectors'; type GetState = () => AppState; +function addProposalUserRoles(p: Proposal, state: AppState) { + if (state.auth.user) { + const authUserId = state.auth.user.userid; + // TODO: add arbiter roll + // user.arbitratedProposals... + console.warn('TODO: add user arbitration role to Proposal'); + if (p.team.find(t => t.userid === authUserId)) { + p.isTeamMember = true; + } + } + return p; +} + // change page, sort, filter, search export function setProposalPage(pageParams: Partial) { return async (dispatch: Dispatch, getState: GetState) => { @@ -49,7 +62,7 @@ export function fetchProposals() { export type TFetchProposal = typeof fetchProposal; export function fetchProposal(proposalId: Proposal['proposalId']) { - return async (dispatch: Dispatch) => { + return async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: types.PROPOSAL_DATA_PENDING, payload: { proposalId }, @@ -58,7 +71,7 @@ export function fetchProposal(proposalId: Proposal['proposalId']) { const proposal = (await getProposal(proposalId)).data; return dispatch({ type: types.PROPOSAL_DATA_FULFILLED, - payload: proposal, + payload: addProposalUserRoles(proposal, getState()), }); } catch (error) { dispatch({ diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 3170b492..39b81812 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -6,7 +6,6 @@ import { PageParams, UserProposal, RFP, - MILESTONE_STATE, ProposalPage, } from 'types'; import { UserState } from 'modules/users/reducers'; @@ -88,16 +87,12 @@ export function formatProposalFromGet(p: any): Proposal { ? 0 : proposal.funded.div(proposal.target.divn(100)).toNumber(); if (proposal.milestones) { - proposal.milestones = proposal.milestones.map((m: any, index: number) => { - return { - ...m, - index, - amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), - // TODO: Get data from backend - state: MILESTONE_STATE.WAITING, - isPaid: false, - }; + const msToFe = (m: any) => ({ + ...m, + amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), }); + proposal.milestones = proposal.milestones.map(msToFe); + proposal.currentMilestone = msToFe(proposal.currentMilestone); } return proposal; } diff --git a/frontend/stories/ProposalMilestones.story.tsx b/frontend/stories/ProposalMilestones.story.tsx index f9c25ef7..afff2bcc 100644 --- a/frontend/stories/ProposalMilestones.story.tsx +++ b/frontend/stories/ProposalMilestones.story.tsx @@ -5,18 +5,18 @@ import { Provider } from 'react-redux'; import { configureStore } from 'store/configure'; import { combineInitialState } from 'store/reducers'; import Milestones from 'components/Proposal/Milestones'; -import { MILESTONE_STATE } from 'types'; -const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; +import { MILESTONE_STAGE } from 'types'; +const { IDLE, ACCEPTED, PAID, REJECTED } = MILESTONE_STAGE; import 'styles/style.less'; import 'components/Proposal/style.less'; import 'components/Proposal/Governance/style.less'; import { generateProposal } from './props'; -const msWaiting = { state: WAITING, isPaid: false }; -const msPaid = { state: PAID, isPaid: true }; -const msActive = { state: ACTIVE, isPaid: false }; -const msRejected = { state: REJECTED, isPaid: false }; +const msWaiting = { stage: IDLE }; +const msPaid = { stage: PAID }; +const msActive = { stage: ACCEPTED }; +const msRejected = { stage: REJECTED }; const trustee = 'z123'; const contributor = 'z456'; @@ -38,11 +38,7 @@ const cases: { [index: string]: any } = { ['first - not paid']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - { state: PAID, isPaid: false }, - msWaiting, - msWaiting, - ], + milestoneOverrides: [{ stage: PAID }, msWaiting, msWaiting], }), // trustee - second @@ -59,20 +55,12 @@ const cases: { [index: string]: any } = { ['second - not paid']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - { state: PAID, isPaid: false }, - msWaiting, - ], + milestoneOverrides: [msPaid, { stage: PAID }, msWaiting], }), ['second - no vote']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - { state: ACTIVE, isPaid: false }, - msWaiting, - ], + milestoneOverrides: [msPaid, { stage: ACCEPTED }, msWaiting], contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], }), ['second - rejected']: generateProposal({ @@ -95,20 +83,12 @@ const cases: { [index: string]: any } = { ['final - not paid']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - msPaid, - { state: PAID, isPaid: false }, - ], + milestoneOverrides: [msPaid, msPaid, { stage: PAID }], }), ['final - no vote']: generateProposal({ amount: 5, funded: 5, - milestoneOverrides: [ - msPaid, - msPaid, - { state: ACTIVE, isPaid: false }, - ], + milestoneOverrides: [msPaid, msPaid, { stage: ACCEPTED }], contributorOverrides: [{ milestoneNoVotes: [false, true, false] }], }), ['final - rejected']: generateProposal({ diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index ac60ef25..e7930b99 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -1,11 +1,4 @@ -import { - Contributor, - Milestone, - MILESTONE_STATE, - Proposal, - ProposalMilestone, - STATUS, -} from 'types'; +import { Contributor, MILESTONE_STAGE, Proposal, ProposalMilestone, STATUS } from 'types'; import { PROPOSAL_CATEGORY } from 'api/constants'; import BN from 'bn.js'; import moment from 'moment'; @@ -40,7 +33,7 @@ export function generateProposal({ funded?: number; created?: number; deadline?: number; - milestoneOverrides?: Array>; + milestoneOverrides?: Array>; contributorOverrides?: Array>; milestoneCount?: number; }) { @@ -115,9 +108,8 @@ export function generateProposal({ dateEstimated: moment().unix(), immediatePayout: true, index: 0, - state: MILESTONE_STATE.WAITING, + stage: MILESTONE_STAGE.IDLE, amount: amountBn, - isPaid: false, payoutPercent: '33', }; return { ...defaults, ...overrides }; diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 46661931..c1de5696 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -7,13 +7,25 @@ export enum MILESTONE_STATE { PAID = 'PAID', } +// NOTE: sync with /backend/grand/utils/enums.py MilestoneStage +export enum MILESTONE_STAGE { + IDLE = 'IDLE', + REQUESTED = 'REQUESTED', + REJECTED = 'REJECTED', + ACCEPTED = 'ACCEPTED', + PAID = 'PAID', +} + export interface Milestone { index: number; - state: MILESTONE_STATE; + stage: MILESTONE_STAGE; amount: Zat; - isPaid: boolean; immediatePayout: boolean; dateEstimated: number; + dateRequested?: number; + dateRejected?: number; + dateAccepted?: number; + datePaid?: number; } export interface ProposalMilestone extends Milestone { diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index f2fec571..5da71e71 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -47,8 +47,11 @@ export interface Proposal extends Omit { percentFunded: number; contributionMatching: number; milestones: ProposalMilestone[]; + currentMilestone?: ProposalMilestone; datePublished: number | null; dateApproved: number | null; + isTeamMember?: boolean; // FE derived + isArbiter?: boolean; // FE derived } export interface TeamInviteWithProposal extends TeamInvite { From ce0ce4feef7bad3afde0444ea8d9cabe3b211253 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 11 Feb 2019 17:03:39 -0600 Subject: [PATCH 3/7] fix migration history --- backend/migrations/versions/3793d9a71e27_.py | 4 ++-- backend/migrations/versions/86d300cb6d69_.py | 22 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/migrations/versions/3793d9a71e27_.py b/backend/migrations/versions/3793d9a71e27_.py index e21d7934..1dc4bfce 100644 --- a/backend/migrations/versions/3793d9a71e27_.py +++ b/backend/migrations/versions/3793d9a71e27_.py @@ -1,7 +1,7 @@ """milestone payment fields Revision ID: 3793d9a71e27 -Revises: 310dca400b81 +Revises: 86d300cb6d69 Create Date: 2019-02-11 11:01:44.703413 """ @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '3793d9a71e27' -down_revision = '310dca400b81' +down_revision = '86d300cb6d69' branch_labels = None depends_on = None diff --git a/backend/migrations/versions/86d300cb6d69_.py b/backend/migrations/versions/86d300cb6d69_.py index 9797292e..cfea18ef 100644 --- a/backend/migrations/versions/86d300cb6d69_.py +++ b/backend/migrations/versions/86d300cb6d69_.py @@ -1,4 +1,4 @@ -"""empty message +"""proposal_arbiter table Revision ID: 86d300cb6d69 Revises: 310dca400b81 @@ -17,23 +17,23 @@ depends_on = None def upgrade(): -# ### commands auto generated by Alembic - please adjust! ### + # ### commands auto generated by Alembic - please adjust! ### op.create_table('proposal_arbiter', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('proposal_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('status', sa.String(length=255), nullable=False), - sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('proposal_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey') op.drop_column('proposal', 'arbiter_id') # ### end Alembic commands ### def downgrade(): -# ### commands auto generated by Alembic - please adjust! ### + # ### commands auto generated by Alembic - please adjust! ### op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True)) op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id']) op.drop_table('proposal_arbiter') From 380eec005e05ae882737b5e2bb64f60122ecee8e Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 11 Feb 2019 23:10:09 -0600 Subject: [PATCH 4/7] payout endpoints + redux actions + ms UX refactors --- backend/grant/milestone/models.py | 2 +- backend/grant/proposal/models.py | 13 +- backend/grant/proposal/views.py | 58 +++- backend/grant/utils/auth.py | 20 ++ backend/grant/utils/enums.py | 1 + frontend/client/api/api.ts | 35 +++ .../Milestones/{style.less => index.less} | 0 .../components/Proposal/Milestones/index.tsx | 284 ++++++++++++++---- frontend/client/modules/proposals/actions.ts | 42 ++- frontend/client/modules/proposals/reducers.ts | 94 +++++- frontend/client/modules/proposals/types.ts | 15 + frontend/types/milestone.ts | 3 + 12 files changed, 488 insertions(+), 79 deletions(-) rename frontend/client/components/Proposal/Milestones/{style.less => index.less} (100%) diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index 4e2c6105..324d9404 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -68,7 +68,7 @@ class Milestone(db.Model): raise ValidationException("Milestone title must be no more than 60 chars") def request_payout(self, user_id: int): - if self.stage not in [MilestoneStage.IDLE, MilestonStage.REJECTED]: + if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]: raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage') self.stage = MilestoneStage.REQUESTED self.date_requested = datetime.datetime.now() diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 26b721a0..a0aad6f3 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -221,7 +221,7 @@ class Proposal(db.Model): comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan") updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan") contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan") - milestones = db.relationship("Milestone", backref="proposal", lazy=True, cascade="all, delete-orphan") + milestones = db.relationship("Milestone", backref="proposal", order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan") arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan") @@ -401,6 +401,7 @@ class Proposal(db.Model): self.date_published = datetime.datetime.now() self.status = ProposalStatus.LIVE + self.stage = ProposalStage.FUNDING_REQUIRED @hybrid_property def contributed(self): @@ -425,6 +426,10 @@ class Proposal(db.Model): def is_staked(self): return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT + @hybrid_property + def is_funded(self): + return Decimal(self.contributed) >= Decimal(self.target) + @hybrid_property def current_milestone(self): if self.milestones: @@ -433,12 +438,6 @@ class Proposal(db.Model): return ms return None - # contributions = ProposalContribution.query \ - # .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \ - # .all() - # funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) - # return str(funded) - class ProposalSchema(ma.Schema): class Meta: diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 516eff16..81d8cb12 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -10,13 +10,14 @@ from grant.rfp.models import RFP from grant.utils.auth import ( requires_auth, requires_team_member_auth, + requires_arbiter_auth, requires_email_verified_auth, get_authed_user, internal_webhook ) from grant.utils.exceptions import ValidationException from grant.utils.misc import is_email, make_url, from_zat -from grant.utils.enums import ProposalStatus, ContributionStatus +from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus from grant.utils import pagination from sqlalchemy import or_ from datetime import datetime @@ -484,7 +485,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): db.session.commit() if contribution.proposal.status == ProposalStatus.STAKING: - # fully staked, set status PENDING & notify user + # fully staked, set status PENDING if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT: contribution.proposal.status = ProposalStatus.PENDING db.session.add(contribution.proposal) @@ -520,6 +521,9 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): # TODO: Once we have a task queuer in place, queue emails to everyone # on funding target reached. + if contribution.proposal.status == ProposalStatus.LIVE: + if contribution.proposal.is_funded: + contribution.proposal.stage = ProposalStage.IN_PROGRESS return None, 200 @@ -543,3 +547,53 @@ def delete_proposal_contribution(contribution_id): db.session.add(contribution) db.session.commit() return None, 202 + + +# TODO +# request MS payout +@blueprint.route("//milestone//request", methods=["PUT"]) +@requires_team_member_auth +@endpoint.api() +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() + return proposal_schema.dump(g.current_proposal), 200 + return {"message": "No milestone matching id"}, 404 + + +# accept MS payout (arbiter) +@blueprint.route("//milestone//accept", methods=["PUT"]) +@requires_arbiter_auth +@endpoint.api() +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() + return proposal_schema.dump(g.current_proposal), 200 + return {"message": "No milestone matching id"}, 404 + + +# reject MS payout (arbiter) (reason) +@blueprint.route("//milestone//reject", methods=["PUT"]) +@requires_arbiter_auth +@endpoint.api( + parameter('reason', type=str, required=True), +) +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() + return proposal_schema.dump(g.current_proposal), 200 + return {"message": "No milestone matching id"}, 404 + +# (ADMIN) MS payout (txid) diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 324ecb5e..08611bda 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -76,6 +76,26 @@ def requires_team_member_auth(f): return requires_email_verified_auth(decorated) +def requires_arbiter_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + proposal_id = kwargs["proposal_id"] + if not proposal_id: + return jsonify(message="Decorator requires_arbiter_auth requires path variable "), 500 + + proposal = Proposal.query.filter_by(id=proposal_id).first() + if not proposal: + return jsonify(message="No proposal exists with id {}".format(proposal_id)), 404 + + if g.current_user != proposal.arbiter.user: + return jsonify(message="You are not arbiter this proposal"), 403 + + g.current_proposal = proposal + return f(*args, **kwargs) + + return requires_email_verified_auth(decorated) + + def internal_webhook(f): @wraps(f) def decorated(*args, **kwargs): diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index 2657e8b4..43b64e7c 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -34,6 +34,7 @@ ProposalSort = ProposalSortEnum() class ProposalStageEnum(CustomEnum): FUNDING_REQUIRED = 'FUNDING_REQUIRED' + IN_PROGRESS = 'IN_PROGRESS' COMPLETED = 'COMPLETED' diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 0fc01e25..e4abf96c 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -228,6 +228,41 @@ export async function putProposalPublish( }); } +export async function requestProposalPayout( + proposalId: number, + milestoneId: number, +): Promise<{ data: Proposal }> { + return axios + .put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/request`) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} +export async function acceptProposalPayout( + proposalId: number, + milestoneId: number, +): Promise<{ data: Proposal }> { + return axios + .put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/accept`) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} +export async function rejectProposalPayout( + proposalId: number, + milestoneId: number, + reason: string, +): Promise<{ data: Proposal }> { + return axios + .put(`/api/v1/proposals/${proposalId}/milestone/${milestoneId}/reject`, { reason }) + .then(res => { + res.data = formatProposalFromGet(res.data); + return res; + }); +} + export function postProposalInvite( proposalId: number, address: string, diff --git a/frontend/client/components/Proposal/Milestones/style.less b/frontend/client/components/Proposal/Milestones/index.less similarity index 100% rename from frontend/client/components/Proposal/Milestones/style.less rename to frontend/client/components/Proposal/Milestones/index.less diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 2a40f9c3..53a41508 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -1,43 +1,49 @@ -import lodash from 'lodash'; -import React from 'react'; +import { throttle } from 'lodash'; +import React, { ReactNode } from 'react'; import moment from 'moment'; -import { Alert, Steps } from 'antd'; -import { Proposal, Milestone, ProposalMilestone, MILESTONE_STAGE } from 'types'; +import { Alert, Steps, Button, message } from 'antd'; +import { Milestone, ProposalMilestone, MILESTONE_STAGE } from 'types'; import UnitDisplay from 'components/UnitDisplay'; import Loader from 'components/Loader'; import { AppState } from 'store/reducers'; import { connect } from 'react-redux'; import classnames from 'classnames'; -import './style.less'; import Placeholder from 'components/Placeholder'; import { AlertProps } from 'antd/lib/alert'; import { StepProps } from 'antd/lib/steps'; +import { proposalActions } from 'modules/proposals'; +import './index.less'; +import { ProposalDetail } from 'modules/proposals/reducers'; -// const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE; +enum STEP_STATUS { + WAIT = 'wait', + PROCESS = 'process', + FINISH = 'finish', + ERROR = 'error', +} -// enum STEP_STATUS { -// WAIT = 'wait', -// PROCESS = 'process', -// FINISH = 'finish', -// ERROR = 'error', -// } +const milestoneStageToStepState = { + [MILESTONE_STAGE.IDLE]: STEP_STATUS.WAIT, + [MILESTONE_STAGE.REQUESTED]: STEP_STATUS.PROCESS, + [MILESTONE_STAGE.ACCEPTED]: STEP_STATUS.PROCESS, + [MILESTONE_STAGE.REJECTED]: STEP_STATUS.ERROR, + [MILESTONE_STAGE.ACCEPTED]: STEP_STATUS.FINISH, +} as { [key in MILESTONE_STAGE]: StepProps['status'] }; -// const milestoneStateToStepState = { -// [WAITING]: STEP_STATUS.WAIT, -// [ACTIVE]: STEP_STATUS.PROCESS, -// [PAID]: STEP_STATUS.FINISH, -// [REJECTED]: STEP_STATUS.ERROR, -// }; +const fmtDate = (n: undefined | number) => + (n && moment(n * 1000).format('MMM Do, YYYY')) || undefined; interface OwnProps { - proposal: Proposal; + proposal: ProposalDetail; } -interface StateProps { - accounts: string[]; +interface DispatchProps { + requestPayout: typeof proposalActions.requestPayout; + acceptPayout: typeof proposalActions.acceptPayout; + rejectPayout: typeof proposalActions.rejectPayout; } -type Props = OwnProps & StateProps; +type Props = OwnProps & DispatchProps; interface State { step: number; @@ -53,10 +59,7 @@ class ProposalMilestones extends React.Component { super(props); this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef()); this.ref = React.createRef(); - this.throttledUpdateDoTitlesOverflow = lodash.throttle( - this.updateDoTitlesOverflow, - 500, - ); + this.throttledUpdateDoTitlesOverflow = throttle(this.updateDoTitlesOverflow, 500); this.state = { step: 0, activeMilestoneIdx: 0, @@ -66,8 +69,8 @@ class ProposalMilestones extends React.Component { componentDidMount() { if (this.props.proposal) { - const activeMilestoneIdx = this.getActiveMilestoneIdx(); - this.setState({ step: activeMilestoneIdx, activeMilestoneIdx }); + const { currentMilestone } = this.props.proposal; + this.setState({ step: (currentMilestone && currentMilestone.index) || 0 }); } this.updateDoTitlesOverflow(); window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow); @@ -76,28 +79,47 @@ class ProposalMilestones extends React.Component { window.removeEventListener('resize', this.throttledUpdateDoTitlesOverflow); } - componentDidUpdate(_: Props, prevState: State) { - const activeMilestoneIdx = this.getActiveMilestoneIdx(); - if (prevState.activeMilestoneIdx !== activeMilestoneIdx) { - this.setState({ step: activeMilestoneIdx, activeMilestoneIdx }); + componentDidUpdate(prevProps: Props, _: State) { + const cm = this.props.proposal.currentMilestone; + const pcm = prevProps.proposal.currentMilestone; + const cmId = (cm && cm.id) || 0; + const pcmId = (pcm && pcm.id) || 0; + if (pcmId !== cmId) { + this.setState({ step: (cm && cm.index) || 0 }); + } + const { + requestPayoutError, + acceptPayoutError, + rejectPayoutError, + } = this.props.proposal; + if (!prevProps.proposal.requestPayoutError && requestPayoutError) { + message.error(requestPayoutError); + } + if (!prevProps.proposal.acceptPayoutError && acceptPayoutError) { + message.error(acceptPayoutError); + } + if (!prevProps.proposal.rejectPayoutError && rejectPayoutError) { + message.error(rejectPayoutError); } } render() { - const { proposal } = this.props; + const { proposal, requestPayout, acceptPayout, rejectPayout } = this.props; if (!proposal) { return ; } - const { milestones } = proposal; + const { milestones, currentMilestone } = proposal; const milestoneCount = milestones.length; - const milestoneSteps = milestones.map((milestone, i) => { - const status: StepProps['status'] = 'wait'; - // this.state.activeMilestoneIdx === i && milestone.state === WAITING - // ? STEP_STATUS.PROCESS - // : milestoneStateToStepState[milestone.state]; + const milestoneSteps = milestones.map((ms, i) => { + const status = + currentMilestone && + currentMilestone.index === i && + ms.stage === MILESTONE_STAGE.IDLE + ? STEP_STATUS.PROCESS + : milestoneStageToStepState[ms.stage]; const className = this.state.step === i ? 'is-active' : 'is-inactive'; const stepProps = { - title:
{milestone.title}
, + title:
{ms.title}
, status, className, onClick: () => this.setState({ step: i }), @@ -106,6 +128,8 @@ class ProposalMilestones extends React.Component { }); const stepSize = milestoneCount > 5 ? 'small' : 'default'; + const activeMilestone = proposal.milestones[this.state.step]; + const activeIsCurrent = activeMilestone.id === proposal.currentMilestone!.id; return (
{ ))} ) : ( @@ -137,20 +165,6 @@ class ProposalMilestones extends React.Component { ); } - private getActiveMilestoneIdx = () => { - return 0; - // const { milestones } = this.props.proposal; - // const activeMilestone = - // milestones.find( - // m => - // m.state === WAITING || - // m.state === ACTIVE || - // (m.state === PAID && !m.isPaid) || - // m.state === REJECTED, - // ) || milestones[0]; - // return milestones.indexOf(activeMilestone); - }; - private updateDoTitlesOverflow = () => { // hmr can sometimes muck up refs, let's make sure they all exist if (!this.ref || !this.ref.current || !this.stepTitleRefs) { @@ -186,11 +200,17 @@ class ProposalMilestones extends React.Component { }; } -const Milestone: React.SFC = p => { +// Milestone +type MSProps = ProposalMilestone & DispatchProps; +interface MilestoneProps extends MSProps { + isTeamMember: boolean; + isArbiter: boolean; + isCurrent: boolean; + proposalId: number; +} +const Milestone: React.SFC = p => { const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY'); const reward = ; - const fmtDate = (n: undefined | number) => - (n && moment(n * 1000).format('MMM Do, YYYY')) || undefined; const getAlertProps = { [MILESTONE_STAGE.IDLE]: () => null, [MILESTONE_STAGE.REQUESTED]: () => ({ @@ -256,16 +276,152 @@ const Milestone: React.SFC = p => )} {p.content}
+ ); }; -const ConnectedProposalMilestones = connect((state: AppState) => { - console.warn('TODO - new redux accounts/user-role-for-proposal', state); - return { - accounts: [], - }; -})(ProposalMilestones); +const MilestoneAction: React.SFC = p => { + if (!p.isCurrent) { + return null; + } + + const team = { + [MILESTONE_STAGE.IDLE]: () => ( + <> + {p.immediatePayout && ( +

+ Congratulations on getting funded! You can now begin the process of receiving + your initial payment. Click below to request the first milestone payout. It + will instantly be approved, and you’ll receive your funds shortly thereafter. +

+ )} + {!p.immediatePayout && + p.index === 0 && ( +

+ Congratulations on getting funded! Click below to request your first + milestone payout. +

+ )} + {!p.immediatePayout && + p.index > 0 &&

You can request a payment for this milestone.

} + + + ), + [MILESTONE_STAGE.REQUESTED]: () => ( +

+ The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be + notified when it has been reviewed. +

+ ), + [MILESTONE_STAGE.REJECTED]: () => ( + <> +

+ The request for payout was rejected for the following reason: + {p.rejectReason} + You may request payout again when you are ready. +

+ + + ), + [MILESTONE_STAGE.ACCEPTED]: () => ( +

+ Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly. +

+ ), + [MILESTONE_STAGE.PAID]: () => <>, + } as { [key in MILESTONE_STAGE]: () => ReactNode }; + + const others = { + [MILESTONE_STAGE.IDLE]: () => ( +

The team may request a payout for this milestone at any time.

+ ), + [MILESTONE_STAGE.REQUESTED]: () => ( +

+ The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval. +

+ ), + [MILESTONE_STAGE.REJECTED]: () => ( +

+ The payout request was denied on {fmtDate(p.dateRejected)} for the following + reason: + {p.rejectReason} +

+ ), + [MILESTONE_STAGE.ACCEPTED]: () => ( + <>The payout request was approved on {fmtDate(p.dateAccepted)}. + ), + [MILESTONE_STAGE.PAID]: () => <>, + } as { [key in MILESTONE_STAGE]: () => ReactNode }; + + const arbiter = { + [MILESTONE_STAGE.IDLE]: () => ( +

+ The team may request a payout for this milestone at any time. As arbiter you will + be responsible for reviewing these requests. +

+ ), + [MILESTONE_STAGE.REQUESTED]: () => ( + <> +

+ The team requested a payout on {fmtDate(p.dateRequested)}, and awaits your + approval. +

+ + + + ), + [MILESTONE_STAGE.REJECTED]: () => ( +

+ The payout request was denied on {fmtDate(p.dateRejected)} for the following + reason: + {p.rejectReason} +

+ ), + [MILESTONE_STAGE.ACCEPTED]: () => ( + <>The payout request was approved on {fmtDate(p.dateAccepted)}. + ), + [MILESTONE_STAGE.PAID]: () => <>, + } as { [key in MILESTONE_STAGE]: () => ReactNode }; + + let content: ReactNode = null; + if (p.isTeamMember) { + content = team[p.stage](); + } else if (p.isArbiter) { + content = arbiter[p.stage](); + } else { + content = others[p.stage](); + } + + return ( + <> +
+
{content}
+ + ); +}; + +const ConnectedProposalMilestones = connect<{}, DispatchProps, OwnProps, AppState>( + undefined, + { + requestPayout: proposalActions.requestPayout, + acceptPayout: proposalActions.acceptPayout, + rejectPayout: proposalActions.rejectPayout, + }, +)(ProposalMilestones); export default ConnectedProposalMilestones; diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 0503bc82..99ef0473 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -6,6 +6,9 @@ import { getProposalUpdates, getProposalContributions, postProposalComment as apiPostProposalComment, + requestProposalPayout, + acceptProposalPayout, + rejectProposalPayout, } from 'api/api'; import { Dispatch } from 'redux'; import { Proposal, Comment, ProposalPageParams } from 'types'; @@ -17,9 +20,9 @@ type GetState = () => AppState; function addProposalUserRoles(p: Proposal, state: AppState) { if (state.auth.user) { const authUserId = state.auth.user.userid; - // TODO: add arbiter roll - // user.arbitratedProposals... - console.warn('TODO: add user arbitration role to Proposal'); + if (p.arbiter.user) { + p.isArbiter = p.arbiter.user.userid === authUserId; + } if (p.team.find(t => t.userid === authUserId)) { p.isTeamMember = true; } @@ -27,6 +30,39 @@ function addProposalUserRoles(p: Proposal, state: AppState) { return p; } +export function requestPayout(proposalId: number, milestoneId: number) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.PROPOSAL_PAYOUT_REQUEST, + payload: async () => { + return (await requestProposalPayout(proposalId, milestoneId)).data; + }, + }); + }; +} + +export function acceptPayout(proposalId: number, milestoneId: number) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.PROPOSAL_PAYOUT_REQUEST, + payload: async () => { + return (await acceptProposalPayout(proposalId, milestoneId)).data; + }, + }); + }; +} + +export function rejectPayout(proposalId: number, milestoneId: number, reason: string) { + return async (dispatch: Dispatch) => { + return dispatch({ + type: types.PROPOSAL_PAYOUT_REQUEST, + payload: async () => { + return (await rejectProposalPayout(proposalId, milestoneId, reason)).data; + }, + }); + }; +} + // change page, sort, filter, search export function setProposalPage(pageParams: Partial) { return async (dispatch: Dispatch, getState: GetState) => { diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 36e4074a..cccfee07 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -10,6 +10,15 @@ import { } from 'types'; import { PROPOSAL_SORT } from 'api/constants'; +export interface ProposalDetail extends Proposal { + isRequestingPayout: boolean; + requestPayoutError: string; + isRejectingPayout: boolean; + rejectPayoutError: string; + isAcceptingPayout: boolean; + acceptPayoutError: string; +} + export interface ProposalState { page: LoadableProposalPage; @@ -36,6 +45,15 @@ export interface ProposalState { deleteContributionError: null | string; } +const PROPOSAL_DETAIL_INITIAL_STATE: Partial = { + isRequestingPayout: false, + requestPayoutError: '', + isRejectingPayout: false, + rejectPayoutError: '', + isAcceptingPayout: false, + acceptPayoutError: '', +}; + export const INITIAL_STATE: ProposalState = { page: { page: 1, @@ -203,14 +221,14 @@ export default (state = INITIAL_STATE, action: any) => { // if requesting same proposal, leave the detail object state.detail && state.detail.proposalId === payload.proposalId ? state.detail - : loadedInPage || null, + : { ...loadedInPage, ...PROPOSAL_DETAIL_INITIAL_STATE } || null, isFetchingDetail: true, detailError: null, }; case types.PROPOSAL_DATA_FULFILLED: return { ...state, - detail: payload, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, isFetchingDetail: false, }; case types.PROPOSAL_DATA_REJECTED: @@ -221,6 +239,78 @@ export default (state = INITIAL_STATE, action: any) => { detailError: (payload && payload.message) || payload.toString(), }; + case types.PROPOSAL_PAYOUT_REQUEST_PENDING: + return { + ...state, + detail: { + ...state.detail, + isRequestingPayout: true, + requestPayoutError: '', + }, + }; + case types.PROPOSAL_PAYOUT_REQUEST_FULFILLED: + return { + ...state, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, + }; + case types.PROPOSAL_PAYOUT_REQUEST_REJECTED: + return { + ...state, + detail: { + ...state.detail, + isRequestingPayout: false, + requestPayoutError: (payload && payload.message) || payload.toString(), + }, + }; + + case types.PROPOSAL_PAYOUT_REJECT_PENDING: + return { + ...state, + detail: { + ...state.detail, + isRejectingPayout: true, + rejectPayoutError: '', + }, + }; + case types.PROPOSAL_PAYOUT_REJECT_FULFILLED: + return { + ...state, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, + }; + case types.PROPOSAL_PAYOUT_REJECT_REJECTED: + return { + ...state, + detail: { + ...state.detail, + isRejectingPayout: false, + rejectPayoutError: (payload && payload.message) || payload.toString(), + }, + }; + + case types.PROPOSAL_PAYOUT_ACCEPT_PENDING: + return { + ...state, + detail: { + ...state.detail, + isAcceptingPayout: true, + acceptingPayoutError: '', + }, + }; + case types.PROPOSAL_PAYOUT_ACCEPT_FULFILLED: + return { + ...state, + detail: { ...payload, ...PROPOSAL_DETAIL_INITIAL_STATE }, + }; + case types.PROPOSAL_PAYOUT_ACCEPT_REJECTED: + return { + ...state, + detail: { + ...state.detail, + isAcceptingPayout: false, + acceptingPayoutError: (payload && payload.message) || payload.toString(), + }, + }; + case types.PROPOSAL_COMMENTS_PENDING: return { ...state, diff --git a/frontend/client/modules/proposals/types.ts b/frontend/client/modules/proposals/types.ts index 7be80b5a..0f06fe03 100644 --- a/frontend/client/modules/proposals/types.ts +++ b/frontend/client/modules/proposals/types.ts @@ -32,6 +32,21 @@ enum proposalTypes { POST_PROPOSAL_CONTRIBUTION = 'POST_PROPOSAL_CONTRIBUTION', SET_PROPOSAL_PAGE = 'SET_PROPOSAL_PAGE', + + PROPOSAL_PAYOUT_REQUEST = 'PROPOSAL_PAYOUT_REQUEST', + PROPOSAL_PAYOUT_REQUEST_FULFILLED = 'PROPOSAL_PAYOUT_REQUEST_FULFILLED', + PROPOSAL_PAYOUT_REQUEST_REJECTED = 'PROPOSAL_PAYOUT_REQUEST_REJECTED', + PROPOSAL_PAYOUT_REQUEST_PENDING = 'PROPOSAL_PAYOUT_REQUEST_PENDING', + + PROPOSAL_PAYOUT_REJECT = 'PROPOSAL_PAYOUT_REJECT', + PROPOSAL_PAYOUT_REJECT_FULFILLED = 'PROPOSAL_PAYOUT_REJECT_FULFILLED', + PROPOSAL_PAYOUT_REJECT_REJECTED = 'PROPOSAL_PAYOUT_REJECT_REJECTED', + PROPOSAL_PAYOUT_REJECT_PENDING = 'PROPOSAL_PAYOUT_REJECT_PENDING', + + PROPOSAL_PAYOUT_ACCEPT = 'PROPOSAL_PAYOUT_ACCEPT', + PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED', + PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED', + PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING', } export default proposalTypes; diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index c1de5696..2809503f 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -26,9 +26,12 @@ export interface Milestone { dateRejected?: number; dateAccepted?: number; datePaid?: number; + rejectReason?: string; + paidTxId?: string; } export interface ProposalMilestone extends Milestone { + id: number; content: string; payoutPercent: string; title: string; From c47c69ea3c65872b65af0f2b4bcdf9abf277ef28 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 11 Feb 2019 23:42:21 -0600 Subject: [PATCH 5/7] tsc fixes --- frontend/client/modules/create/utils.ts | 9 +++++-- frontend/client/modules/proposals/reducers.ts | 4 +-- frontend/stories/ProposalMilestones.story.tsx | 26 +++++++++---------- frontend/stories/props.tsx | 2 ++ 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 77a9684a..c06368de 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -7,10 +7,13 @@ import { } from 'types'; import { User } from 'types'; import { getAmountError, isValidAddress } from 'utils/validators'; -import { Proposal } from 'types'; import { Zat, toZat } from 'utils/units'; import { ONE_DAY } from 'utils/time'; import { PROPOSAL_CATEGORY } from 'api/constants'; +import { + ProposalDetail, + PROPOSAL_DETAIL_INITIAL_STATE, +} from 'modules/proposals/reducers'; export const TARGET_ZEC_LIMIT = 1000; @@ -176,7 +179,7 @@ export function proposalToContractData(form: ProposalDraft): any { } // This is kind of a disgusting function, sorry. -export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { +export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDetail { const { invites, ...rest } = draft; const target = parseFloat(draft.target); @@ -202,6 +205,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { status: PROPOSAL_ARBITER_STATUS.ACCEPTED, }, milestones: draft.milestones.map((m, idx) => ({ + id: idx, index: idx, title: m.title, content: m.content, @@ -211,5 +215,6 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { payoutPercent: m.payoutPercent.toString(), stage: MILESTONE_STAGE.IDLE, })), + ...PROPOSAL_DETAIL_INITIAL_STATE, }; } diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index cccfee07..74d223d6 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -22,7 +22,7 @@ export interface ProposalDetail extends Proposal { export interface ProposalState { page: LoadableProposalPage; - detail: null | Proposal; + detail: null | ProposalDetail; isFetchingDetail: boolean; detailError: null | string; @@ -45,7 +45,7 @@ export interface ProposalState { deleteContributionError: null | string; } -const PROPOSAL_DETAIL_INITIAL_STATE: Partial = { +export const PROPOSAL_DETAIL_INITIAL_STATE = { isRequestingPayout: false, requestPayoutError: '', isRejectingPayout: false, diff --git a/frontend/stories/ProposalMilestones.story.tsx b/frontend/stories/ProposalMilestones.story.tsx index afff2bcc..96be4161 100644 --- a/frontend/stories/ProposalMilestones.story.tsx +++ b/frontend/stories/ProposalMilestones.story.tsx @@ -21,9 +21,9 @@ const msRejected = { stage: REJECTED }; const trustee = 'z123'; const contributor = 'z456'; -const geometryCases = [...Array(10).keys()].map(i => - generateProposal({ milestoneCount: i + 1 }), -); +// const geometryCases = [...Array(10).keys()].map(i => +// generateProposal({ milestoneCount: i + 1 }), +// ); const cases: { [index: string]: any } = { // trustee - first @@ -149,14 +149,14 @@ for (const key of Object.keys(cases)) { )); } -const geometryStories = storiesOf('Proposal/Milestones/geometry', module); +// const geometryStories = storiesOf('Proposal/Milestones/geometry', module); -geometryCases.forEach((gc, idx) => - geometryStories.add(`${idx + 1} steps`, () => ( -
- - - -
- )), -); +// geometryCases.forEach((gc, idx) => +// geometryStories.add(`${idx + 1} steps`, () => ( +//
+// +// +// +//
+// )), +// ); diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 02d0e015..83d990e5 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -109,6 +109,7 @@ export function generateProposal({ } const defaults: ProposalMilestone = { + id: 0, title: 'Milestone A', content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, @@ -124,6 +125,7 @@ export function generateProposal({ const milestones = [...Array(milestoneCount).keys()].map(i => { const overrides = { + id: i, index: i, title: genMilestoneTitle(), immediatePayout: i === 0, From fd9a4c53938e3fd663862eaf0d8de955e68107c0 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 13 Feb 2019 10:54:46 -0600 Subject: [PATCH 6/7] full payout flow operational --- admin/src/components/Home/index.tsx | 9 + .../src/components/ProposalDetail/index.less | 9 + admin/src/components/ProposalDetail/index.tsx | 66 ++++- admin/src/store.ts | 21 ++ admin/src/types.ts | 21 +- admin/src/util/filters.ts | 9 + admin/src/util/statuses.ts | 34 +++ admin/src/util/units.ts | 63 +++++ backend/grant/admin/views.py | 41 ++- backend/grant/milestone/models.py | 2 +- backend/grant/proposal/models.py | 6 +- backend/grant/proposal/views.py | 31 ++- backend/grant/utils/enums.py | 3 +- backend/grant/utils/pagination.py | 8 +- frontend/client/api/constants.ts | 5 + .../components/Profile/ProfileArbitrated.tsx | 36 ++- .../components/Proposal/Milestones/index.less | 25 ++ .../components/Proposal/Milestones/index.tsx | 249 +++++++++++++----- frontend/client/modules/create/utils.ts | 4 +- frontend/client/modules/proposals/actions.ts | 4 +- frontend/stories/props.tsx | 4 +- frontend/types/proposal.ts | 4 +- 22 files changed, 552 insertions(+), 102 deletions(-) create mode 100644 admin/src/util/units.ts diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx index b6a08aa4..c86acaa4 100644 --- a/admin/src/components/Home/index.tsx +++ b/admin/src/components/Home/index.tsx @@ -16,6 +16,7 @@ class Home extends React.Component { proposalCount, proposalPendingCount, proposalNoArbiterCount, + proposalMilestonePayoutsCount, } = store.stats; const actionItems = [ @@ -36,6 +37,14 @@ class Home extends React.Component { to view them.
), + !!proposalMilestonePayoutsCount && ( +
+ There are{' '} + {proposalMilestonePayoutsCount} proposals with approved payouts.{' '} + Click here to view + them. +
+ ), ].filter(Boolean); return ( diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index a391bd1c..211703c2 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -37,4 +37,13 @@ max-width: 400px; } } + + &-alert { + & pre { + margin: 1rem 0; + overflow: hidden; + word-break: break-all; + white-space: inherit; + } + } } diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index e955cef1..a0b18221 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import BN from 'bn.js'; import { view } from 'react-easy-state'; import { RouteComponentProps, withRouter } from 'react-router'; import { @@ -12,23 +13,26 @@ import { Modal, Input, Switch, + message, } from 'antd'; import TextArea from 'antd/lib/input/TextArea'; import store from 'src/store'; import { formatDateSeconds } from 'util/time'; -import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS } from 'src/types'; +import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'src/types'; import { Link } from 'react-router-dom'; import Back from 'components/Back'; import Info from 'components/Info'; import Markdown from 'components/Markdown'; import ArbiterControl from 'components/ArbiterControl'; import './index.less'; +import { toZat, fromZat } from 'src/util/units'; type Props = RouteComponentProps; const STATE = { showRejectModal: false, rejectReason: '', + paidTxId: '', }; type State = typeof STATE; @@ -68,7 +72,7 @@ class ProposalDetailNaked extends React.Component { type: 'default', className: 'ProposalDetail-controls-control', block: true, - disabled: p.status !== PROPOSAL_STATUS.LIVE + disabled: p.status !== PROPOSAL_STATUS.LIVE, }} /> ); @@ -245,6 +249,54 @@ class ProposalDetailNaked extends React.Component { /> ); + const renderMilestoneAccepted = () => { + if ( + !( + p.status === PROPOSAL_STATUS.LIVE && + p.currentMilestone && + p.currentMilestone.stage === MILESTONE_STAGE.ACCEPTED + ) + ) { + return; + } + const ms = p.currentMilestone; + const amount = fromZat( + toZat(p.target) + .mul(new BN(ms.payoutPercent)) + .divn(100), + ); + return ( + +

+ + Milestone {ms.index + 1} - {ms.title} + {' '} + was accepted on {formatDateSeconds(ms.dateAccepted)}. +

+

+ {' '} + Please make a payment of {amount.toString()} ZEC to: +

{' '} +
{p.payoutAddress}
+ this.setState({ paidTxId: e.target.value })} + onSearch={this.handlePaidMilestone} + /> + + } + /> + ); + }; + const renderDeetItem = (name: string, val: any) => (
{name} @@ -264,6 +316,7 @@ class ProposalDetailNaked extends React.Component { {renderRejected()} {renderNominateArbiter()} {renderNominatedArbiter()} + {renderMilestoneAccepted()} {p.brief} @@ -295,12 +348,12 @@ class ProposalDetailNaked extends React.Component { {renderDeetItem('id', p.proposalId)} {renderDeetItem('created', formatDateSeconds(p.dateCreated))} {renderDeetItem('status', p.status)} + {renderDeetItem('stage', p.stage)} {renderDeetItem('category', p.category)} {renderDeetItem('target', p.target)} {renderDeetItem('contributed', p.contributed)} {renderDeetItem('funded (inc. matching)', p.funded)} {renderDeetItem('matching', p.contributionMatching)} - {renderDeetItem( 'arbiter', <> @@ -365,6 +418,13 @@ class ProposalDetailNaked extends React.Component { store.updateProposalDetail({ contributionMatching }); } }; + + private handlePaidMilestone = async () => { + const pid = store.proposalDetail!.proposalId; + const mid = store.proposalDetail!.currentMilestone!.id; + await store.markMilestonePaid(pid, mid, this.state.paidTxId); + message.success('Marked milestone paid.'); + }; } const ProposalDetail = withRouter(view(ProposalDetailNaked)); diff --git a/admin/src/store.ts b/admin/src/store.ts index 13774f78..04646d43 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -95,6 +95,14 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st return data; } +async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { + const { data } = await api.put( + `/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`, + { txId }, + ); + return data; +} + async function getEmailExample(type: string) { const { data } = await api.get(`/admin/email/example/${type}`); return data; @@ -154,6 +162,7 @@ const app = store({ proposalCount: 0, proposalPendingCount: 0, proposalNoArbiterCount: 0, + proposalMilestonePayoutsCount: 0, }, usersFetching: false, @@ -178,6 +187,7 @@ const app = store({ proposalDetail: null as null | Proposal, proposalDetailFetching: false, proposalDetailApproving: false, + proposalDetailMarkingMilestonePaid: false, rfps: [] as RFP[], rfpsFetching: false, @@ -424,6 +434,17 @@ const app = store({ app.proposalDetailApproving = false; }, + async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { + app.proposalDetailMarkingMilestonePaid = true; + try { + const res = await markMilestonePaid(proposalId, milestoneId, txId); + app.updateProposalInStore(res); + } catch (e) { + handleApiError(e); + } + app.proposalDetailMarkingMilestonePaid = false; + }, + // Email async getEmailExample(type: string) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 0473d561..81a63a61 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -4,10 +4,24 @@ export interface SocialMedia { service: string; username: string; } +// NOTE: sync with backend/grant/utils/enums.py MilestoneStage +export enum MILESTONE_STAGE { + IDLE = 'IDLE', + REQUESTED = 'REQUESTED', + REJECTED = 'REJECTED', + ACCEPTED = 'ACCEPTED', + PAID = 'PAID', +} export interface Milestone { + id: number; + index: number; content: string; - dateCreated: string; - dateEstimated: string; + dateCreated: number; + dateEstimated: number; + dateRequested: number; + dateAccepted: number; + dateRejected: number; + datePaid: number; immediatePayout: boolean; payoutPercent: string; stage: string; @@ -61,7 +75,7 @@ export interface Proposal { proposalId: number; brief: string; status: PROPOSAL_STATUS; - proposalAddress: string; + payoutAddress: string; dateCreated: number; dateApproved: number; datePublished: number; @@ -70,6 +84,7 @@ export interface Proposal { stage: string; category: string; milestones: Milestone[]; + currentMilestone?: Milestone; team: User[]; comments: Comment[]; contractStatus: string; diff --git a/admin/src/util/filters.ts b/admin/src/util/filters.ts index df91f8d2..3878eee4 100644 --- a/admin/src/util/filters.ts +++ b/admin/src/util/filters.ts @@ -3,6 +3,7 @@ import { RFP_STATUSES, CONTRIBUTION_STATUSES, PROPOSAL_ARBITER_STATUSES, + MILESTONE_STAGES, } from './statuses'; export interface Filter { @@ -41,6 +42,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({ color: s.tagColor, group: 'Arbiter', })), + ) + .concat( + MILESTONE_STAGES.map(s => ({ + id: `MILESTONE_${s.id}`, + display: `Milestone: ${s.tagDisplay}`, + color: s.tagColor, + group: 'Milestone', + })), ); export const proposalFilters: Filters = { diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index c9e461f0..6f965979 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -3,6 +3,7 @@ import { RFP_STATUS, CONTRIBUTION_STATUS, PROPOSAL_ARBITER_STATUS, + MILESTONE_STAGE, } from 'src/types'; export interface StatusSoT { @@ -12,6 +13,39 @@ export interface StatusSoT { hint: string; } +export const MILESTONE_STAGES: Array> = [ + { + id: MILESTONE_STAGE.IDLE, + tagDisplay: 'Idle', + tagColor: '#e9c510', + hint: 'Proposal has has an idle milestone.', + }, + { + id: MILESTONE_STAGE.REQUESTED, + tagDisplay: 'Requested', + tagColor: '#e9c510', + hint: 'Proposal has has a milestone with a requested payout.', + }, + { + id: MILESTONE_STAGE.REJECTED, + tagDisplay: 'Rejected', + tagColor: '#e9c510', + hint: 'Proposal has has a milestone with a rejected payout.', + }, + { + id: MILESTONE_STAGE.ACCEPTED, + tagDisplay: 'Accepted', + tagColor: '#e9c510', + hint: 'Proposal has an accepted milestone, and awaits payment.', + }, + { + id: MILESTONE_STAGE.PAID, + tagDisplay: 'Paid', + tagColor: '#e9c510', + hint: 'Proposal has a paid milestone.', + }, +]; + export const PROPOSAL_STATUSES: Array> = [ { id: PROPOSAL_STATUS.APPROVED, diff --git a/admin/src/util/units.ts b/admin/src/util/units.ts new file mode 100644 index 00000000..d96c68b8 --- /dev/null +++ b/admin/src/util/units.ts @@ -0,0 +1,63 @@ +// From https://github.com/MyCryptoHQ/MyCrypto/blob/develop/common/libs/units.ts +import BN from 'bn.js'; + +export const ZCASH_DECIMAL = 8; +export const Units = { + zat: '1', + zcash: '100000000', +}; + +export type Zat = BN; +export type UnitKey = keyof typeof Units; + +export const handleValues = (input: string | BN) => { + if (typeof input === 'string') { + return new BN(input); + } + if (typeof input === 'number') { + return new BN(input); + } + if (BN.isBN(input)) { + return input; + } else { + throw Error('unsupported value conversion'); + } +}; + +export const Zat = (input: string | BN): Zat => handleValues(input); + +const stripRightZeros = (str: string) => { + const strippedStr = str.replace(/0+$/, ''); + return strippedStr === '' ? null : strippedStr; +}; + +export const baseToConvertedUnit = (value: string, decimal: number) => { + if (decimal === 0) { + return value; + } + const paddedValue = value.padStart(decimal + 1, '0'); // 0.1 ==> + const integerPart = paddedValue.slice(0, -decimal); + const fractionPart = stripRightZeros(paddedValue.slice(-decimal)); + return fractionPart ? `${integerPart}.${fractionPart}` : `${integerPart}`; +}; + +const convertedToBaseUnit = (value: string, decimal: number) => { + if (decimal === 0) { + return value; + } + const [integerPart, fractionPart = ''] = value.split('.'); + const paddedFraction = fractionPart.padEnd(decimal, '0'); + return `${integerPart}${paddedFraction}`; +}; + +export const fromZat = (zat: Zat) => { + return baseToConvertedUnit(zat.toString(), ZCASH_DECIMAL); +}; + +export const toZat = (value: string | number): Zat => { + value = value.toString(); + const zat = convertedToBaseUnit(value, ZCASH_DECIMAL); + return Zat(zat); +}; + +export const getDecimalFromUnitKey = (key: UnitKey) => Units[key].length - 1; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 23ac8cb9..b714f430 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request +from functools import reduce from flask import Blueprint, request from flask_yoloapi import endpoint, parameter from decimal import Decimal @@ -14,11 +14,12 @@ from grant.proposal.models import ( proposal_contribution_schema, user_proposal_contributions_schema, ) +from grant.milestone.models import Milestone from grant.user.models import User, admin_users_schema, admin_user_schema from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout from grant.utils.misc import make_url -from grant.utils.enums import ProposalStatus, ContributionStatus, ProposalArbiterStatus +from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, ProposalArbiterStatus, MilestoneStage from grant.utils import pagination from sqlalchemy import func, or_ @@ -66,11 +67,17 @@ def stats(): .filter(Proposal.status == ProposalStatus.LIVE) \ .filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \ .scalar() + proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \ + .join(Proposal.milestones) \ + .filter(Proposal.status == ProposalStatus.LIVE) \ + .filter(Milestone.stage == MilestoneStage.ACCEPTED) \ + .scalar() return { "userCount": user_count, "proposalCount": proposal_count, "proposalPendingCount": proposal_pending_count, "proposalNoArbiterCount": proposal_no_arbiter_count, + "proposalMilestonePayoutsCount": proposal_milestone_payouts_count, } @@ -253,7 +260,35 @@ def approve_proposal(id, is_approve, reject_reason=None): db.session.commit() return proposal_schema.dump(proposal) - return {"message": "Not implemented."}, 400 + return {"message": "No proposal found."}, 404 + + +@blueprint.route("/proposals//milestone//paid", methods=["PUT"]) +@endpoint.api( + parameter('txId', type=str, required=True), +) +@admin_auth_required +def paid_milestone_payout_request(id, mid, tx_id): + proposal = Proposal.query.filter_by(id=id).first() + if not proposal: + return {"message": "No proposal matching id"}, 404 + if not proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 + 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() + 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() + return proposal_schema.dump(proposal), 200 + + return {"message": "No milestone matching id"}, 404 # EMAIL diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index 324d9404..c6f9b02d 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -85,7 +85,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.stage = MilestoneStage.PAID + self.stage = MilestoneStage.ACCEPTED self.date_accepted = datetime.datetime.now() self.accept_arbiter_id = arbiter_id diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index a0aad6f3..8243d959 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -221,7 +221,8 @@ class Proposal(db.Model): comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan") updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan") contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan") - milestones = db.relationship("Milestone", backref="proposal", order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") + milestones = db.relationship("Milestone", backref="proposal", + order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan") arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan") @@ -231,7 +232,7 @@ class Proposal(db.Model): title: str = '', brief: str = '', content: str = '', - stage: str = '', + stage: str = ProposalStage.PREVIEW, target: str = '0', payout_address: str = '', deadline_duration: int = 5184000, # 60 days @@ -436,6 +437,7 @@ class Proposal(db.Model): for ms in self.milestones: if ms.stage != MilestoneStage.PAID: return ms + return self.milestones[-1] # return last one if all PAID return None diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 81d8cb12..54d2c545 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -482,14 +482,14 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): contribution.confirm(tx_id=txid, amount=zec_amount) db.session.add(contribution) - db.session.commit() + db.session.flush() if contribution.proposal.status == ProposalStatus.STAKING: # fully staked, set status PENDING if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT: contribution.proposal.status = ProposalStatus.PENDING db.session.add(contribution.proposal) - db.session.commit() + db.session.flush() # email progress of staking, partial or complete send_email(contribution.user.email_address, 'staking_contribution_confirmed', { @@ -523,8 +523,11 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): # on funding target reached. if contribution.proposal.status == ProposalStatus.LIVE: if contribution.proposal.is_funded: - contribution.proposal.stage = ProposalStage.IN_PROGRESS + contribution.proposal.stage = ProposalStage.WIP + db.session.add(contribution.proposal) + db.session.flush() + db.session.commit() return None, 200 @@ -549,20 +552,22 @@ def delete_proposal_contribution(contribution_id): return None, 202 -# TODO # request MS payout @blueprint.route("//milestone//request", methods=["PUT"]) @requires_team_member_auth @endpoint.api() def request_milestone_payout(proposal_id, milestone_id): + if not g.current_proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 for ms in g.current_proposal.milestones: - if ms.id == int(milestone_id) : + 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() return proposal_schema.dump(g.current_proposal), 200 - return {"message": "No milestone matching id"}, 404 + + return {"message": "No milestone matching id"}, 404 # accept MS payout (arbiter) @@ -570,14 +575,17 @@ def request_milestone_payout(proposal_id, milestone_id): @requires_arbiter_auth @endpoint.api() def accept_milestone_payout_request(proposal_id, milestone_id): + if not g.current_proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 for ms in g.current_proposal.milestones: - if ms.id == int(milestone_id) : + 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() return proposal_schema.dump(g.current_proposal), 200 - return {"message": "No milestone matching id"}, 404 + + return {"message": "No milestone matching id"}, 404 # reject MS payout (arbiter) (reason) @@ -587,13 +595,14 @@ def accept_milestone_payout_request(proposal_id, milestone_id): parameter('reason', type=str, required=True), ) def reject_milestone_payout_request(proposal_id, milestone_id, reason): + if not g.current_proposal.is_funded: + return {"message": "Proposal is not fully funded"}, 400 for ms in g.current_proposal.milestones: - if ms.id == int(milestone_id) : + 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() return proposal_schema.dump(g.current_proposal), 200 - return {"message": "No milestone matching id"}, 404 -# (ADMIN) MS payout (txid) + return {"message": "No milestone matching id"}, 404 diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index 43b64e7c..31b255f4 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -33,8 +33,9 @@ ProposalSort = ProposalSortEnum() class ProposalStageEnum(CustomEnum): + PREVIEW = 'PREVIEW' FUNDING_REQUIRED = 'FUNDING_REQUIRED' - IN_PROGRESS = 'IN_PROGRESS' + WIP = 'WIP' COMPLETED = 'COMPLETED' diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index d75c50e8..39c45194 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -2,7 +2,8 @@ import abc from sqlalchemy import or_, and_ from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema -from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus +from grant.milestone.models import Milestone +from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage def extract_filters(sw, strings): @@ -50,6 +51,7 @@ class ProposalPagination(Pagination): self.FILTERS.extend([f'STAGE_{s}' for s in ProposalStage.list()]) self.FILTERS.extend([f'CAT_{c}' for c in Category.list()]) self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()]) + self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()]) self.PAGE_SIZE = 9 self.SORT_MAP = { 'CREATED:DESC': Proposal.date_created.desc(), @@ -77,6 +79,7 @@ class ProposalPagination(Pagination): stage_filters = extract_filters('STAGE_', filters) cat_filters = extract_filters('CAT_', filters) arbiter_filters = extract_filters('ARBITER_', filters) + milestone_filters = extract_filters('MILESTONE_', filters) if status_filters: query = query.filter(Proposal.status.in_(status_filters)) @@ -89,6 +92,9 @@ class ProposalPagination(Pagination): if arbiter_filters: query = query.join(Proposal.arbiter) \ .filter(ProposalArbiter.status.in_(arbiter_filters)) + if milestone_filters: + query = query.join(Proposal.milestones) \ + .filter(Milestone.stage.in_(milestone_filters)) # SORT (see self.SORT_MAP) if sort: diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts index 0ff5ff3d..53688cdd 100644 --- a/frontend/client/api/constants.ts +++ b/frontend/client/api/constants.ts @@ -57,6 +57,7 @@ export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: CategoryUI } = { }; export enum PROPOSAL_STAGE { + PREVIEW = 'PREVIEW', FUNDING_REQUIRED = 'FUNDING_REQUIRED', WIP = 'WIP', COMPLETED = 'COMPLETED', @@ -68,6 +69,10 @@ interface StageUI { } export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = { + PREVIEW: { + label: 'Preview', + color: '#8e44ad', + }, FUNDING_REQUIRED: { label: 'Funding required', color: '#8e44ad', diff --git a/frontend/client/components/Profile/ProfileArbitrated.tsx b/frontend/client/components/Profile/ProfileArbitrated.tsx index 191eec30..c1b31a8e 100644 --- a/frontend/client/components/Profile/ProfileArbitrated.tsx +++ b/frontend/client/components/Profile/ProfileArbitrated.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS } from 'types'; +import moment from 'moment'; +import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'types'; import { connect } from 'react-redux'; import { AppState } from 'store/reducers'; import { updateUserArbiter } from 'api/api'; @@ -27,15 +28,32 @@ type Props = OwnProps & StateProps & DispatchProps; class ProfileArbitrated extends React.Component { render() { const { status } = this.props.arbiter; - const { title, proposalId } = this.props.arbiter.proposal; + const { title, proposalId, currentMilestone } = this.props.arbiter.proposal; + const isMsPayoutReq = + currentMilestone && currentMilestone.stage === MILESTONE_STAGE.REQUESTED; + const msTitle = currentMilestone && currentMilestone.title; const info = { [PAS.MISSING]: <>{/* nada */}, [PAS.NOMINATED]: <>You have been nominated to be the arbiter for this proposal., [PAS.ACCEPTED]: ( <> - As arbiter of this proposal, you are responsible for reviewing milestone payout - requests. You may{' '} + {isMsPayoutReq && ( + <> + The team has requested payout for {msTitle}{' '} + {moment((currentMilestone!.dateRequested || 0) * 1000).fromNow()}. Please + click the button to proceed. + + )} + {!isMsPayoutReq && ( + <> + As arbiter of this proposal, you are responsible for reviewing milestone + payout requests.{' '} + + )} +
+
+ You may{' '} this.acceptArbiter(false)} @@ -57,7 +75,15 @@ class ProfileArbitrated extends React.Component { ), - [PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}, + [PAS.ACCEPTED]: ( + <> + {isMsPayoutReq && ( + + + + )} + + ), }; return ( diff --git a/frontend/client/components/Proposal/Milestones/index.less b/frontend/client/components/Proposal/Milestones/index.less index 20250426..e2a9ecae 100644 --- a/frontend/client/components/Proposal/Milestones/index.less +++ b/frontend/client/components/Proposal/Milestones/index.less @@ -102,6 +102,31 @@ flex: 1; } + &-action { + q { + display: block; + margin-bottom: 0.5rem; + background: rgba(0, 0, 0, 0.06); + padding: 0.5rem; + } + + h3 { + font-size: 1rem; + text-align: center; + } + + &-controls { + display: flex; + + & > * { + flex-grow: 1; + } + & > * + * { + margin-left: 0.5rem; + } + } + } + &-divider { width: 1px; background: rgba(0, 0, 0, 0.05); diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 53a41508..27d5de24 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -1,19 +1,21 @@ import { throttle } from 'lodash'; import React, { ReactNode } from 'react'; import moment from 'moment'; -import { Alert, Steps, Button, message } from 'antd'; +import classnames from 'classnames'; +import { connect } from 'react-redux'; +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 { PROPOSAL_STAGE } from 'api/constants'; import UnitDisplay from 'components/UnitDisplay'; import Loader from 'components/Loader'; import { AppState } from 'store/reducers'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; import Placeholder from 'components/Placeholder'; -import { AlertProps } from 'antd/lib/alert'; -import { StepProps } from 'antd/lib/steps'; import { proposalActions } from 'modules/proposals'; -import './index.less'; import { ProposalDetail } from 'modules/proposals/reducers'; +import './index.less'; enum STEP_STATUS { WAIT = 'wait', @@ -31,7 +33,9 @@ const milestoneStageToStepState = { } as { [key in MILESTONE_STAGE]: StepProps['status'] }; const fmtDate = (n: undefined | number) => - (n && moment(n * 1000).format('MMM Do, YYYY')) || undefined; + (n && moment(n * 1000).format('MMM Do, YYYY, h:mm a')) || undefined; + +const fmtDateFromNow = (n: undefined | number) => (n && moment(n * 1000).fromNow()) || ''; interface OwnProps { proposal: ProposalDetail; @@ -49,29 +53,39 @@ interface State { step: number; activeMilestoneIdx: number; doTitlesOverflow: boolean; + showRejectModal: boolean; + rejectReason: string; + rejectMilestoneId: number; } class ProposalMilestones extends React.Component { stepTitleRefs: Array> = []; ref: React.RefObject; + rejectInput: null | TextArea; throttledUpdateDoTitlesOverflow: () => void; + constructor(props: Props) { super(props); + this.rejectInput = null; this.stepTitleRefs = this.props.proposal.milestones.map(() => React.createRef()); this.ref = React.createRef(); this.throttledUpdateDoTitlesOverflow = throttle(this.updateDoTitlesOverflow, 500); + const step = + (this.props.proposal && + this.props.proposal.currentMilestone && + this.props.proposal.currentMilestone.index) || + 0; this.state = { - step: 0, + step, activeMilestoneIdx: 0, doTitlesOverflow: true, + showRejectModal: false, + rejectReason: '', + rejectMilestoneId: -1, }; } componentDidMount() { - if (this.props.proposal) { - const { currentMilestone } = this.props.proposal; - this.setState({ step: (currentMilestone && currentMilestone.index) || 0 }); - } this.updateDoTitlesOverflow(); window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow); } @@ -89,27 +103,86 @@ class ProposalMilestones extends React.Component { } const { requestPayoutError, + isRequestingPayout, acceptPayoutError, + isAcceptingPayout, rejectPayoutError, + isRejectingPayout, } = this.props.proposal; + if (!prevProps.proposal.requestPayoutError && requestPayoutError) { message.error(requestPayoutError); } + if ( + prevProps.proposal.isRequestingPayout && + !isRequestingPayout && + !requestPayoutError + ) { + message.success('Payout requested.'); + } + if (!prevProps.proposal.acceptPayoutError && acceptPayoutError) { message.error(acceptPayoutError); } + if ( + prevProps.proposal.isAcceptingPayout && + !isAcceptingPayout && + !acceptPayoutError + ) { + message.success('Payout approved.'); + } + if (!prevProps.proposal.rejectPayoutError && rejectPayoutError) { message.error(rejectPayoutError); } + if ( + prevProps.proposal.isRejectingPayout && + !isRejectingPayout && + !rejectPayoutError + ) { + message.info('Payout rejected.'); + } } render() { const { proposal, requestPayout, acceptPayout, rejectPayout } = this.props; + const { rejectReason, showRejectModal } = this.state; if (!proposal) { return ; } - const { milestones, currentMilestone } = proposal; + const { milestones, currentMilestone, isRejectingPayout } = proposal; const milestoneCount = milestones.length; + + // arbiter reject modal + const rejectModal = ( + this.setState({ showRejectModal: false })} + okButtonProps={{ + disabled: rejectReason.length === 0, + loading: isRejectingPayout, + }} + cancelButtonProps={{ + loading: isRejectingPayout, + }} + > + Please provide a reason: + (this.rejectInput = ta)} + rows={4} + maxLength={250} + required={true} + value={rejectReason} + onChange={e => { + this.setState({ rejectReason: e.target.value }); + }} + /> + + ); + + // generate steps const milestoneSteps = milestones.map((ms, i) => { const status = currentMilestone && @@ -147,7 +220,11 @@ class ProposalMilestones extends React.Component { ))} { subtitle="The creator of this proposal has not setup any milestones" /> )} + {rejectModal}
); } + private handleShowRejectPayout = (milestoneId: number) => { + this.setState({ showRejectModal: true, rejectMilestoneId: milestoneId }); + // try to focus on text-area after modal loads + setTimeout(() => { + if (this.rejectInput) this.rejectInput.focus(); + }, 200); + }; + + private handleReject = () => { + const { proposalId } = this.props.proposal; + const { rejectMilestoneId, rejectReason } = this.state; + + this.props.rejectPayout(proposalId, rejectMilestoneId, rejectReason); + + this.setState({ showRejectModal: false, rejectMilestoneId: -1, rejectReason: '' }); + }; + private updateDoTitlesOverflow = () => { // hmr can sometimes muck up refs, let's make sure they all exist if (!this.ref || !this.ref.current || !this.stepTitleRefs) { @@ -203,10 +298,12 @@ class ProposalMilestones extends React.Component { // Milestone type MSProps = ProposalMilestone & DispatchProps; interface MilestoneProps extends MSProps { + showRejectPayout: (milestoneId: number) => void; isTeamMember: boolean; isArbiter: boolean; isCurrent: boolean; proposalId: number; + isFunded: boolean; } const Milestone: React.SFC = p => { const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY'); @@ -217,8 +314,8 @@ const Milestone: React.SFC = p => { type: 'info', message: ( <> - The team has requested a payout for this milestone. It is currently under - review. + The team requested a payout for this milestone {fmtDateFromNow(p.dateRequested)} + . It is currently under review. ), }), @@ -226,7 +323,7 @@ const Milestone: React.SFC = p => { type: 'warning', message: ( - Payout for this milestone was rejected on {fmtDate(p.dateRejected)}. + Payout for this milestone was rejected {fmtDateFromNow(p.dateRejected)}. {p.isTeamMember ? ' You ' : ' The team '} can request another review for payout at any time. @@ -236,7 +333,7 @@ const Milestone: React.SFC = p => { type: 'info', message: ( - Payout for this milestone was accepted on {fmtDate(p.dateAccepted)}. + Payout for this milestone was accepted {fmtDateFromNow(p.dateAccepted)}.{' '} {reward} will be sent to{' '} {p.isTeamMember ? ' you ' : ' the team '} soon. @@ -247,8 +344,7 @@ const Milestone: React.SFC = p => { message: ( The team was awarded {reward}{' '} - {p.immediatePayout && ` as an initial payout `} on ${fmtDate(p.datePaid)} - `. + {p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}. ), }), @@ -283,13 +379,14 @@ const Milestone: React.SFC = p => { }; const MilestoneAction: React.SFC = p => { - if (!p.isCurrent) { + if (!p.isCurrent || !p.isFunded || p.stage === MILESTONE_STAGE.PAID) { return null; } const team = { [MILESTONE_STAGE.IDLE]: () => ( <> +

Payment Request

{p.immediatePayout && (

Congratulations on getting funded! You can now begin the process of receiving @@ -306,99 +403,123 @@ const MilestoneAction: React.SFC = p => { )} {!p.immediatePayout && p.index > 0 &&

You can request a payment for this milestone.

} - ), [MILESTONE_STAGE.REQUESTED]: () => ( -

- The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be - notified when it has been reviewed. -

+ <> +

Payment Requested

+

+ The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be + notified when it has been reviewed. +

+ ), [MILESTONE_STAGE.REJECTED]: () => ( <> -

- The request for payout was rejected for the following reason: - {p.rejectReason} - You may request payout again when you are ready. -

- ), [MILESTONE_STAGE.ACCEPTED]: () => ( -

- Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly. -

+ <> +

Awaiting Payment

+

+ Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly. +

+ ), [MILESTONE_STAGE.PAID]: () => <>, } as { [key in MILESTONE_STAGE]: () => ReactNode }; const others = { [MILESTONE_STAGE.IDLE]: () => ( -

The team may request a payout for this milestone at any time.

+ <> +

Payment Request

+

The team may request a payout for this milestone at any time.

+ ), [MILESTONE_STAGE.REQUESTED]: () => ( -

- The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval. -

+ <> +

Payment Requested

+

+ The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval. +

+ ), [MILESTONE_STAGE.REJECTED]: () => ( -

- The payout request was denied on {fmtDate(p.dateRejected)} for the following - reason: + <> +

Payment Rejected

+

+ The payout request was denied on {fmtDate(p.dateRejected)} for the following + reason: +

{p.rejectReason} -

+ ), [MILESTONE_STAGE.ACCEPTED]: () => ( - <>The payout request was approved on {fmtDate(p.dateAccepted)}. + <> +

Awaiting Payment

+

The payout request was approved on {fmtDate(p.dateAccepted)}.

+ ), [MILESTONE_STAGE.PAID]: () => <>, } as { [key in MILESTONE_STAGE]: () => ReactNode }; const arbiter = { [MILESTONE_STAGE.IDLE]: () => ( -

- The team may request a payout for this milestone at any time. As arbiter you will - be responsible for reviewing these requests. -

+ <> +

Payment Request

+

+ The team may request a payout for this milestone at any time. As arbiter you + will be responsible for reviewing these requests. +

+ ), [MILESTONE_STAGE.REQUESTED]: () => ( <> +

Payment Requested

The team requested a payout on {fmtDate(p.dateRequested)}, and awaits your approval.

- - +
+ + +
), [MILESTONE_STAGE.REJECTED]: () => ( -

- The payout request was denied on {fmtDate(p.dateRejected)} for the following - reason: + <> +

Payment Rejected

+

+ You rejected this payment request on {fmtDate(p.dateRejected)} for the following + reason: +

{p.rejectReason} -

+ ), [MILESTONE_STAGE.ACCEPTED]: () => ( - <>The payout request was approved on {fmtDate(p.dateAccepted)}. + <> +

Awaiting Payment

+

You approved this payment request on {fmtDate(p.dateAccepted)}.

+ ), [MILESTONE_STAGE.PAID]: () => <>, } as { [key in MILESTONE_STAGE]: () => ReactNode }; - let content: ReactNode = null; + let content = null; if (p.isTeamMember) { content = team[p.stage](); } else if (p.isArbiter) { diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index c06368de..f4524330 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -9,7 +9,7 @@ import { User } from 'types'; import { getAmountError, isValidAddress } from 'utils/validators'; import { Zat, toZat } from 'utils/units'; import { ONE_DAY } from 'utils/time'; -import { PROPOSAL_CATEGORY } from 'api/constants'; +import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; import { ProposalDetail, PROPOSAL_DETAIL_INITIAL_STATE, @@ -198,7 +198,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta funded: Zat('0'), contributionMatching: 0, percentFunded: 0, - stage: 'preview', + stage: PROPOSAL_STAGE.PREVIEW, category: draft.category || PROPOSAL_CATEGORY.DAPP, isStaked: true, arbiter: { diff --git a/frontend/client/modules/proposals/actions.ts b/frontend/client/modules/proposals/actions.ts index 99ef0473..02e083c5 100644 --- a/frontend/client/modules/proposals/actions.ts +++ b/frontend/client/modules/proposals/actions.ts @@ -44,7 +44,7 @@ export function requestPayout(proposalId: number, milestoneId: number) { export function acceptPayout(proposalId: number, milestoneId: number) { return async (dispatch: Dispatch) => { return dispatch({ - type: types.PROPOSAL_PAYOUT_REQUEST, + type: types.PROPOSAL_PAYOUT_ACCEPT, payload: async () => { return (await acceptProposalPayout(proposalId, milestoneId)).data; }, @@ -55,7 +55,7 @@ export function acceptPayout(proposalId: number, milestoneId: number) { export function rejectPayout(proposalId: number, milestoneId: number, reason: string) { return async (dispatch: Dispatch) => { return dispatch({ - type: types.PROPOSAL_PAYOUT_REQUEST, + type: types.PROPOSAL_PAYOUT_REJECT, payload: async () => { return (await rejectProposalPayout(proposalId, milestoneId, reason)).data; }, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 83d990e5..2842b1f1 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -6,7 +6,7 @@ import { STATUS, PROPOSAL_ARBITER_STATUS, } from 'types'; -import { PROPOSAL_CATEGORY } from 'api/constants'; +import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; import BN from 'bn.js'; import moment from 'moment'; @@ -158,7 +158,7 @@ export function generateProposal({ title: 'Crowdfund Title', brief: 'A cool test crowdfund', content: 'body', - stage: 'FUNDING_REQUIRED', + stage: PROPOSAL_STAGE.WIP, category: PROPOSAL_CATEGORY.COMMUNITY, isStaked: true, arbiter: { diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 4832ad67..143d9033 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -1,5 +1,5 @@ import { Zat } from 'utils/units'; -import { PROPOSAL_CATEGORY } from 'api/constants'; +import { PROPOSAL_CATEGORY, PROPOSAL_STAGE } from 'api/constants'; import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types'; import { ProposalMilestone } from './milestone'; import { RFP } from './rfp'; @@ -36,7 +36,7 @@ export interface ProposalDraft { brief: string; category: PROPOSAL_CATEGORY; content: string; - stage: string; + stage: PROPOSAL_STAGE; target: string; payoutAddress: string; deadlineDuration: number; From 47f827693dd177613c569cff514606ad0d71dd86 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 13 Feb 2019 14:30:58 -0600 Subject: [PATCH 7/7] mileston payout emails + some bug fixes --- admin/src/components/Emails/emails.ts | 20 +++++++ backend/grant/admin/example_emails.py | 31 ++++++++++- backend/grant/admin/views.py | 12 ++++- backend/grant/email/send.py | 52 ++++++++++++++++++- backend/grant/proposal/views.py | 24 +++++++-- .../templates/emails/milestone_accept.html | 11 ++++ .../templates/emails/milestone_accept.txt | 6 +++ .../templates/emails/milestone_paid.html | 12 +++++ .../grant/templates/emails/milestone_paid.txt | 6 +++ .../templates/emails/milestone_reject.html | 21 ++++++++ .../templates/emails/milestone_reject.txt | 12 +++++ .../templates/emails/milestone_request.html | 33 ++++++++++++ .../templates/emails/milestone_request.txt | 4 ++ .../components/Proposal/Milestones/index.tsx | 39 +++++++++++++- frontend/client/modules/proposals/reducers.ts | 4 +- 15 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 backend/grant/templates/emails/milestone_accept.html create mode 100644 backend/grant/templates/emails/milestone_accept.txt create mode 100644 backend/grant/templates/emails/milestone_paid.html create mode 100644 backend/grant/templates/emails/milestone_paid.txt create mode 100644 backend/grant/templates/emails/milestone_reject.html create mode 100644 backend/grant/templates/emails/milestone_reject.txt create mode 100644 backend/grant/templates/emails/milestone_request.html create mode 100644 backend/grant/templates/emails/milestone_request.txt diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index 3574594f..0b119ff1 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -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[]; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index dfb58229..65b7215d 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -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', } } diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index b714f430..66de4099 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 5dd1ac2b..37d0254b 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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 } diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 54d2c545..5718c41c 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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 diff --git a/backend/grant/templates/emails/milestone_accept.html b/backend/grant/templates/emails/milestone_accept.html new file mode 100644 index 00000000..9eb78267 --- /dev/null +++ b/backend/grant/templates/emails/milestone_accept.html @@ -0,0 +1,11 @@ +

+ The proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} + + payout of {{ args.amount }} ZEC has been approved. +

+ +

+ You will receive payment shortly! +

diff --git a/backend/grant/templates/emails/milestone_accept.txt b/backend/grant/templates/emails/milestone_accept.txt new file mode 100644 index 00000000..a087f88d --- /dev/null +++ b/backend/grant/templates/emails/milestone_accept.txt @@ -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 }} diff --git a/backend/grant/templates/emails/milestone_paid.html b/backend/grant/templates/emails/milestone_paid.html new file mode 100644 index 00000000..f5d0aaf6 --- /dev/null +++ b/backend/grant/templates/emails/milestone_paid.html @@ -0,0 +1,12 @@ +

+ 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 }} + +

diff --git a/backend/grant/templates/emails/milestone_paid.txt b/backend/grant/templates/emails/milestone_paid.txt new file mode 100644 index 00000000..d2177a85 --- /dev/null +++ b/backend/grant/templates/emails/milestone_paid.txt @@ -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 }} diff --git a/backend/grant/templates/emails/milestone_reject.html b/backend/grant/templates/emails/milestone_reject.html new file mode 100644 index 00000000..31bb42b3 --- /dev/null +++ b/backend/grant/templates/emails/milestone_reject.html @@ -0,0 +1,21 @@ +

+ 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. +

diff --git a/backend/grant/templates/emails/milestone_reject.txt b/backend/grant/templates/emails/milestone_reject.txt new file mode 100644 index 00000000..ac6f2caf --- /dev/null +++ b/backend/grant/templates/emails/milestone_reject.txt @@ -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 }} diff --git a/backend/grant/templates/emails/milestone_request.html b/backend/grant/templates/emails/milestone_request.html new file mode 100644 index 00000000..4b0c5ef7 --- /dev/null +++ b/backend/grant/templates/emails/milestone_request.html @@ -0,0 +1,33 @@ +

+ 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 + +
+
diff --git a/backend/grant/templates/emails/milestone_request.txt b/backend/grant/templates/emails/milestone_request.txt new file mode 100644 index 00000000..3592a4ab --- /dev/null +++ b/backend/grant/templates/emails/milestone_request.txt @@ -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 }} diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 27d5de24..348729d1 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -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 { 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 = 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 = 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 = 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 = p => { content = others[p.stage](); } + // special warning if no arbiter is set for team members + if (!p.hasArbiter && p.isTeamMember) { + content = ( + + We are sorry for the inconvenience, but in order to have milestone payouts + reviewed an arbiter must be assigned. Please{' '} + + contact support + {' '} + for help. +

+ } + /> + ); + } + return ( <>
diff --git a/frontend/client/modules/proposals/reducers.ts b/frontend/client/modules/proposals/reducers.ts index 74d223d6..df14a1fd 100644 --- a/frontend/client/modules/proposals/reducers.ts +++ b/frontend/client/modules/proposals/reducers.ts @@ -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(), }, };