Proposal contribution matching (#117)

* BE: contribution_matching + admin proposal update end-point + tests

* admin: set proposal matching status

* frontend: contributionMatching

* improve CampaignBlock matching callout - thx Will

* adjust ProposalDetail matching confirmation popover child scope

* contributed & funded Proposal fields (hybrid props) + remove funded derivation from FE

* include "contributed" sum for ProposalDetail view

* fix branched migration revision
This commit is contained in:
AMStrix 2019-01-29 17:50:27 -06:00 committed by William O'Beirne
parent 73bbd191d9
commit b0d16ace7d
19 changed files with 313 additions and 20 deletions

View File

@ -0,0 +1,10 @@
.Info {
position: relative;
&-overlay {
max-width: 400px;
}
& .anticon {
color: #1890ff;
}
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Popover, Icon } from 'antd';
import './index.less';
import { PopoverProps } from 'antd/lib/popover';
const Info: React.SFC<PopoverProps> = p => (
<span className="Info">
<Popover overlayClassName="Info-overlay" {...p}>
{p.children} <Icon type="question-circle" />
</Popover>
</span>
);
export default Info;

View File

@ -3,6 +3,12 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
&-controls {
&-control + &-control {
margin-top: 0.8rem;
}
}
&-deet { &-deet {
position: relative; position: relative;
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
@ -23,4 +29,10 @@
margin-left: 0.5rem; margin-left: 0.5rem;
} }
} }
&-popover {
&-overlay {
max-width: 400px;
}
}
} }

View File

@ -1,13 +1,25 @@
import React from 'react'; import React from 'react';
import { view } from 'react-easy-state'; import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router'; import { RouteComponentProps, withRouter } from 'react-router';
import { Row, Col, Card, Alert, Button, Collapse, Popconfirm, Modal, Input } from 'antd'; import {
Row,
Col,
Card,
Alert,
Button,
Collapse,
Popconfirm,
Modal,
Input,
Switch,
} from 'antd';
import TextArea from 'antd/lib/input/TextArea'; import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store'; import store from 'src/store';
import { formatDateSeconds } from 'util/time'; import { formatDateSeconds } from 'util/time';
import { PROPOSAL_STATUS } from 'src/types'; import { PROPOSAL_STATUS } 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 './index.less'; import './index.less';
@ -42,12 +54,50 @@ class ProposalDetailNaked extends React.Component<Props, State> {
okText="delete" okText="delete"
cancelText="cancel" cancelText="cancel"
> >
<Button icon="delete" block> <Button icon="delete" className="ProposalDetail-controls-control" block>
Delete Delete
</Button> </Button>
</Popconfirm> </Popconfirm>
); );
const renderMatching = () => (
<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={false} />{' '}
</Popconfirm>
<span>
matching{' '}
<Info
placement="right"
content={
<span>
<b>Contribution matching</b>
<br /> Funded amount will be multiplied by 2.
</span>
}
/>
</span>
</div>
);
const renderApproved = () => const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && ( p.status === PROPOSAL_STATUS.APPROVED && (
<Alert <Alert
@ -150,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
const renderDeetItem = (name: string, val: any) => ( const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet"> <div className="ProposalDetail-deet">
<span>{name}</span> <span>{name}</span>
{val} {val} &nbsp;
</div> </div>
); );
@ -183,8 +233,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{/* RIGHT SIDE */} {/* RIGHT SIDE */}
<Col span={6}> <Col span={6}>
{/* ACTIONS */} {/* ACTIONS */}
<Card size="small"> <Card size="small" className="ProposalDetail-controls">
{renderDelete()} {renderDelete()}
{renderMatching()}
{/* TODO - other actions */} {/* TODO - other actions */}
</Card> </Card>
@ -195,10 +246,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('status', p.status)} {renderDeetItem('status', p.status)}
{renderDeetItem('category', p.category)} {renderDeetItem('category', p.category)}
{renderDeetItem('target', p.target)} {renderDeetItem('target', p.target)}
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
</Card> </Card>
{/* TEAM */} {/* TEAM */}
<Card title="Team" size="small"> <Card title="team" size="small">
{p.team.map(t => ( {p.team.map(t => (
<div key={t.userid}> <div key={t.userid}>
<Link to={`/users/${t.userid}`}>{t.displayName}</Link> <Link to={`/users/${t.userid}`}>{t.displayName}</Link>
@ -233,6 +287,15 @@ class ProposalDetailNaked extends React.Component<Props, State> {
await store.approveProposal(false, this.state.rejectReason); await store.approveProposal(false, this.state.rejectReason);
this.setState({ showRejectModal: false }); this.setState({ showRejectModal: false });
}; };
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;
store.updateProposalDetail({ contributionMatching });
}
};
} }
const ProposalDetail = withRouter(view(ProposalDetailNaked)); const ProposalDetail = withRouter(view(ProposalDetailNaked));

View File

@ -58,6 +58,11 @@ async function fetchProposalDetail(id: number) {
return data; return data;
} }
async function updateProposal(p: Partial<Proposal>) {
const { data } = await api.put('/admin/proposals/' + p.proposalId, p);
return data;
}
async function deleteProposal(id: number) { async function deleteProposal(id: number) {
const { data } = await api.delete('/admin/proposals/' + id); const { data } = await api.delete('/admin/proposals/' + id);
return data; return data;
@ -202,6 +207,21 @@ const app = store({
app.proposalDetailFetching = false; app.proposalDetailFetching = false;
}, },
async updateProposalDetail(updates: Partial<Proposal>) {
if (!app.proposalDetail) {
return;
}
try {
const res = await updateProposal({
...updates,
proposalId: app.proposalDetail.proposalId,
});
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
},
async deleteProposal(id: number) { async deleteProposal(id: number) {
try { try {
await deleteProposal(id); await deleteProposal(id);
@ -213,10 +233,9 @@ const app = store({
async approveProposal(isApprove: boolean, rejectReason?: string) { async approveProposal(isApprove: boolean, rejectReason?: string) {
if (!app.proposalDetail) { if (!app.proposalDetail) {
(x => { const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
app.generalError.push(x); app.generalError.push(m);
console.error(x); console.error(m);
})('store.approveProposal(): Expected proposalDetail to be populated!');
return; return;
} }
app.proposalDetailApproving = true; app.proposalDetailApproving = true;

View File

@ -39,7 +39,10 @@ export interface Proposal {
comments: Comment[]; comments: Comment[];
contractStatus: string; contractStatus: string;
target: string; target: string;
contributed: string;
funded: string;
rejectReason: string; rejectReason: string;
contributionMatching: number;
} }
export interface Comment { export interface Comment {
commentId: string; commentId: string;

View File

@ -124,7 +124,7 @@ def get_proposal(id):
proposal = Proposal.query.filter(Proposal.id == id).first() proposal = Proposal.query.filter(Proposal.id == id).first()
if proposal: if proposal:
return proposal_schema.dump(proposal) return proposal_schema.dump(proposal)
return {"message": "Could not find proposal with id %s" % id}, 404 return {"message": f"Could not find proposal with id {id}"}, 404
@blueprint.route('/proposals/<id>', methods=['DELETE']) @blueprint.route('/proposals/<id>', methods=['DELETE'])
@ -134,6 +134,29 @@ def delete_proposal(id):
return {"message": "Not implemented."}, 400 return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>', methods=['PUT'])
@endpoint.api(
parameter('contributionMatching', type=float, required=False, default=None)
)
@admin_auth_required
def update_proposal(id, contribution_matching):
proposal = Proposal.query.filter(Proposal.id == id).first()
if proposal:
if contribution_matching is not None:
# enforce 1 or 0 for now
if contribution_matching == 0.0 or contribution_matching == 1.0:
proposal.contribution_matching = contribution_matching
# TODO: trigger check if funding target reached OR make sure
# job schedule checks for funding completion include matching funds
else:
return {"message": f"Bad value for contributionMatching: {contribution_matching}"}, 400
db.session.commit()
return proposal_schema.dump(proposal)
return {"message": f"Could not find proposal with id {id}"}, 404
@blueprint.route('/proposals/<id>/approve', methods=['PUT']) @blueprint.route('/proposals/<id>/approve', methods=['PUT'])
@endpoint.api( @endpoint.api(
parameter('isApprove', type=bool, required=True), parameter('isApprove', type=bool, required=True),

View File

@ -8,6 +8,7 @@ from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url from grant.utils.misc import dt_to_unix, make_url
from grant.utils.requests import blockchain_get from grant.utils.requests import blockchain_get
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
# Proposal states # Proposal states
DRAFT = 'DRAFT' DRAFT = 'DRAFT'
@ -154,6 +155,8 @@ class Proposal(db.Model):
target = db.Column(db.String(255), nullable=False) target = db.Column(db.String(255), nullable=False)
payout_address = db.Column(db.String(255), nullable=False) payout_address = db.Column(db.String(255), nullable=False)
deadline_duration = db.Column(db.Integer(), nullable=False) deadline_duration = db.Column(db.Integer(), nullable=False)
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
contributed = db.column_property()
# Relations # Relations
team = db.relationship("User", secondary=proposal_team) team = db.relationship("User", secondary=proposal_team)
@ -298,13 +301,25 @@ class Proposal(db.Model):
self.date_published = datetime.datetime.now() self.date_published = datetime.datetime.now()
self.status = LIVE self.status = LIVE
def get_amount_funded(self): @hybrid_property
def contributed(self):
contributions = ProposalContribution.query \ contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=CONFIRMED) \ .filter_by(proposal_id=self.id, status=CONFIRMED) \
.all() .all()
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0) funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
return str(funded) return str(funded)
@hybrid_property
def funded(self):
target = float(self.target)
# apply matching multiplier
funded = float(self.contributed) * (1 + self.contribution_matching)
# if funded > target, just set as target
if funded > target:
return str(target)
return str(funded)
class ProposalSchema(ma.Schema): class ProposalSchema(ma.Schema):
class Meta: class Meta:
@ -321,6 +336,7 @@ class ProposalSchema(ma.Schema):
"brief", "brief",
"proposal_id", "proposal_id",
"target", "target",
"contributed",
"funded", "funded",
"content", "content",
"comments", "comments",
@ -330,6 +346,7 @@ class ProposalSchema(ma.Schema):
"team", "team",
"payout_address", "payout_address",
"deadline_duration", "deadline_duration",
"contribution_matching",
"invites" "invites"
) )
@ -337,7 +354,6 @@ class ProposalSchema(ma.Schema):
date_approved = ma.Method("get_date_approved") date_approved = ma.Method("get_date_approved")
date_published = ma.Method("get_date_published") date_published = ma.Method("get_date_published")
proposal_id = ma.Method("get_proposal_id") proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded")
comments = ma.Nested("CommentSchema", many=True) comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True) updates = ma.Nested("ProposalUpdateSchema", many=True)
@ -357,9 +373,6 @@ class ProposalSchema(ma.Schema):
def get_date_published(self, obj): def get_date_published(self, obj):
return dt_to_unix(obj.date_published) if obj.date_published else None return dt_to_unix(obj.date_published) if obj.date_published else None
def get_funded(self, obj):
return obj.get_amount_funded()
proposal_schema = ProposalSchema() proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True) proposals_schema = ProposalSchema(many=True)
@ -370,6 +383,7 @@ user_fields = [
"brief", "brief",
"target", "target",
"funded", "funded",
"contribution_matching",
"date_created", "date_created",
"date_approved", "date_approved",
"date_published", "date_published",

View File

@ -465,7 +465,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'proposal': contribution.proposal, 'proposal': contribution.proposal,
'contribution': contribution, 'contribution': contribution,
'contributor': contribution.user, 'contributor': contribution.user,
'funded': contribution.proposal.get_amount_funded(), 'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'), 'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'), 'contributor_url': make_url(f'/profile/{contribution.user.id}'),
}) })

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: eddbe541cff1
Revises: 722b4e7f7a58
Create Date: 2019-01-24 11:20:32.989266
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eddbe541cff1'
down_revision = '722b4e7f7a58'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('contribution_matching',
sa.Float(), server_default=sa.text('0'), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('proposal', 'contribution_matching')
# ### end Alembic commands ###

View File

@ -75,6 +75,25 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# 2 proposals created by BaseProposalCreatorConfig # 2 proposals created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json), 2) self.assertEqual(len(resp.json), 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={"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={"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={"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={"contributionMatching": 2})
self.assert400(resp)
def test_approve_proposal(self): def test_approve_proposal(self):
self.login_admin() self.login_admin()
# submit for approval (performed by end-user) # submit for approval (performed by end-user)

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Form, Input, Button, Icon } from 'antd'; import { Form, Input, Button, Icon, Popover } from 'antd';
import { Proposal, STATUS } from 'types'; import { Proposal, STATUS } from 'types';
import classnames from 'classnames'; import classnames from 'classnames';
import { fromZat } from 'utils/units'; import { fromZat } from 'utils/units';
@ -95,6 +95,26 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</div> </div>
</div> </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>
)}
{isFundingOver ? ( {isFundingOver ? (
<div <div
className={classnames({ className={classnames({

View File

@ -23,6 +23,31 @@
flex-grow: 1; flex-grow: 1;
overflow: hidden; overflow: hidden;
text-align: right; text-align: right;
&-matching {
font-size: 0.9rem;
color: @info-color;
font-weight: 600;
}
}
}
&-matching {
margin: 0.5rem -1.5rem;
padding: 0.75rem 1.5rem;
text-align: center;
background: @info-color;
color: #FFF;
font-size: 1rem;
.anticon {
margin-left: 0.5rem;
}
}
&-popover {
&-overlay {
max-width: 400px;
} }
} }

View File

@ -24,6 +24,7 @@ export class ProposalCard extends React.Component<Proposal> {
team, team,
target, target,
funded, funded,
contributionMatching,
percentFunded, percentFunded,
} = this.props; } = this.props;
@ -33,6 +34,14 @@ export class ProposalCard extends React.Component<Proposal> {
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })} onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
> >
<h3 className="ProposalCard-title">{title}</h3> <h3 className="ProposalCard-title">{title}</h3>
{contributionMatching > 0 && (
<div className="ProposalCard-ribbon">
<span>
x2
<small>matching</small>
</span>
</div>
)}
<div className="ProposalCard-funding"> <div className="ProposalCard-funding">
<div className="ProposalCard-funding-raised"> <div className="ProposalCard-funding-raised">
<UnitDisplay value={funded} symbol="ZEC" /> <small>raised</small> of{' '} <UnitDisplay value={funded} symbol="ZEC" /> <small>raised</small> of{' '}

View File

@ -1,6 +1,8 @@
@import '~styles/variables.less'; @import '~styles/variables.less';
.ProposalCard { .ProposalCard {
position: relative;
background: white;
border: 1px solid #eee; border: 1px solid #eee;
padding: 1rem 1rem 0; padding: 1rem 1rem 0;
border-radius: 2px; border-radius: 2px;
@ -17,6 +19,34 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
&-ribbon {
position: absolute;
top: 0;
right: 0;
width: 66px;
height: 66px;
background: transparent;
overflow: hidden;
& span {
position: absolute;
top: 10px;
right: -80px;
padding: 0.2rem 0;
line-height: 0.8rem;
display: block;
background: @info-color;
color: white;
transform: rotate(45deg);
width: 200px;
text-align: center;
& small {
display: block;
}
}
}
&-title { &-title {
display: -webkit-box; display: -webkit-box;
font-size: 1rem; font-size: 1rem;
@ -58,7 +88,7 @@
height: 1.8rem; height: 1.8rem;
margin-left: -0.75rem; margin-left: -0.75rem;
border-radius: 100%; border-radius: 100%;
border: 2px solid #FFF; border: 2px solid #fff;
} }
} }
} }

View File

@ -187,6 +187,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
deadlineDuration: 86400 * 60, deadlineDuration: 86400 * 60,
target: toZat(draft.target), target: toZat(draft.target),
funded: Zat('0'), funded: Zat('0'),
contributionMatching: 0,
percentFunded: 0, percentFunded: 0,
stage: 'preview', stage: 'preview',
category: draft.category || PROPOSAL_CATEGORY.DAPP, category: draft.category || PROPOSAL_CATEGORY.DAPP,

View File

@ -22,7 +22,7 @@ export function formatUserFromGet(user: UserState) {
} }
user.proposals = user.proposals.map(bnUserProp); user.proposals = user.proposals.map(bnUserProp);
user.contributions = user.contributions.map(c => { user.contributions = user.contributions.map(c => {
c.amount = toZat(c.amount as any as string); c.amount = toZat((c.amount as any) as string);
return c; return c;
}); });
return user; return user;

View File

@ -153,6 +153,7 @@ export function generateProposal({
target: amountBn, target: amountBn,
funded: fundedBn, funded: fundedBn,
percentFunded, percentFunded,
contributionMatching: 0,
title: 'Crowdfund Title', title: 'Crowdfund Title',
brief: 'A cool test crowdfund', brief: 'A cool test crowdfund',
content: 'body', content: 'body',

View File

@ -42,6 +42,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
target: Zat; target: Zat;
funded: Zat; funded: Zat;
percentFunded: number; percentFunded: number;
contributionMatching: number;
milestones: ProposalMilestone[]; milestones: ProposalMilestone[];
datePublished: number; datePublished: number;
} }