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.
}
+ p.requestPayout(p.proposalId, p.id)}>
+ {(p.immediatePayout && 'Request initial payout') || 'Request payout'}
+
+ >
+ ),
+ [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.
+
+ p.requestPayout(p.proposalId, p.id)}>
+ Request payout
+
+ >
+ ),
+ [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.
+
+ p.acceptPayout(p.proposalId, p.id)}>
+ Accept
+
+
+ p.rejectPayout(p.proposalId, p.id, 'Test reason. (TODO: modal w/ text input)')
+ }
+ >
+ Reject
+
+ >
+ ),
+ [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 {
this.acceptArbiter(false)}>Reject
>
),
- [PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}>,
+ [PAS.ACCEPTED]: (
+ <>
+ {isMsPayoutReq && (
+
+ Review Milestone
+
+ )}
+ >
+ ),
};
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.
}
- p.requestPayout(p.proposalId, p.id)}>
+ p.requestPayout(p.proposalId, p.id)} block>
{(p.immediatePayout && 'Request initial payout') || 'Request payout'}
>
),
[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.
-
- p.requestPayout(p.proposalId, p.id)}>
+ Payment Rejected
+ The request for payout was rejected for the following reason:
+ {p.rejectReason}
+ You may request payout again when you are ready.
+ p.requestPayout(p.proposalId, p.id)} block>
Request payout
>
),
[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.
- p.acceptPayout(p.proposalId, p.id)}>
- Accept
-
-
- p.rejectPayout(p.proposalId, p.id, 'Test reason. (TODO: modal w/ text input)')
- }
- >
- Reject
-
+
+ p.acceptPayout(p.proposalId, p.id)}>
+ Accept
+
+ p.showRejectPayout(p.id)}>
+ Reject
+
+
>
),
[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.
+
+
+
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(),
},
};