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:
Danny Skubak 2019-10-16 23:43:20 -04:00 committed by Daniel Ternyak
parent 701a2f95a9
commit fb6b9b5af7
18 changed files with 195 additions and 331 deletions

View File

@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
}, 1000); }, 1000);
render() { render() {
const { arbiter } = this.props; const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
const { showSearch, searching } = this.state; const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch; const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching; const showEmpty = !results.length && !searching;
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
const disp = { const disp = {
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter', [PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
type="primary" type="primary"
onClick={this.handleShowSearch} onClick={this.handleShowSearch}
{...this.props.buttonProps} {...this.props.buttonProps}
disabled={buttonDisabled}
> >
{disp[arbiter.status]} {disp[arbiter.status]}
</Button> </Button>

View File

@ -27,8 +27,9 @@
.ant-collapse { .ant-collapse {
margin-bottom: 16px; margin-bottom: 16px;
button + button { button {
margin-left: 0.5rem; margin-right: 0.5rem;
margin-bottom: 0.25rem;
} }
} }

View File

@ -11,7 +11,6 @@ import {
Collapse, Collapse,
Popconfirm, Popconfirm,
Input, Input,
Switch,
Tag, Tag,
message, message,
} from 'antd'; } from 'antd';
@ -26,7 +25,6 @@ import {
} from 'src/types'; } from 'src/types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Back from 'components/Back'; import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown'; import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl'; import ArbiterControl from 'components/ArbiterControl';
import { toZat, fromZat } from 'src/util/units'; 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; return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100); }, 100);
const { isVersionTwo } = p
const shouldShowArbiter =
!isVersionTwo ||
(isVersionTwo && p.acceptedWithFunding === true);
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund'
const renderCancelControl = () => { const renderCancelControl = () => {
const disabled = this.getCancelAndRefundDisabled(); const disabled = this.getCancelAndRefundDisabled();
@ -95,7 +99,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
disabled={disabled} disabled={disabled}
block block
> >
Cancel & refund { cancelButtonText }
</Button> </Button>
</Popconfirm> </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 = () => const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && ( p.status === PROPOSAL_STATUS.APPROVED && (
@ -205,9 +147,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
loading={store.proposalDetailApproving} loading={store.proposalDetailApproving}
icon="check" icon="check"
type="primary" 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>
<Button <Button
loading={store.proposalDetailApproving} loading={store.proposalDetailApproving}
@ -250,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
); );
const renderNominateArbiter = () => const renderNominateArbiter = () =>
needsArbiter && ( needsArbiter && shouldShowArbiter && (
<Alert <Alert
showIcon showIcon
type="warning" type="warning"
@ -381,7 +331,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderMilestoneAccepted()} {renderMilestoneAccepted()}
{renderFailed()} {renderFailed()}
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}> <Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
<Collapse.Panel key="brief" header="brief"> <Collapse.Panel key="brief" header="brief">
{p.brief} {p.brief}
</Collapse.Panel> </Collapse.Panel>
@ -391,24 +340,25 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel key="milestones" header="milestones"> <Collapse.Panel key="milestones" header="milestones">
{ {p.milestones.map((milestone, i) => (
p.milestones.map((milestone, i) => <Card
title={
<Card title={ <>
<> {milestone.title + ' '}
{milestone.title + ' '} {milestone.immediatePayout && (
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>} <Tag color="magenta">Immediate Payout</Tag>
</> )}
} </>
extra={`${milestone.payoutPercent}% Payout`} }
key={i} extra={`${milestone.payoutPercent}% Payout`}
> key={i}
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p> >
<p>{milestone.content}</p> <p>
</Card> <b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated)}{' '}
</p>
) <p>{milestone.content}</p>
} </Card>
))}
</Collapse.Panel> </Collapse.Panel>
<Collapse.Panel key="json" header="json"> <Collapse.Panel key="json" header="json">
@ -423,8 +373,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Card size="small" className="ProposalDetail-controls"> <Card size="small" className="ProposalDetail-controls">
{renderCancelControl()} {renderCancelControl()}
{renderArbiterControl()} {renderArbiterControl()}
{renderBountyControl()}
{renderMatchingControl()}
</Card> </Card>
{/* DETAILS */} {/* DETAILS */}
@ -454,6 +402,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('matching', p.contributionMatching)} {renderDeetItem('matching', p.contributionMatching)}
{renderDeetItem('bounty', p.contributionBounty)} {renderDeetItem('bounty', p.contributionBounty)}
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))} {renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
{renderDeetItem('acceptedWithFunding', JSON.stringify(p.acceptedWithFunding))}
{renderDeetItem( {renderDeetItem(
'arbiter', 'arbiter',
<> <>
@ -526,46 +475,15 @@ class ProposalDetailNaked extends React.Component<Props, State> {
this.setState({ showCancelAndRefundPopover: false }); this.setState({ showCancelAndRefundPopover: false });
}; };
private handleApprove = () => { private handleApprove = (withFunding: boolean) => {
store.approveProposal(true); store.approveProposal(true, withFunding);
}; };
private handleReject = async (reason: string) => { private handleReject = async (reason: string) => {
await store.approveProposal(false, reason); await store.approveProposal(false, false, reason);
message.info('Proposal rejected'); 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 () => { private handlePaidMilestone = async () => {
const pid = store.proposalDetail!.proposalId; const pid = store.proposalDetail!.proposalId;
const mid = store.proposalDetail!.currentMilestone!.id; const mid = store.proposalDetail!.currentMilestone!.id;

View File

@ -11,7 +11,6 @@ import {
Button, Button,
message, message,
Spin, Spin,
Checkbox,
Row, Row,
Col, Col,
DatePicker, DatePicker,
@ -208,17 +207,6 @@ class RFPForm extends React.Component<Props, State> {
size="large" 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> </Form.Item>
</Col> </Col>
<Col sm={12} xs={24}> <Col sm={12} xs={24}>

View File

@ -129,9 +129,15 @@ async function deleteProposal(id: number) {
return data; return data;
} }
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) { async function approveProposal(
const { data } = await api.put(`/admin/proposals/${id}/approve`, { id: number,
isApprove, isAccepted: boolean,
withFunding: boolean,
rejectReason?: string,
) {
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
isAccepted,
withFunding,
rejectReason, rejectReason,
}); });
return data; 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) { if (!app.proposalDetail) {
const m = 'store.approveProposal(): Expected proposalDetail to be populated!'; const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m); app.generalError.push(m);
@ -546,7 +552,7 @@ const app = store({
app.proposalDetailApproving = true; app.proposalDetailApproving = true;
try { try {
const { proposalId } = app.proposalDetail; const { proposalId } = app.proposalDetail;
const res = await approveProposal(proposalId, isApprove, rejectReason); const res = await approveProposal(proposalId, isAccepted, withFunding, rejectReason);
app.updateProposalInStore(res); app.updateProposalInStore(res);
} catch (e) { } catch (e) {
handleApiError(e); handleApiError(e);

View File

@ -116,6 +116,7 @@ export interface Proposal {
rfpOptIn: null | boolean; rfpOptIn: null | boolean;
rfp?: RFP; rfp?: RFP;
arbiter: ProposalArbiter; arbiter: ProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean; isVersionTwo: boolean;
} }
export interface Comment { export interface Comment {

View File

@ -352,39 +352,17 @@ def delete_proposal(id):
return {"message": "Not implemented."}, 400 return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>', methods=['PUT']) @blueprint.route('/proposals/<id>/accept', methods=['PUT'])
@body({ @body({
"contributionMatching": fields.Int(required=False, missing=None), "isAccepted": fields.Bool(required=True),
"contributionBounty": fields.Str(required=False, missing=None) "withFunding": fields.Bool(required=True),
})
@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),
"rejectReason": fields.Str(required=False, missing=None) "rejectReason": fields.Str(required=False, missing=None)
}) })
@admin.admin_auth_required @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() proposal = Proposal.query.filter_by(id=id).first()
if proposal: if proposal:
proposal.approve_pending(is_approve, reject_reason) proposal.approve_pending(is_accepted, with_funding, reject_reason)
db.session.commit() db.session.commit()
return proposal_schema.dump(proposal) 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.add(contribution)
db.session.flush() db.session.flush()
#TODO: should this stay?
contribution.proposal.set_pending_when_ready() contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit() db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200 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.add(contribution)
db.session.flush() db.session.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready() contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit() db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200 return admin_proposal_contribution_schema.dump(contribution), 200

View File

@ -35,7 +35,7 @@ def create_proposals(count):
user = User.query.filter_by().first() user = User.query.filter_by().first()
for i in range(count): for i in range(count):
if i < 5: if i < 5:
stage = ProposalStageEnum.FUNDING_REQUIRED stage = ProposalStageEnum.WIP
else: else:
stage = ProposalStageEnum.COMPLETED stage = ProposalStageEnum.COMPLETED
p = Proposal.create( p = Proposal.create(

View File

@ -230,6 +230,7 @@ class Proposal(db.Model):
date_approved = db.Column(db.DateTime) date_approved = db.Column(db.DateTime)
date_published = db.Column(db.DateTime) date_published = db.Column(db.DateTime)
reject_reason = db.Column(db.String()) reject_reason = db.Column(db.String())
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
# Payment info # Payment info
target = db.Column(db.String(255), nullable=False) 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): def update_rfp_opt_in(self, opt_in: bool):
self.rfp_opt_in = opt_in 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( def create_contribution(
self, self,
@ -501,7 +495,7 @@ class Proposal(db.Model):
db.session.flush() db.session.flush()
# state: status PENDING -> (APPROVED || REJECTED) # 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() self.validate_publishable()
# specific validation # specific validation
if not self.status == ProposalStatus.PENDING: if not self.status == ProposalStatus.PENDING:
@ -510,12 +504,17 @@ class Proposal(db.Model):
if is_approve: if is_approve:
self.status = ProposalStatus.APPROVED self.status = ProposalStatus.APPROVED
self.date_approved = datetime.datetime.now() 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: for t in self.team:
send_email(t.email_address, 'proposal_approved', { send_email(t.email_address, 'proposal_approved', {
'user': t, 'user': t,
'proposal': self, 'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'), '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: else:
if not reject_reason: if not reject_reason:
@ -538,28 +537,7 @@ class Proposal(db.Model):
raise ValidationException(f"Proposal status must be approved") raise ValidationException(f"Proposal status must be approved")
self.date_published = datetime.datetime.now() self.date_published = datetime.datetime.now()
self.status = ProposalStatus.LIVE 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 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): def set_contribution_bounty(self, bounty: str):
# do not allow changes on funded/WIP proposals # do not allow changes on funded/WIP proposals
@ -569,20 +547,9 @@ class Proposal(db.Model):
self.contribution_bounty = str(Decimal(bounty)) self.contribution_bounty = str(Decimal(bounty))
db.session.add(self) db.session.add(self)
db.session.flush() db.session.flush()
self.set_funded_when_ready()
def set_contribution_matching(self, matching: float): def fully_fund_contibution_bounty(self):
# do not allow on funded/WIP proposals self.set_contribution_bounty(self.target)
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 cancel(self): def cancel(self):
if self.status != ProposalStatus.LIVE: if self.status != ProposalStatus.LIVE:
@ -706,6 +673,7 @@ class ProposalSchema(ma.Schema):
"rfp", "rfp",
"rfp_opt_in", "rfp_opt_in",
"arbiter", "arbiter",
"accepted_with_funding",
"is_version_two" "is_version_two"
) )

View File

@ -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 '', '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() db.session.commit()
return {"message": "ok"}, 200 return {"message": "ok"}, 200

View File

@ -34,7 +34,6 @@ ProposalSort = ProposalSortEnum()
class ProposalStageEnum(CustomEnum): class ProposalStageEnum(CustomEnum):
PREVIEW = 'PREVIEW' PREVIEW = 'PREVIEW'
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
WIP = 'WIP' WIP = 'WIP'
COMPLETED = 'COMPLETED' COMPLETED = 'COMPLETED'
FAILED = 'FAILED' FAILED = 'FAILED'

View File

@ -242,30 +242,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# 2 proposals created by BaseProposalCreatorConfig # 2 proposals created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json['items']), 2) 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) @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() self.login_admin()
# proposal needs to be PENDING # proposal needs to be PENDING
@ -273,11 +251,33 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# approve # approve
resp = self.app.put( resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id), "/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
data=json.dumps({"isApprove": True}) data=json.dumps({"isAccepted": True, "withFunding": True})
) )
print(resp.json)
self.assert200(resp) self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED) 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) @patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_reject_proposal(self, mock_get): def test_reject_proposal(self, mock_get):
@ -288,8 +288,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# reject # reject
resp = self.app.put( resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id), "/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."}) data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
) )
self.assert200(resp) self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED) self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)

View File

@ -84,8 +84,8 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
}, },
[CREATE_STEP.PAYMENT]: { [CREATE_STEP.PAYMENT]: {
short: 'Payment', short: 'Payment',
title: 'Choose how you get paid', title: 'Set your payout address',
subtitle: 'Youll only be paid if your funding target is reached', subtitle: '',
help: help:
'Double check your address, and make sure its secure. Once sent, payments are irreversible!', 'Double check your address, and make sure its secure. Once sent, payments are irreversible!',
component: Payment, component: Payment,

View File

@ -1,18 +1,14 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd'; import { Icon, Popover } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { Proposal, STATUS } from 'types'; import { Proposal, STATUS } from 'types';
import classnames from 'classnames'; import classnames from 'classnames';
import { fromZat } from 'utils/units';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { compose } from 'recompose'; import { compose } from 'recompose';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import ContributionModal from 'components/ContributionModal';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import { getAmountError } from 'utils/validators';
import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants'; import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
import './style.less'; import './style.less';
@ -46,12 +42,10 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
} }
render() { render() {
const { proposal, isPreview, authUser } = this.props; const { proposal } = this.props;
const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0;
let content; let content;
if (proposal) { if (proposal) {
const { target, funded, percentFunded } = proposal; const { target, funded, percentFunded, isVersionTwo } = proposal;
const datePublished = proposal.datePublished || Date.now() / 1000; const datePublished = proposal.datePublished || Date.now() / 1000;
const isRaiseGoalReached = funded.gte(target); const isRaiseGoalReached = funded.gte(target);
const deadline = proposal.deadlineDuration const deadline = proposal.deadlineDuration
@ -62,9 +56,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
proposal.stage === PROPOSAL_STAGE.CANCELED; proposal.stage === PROPOSAL_STAGE.CANCELED;
const isLive = proposal.status === STATUS.LIVE; const isLive = proposal.status === STATUS.LIVE;
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen; const isFundingOver = deadline
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview; ? isRaiseGoalReached || deadline < Date.now() || isFrozen
const remainingTargetNum = parseFloat(fromZat(target.sub(funded))); : null;
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount // Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
let bounty; let bounty;
@ -74,6 +68,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
: proposal.contributionBounty; : 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 = ( content = (
<React.Fragment> <React.Fragment>
{isLive && ( {isLive && (
@ -94,14 +97,15 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
{CATEGORY_UI[proposal.category].label} {CATEGORY_UI[proposal.category].label}
</div> </div>
</div> </div>
{!isFundingOver && ( {!isVersionTwo &&
<div className="ProposalCampaignBlock-info"> !isFundingOver && (
<div className="ProposalCampaignBlock-info-label">Deadline</div> <div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-value"> <div className="ProposalCampaignBlock-info-label">Deadline</div>
{moment(deadline).fromNow()} <div className="ProposalCampaignBlock-info-value">
{moment(deadline).fromNow()}
</div>
</div> </div>
</div> )}
)}
<div className="ProposalCampaignBlock-info"> <div className="ProposalCampaignBlock-info">
<div className="ProposalCampaignBlock-info-label">Funding</div> <div className="ProposalCampaignBlock-info-label">Funding</div>
<div className="ProposalCampaignBlock-info-value"> <div className="ProposalCampaignBlock-info-value">
@ -109,40 +113,46 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</div> </div>
</div> </div>
{bounty && ( {bounty &&
<div className="ProposalCampaignBlock-bounty"> displayBountyFunding && (
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty <div className="ProposalCampaignBlock-bounty">
</div> Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
</div>
)}
{isAcceptedWithoutFunding && (
<div className="ProposalCampaignBlock-bounty">Accepted without funding</div>
)} )}
{proposal.contributionMatching > 0 && ( {!isVersionTwo &&
<div className="ProposalCampaignBlock-matching"> proposal.contributionMatching > 0 && (
<span>Funds are being matched x{proposal.contributionMatching + 1}</span> <div className="ProposalCampaignBlock-matching">
<Popover <span>Funds are being matched x{proposal.contributionMatching + 1}</span>
overlayClassName="ProposalCampaignBlock-popover-overlay" <Popover
placement="left" overlayClassName="ProposalCampaignBlock-popover-overlay"
content={ placement="left"
<> content={
<b>Matching</b> <>
<br /> <b>Matching</b>
Increase your impact! Contributions to this proposal are being matched <br />
by the Zcash Foundation, up to the target amount. 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> <Icon type="question-circle" theme="filled" />
</div> </Popover>
)} </div>
)}
{isFundingOver ? ( {!isVersionTwo && isFundingOver ? (
<div <div
className={classnames({ className={classnames({
['ProposalCampaignBlock-fundingOver']: true, ['ProposalCampaignBlock-fundingOver']: true,
['is-success']: isRaiseGoalReached, ['is-success']: isRaiseGoalReached,
})} })}
> >
{proposal.stage === PROPOSAL_STAGE.CANCELED ? ( {isCancelled ? (
<> <>
<Icon type="close-circle-o" /> <Icon type="close-circle-o" />
<span>Proposal was canceled</span> <span>Proposal was canceled</span>
@ -170,7 +180,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
/> />
</div> </div>
<Form layout="vertical" className="ProposalCampaignBlock-contribute"> {/* TODO: use this as a base for tipjar? */}
{/* <Form layout="vertical" className="ProposalCampaignBlock-contribute">
<Form.Item <Form.Item
validateStatus={amountError ? 'error' : undefined} validateStatus={amountError ? 'error' : undefined}
help={amountError} help={amountError}
@ -219,18 +231,41 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
> >
Fund this project Fund this project
</Button> </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} isVisible={isContributing}
proposalId={proposal.proposalId} proposalId={proposal.proposalId}
amount={amountToRaise} amount={amountToRaise}
isAnonymous={!authUser} isAnonymous={!authUser}
isPublic={!isPrivate} isPublic={!isPrivate}
handleClose={this.closeContributionModal} handleClose={this.closeContributionModal}
/> /> */}
</React.Fragment> </React.Fragment>
); );
} else { } else {
@ -244,38 +279,6 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</div> </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, theyll 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) { function mapStateToProps(state: AppState) {

View File

@ -72,7 +72,7 @@ class RFPDetail extends React.Component<Props> {
tags.push( tags.push(
<Tag key="closed" color="#f5222d"> <Tag key="closed" color="#f5222d">
Closed Closed
</Tag> </Tag>,
); );
} }

View File

@ -248,6 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
arbiter: { arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED, status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
}, },
acceptedWithFunding: false,
isVersionTwo: true, isVersionTwo: true,
milestones: draft.milestones.map((m, idx) => ({ milestones: draft.milestones.map((m, idx) => ({
id: idx, id: idx,

View File

@ -173,7 +173,8 @@ export function generateProposal({
socialMedias: [], socialMedias: [],
}, },
}, },
isVersionTwo: true, acceptedWithFunding: null,
isVersionTwo: false,
team: [ team: [
{ {
userid: 123, userid: 123,

View File

@ -62,6 +62,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
datePublished: number | null; datePublished: number | null;
dateApproved: number | null; dateApproved: number | null;
arbiter: ProposalProposalArbiter; arbiter: ProposalProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean; isVersionTwo: boolean;
isTeamMember?: boolean; // FE derived isTeamMember?: boolean; // FE derived
isArbiter?: boolean; // FE derived isArbiter?: boolean; // FE derived