Merge pull request #194 from grant-project/zcash-milestones
Zcash milestones
This commit is contained in:
commit
90d5cae094
|
@ -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[];
|
||||
|
|
|
@ -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.
|
||||
</div>
|
||||
),
|
||||
!!proposalMilestonePayoutsCount && (
|
||||
<div>
|
||||
<Icon type="exclamation-circle" /> There are{' '}
|
||||
<b>{proposalMilestonePayoutsCount}</b> proposals <b>with approved payouts</b>.{' '}
|
||||
<Link to="/proposals?filters[]=MILESTONE_ACCEPTED">Click here</Link> to view
|
||||
them.
|
||||
</div>
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
|
|
|
@ -37,4 +37,13 @@
|
|||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
&-alert {
|
||||
& pre {
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
white-space: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<any>;
|
||||
|
||||
const STATE = {
|
||||
showRejectModal: false,
|
||||
rejectReason: '',
|
||||
paidTxId: '',
|
||||
};
|
||||
|
||||
type State = typeof STATE;
|
||||
|
@ -68,7 +72,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
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<Props, State> {
|
|||
/>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Alert
|
||||
className="ProposalDetail-alert"
|
||||
showIcon
|
||||
type="warning"
|
||||
message={null}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
<b>
|
||||
Milestone {ms.index + 1} - {ms.title}
|
||||
</b>{' '}
|
||||
was accepted on {formatDateSeconds(ms.dateAccepted)}.
|
||||
</p>
|
||||
<p>
|
||||
{' '}
|
||||
Please make a payment of <b>{amount.toString()} ZEC</b> to:
|
||||
</p>{' '}
|
||||
<pre>{p.payoutAddress}</pre>
|
||||
<Input.Search
|
||||
placeholder="please enter payment txid"
|
||||
value={this.state.paidTxId}
|
||||
enterButton="Mark Paid"
|
||||
onChange={e => this.setState({ paidTxId: e.target.value })}
|
||||
onSearch={this.handlePaidMilestone}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDeetItem = (name: string, val: any) => (
|
||||
<div className="ProposalDetail-deet">
|
||||
<span>{name}</span>
|
||||
|
@ -264,6 +316,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderRejected()}
|
||||
{renderNominateArbiter()}
|
||||
{renderNominatedArbiter()}
|
||||
{renderMilestoneAccepted()}
|
||||
<Collapse defaultActiveKey={['brief', 'content']}>
|
||||
<Collapse.Panel key="brief" header="brief">
|
||||
{p.brief}
|
||||
|
@ -295,12 +348,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{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<Props, State> {
|
|||
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));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
RFP_STATUS,
|
||||
CONTRIBUTION_STATUS,
|
||||
PROPOSAL_ARBITER_STATUS,
|
||||
MILESTONE_STAGE,
|
||||
} from 'src/types';
|
||||
|
||||
export interface StatusSoT<E> {
|
||||
|
@ -12,6 +13,39 @@ export interface StatusSoT<E> {
|
|||
hint: string;
|
||||
}
|
||||
|
||||
export const MILESTONE_STAGES: Array<StatusSoT<MILESTONE_STAGE>> = [
|
||||
{
|
||||
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<StatusSoT<PROPOSAL_STATUS>> = [
|
||||
{
|
||||
id: PROPOSAL_STATUS.APPROVED,
|
||||
|
|
|
@ -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;
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,12 +14,14 @@ 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 grant.settings import EXPLORER_URL
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from .example_emails import example_email_args
|
||||
|
@ -66,11 +68,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 +261,44 @@ 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/<id>/milestone/<mid>/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)
|
||||
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
|
||||
|
||||
|
||||
# EMAIL
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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, MilestoneStage.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.ACCEPTED
|
||||
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()
|
||||
|
|
|
@ -10,8 +10,15 @@ 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, ProposalArbiterStatus
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||
from grant.utils.enums import (
|
||||
ProposalStatus,
|
||||
ProposalStage,
|
||||
Category,
|
||||
ContributionStatus,
|
||||
ProposalArbiterStatus,
|
||||
MilestoneStage
|
||||
)
|
||||
|
||||
proposal_team = db.Table(
|
||||
'proposal_team', db.Model.metadata,
|
||||
|
@ -214,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", 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")
|
||||
|
||||
|
@ -224,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
|
||||
|
@ -394,6 +402,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):
|
||||
|
@ -418,6 +427,19 @@ 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:
|
||||
for ms in self.milestones:
|
||||
if ms.stage != MilestoneStage.PAID:
|
||||
return ms
|
||||
return self.milestones[-1] # return last one if all PAID
|
||||
return None
|
||||
|
||||
|
||||
class ProposalSchema(ma.Schema):
|
||||
class Meta:
|
||||
|
@ -441,6 +463,7 @@ class ProposalSchema(ma.Schema):
|
|||
"comments",
|
||||
"updates",
|
||||
"milestones",
|
||||
"current_milestone",
|
||||
"category",
|
||||
"team",
|
||||
"payout_address",
|
||||
|
@ -460,6 +483,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("ProposalArbiterSchema", exclude=["proposal"])
|
||||
|
|
|
@ -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
|
||||
|
@ -10,13 +11,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
|
||||
|
@ -213,14 +215,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)
|
||||
|
||||
|
@ -480,14 +483,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 & 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)
|
||||
db.session.commit()
|
||||
db.session.flush()
|
||||
|
||||
# email progress of staking, partial or complete
|
||||
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
|
||||
|
@ -519,7 +522,13 @@ 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.WIP
|
||||
db.session.add(contribution.proposal)
|
||||
db.session.flush()
|
||||
|
||||
db.session.commit()
|
||||
return None, 200
|
||||
|
||||
|
||||
|
@ -542,3 +551,76 @@ def delete_proposal_contribution(contribution_id):
|
|||
db.session.add(contribution)
|
||||
db.session.commit()
|
||||
return None, 202
|
||||
|
||||
|
||||
# request MS payout
|
||||
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/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):
|
||||
ms.request_payout(g.current_user.id)
|
||||
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
|
||||
|
||||
|
||||
# accept MS payout (arbiter)
|
||||
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/accept", methods=["PUT"])
|
||||
@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):
|
||||
ms.accept_request(g.current_user.id)
|
||||
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
|
||||
|
||||
|
||||
# reject MS payout (arbiter) (reason)
|
||||
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/reject", methods=["PUT"])
|
||||
@requires_arbiter_auth
|
||||
@endpoint.api(
|
||||
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):
|
||||
ms.reject_request(g.current_user.id, reason)
|
||||
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
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
payout of <b>{{ args.amount }} ZEC</b> has been approved.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0;">
|
||||
You will receive payment shortly!
|
||||
</p>
|
|
@ -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 }}
|
|
@ -0,0 +1,12 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} </a
|
||||
>! You can view the transaction below:
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 20px;">
|
||||
<a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
|
||||
{{ args.tx_explorer_url }}
|
||||
</a>
|
||||
</p>
|
|
@ -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 }}
|
|
@ -0,0 +1,21 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The payout request for proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
has been rejected.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
The following reason was provided:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0;">
|
||||
Another request for payment can be made when the above concerns have been
|
||||
addressed.
|
||||
</p>
|
|
@ -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 }}
|
|
@ -0,0 +1,33 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
A payout request for the proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
has been made. As arbiter, you are responsible for reviewing this request.
|
||||
</p>
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="border-radius: 3px;"
|
||||
bgcolor="{{ UI.PRIMARY }}"
|
||||
>
|
||||
<a
|
||||
href="{{ args.proposal_milestones_url }}"
|
||||
target="_blank"
|
||||
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||
UI.PRIMARY
|
||||
}}; display: inline-block;"
|
||||
>
|
||||
Review the request
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
|
@ -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 }}
|
|
@ -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 <proposal_id>"), 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):
|
||||
|
|
|
@ -33,7 +33,9 @@ ProposalSort = ProposalSortEnum()
|
|||
|
||||
|
||||
class ProposalStageEnum(CustomEnum):
|
||||
PREVIEW = 'PREVIEW'
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||
WIP = 'WIP'
|
||||
COMPLETED = 'COMPLETED'
|
||||
|
||||
|
||||
|
@ -69,6 +71,17 @@ class RFPStatusEnum(CustomEnum):
|
|||
RFPStatus = RFPStatusEnum()
|
||||
|
||||
|
||||
class MilestoneStageEnum(CustomEnum):
|
||||
IDLE = 'IDLE'
|
||||
REQUESTED = 'REQUESTED'
|
||||
REJECTED = 'REJECTED'
|
||||
ACCEPTED = 'ACCEPTED'
|
||||
PAID = 'PAID'
|
||||
|
||||
|
||||
MilestoneStage = MilestoneStageEnum()
|
||||
|
||||
|
||||
class ProposalArbiterStatusEnum(CustomEnum):
|
||||
MISSING = 'MISSING'
|
||||
NOMINATED = 'NOMINATED'
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
"""milestone payment fields
|
||||
|
||||
Revision ID: 3793d9a71e27
|
||||
Revises: 86d300cb6d69
|
||||
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 = '86d300cb6d69'
|
||||
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 ###
|
|
@ -1,4 +1,4 @@
|
|||
"""empty message
|
||||
"""proposal_arbiter table
|
||||
|
||||
Revision ID: 86d300cb6d69
|
||||
Revises: 310dca400b81
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -51,6 +51,7 @@ export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: CategoryUI } = {
|
|||
};
|
||||
|
||||
export enum PROPOSAL_STAGE {
|
||||
PREVIEW = 'PREVIEW',
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
||||
WIP = 'WIP',
|
||||
COMPLETED = 'COMPLETED',
|
||||
|
@ -62,6 +63,10 @@ interface StageUI {
|
|||
}
|
||||
|
||||
export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
|
||||
PREVIEW: {
|
||||
label: 'Preview',
|
||||
color: '#8e44ad',
|
||||
},
|
||||
FUNDING_REQUIRED: {
|
||||
label: 'Funding required',
|
||||
color: '#8e44ad',
|
||||
|
|
|
@ -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<Props, {}> {
|
||||
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 <b>{msTitle}</b>{' '}
|
||||
{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.{' '}
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<br />
|
||||
You may{' '}
|
||||
<Popconfirm
|
||||
title="Stop acting as arbiter?"
|
||||
onConfirm={() => this.acceptArbiter(false)}
|
||||
|
@ -57,7 +75,15 @@ class ProfileArbitrated extends React.Component<Props, {}> {
|
|||
<Button onClick={() => this.acceptArbiter(false)}>Reject</Button>
|
||||
</>
|
||||
),
|
||||
[PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}</>,
|
||||
[PAS.ACCEPTED]: (
|
||||
<>
|
||||
{isMsPayoutReq && (
|
||||
<Link to={`/proposals/${proposalId}?tab=milestones`}>
|
||||
<Button type="primary">Review Milestone</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -39,6 +39,11 @@
|
|||
margin-left: 0;
|
||||
}
|
||||
|
||||
&-alert {
|
||||
width: fit-content;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
|
@ -97,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);
|
|
@ -1,17 +1,27 @@
|
|||
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_STATE } from 'types';
|
||||
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,
|
||||
PROPOSAL_ARBITER_STATUS,
|
||||
} 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 './style.less';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
|
||||
const { WAITING, ACTIVE, PAID, REJECTED } = MILESTONE_STATE;
|
||||
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',
|
||||
|
@ -20,53 +30,68 @@ enum STEP_STATUS {
|
|||
ERROR = 'error',
|
||||
}
|
||||
|
||||
const milestoneStateToStepState = {
|
||||
[WAITING]: STEP_STATUS.WAIT,
|
||||
[ACTIVE]: STEP_STATUS.PROCESS,
|
||||
[PAID]: STEP_STATUS.FINISH,
|
||||
[REJECTED]: STEP_STATUS.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 fmtDate = (n: undefined | number) =>
|
||||
(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: 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;
|
||||
activeMilestoneIdx: number;
|
||||
doTitlesOverflow: boolean;
|
||||
showRejectModal: boolean;
|
||||
rejectReason: string;
|
||||
rejectMilestoneId: number;
|
||||
}
|
||||
|
||||
class ProposalMilestones extends React.Component<Props, State> {
|
||||
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
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 = lodash.throttle(
|
||||
this.updateDoTitlesOverflow,
|
||||
500,
|
||||
);
|
||||
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 activeMilestoneIdx = this.getActiveMilestoneIdx();
|
||||
this.setState({ step: activeMilestoneIdx, activeMilestoneIdx });
|
||||
}
|
||||
this.updateDoTitlesOverflow();
|
||||
window.addEventListener('resize', this.throttledUpdateDoTitlesOverflow);
|
||||
}
|
||||
|
@ -74,123 +99,116 @@ class ProposalMilestones extends React.Component<Props, State> {
|
|||
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,
|
||||
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 } = this.props;
|
||||
const { proposal, requestPayout, acceptPayout, rejectPayout } = this.props;
|
||||
const { rejectReason, showRejectModal } = this.state;
|
||||
if (!proposal) {
|
||||
return <Loader />;
|
||||
}
|
||||
const { milestones } = proposal;
|
||||
|
||||
const isTrustee = false; // TODO: Replace with being on the team
|
||||
const { milestones, currentMilestone, isRejectingPayout } = proposal;
|
||||
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 className = this.state.step === i ? 'is-active' : 'is-inactive';
|
||||
const estimatedDate = moment(milestone.dateEstimated * 1000).format('MMMM YYYY');
|
||||
const reward = (
|
||||
<UnitDisplay value={milestone.amount} symbol="ZEC" displayShortBalance={4} />
|
||||
// arbiter reject modal
|
||||
const rejectModal = (
|
||||
<Modal
|
||||
visible={showRejectModal}
|
||||
title="Reject this milestone payout"
|
||||
onOk={this.handleReject}
|
||||
onCancel={() => this.setState({ showRejectModal: false })}
|
||||
okButtonProps={{
|
||||
disabled: rejectReason.length === 0,
|
||||
loading: isRejectingPayout,
|
||||
}}
|
||||
cancelButtonProps={{
|
||||
loading: isRejectingPayout,
|
||||
}}
|
||||
>
|
||||
Please provide a reason:
|
||||
<Input.TextArea
|
||||
ref={ta => (this.rejectInput = ta)}
|
||||
rows={4}
|
||||
maxLength={250}
|
||||
required={true}
|
||||
value={rejectReason}
|
||||
onChange={e => {
|
||||
this.setState({ rejectReason: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' };
|
||||
|
||||
// generate steps
|
||||
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: <div ref={this.stepTitleRefs[i]}>{milestone.title}</div>,
|
||||
title: <div ref={this.stepTitleRefs[i]}>{ms.title}</div>,
|
||||
status,
|
||||
className,
|
||||
onClick: () => this.setState({ step: i }),
|
||||
};
|
||||
|
||||
let notification;
|
||||
|
||||
switch (milestone.state) {
|
||||
case PAID:
|
||||
notification = (
|
||||
<Alert
|
||||
type="success"
|
||||
message={
|
||||
<span>
|
||||
The team was awarded <strong>{reward}</strong>{' '}
|
||||
{milestone.immediatePayout
|
||||
? 'as an initial payout'
|
||||
: // TODO: Add property for payout date on milestones
|
||||
`on ${moment().format('MMM Do, YYYY')}`}
|
||||
.
|
||||
</span>
|
||||
}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case ACTIVE:
|
||||
notification = (
|
||||
<Alert
|
||||
type="info"
|
||||
message={`
|
||||
The team has requested a payout for this milestone. It is
|
||||
currently under review.
|
||||
`}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case REJECTED:
|
||||
notification = (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
}
|
||||
style={alertStyle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const statuses = (
|
||||
<div className="ProposalMilestones-milestone-status">
|
||||
{!milestone.immediatePayout && (
|
||||
<div>
|
||||
Estimate: <strong>{estimatedDate}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Reward: <strong>{reward}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="ProposalMilestones-milestone">
|
||||
<div className="ProposalMilestones-milestone-body">
|
||||
<div className="ProposalMilestones-milestone-description">
|
||||
<h3 className="ProposalMilestones-milestone-title">{milestone.title}</h3>
|
||||
{statuses}
|
||||
{notification}
|
||||
{milestone.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return { key: i, stepProps, content };
|
||||
return { key: i, stepProps };
|
||||
});
|
||||
|
||||
const stepSize = milestoneCount > 5 ? 'small' : 'default';
|
||||
const activeMilestone = proposal.milestones[this.state.step];
|
||||
const activeIsCurrent = activeMilestone.id === proposal.currentMilestone!.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -198,7 +216,6 @@ class ProposalMilestones extends React.Component<Props, State> {
|
|||
className={classnames({
|
||||
['ProposalMilestones']: true,
|
||||
['do-titles-overflow']: this.state.doTitlesOverflow,
|
||||
[`is-count-${milestoneCount}`]: true,
|
||||
})}
|
||||
>
|
||||
{!!milestoneSteps.length ? (
|
||||
|
@ -208,7 +225,22 @@ class ProposalMilestones extends React.Component<Props, State> {
|
|||
<Steps.Step key={mss.key} {...mss.stepProps} />
|
||||
))}
|
||||
</Steps>
|
||||
{milestoneSteps[this.state.step].content}
|
||||
<Milestone
|
||||
isFunded={[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(
|
||||
proposal.stage,
|
||||
)}
|
||||
proposalId={proposal.proposalId}
|
||||
showRejectPayout={this.handleShowRejectPayout}
|
||||
{...{ requestPayout, acceptPayout, rejectPayout }}
|
||||
{...activeMilestone}
|
||||
isCurrent={activeIsCurrent}
|
||||
isTeamMember={proposal.isTeamMember || false}
|
||||
isArbiter={proposal.isArbiter || false}
|
||||
hasArbiter={
|
||||
!!proposal.arbiter.user &&
|
||||
proposal.arbiter.status === PROPOSAL_ARBITER_STATUS.ACCEPTED
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Placeholder
|
||||
|
@ -216,21 +248,26 @@ class ProposalMilestones extends React.Component<Props, State> {
|
|||
subtitle="The creator of this proposal has not setup any milestones"
|
||||
/>
|
||||
)}
|
||||
{rejectModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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 = () => {
|
||||
|
@ -268,11 +305,281 @@ class ProposalMilestones extends React.Component<Props, State> {
|
|||
};
|
||||
}
|
||||
|
||||
const ConnectedProposalMilestones = connect((state: AppState) => {
|
||||
console.warn('TODO - new redux accounts/user-role-for-proposal', state);
|
||||
return {
|
||||
accounts: [],
|
||||
// Milestone
|
||||
type MSProps = ProposalMilestone & DispatchProps;
|
||||
interface MilestoneProps extends MSProps {
|
||||
showRejectPayout: (milestoneId: number) => void;
|
||||
isTeamMember: boolean;
|
||||
isArbiter: boolean;
|
||||
hasArbiter: boolean;
|
||||
isCurrent: boolean;
|
||||
proposalId: number;
|
||||
isFunded: boolean;
|
||||
}
|
||||
const Milestone: React.SFC<MilestoneProps> = p => {
|
||||
const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY');
|
||||
const reward = <UnitDisplay value={p.amount} symbol="ZEC" displayShortBalance={4} />;
|
||||
const getAlertProps = {
|
||||
[MILESTONE_STAGE.IDLE]: () => null,
|
||||
[MILESTONE_STAGE.REQUESTED]: () => ({
|
||||
type: 'info',
|
||||
message: (
|
||||
<>
|
||||
The team requested a payout for this milestone {fmtDateFromNow(p.dateRequested)}
|
||||
. It is currently under review.
|
||||
</>
|
||||
),
|
||||
}),
|
||||
[MILESTONE_STAGE.REJECTED]: () => ({
|
||||
type: 'warning',
|
||||
message: (
|
||||
<span>
|
||||
Payout for this milestone was rejected {fmtDateFromNow(p.dateRejected)}.
|
||||
{p.isTeamMember ? ' You ' : ' The team '} can request another review for payout
|
||||
at any time.
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
[MILESTONE_STAGE.ACCEPTED]: () => ({
|
||||
type: 'info',
|
||||
message: (
|
||||
<span>
|
||||
Payout for this milestone was accepted {fmtDateFromNow(p.dateAccepted)}.{' '}
|
||||
<strong>{reward}</strong> will be sent to{' '}
|
||||
{p.isTeamMember ? ' you ' : ' the team '} soon.
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
[MILESTONE_STAGE.PAID]: () => ({
|
||||
type: 'success',
|
||||
message: (
|
||||
<span>
|
||||
The team was awarded <strong>{reward}</strong>{' '}
|
||||
{p.immediatePayout && ` as an initial payout `} on {fmtDate(p.datePaid)}.
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
} as { [key in MILESTONE_STAGE]: () => AlertProps | null };
|
||||
|
||||
const alertProps = getAlertProps[p.stage]();
|
||||
|
||||
return (
|
||||
<div className="ProposalMilestones-milestone">
|
||||
<div className="ProposalMilestones-milestone-body">
|
||||
<div className="ProposalMilestones-milestone-description">
|
||||
<h3 className="ProposalMilestones-milestone-title">{p.title}</h3>
|
||||
<div className="ProposalMilestones-milestone-status">
|
||||
{!p.immediatePayout && (
|
||||
<div>
|
||||
Estimate: <strong>{estimatedDate}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Reward: <strong>{reward}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{alertProps && (
|
||||
<Alert {...alertProps} className="ProposalMilestones-milestone-alert" />
|
||||
)}
|
||||
{p.content}
|
||||
</div>
|
||||
<MilestoneAction {...p} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
})(ProposalMilestones);
|
||||
|
||||
const MilestoneAction: React.SFC<MilestoneProps> = p => {
|
||||
if (!p.isCurrent || !p.isFunded || p.stage === MILESTONE_STAGE.PAID) {
|
||||
return null;
|
||||
}
|
||||
if (!p.hasArbiter && !p.isTeamMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TEAM INFO
|
||||
const team = {
|
||||
[MILESTONE_STAGE.IDLE]: () => (
|
||||
<>
|
||||
<h3>Payment Request</h3>
|
||||
{p.immediatePayout && (
|
||||
<p>
|
||||
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>
|
||||
)}
|
||||
{!p.immediatePayout &&
|
||||
p.index === 0 && (
|
||||
<p>
|
||||
Congratulations on getting funded! Click below to request your first
|
||||
milestone payout.
|
||||
</p>
|
||||
)}
|
||||
{!p.immediatePayout &&
|
||||
p.index > 0 && <p>You can request a payment for this milestone.</p>}
|
||||
<Button type="primary" onClick={() => p.requestPayout(p.proposalId, p.id)} block>
|
||||
{(p.immediatePayout && 'Request initial payout') || 'Request payout'}
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.REQUESTED]: () => (
|
||||
<>
|
||||
<h3>Payment Requested</h3>
|
||||
<p>
|
||||
The milestone payout was requested on {fmtDate(p.dateRequested)}. You will be
|
||||
notified when it has been reviewed.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.REJECTED]: () => (
|
||||
<>
|
||||
<h3>Payment Rejected</h3>
|
||||
<p>The request for payout was rejected for the following reason:</p>
|
||||
<q>{p.rejectReason}</q>
|
||||
<p>You may request payout again when you are ready.</p>
|
||||
<Button type="primary" onClick={() => p.requestPayout(p.proposalId, p.id)} block>
|
||||
Request payout
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.ACCEPTED]: () => (
|
||||
<>
|
||||
<h3>Awaiting Payment</h3>
|
||||
<p>
|
||||
Payout approved on {fmtDate(p.dateAccepted)}! You will receive payment shortly.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.PAID]: () => <></>,
|
||||
} as { [key in MILESTONE_STAGE]: () => ReactNode };
|
||||
|
||||
// OUTSIDERS/OTHERS INFO
|
||||
const others = {
|
||||
[MILESTONE_STAGE.IDLE]: () => (
|
||||
<>
|
||||
<h3>Payment Request</h3>
|
||||
<p>The team may request a payout for this milestone at any time.</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.REQUESTED]: () => (
|
||||
<>
|
||||
<h3>Payment Requested</h3>
|
||||
<p>
|
||||
The team requested a payout on {fmtDate(p.dateRequested)}, and awaits approval.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.REJECTED]: () => (
|
||||
<>
|
||||
<h3>Payment Rejected</h3>
|
||||
<p>
|
||||
The payout request was denied on {fmtDate(p.dateRejected)} for the following
|
||||
reason:
|
||||
</p>
|
||||
<q>{p.rejectReason}</q>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.ACCEPTED]: () => (
|
||||
<>
|
||||
<h3>Awaiting Payment</h3>
|
||||
<p>The payout request was approved on {fmtDate(p.dateAccepted)}.</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.PAID]: () => <></>,
|
||||
} as { [key in MILESTONE_STAGE]: () => ReactNode };
|
||||
|
||||
// ARBITER INFO
|
||||
const arbiter = {
|
||||
[MILESTONE_STAGE.IDLE]: () => (
|
||||
<>
|
||||
<h3>Payment Request</h3>
|
||||
<p>
|
||||
The team may request a payout for this milestone at any time. As arbiter you
|
||||
will be responsible for reviewing these requests.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.REQUESTED]: () => (
|
||||
<>
|
||||
<h3>Payment Requested</h3>
|
||||
<p>
|
||||
The team requested a payout on {fmtDate(p.dateRequested)}, and awaits your
|
||||
approval.
|
||||
</p>
|
||||
<div className="ProposalMilestones-milestone-action-controls">
|
||||
<Button type="primary" onClick={() => p.acceptPayout(p.proposalId, p.id)}>
|
||||
Accept
|
||||
</Button>
|
||||
<Button type="danger" onClick={() => p.showRejectPayout(p.id)}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.REJECTED]: () => (
|
||||
<>
|
||||
<h3>Payment Rejected</h3>
|
||||
<p>
|
||||
You rejected this payment request on {fmtDate(p.dateRejected)} for the following
|
||||
reason:
|
||||
</p>
|
||||
<q>{p.rejectReason}</q>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.ACCEPTED]: () => (
|
||||
<>
|
||||
<h3>Awaiting Payment</h3>
|
||||
<p>You approved this payment request on {fmtDate(p.dateAccepted)}.</p>
|
||||
</>
|
||||
),
|
||||
[MILESTONE_STAGE.PAID]: () => <></>,
|
||||
} as { [key in MILESTONE_STAGE]: () => ReactNode };
|
||||
|
||||
let content = null;
|
||||
if (p.isTeamMember) {
|
||||
content = team[p.stage]();
|
||||
} else if (p.isArbiter) {
|
||||
content = arbiter[p.stage]();
|
||||
} else {
|
||||
content = others[p.stage]();
|
||||
}
|
||||
|
||||
// special warning if no arbiter is set for team members
|
||||
if (!p.hasArbiter && p.isTeamMember) {
|
||||
content = (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Arbiter not assigned"
|
||||
description={
|
||||
<p>
|
||||
We are sorry for the inconvenience, but in order to have milestone payouts
|
||||
reviewed an arbiter must be assigned. Please{' '}
|
||||
<Link target="_blank" to="/contact">
|
||||
contact support
|
||||
</Link>{' '}
|
||||
for help.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ProposalMilestones-milestone-divider" />
|
||||
<div className="ProposalMilestones-milestone-action">{content}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectedProposalMilestones = connect<{}, DispatchProps, OwnProps, AppState>(
|
||||
undefined,
|
||||
{
|
||||
requestPayout: proposalActions.requestPayout,
|
||||
acceptPayout: proposalActions.acceptPayout,
|
||||
rejectPayout: proposalActions.rejectPayout,
|
||||
},
|
||||
)(ProposalMilestones);
|
||||
|
||||
export default ConnectedProposalMilestones;
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { ProposalDraft, CreateMilestone, STATUS, PROPOSAL_ARBITER_STATUS } from 'types';
|
||||
import {
|
||||
ProposalDraft,
|
||||
CreateMilestone,
|
||||
STATUS,
|
||||
MILESTONE_STAGE,
|
||||
PROPOSAL_ARBITER_STATUS,
|
||||
} from 'types';
|
||||
import { User } from 'types';
|
||||
import { getAmountError, isValidAddress } from 'utils/validators';
|
||||
import { MILESTONE_STATE, Proposal } from 'types';
|
||||
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,
|
||||
} from 'modules/proposals/reducers';
|
||||
|
||||
export const TARGET_ZEC_LIMIT = 1000;
|
||||
|
||||
|
@ -170,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);
|
||||
|
||||
|
@ -189,22 +198,23 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
|
|||
funded: Zat('0'),
|
||||
contributionMatching: 0,
|
||||
percentFunded: 0,
|
||||
stage: 'preview',
|
||||
stage: PROPOSAL_STAGE.PREVIEW,
|
||||
category: draft.category || PROPOSAL_CATEGORY.CORE_DEV,
|
||||
isStaked: true,
|
||||
arbiter: {
|
||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||
},
|
||||
milestones: draft.milestones.map((m, idx) => ({
|
||||
id: idx,
|
||||
index: idx,
|
||||
title: m.title,
|
||||
content: m.content,
|
||||
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,
|
||||
})),
|
||||
...PROPOSAL_DETAIL_INITIAL_STATE,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
@ -14,6 +17,52 @@ import { getProposalPageSettings } from './selectors';
|
|||
|
||||
type GetState = () => AppState;
|
||||
|
||||
function addProposalUserRoles(p: Proposal, state: AppState) {
|
||||
if (state.auth.user) {
|
||||
const authUserId = state.auth.user.userid;
|
||||
if (p.arbiter.user) {
|
||||
p.isArbiter = p.arbiter.user.userid === authUserId;
|
||||
}
|
||||
if (p.team.find(t => t.userid === authUserId)) {
|
||||
p.isTeamMember = true;
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function requestPayout(proposalId: number, milestoneId: number) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
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<any>) => {
|
||||
return dispatch({
|
||||
type: types.PROPOSAL_PAYOUT_ACCEPT,
|
||||
payload: async () => {
|
||||
return (await acceptProposalPayout(proposalId, milestoneId)).data;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function rejectPayout(proposalId: number, milestoneId: number, reason: string) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
return dispatch({
|
||||
type: types.PROPOSAL_PAYOUT_REJECT,
|
||||
payload: async () => {
|
||||
return (await rejectProposalPayout(proposalId, milestoneId, reason)).data;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// change page, sort, filter, search
|
||||
export function setProposalPage(pageParams: Partial<ProposalPageParams>) {
|
||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||
|
@ -49,7 +98,7 @@ export function fetchProposals() {
|
|||
|
||||
export type TFetchProposal = typeof fetchProposal;
|
||||
export function fetchProposal(proposalId: Proposal['proposalId']) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||
dispatch({
|
||||
type: types.PROPOSAL_DATA_PENDING,
|
||||
payload: { proposalId },
|
||||
|
@ -58,7 +107,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({
|
||||
|
|
|
@ -10,10 +10,19 @@ 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;
|
||||
|
||||
detail: null | Proposal;
|
||||
detail: null | ProposalDetail;
|
||||
isFetchingDetail: boolean;
|
||||
detailError: null | string;
|
||||
|
||||
|
@ -36,6 +45,15 @@ export interface ProposalState {
|
|||
deleteContributionError: null | string;
|
||||
}
|
||||
|
||||
export const PROPOSAL_DETAIL_INITIAL_STATE = {
|
||||
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,
|
||||
acceptPayoutError: '',
|
||||
},
|
||||
};
|
||||
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,
|
||||
acceptPayoutError: (payload && payload.message) || payload.toString(),
|
||||
},
|
||||
};
|
||||
|
||||
case types.PROPOSAL_COMMENTS_PENDING:
|
||||
return {
|
||||
...state,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
PageParams,
|
||||
UserProposal,
|
||||
RFP,
|
||||
MILESTONE_STATE,
|
||||
ProposalPage,
|
||||
} from 'types';
|
||||
import { UserState } from 'modules/users/reducers';
|
||||
|
@ -91,16 +90,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 {
|
||||
const msToFe = (m: any) => ({
|
||||
...m,
|
||||
index,
|
||||
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
|
||||
// TODO: Get data from backend
|
||||
state: MILESTONE_STATE.WAITING,
|
||||
isPaid: false,
|
||||
};
|
||||
});
|
||||
proposal.milestones = proposal.milestones.map(msToFe);
|
||||
proposal.currentMilestone = msToFe(proposal.currentMilestone);
|
||||
}
|
||||
return proposal;
|
||||
}
|
||||
|
|
|
@ -5,25 +5,25 @@ 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';
|
||||
|
||||
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
|
||||
|
@ -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({
|
||||
|
@ -169,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`, () => (
|
||||
<div key={idx} style={{ padding: '3em', display: 'flex' }}>
|
||||
<Provider store={storeOutsider}>
|
||||
<Milestones {...gc} />
|
||||
</Provider>
|
||||
</div>
|
||||
)),
|
||||
);
|
||||
// geometryCases.forEach((gc, idx) =>
|
||||
// geometryStories.add(`${idx + 1} steps`, () => (
|
||||
// <div key={idx} style={{ padding: '3em', display: 'flex' }}>
|
||||
// <Provider store={storeOutsider}>
|
||||
// <Milestones {...gc} />
|
||||
// </Provider>
|
||||
// </div>
|
||||
// )),
|
||||
// );
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import {
|
||||
Contributor,
|
||||
Milestone,
|
||||
MILESTONE_STATE,
|
||||
MILESTONE_STAGE,
|
||||
Proposal,
|
||||
ProposalMilestone,
|
||||
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';
|
||||
|
||||
|
@ -41,7 +40,7 @@ export function generateProposal({
|
|||
funded?: number;
|
||||
created?: number;
|
||||
deadline?: number;
|
||||
milestoneOverrides?: Array<Partial<Milestone>>;
|
||||
milestoneOverrides?: Array<Partial<ProposalMilestone>>;
|
||||
contributorOverrides?: Array<Partial<Contributor>>;
|
||||
milestoneCount?: number;
|
||||
}) {
|
||||
|
@ -110,15 +109,15 @@ 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.`,
|
||||
dateEstimated: moment().unix(),
|
||||
immediatePayout: true,
|
||||
index: 0,
|
||||
state: MILESTONE_STATE.WAITING,
|
||||
stage: MILESTONE_STAGE.IDLE,
|
||||
amount: amountBn,
|
||||
isPaid: false,
|
||||
payoutPercent: '33',
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
|
@ -126,6 +125,7 @@ export function generateProposal({
|
|||
|
||||
const milestones = [...Array(milestoneCount).keys()].map(i => {
|
||||
const overrides = {
|
||||
id: i,
|
||||
index: i,
|
||||
title: genMilestoneTitle(),
|
||||
immediatePayout: i === 0,
|
||||
|
@ -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: {
|
||||
|
|
|
@ -7,16 +7,31 @@ 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;
|
||||
rejectReason?: string;
|
||||
paidTxId?: string;
|
||||
}
|
||||
|
||||
export interface ProposalMilestone extends Milestone {
|
||||
id: number;
|
||||
content: string;
|
||||
payoutPercent: string;
|
||||
title: string;
|
||||
|
|
|
@ -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;
|
||||
|
@ -56,9 +56,12 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
|||
percentFunded: number;
|
||||
contributionMatching: number;
|
||||
milestones: ProposalMilestone[];
|
||||
currentMilestone?: ProposalMilestone;
|
||||
datePublished: number | null;
|
||||
dateApproved: number | null;
|
||||
arbiter: ProposalProposalArbiter;
|
||||
isTeamMember?: boolean; // FE derived
|
||||
isArbiter?: boolean; // FE derived
|
||||
}
|
||||
|
||||
export interface TeamInviteWithProposal extends TeamInvite {
|
||||
|
|
Loading…
Reference in New Issue