Proposal Lifecycle & Crowdfunding (#23)
* add proposal versioning * remove deadlines * update proposal lifecycle for admin * update proposal lifecycle for backend * update proposal lifecycle for frontend * fix tests * remove acceptedWithFunding * fix lint, remove commented code * remove commented code * refactor backend to provide isVersionTwo * refactor backend to provide isVersionTwo * Revert "refactor backend to provide isVersionTwo" This reverts commit e3b9bc661081e482326f83fa6aa517cf6bdebe6c. * use isVersionTwo in admin * add acceptedWithFunding * trigger ci * remove "version" * remove "version" * remove rejected from campaign block
This commit is contained in:
parent
701a2f95a9
commit
fb6b9b5af7
|
@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
}, 1000);
|
||||
|
||||
render() {
|
||||
const { arbiter } = this.props;
|
||||
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
|
||||
const { showSearch, searching } = this.state;
|
||||
const { results, search, error } = store.arbitersSearch;
|
||||
const showEmpty = !results.length && !searching;
|
||||
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
|
||||
|
||||
const disp = {
|
||||
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
||||
|
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
type="primary"
|
||||
onClick={this.handleShowSearch}
|
||||
{...this.props.buttonProps}
|
||||
disabled={buttonDisabled}
|
||||
>
|
||||
{disp[arbiter.status]}
|
||||
</Button>
|
||||
|
|
|
@ -27,8 +27,9 @@
|
|||
.ant-collapse {
|
||||
margin-bottom: 16px;
|
||||
|
||||
button + button {
|
||||
margin-left: 0.5rem;
|
||||
button {
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
Collapse,
|
||||
Popconfirm,
|
||||
Input,
|
||||
Switch,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
|
@ -26,7 +25,6 @@ import {
|
|||
} 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 { toZat, fromZat } from 'src/util/units';
|
||||
|
@ -65,6 +63,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
||||
}, 100);
|
||||
|
||||
const { isVersionTwo } = p
|
||||
const shouldShowArbiter =
|
||||
!isVersionTwo ||
|
||||
(isVersionTwo && p.acceptedWithFunding === true);
|
||||
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund'
|
||||
|
||||
const renderCancelControl = () => {
|
||||
const disabled = this.getCancelAndRefundDisabled();
|
||||
|
||||
|
@ -95,7 +99,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
disabled={disabled}
|
||||
block
|
||||
>
|
||||
Cancel & refund
|
||||
{ cancelButtonText }
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
|
@ -116,68 +120,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
/>
|
||||
);
|
||||
|
||||
const renderMatchingControl = () => (
|
||||
<div className="ProposalDetail-controls-control">
|
||||
<Popconfirm
|
||||
overlayClassName="ProposalDetail-popover-overlay"
|
||||
onConfirm={this.handleToggleMatching}
|
||||
title={
|
||||
<>
|
||||
<div>
|
||||
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
|
||||
</div>
|
||||
{p.status === PROPOSAL_STATUS.LIVE && (
|
||||
<div>
|
||||
This is a LIVE proposal, this will alter the funding state of the
|
||||
proposal!
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
okText="ok"
|
||||
cancelText="cancel"
|
||||
>
|
||||
<Switch
|
||||
checked={p.contributionMatching === 1}
|
||||
loading={store.proposalDetailUpdating}
|
||||
disabled={
|
||||
p.isFailed ||
|
||||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
||||
}
|
||||
/>{' '}
|
||||
</Popconfirm>
|
||||
<span>
|
||||
matching{' '}
|
||||
<Info
|
||||
placement="right"
|
||||
content={
|
||||
<span>
|
||||
<b>Contribution matching</b>
|
||||
<br /> Funded amount will be multiplied by 2.
|
||||
<br /> <i>Disabled after proposal is fully-funded.</i>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBountyControl = () => (
|
||||
<div className="ProposalDetail-controls-control">
|
||||
<Button
|
||||
icon="dollar"
|
||||
className="ProposalDetail-controls-control"
|
||||
loading={store.proposalDetailUpdating}
|
||||
onClick={this.handleSetBounty}
|
||||
disabled={
|
||||
p.isFailed || [PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
||||
}
|
||||
block
|
||||
>
|
||||
Set bounty
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderApproved = () =>
|
||||
p.status === PROPOSAL_STATUS.APPROVED && (
|
||||
|
@ -205,9 +147,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
loading={store.proposalDetailApproving}
|
||||
icon="check"
|
||||
type="primary"
|
||||
onClick={this.handleApprove}
|
||||
onClick={() => this.handleApprove(true)}
|
||||
>
|
||||
Approve
|
||||
Approve With Funding
|
||||
</Button>
|
||||
<Button
|
||||
loading={store.proposalDetailApproving}
|
||||
icon="check"
|
||||
type="default"
|
||||
onClick={() => this.handleApprove(false)}
|
||||
>
|
||||
Approve Without Funding
|
||||
</Button>
|
||||
<Button
|
||||
loading={store.proposalDetailApproving}
|
||||
|
@ -250,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
|
||||
const renderNominateArbiter = () =>
|
||||
needsArbiter && (
|
||||
needsArbiter && shouldShowArbiter && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
|
@ -381,7 +331,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderMilestoneAccepted()}
|
||||
{renderFailed()}
|
||||
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
||||
|
||||
<Collapse.Panel key="brief" header="brief">
|
||||
{p.brief}
|
||||
</Collapse.Panel>
|
||||
|
@ -391,24 +340,25 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
</Collapse.Panel>
|
||||
|
||||
<Collapse.Panel key="milestones" header="milestones">
|
||||
{
|
||||
p.milestones.map((milestone, i) =>
|
||||
|
||||
<Card title={
|
||||
<>
|
||||
{milestone.title + ' '}
|
||||
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>}
|
||||
</>
|
||||
}
|
||||
extra={`${milestone.payoutPercent}% Payout`}
|
||||
key={i}
|
||||
>
|
||||
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p>
|
||||
<p>{milestone.content}</p>
|
||||
</Card>
|
||||
|
||||
)
|
||||
}
|
||||
{p.milestones.map((milestone, i) => (
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
{milestone.title + ' '}
|
||||
{milestone.immediatePayout && (
|
||||
<Tag color="magenta">Immediate Payout</Tag>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
extra={`${milestone.payoutPercent}% Payout`}
|
||||
key={i}
|
||||
>
|
||||
<p>
|
||||
<b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated)}{' '}
|
||||
</p>
|
||||
<p>{milestone.content}</p>
|
||||
</Card>
|
||||
))}
|
||||
</Collapse.Panel>
|
||||
|
||||
<Collapse.Panel key="json" header="json">
|
||||
|
@ -423,8 +373,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<Card size="small" className="ProposalDetail-controls">
|
||||
{renderCancelControl()}
|
||||
{renderArbiterControl()}
|
||||
{renderBountyControl()}
|
||||
{renderMatchingControl()}
|
||||
</Card>
|
||||
|
||||
{/* DETAILS */}
|
||||
|
@ -454,6 +402,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderDeetItem('matching', p.contributionMatching)}
|
||||
{renderDeetItem('bounty', p.contributionBounty)}
|
||||
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
||||
{renderDeetItem('acceptedWithFunding', JSON.stringify(p.acceptedWithFunding))}
|
||||
{renderDeetItem(
|
||||
'arbiter',
|
||||
<>
|
||||
|
@ -526,46 +475,15 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
this.setState({ showCancelAndRefundPopover: false });
|
||||
};
|
||||
|
||||
private handleApprove = () => {
|
||||
store.approveProposal(true);
|
||||
private handleApprove = (withFunding: boolean) => {
|
||||
store.approveProposal(true, withFunding);
|
||||
};
|
||||
|
||||
private handleReject = async (reason: string) => {
|
||||
await store.approveProposal(false, reason);
|
||||
await store.approveProposal(false, false, reason);
|
||||
message.info('Proposal rejected');
|
||||
};
|
||||
|
||||
private handleToggleMatching = async () => {
|
||||
if (store.proposalDetail) {
|
||||
// we lock this to be 1 or 0 for now, we may support more values later on
|
||||
const contributionMatching =
|
||||
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
|
||||
await store.updateProposalDetail({ contributionMatching });
|
||||
message.success('Updated matching');
|
||||
}
|
||||
};
|
||||
|
||||
private handleSetBounty = async () => {
|
||||
if (store.proposalDetail) {
|
||||
FeedbackModal.open({
|
||||
title: 'Set bounty?',
|
||||
content:
|
||||
'Set the bounty for this proposal. The bounty will count towards the funding goal.',
|
||||
type: 'input',
|
||||
inputProps: {
|
||||
addonBefore: 'Amount',
|
||||
addonAfter: 'ZEC',
|
||||
placeholder: '1.5',
|
||||
},
|
||||
okText: 'Set bounty',
|
||||
onOk: async contributionBounty => {
|
||||
await store.updateProposalDetail({ contributionBounty });
|
||||
message.success('Updated bounty');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handlePaidMilestone = async () => {
|
||||
const pid = store.proposalDetail!.proposalId;
|
||||
const mid = store.proposalDetail!.currentMilestone!.id;
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
Button,
|
||||
message,
|
||||
Spin,
|
||||
Checkbox,
|
||||
Row,
|
||||
Col,
|
||||
DatePicker,
|
||||
|
@ -208,17 +207,6 @@ class RFPForm extends React.Component<Props, State> {
|
|||
size="large"
|
||||
/>,
|
||||
)}
|
||||
{getFieldDecorator('matching', {
|
||||
initialValue: defaults.matching,
|
||||
})(
|
||||
<Checkbox
|
||||
className="RFPForm-bounty-matching"
|
||||
name="matching"
|
||||
defaultChecked={defaults.matching}
|
||||
>
|
||||
Match community contributions for approved proposals
|
||||
</Checkbox>,
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col sm={12} xs={24}>
|
||||
|
|
|
@ -129,9 +129,15 @@ async function deleteProposal(id: number) {
|
|||
return data;
|
||||
}
|
||||
|
||||
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
|
||||
isApprove,
|
||||
async function approveProposal(
|
||||
id: number,
|
||||
isAccepted: boolean,
|
||||
withFunding: boolean,
|
||||
rejectReason?: string,
|
||||
) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
|
||||
isAccepted,
|
||||
withFunding,
|
||||
rejectReason,
|
||||
});
|
||||
return data;
|
||||
|
@ -536,7 +542,7 @@ const app = store({
|
|||
}
|
||||
},
|
||||
|
||||
async approveProposal(isApprove: boolean, rejectReason?: string) {
|
||||
async approveProposal(isAccepted: boolean, withFunding: boolean, rejectReason?: string) {
|
||||
if (!app.proposalDetail) {
|
||||
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
|
@ -546,7 +552,7 @@ const app = store({
|
|||
app.proposalDetailApproving = true;
|
||||
try {
|
||||
const { proposalId } = app.proposalDetail;
|
||||
const res = await approveProposal(proposalId, isApprove, rejectReason);
|
||||
const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
|
|
|
@ -116,6 +116,7 @@ export interface Proposal {
|
|||
rfpOptIn: null | boolean;
|
||||
rfp?: RFP;
|
||||
arbiter: ProposalArbiter;
|
||||
acceptedWithFunding: boolean | null;
|
||||
isVersionTwo: boolean;
|
||||
}
|
||||
export interface Comment {
|
||||
|
|
|
@ -352,39 +352,17 @@ def delete_proposal(id):
|
|||
return {"message": "Not implemented."}, 400
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>', methods=['PUT'])
|
||||
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
|
||||
@body({
|
||||
"contributionMatching": fields.Int(required=False, missing=None),
|
||||
"contributionBounty": fields.Str(required=False, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def update_proposal(id, contribution_matching, contribution_bounty):
|
||||
proposal = Proposal.query.filter(Proposal.id == id).first()
|
||||
if not proposal:
|
||||
return {"message": f"Could not find proposal with id {id}"}, 404
|
||||
|
||||
if contribution_matching is not None:
|
||||
proposal.set_contribution_matching(contribution_matching)
|
||||
|
||||
if contribution_bounty is not None:
|
||||
proposal.set_contribution_bounty(contribution_bounty)
|
||||
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
|
||||
@body({
|
||||
"isApprove": fields.Bool(required=True),
|
||||
"isAccepted": fields.Bool(required=True),
|
||||
"withFunding": fields.Bool(required=True),
|
||||
"rejectReason": fields.Str(required=False, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def approve_proposal(id, is_approve, reject_reason=None):
|
||||
def approve_proposal(id, is_accepted, with_funding, reject_reason=None):
|
||||
proposal = Proposal.query.filter_by(id=id).first()
|
||||
if proposal:
|
||||
proposal.approve_pending(is_approve, reject_reason)
|
||||
proposal.approve_pending(is_accepted, with_funding, reject_reason)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
@ -587,8 +565,8 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
|||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
#TODO: should this stay?
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||
|
@ -660,8 +638,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
# TODO: should this stay?
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||
|
|
|
@ -35,7 +35,7 @@ def create_proposals(count):
|
|||
user = User.query.filter_by().first()
|
||||
for i in range(count):
|
||||
if i < 5:
|
||||
stage = ProposalStageEnum.FUNDING_REQUIRED
|
||||
stage = ProposalStageEnum.WIP
|
||||
else:
|
||||
stage = ProposalStageEnum.COMPLETED
|
||||
p = Proposal.create(
|
||||
|
|
|
@ -230,6 +230,7 @@ class Proposal(db.Model):
|
|||
date_approved = db.Column(db.DateTime)
|
||||
date_published = db.Column(db.DateTime)
|
||||
reject_reason = db.Column(db.String())
|
||||
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
|
||||
|
||||
# Payment info
|
||||
target = db.Column(db.String(255), nullable=False)
|
||||
|
@ -411,13 +412,6 @@ class Proposal(db.Model):
|
|||
|
||||
def update_rfp_opt_in(self, opt_in: bool):
|
||||
self.rfp_opt_in = opt_in
|
||||
# add/remove matching and/or bounty values from RFP
|
||||
if opt_in and self.rfp:
|
||||
self.set_contribution_matching(1 if self.rfp.matching else 0)
|
||||
self.set_contribution_bounty(self.rfp.bounty or '0')
|
||||
else:
|
||||
self.set_contribution_matching(0)
|
||||
self.set_contribution_bounty('0')
|
||||
|
||||
def create_contribution(
|
||||
self,
|
||||
|
@ -501,7 +495,7 @@ class Proposal(db.Model):
|
|||
db.session.flush()
|
||||
|
||||
# state: status PENDING -> (APPROVED || REJECTED)
|
||||
def approve_pending(self, is_approve, reject_reason=None):
|
||||
def approve_pending(self, is_approve, with_funding, reject_reason=None):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
if not self.status == ProposalStatus.PENDING:
|
||||
|
@ -510,12 +504,17 @@ class Proposal(db.Model):
|
|||
if is_approve:
|
||||
self.status = ProposalStatus.APPROVED
|
||||
self.date_approved = datetime.datetime.now()
|
||||
self.accepted_with_funding = with_funding
|
||||
with_or_out = 'without'
|
||||
if with_funding:
|
||||
self.fully_fund_contibution_bounty()
|
||||
with_or_out = 'with'
|
||||
for t in self.team:
|
||||
send_email(t.email_address, 'proposal_approved', {
|
||||
'user': t,
|
||||
'proposal': self,
|
||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||
'admin_note': 'Congratulations! Your proposal has been approved.'
|
||||
'admin_note': f'Congratulations! Your proposal has been accepted {with_or_out} funding.'
|
||||
})
|
||||
else:
|
||||
if not reject_reason:
|
||||
|
@ -538,28 +537,7 @@ class Proposal(db.Model):
|
|||
raise ValidationException(f"Proposal status must be approved")
|
||||
self.date_published = datetime.datetime.now()
|
||||
self.status = ProposalStatus.LIVE
|
||||
self.stage = ProposalStage.FUNDING_REQUIRED
|
||||
# If we had a bounty that pushed us into funding, skip straight into WIP
|
||||
self.set_funded_when_ready()
|
||||
|
||||
def set_funded_when_ready(self):
|
||||
if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded:
|
||||
self.set_funded()
|
||||
|
||||
# state: stage FUNDING_REQUIRED -> WIP
|
||||
def set_funded(self):
|
||||
if self.status != ProposalStatus.LIVE:
|
||||
raise ValidationException(f"Proposal status must be live in order transition to funded state")
|
||||
if self.stage != ProposalStage.FUNDING_REQUIRED:
|
||||
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
|
||||
if not self.is_funded:
|
||||
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
|
||||
self.send_admin_email('admin_arbiter')
|
||||
self.stage = ProposalStage.WIP
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
# check the first step, if immediate payout bump it to accepted
|
||||
self.current_milestone.accept_immediate()
|
||||
|
||||
def set_contribution_bounty(self, bounty: str):
|
||||
# do not allow changes on funded/WIP proposals
|
||||
|
@ -569,20 +547,9 @@ class Proposal(db.Model):
|
|||
self.contribution_bounty = str(Decimal(bounty))
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
self.set_funded_when_ready()
|
||||
|
||||
def set_contribution_matching(self, matching: float):
|
||||
# do not allow on funded/WIP proposals
|
||||
if self.is_funded:
|
||||
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
|
||||
# enforce 1 or 0 for now
|
||||
if matching == 0.0 or matching == 1.0:
|
||||
self.contribution_matching = matching
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
self.set_funded_when_ready()
|
||||
else:
|
||||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
||||
def fully_fund_contibution_bounty(self):
|
||||
self.set_contribution_bounty(self.target)
|
||||
|
||||
def cancel(self):
|
||||
if self.status != ProposalStatus.LIVE:
|
||||
|
@ -706,6 +673,7 @@ class ProposalSchema(ma.Schema):
|
|||
"rfp",
|
||||
"rfp_opt_in",
|
||||
"arbiter",
|
||||
"accepted_with_funding",
|
||||
"is_version_two"
|
||||
)
|
||||
|
||||
|
|
|
@ -570,9 +570,6 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
|
||||
})
|
||||
|
||||
# on funding target reached.
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return {"message": "ok"}, 200
|
||||
|
||||
|
|
|
@ -34,7 +34,6 @@ ProposalSort = ProposalSortEnum()
|
|||
|
||||
class ProposalStageEnum(CustomEnum):
|
||||
PREVIEW = 'PREVIEW'
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||
WIP = 'WIP'
|
||||
COMPLETED = 'COMPLETED'
|
||||
FAILED = 'FAILED'
|
||||
|
|
|
@ -242,30 +242,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
# 2 proposals created by BaseProposalCreatorConfig
|
||||
self.assertEqual(len(resp.json['items']), 2)
|
||||
|
||||
def test_update_proposal(self):
|
||||
self.login_admin()
|
||||
# set to 1 (on)
|
||||
resp_on = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
|
||||
data=json.dumps({"contributionMatching": 1}))
|
||||
self.assert200(resp_on)
|
||||
self.assertEqual(resp_on.json['contributionMatching'], 1)
|
||||
resp_off = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
|
||||
data=json.dumps({"contributionMatching": 0}))
|
||||
self.assert200(resp_off)
|
||||
self.assertEqual(resp_off.json['contributionMatching'], 0)
|
||||
|
||||
def test_update_proposal_no_auth(self):
|
||||
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 1}))
|
||||
self.assert401(resp)
|
||||
|
||||
def test_update_proposal_bad_matching(self):
|
||||
self.login_admin()
|
||||
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 2}))
|
||||
self.assert400(resp)
|
||||
self.assertTrue(resp.json['message'])
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_approve_proposal(self, mock_get):
|
||||
def test_accept_proposal_with_funding(self, mock_get):
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
|
@ -273,11 +251,33 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
||||
data=json.dumps({"isApprove": True})
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": True, "withFunding": True})
|
||||
)
|
||||
print(resp.json)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
|
||||
self.assertEqual(resp.json["acceptedWithFunding"], True)
|
||||
self.assertEqual(resp.json["target"], resp.json["contributionBounty"])
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_accept_proposal_without_funding(self, mock_get):
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": True, "withFunding": False})
|
||||
)
|
||||
print(resp.json)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
|
||||
self.assertEqual(resp.json["acceptedWithFunding"], False)
|
||||
self.assertEqual(resp.json["contributionBounty"], "0")
|
||||
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_reject_proposal(self, mock_get):
|
||||
|
@ -288,8 +288,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
|
||||
# reject
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
||||
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."})
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
||||
|
|
|
@ -84,8 +84,8 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
|
|||
},
|
||||
[CREATE_STEP.PAYMENT]: {
|
||||
short: 'Payment',
|
||||
title: 'Choose how you get paid',
|
||||
subtitle: 'You’ll only be paid if your funding target is reached',
|
||||
title: 'Set your payout address',
|
||||
subtitle: '',
|
||||
help:
|
||||
'Double check your address, and make sure it’s secure. Once sent, payments are irreversible!',
|
||||
component: Payment,
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||
import { Icon, Popover } from 'antd';
|
||||
import { Proposal, STATUS } from 'types';
|
||||
import classnames from 'classnames';
|
||||
import { fromZat } from 'utils/units';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'recompose';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { withRouter } from 'react-router';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
import ContributionModal from 'components/ContributionModal';
|
||||
import Loader from 'components/Loader';
|
||||
import { getAmountError } from 'utils/validators';
|
||||
import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
|
||||
import './style.less';
|
||||
|
||||
|
@ -46,12 +42,10 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { proposal, isPreview, authUser } = this.props;
|
||||
const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
|
||||
const amountFloat = parseFloat(amountToRaise) || 0;
|
||||
const { proposal } = this.props;
|
||||
let content;
|
||||
if (proposal) {
|
||||
const { target, funded, percentFunded } = proposal;
|
||||
const { target, funded, percentFunded, isVersionTwo } = proposal;
|
||||
const datePublished = proposal.datePublished || Date.now() / 1000;
|
||||
const isRaiseGoalReached = funded.gte(target);
|
||||
const deadline = proposal.deadlineDuration
|
||||
|
@ -62,9 +56,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
proposal.stage === PROPOSAL_STAGE.CANCELED;
|
||||
const isLive = proposal.status === STATUS.LIVE;
|
||||
|
||||
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
|
||||
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
|
||||
const remainingTargetNum = parseFloat(fromZat(target.sub(funded)));
|
||||
const isFundingOver = deadline
|
||||
? isRaiseGoalReached || deadline < Date.now() || isFrozen
|
||||
: null;
|
||||
|
||||
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
|
||||
let bounty;
|
||||
|
@ -74,6 +68,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
: proposal.contributionBounty;
|
||||
}
|
||||
|
||||
const isAcceptedWithFunding = proposal.acceptedWithFunding === true;
|
||||
const isAcceptedWithoutFunding = proposal.acceptedWithFunding === false;
|
||||
const isAccepted = isAcceptedWithFunding || isAcceptedWithoutFunding;
|
||||
const isCancelled = proposal.stage === PROPOSAL_STAGE.CANCELED;
|
||||
const isJudged = isAccepted || isCancelled;
|
||||
|
||||
const displayBountyFunding =
|
||||
!isVersionTwo || (isVersionTwo && isAcceptedWithFunding);
|
||||
|
||||
content = (
|
||||
<React.Fragment>
|
||||
{isLive && (
|
||||
|
@ -94,14 +97,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
{CATEGORY_UI[proposal.category].label}
|
||||
</div>
|
||||
</div>
|
||||
{!isFundingOver && (
|
||||
<div className="ProposalCampaignBlock-info">
|
||||
<div className="ProposalCampaignBlock-info-label">Deadline</div>
|
||||
<div className="ProposalCampaignBlock-info-value">
|
||||
{moment(deadline).fromNow()}
|
||||
{!isVersionTwo &&
|
||||
!isFundingOver && (
|
||||
<div className="ProposalCampaignBlock-info">
|
||||
<div className="ProposalCampaignBlock-info-label">Deadline</div>
|
||||
<div className="ProposalCampaignBlock-info-value">
|
||||
{moment(deadline).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="ProposalCampaignBlock-info">
|
||||
<div className="ProposalCampaignBlock-info-label">Funding</div>
|
||||
<div className="ProposalCampaignBlock-info-value">
|
||||
|
@ -109,40 +113,46 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{bounty && (
|
||||
<div className="ProposalCampaignBlock-bounty">
|
||||
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
|
||||
</div>
|
||||
{bounty &&
|
||||
displayBountyFunding && (
|
||||
<div className="ProposalCampaignBlock-bounty">
|
||||
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAcceptedWithoutFunding && (
|
||||
<div className="ProposalCampaignBlock-bounty">Accepted without funding</div>
|
||||
)}
|
||||
|
||||
{proposal.contributionMatching > 0 && (
|
||||
<div className="ProposalCampaignBlock-matching">
|
||||
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
|
||||
<Popover
|
||||
overlayClassName="ProposalCampaignBlock-popover-overlay"
|
||||
placement="left"
|
||||
content={
|
||||
<>
|
||||
<b>Matching</b>
|
||||
<br />
|
||||
Increase your impact! Contributions to this proposal are being matched
|
||||
by the Zcash Foundation, up to the target amount.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon type="question-circle" theme="filled" />
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{!isVersionTwo &&
|
||||
proposal.contributionMatching > 0 && (
|
||||
<div className="ProposalCampaignBlock-matching">
|
||||
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
|
||||
<Popover
|
||||
overlayClassName="ProposalCampaignBlock-popover-overlay"
|
||||
placement="left"
|
||||
content={
|
||||
<>
|
||||
<b>Matching</b>
|
||||
<br />
|
||||
Increase your impact! Contributions to this proposal are being
|
||||
matched by the Zcash Foundation, up to the target amount.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon type="question-circle" theme="filled" />
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFundingOver ? (
|
||||
{!isVersionTwo && isFundingOver ? (
|
||||
<div
|
||||
className={classnames({
|
||||
['ProposalCampaignBlock-fundingOver']: true,
|
||||
['is-success']: isRaiseGoalReached,
|
||||
})}
|
||||
>
|
||||
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
|
||||
{isCancelled ? (
|
||||
<>
|
||||
<Icon type="close-circle-o" />
|
||||
<span>Proposal was canceled</span>
|
||||
|
@ -170,7 +180,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" className="ProposalCampaignBlock-contribute">
|
||||
{/* TODO: use this as a base for tipjar? */}
|
||||
|
||||
{/* <Form layout="vertical" className="ProposalCampaignBlock-contribute">
|
||||
<Form.Item
|
||||
validateStatus={amountError ? 'error' : undefined}
|
||||
help={amountError}
|
||||
|
@ -219,18 +231,41 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
>
|
||||
Fund this project
|
||||
</Button>
|
||||
</Form>
|
||||
</Form> */}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContributionModal
|
||||
{isVersionTwo &&
|
||||
isJudged && (
|
||||
<div
|
||||
className={classnames({
|
||||
['ProposalCampaignBlock-fundingOver']: true,
|
||||
['is-success']: isAccepted,
|
||||
})}
|
||||
>
|
||||
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
|
||||
<>
|
||||
<Icon type="close-circle-o" />
|
||||
<span>Proposal was canceled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon type="check-circle-o" />
|
||||
<span>Proposal has been accepted</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: adapt below for tipjar? */}
|
||||
{/* <ContributionModal
|
||||
isVisible={isContributing}
|
||||
proposalId={proposal.proposalId}
|
||||
amount={amountToRaise}
|
||||
isAnonymous={!authUser}
|
||||
isPublic={!isPrivate}
|
||||
handleClose={this.closeContributionModal}
|
||||
/>
|
||||
/> */}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
|
@ -244,38 +279,6 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handleAmountChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { value } = event.currentTarget;
|
||||
if (!value) {
|
||||
this.setState({ amountToRaise: '', amountError: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, funded } = this.props.proposal;
|
||||
const remainingTarget = target.sub(funded);
|
||||
const amount = parseFloat(value);
|
||||
let amountError = null;
|
||||
|
||||
if (Number.isNaN(amount)) {
|
||||
// They're entering some garbage, they’ll work it out
|
||||
} else {
|
||||
const remainingTargetNum = parseFloat(fromZat(remainingTarget));
|
||||
amountError = getAmountError(amount, remainingTargetNum);
|
||||
}
|
||||
|
||||
this.setState({ amountToRaise: value, amountError });
|
||||
};
|
||||
|
||||
private handleChangePrivate = (ev: RadioChangeEvent) => {
|
||||
const isPrivate = ev.target.value === 'isPrivate';
|
||||
this.setState({ isPrivate });
|
||||
};
|
||||
|
||||
private openContributionModal = () => this.setState({ isContributing: true });
|
||||
private closeContributionModal = () => this.setState({ isContributing: false });
|
||||
}
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
|
|
|
@ -72,7 +72,7 @@ class RFPDetail extends React.Component<Props> {
|
|||
tags.push(
|
||||
<Tag key="closed" color="#f5222d">
|
||||
Closed
|
||||
</Tag>
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -248,6 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
|
|||
arbiter: {
|
||||
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||
},
|
||||
acceptedWithFunding: false,
|
||||
isVersionTwo: true,
|
||||
milestones: draft.milestones.map((m, idx) => ({
|
||||
id: idx,
|
||||
|
|
|
@ -173,7 +173,8 @@ export function generateProposal({
|
|||
socialMedias: [],
|
||||
},
|
||||
},
|
||||
isVersionTwo: true,
|
||||
acceptedWithFunding: null,
|
||||
isVersionTwo: false,
|
||||
team: [
|
||||
{
|
||||
userid: 123,
|
||||
|
|
|
@ -62,6 +62,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
|||
datePublished: number | null;
|
||||
dateApproved: number | null;
|
||||
arbiter: ProposalProposalArbiter;
|
||||
acceptedWithFunding: boolean | null;
|
||||
isVersionTwo: boolean;
|
||||
isTeamMember?: boolean; // FE derived
|
||||
isArbiter?: boolean; // FE derived
|
||||
|
|
Loading…
Reference in New Issue