Merge pull request #194 from grant-project/zcash-milestones

Zcash milestones
This commit is contained in:
Daniel Ternyak 2019-02-14 15:37:12 -06:00 committed by GitHub
commit 90d5cae094
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1560 additions and 277 deletions

View File

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

View File

@ -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 (

View File

@ -37,4 +37,13 @@
max-width: 400px;
}
}
&-alert {
& pre {
margin: 1rem 0;
overflow: hidden;
word-break: break-all;
white-space: inherit;
}
}
}

View File

@ -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));

View File

@ -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) {

View File

@ -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;

View File

@ -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 = {

View File

@ -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,

63
admin/src/util/units.ts Normal file
View File

@ -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;

View File

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

View File

@ -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

View File

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

View File

@ -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()

View File

@ -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"])

View File

@ -1,4 +1,5 @@
from dateutil.parser import parse
from decimal import Decimal
from flask import Blueprint, g, request
from flask_yoloapi import endpoint, parameter
from grant.comment.models import Comment, comment_schema, comments_schema
@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
<p style="margin: 0 0 20px;">
A payout request for the proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
has been made. As arbiter, you are responsible for reviewing this request.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.proposal_milestones_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
Review the request
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

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

View File

@ -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):

View File

@ -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'

View File

@ -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

View File

@ -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:

View File

@ -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 ###

View File

@ -1,4 +1,4 @@
"""empty message
"""proposal_arbiter table
Revision ID: 86d300cb6d69
Revises: 310dca400b81
@ -17,23 +17,23 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('proposal_arbiter',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey')
op.drop_column('proposal', 'arbiter_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True))
op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id'])
op.drop_table('proposal_arbiter')

View File

@ -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,

View File

@ -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',

View File

@ -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 (

View File

@ -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);

View File

@ -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) => {
// 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>
);
// generate steps
const milestoneSteps = milestones.map((ms, i) => {
const status =
this.state.activeMilestoneIdx === i && milestone.state === WAITING
currentMilestone &&
currentMilestone.index === i &&
ms.stage === MILESTONE_STAGE.IDLE
? STEP_STATUS.PROCESS
: milestoneStateToStepState[milestone.state];
: milestoneStageToStepState[ms.stage];
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} />
);
const alertStyle = { width: 'fit-content', margin: '0 0 1rem 0' };
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: [],
};
})(ProposalMilestones);
// 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>
);
};
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 youll 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;

View File

@ -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,
};
}

View File

@ -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({

View File

@ -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,

View File

@ -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;

View File

@ -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 {
...m,
index,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
// TODO: Get data from backend
state: MILESTONE_STATE.WAITING,
isPaid: false,
};
const msToFe = (m: any) => ({
...m,
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
});
proposal.milestones = proposal.milestones.map(msToFe);
proposal.currentMilestone = msToFe(proposal.currentMilestone);
}
return proposal;
}

View File

@ -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>
// )),
// );

View File

@ -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: {

View File

@ -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;

View File

@ -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 {