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;
}
&-controls {
&-control + &-control {
margin-top: 0.8rem;
}
}
&-deet {
position: relative;
margin-bottom: 0.6rem;
@ -23,4 +29,10 @@
margin-left: 0.5rem;
}
}
&-popover {
&-overlay {
max-width: 400px;
}
}
}

View File

@ -1,13 +1,25 @@
import React from 'react';
import { view } from 'react-easy-state';
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 store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { PROPOSAL_STATUS } 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 './index.less';
@ -42,12 +54,50 @@ class ProposalDetailNaked extends React.Component<Props, State> {
okText="delete"
cancelText="cancel"
>
<Button icon="delete" block>
<Button icon="delete" className="ProposalDetail-controls-control" block>
Delete
</Button>
</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 = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
<Alert
@ -150,7 +200,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet">
<span>{name}</span>
{val}
{val} &nbsp;
</div>
);
@ -183,8 +233,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{/* RIGHT SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card size="small">
<Card size="small" className="ProposalDetail-controls">
{renderDelete()}
{renderMatching()}
{/* TODO - other actions */}
</Card>
@ -195,10 +246,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('status', p.status)}
{renderDeetItem('category', p.category)}
{renderDeetItem('target', p.target)}
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
</Card>
{/* TEAM */}
<Card title="Team" size="small">
<Card title="team" size="small">
{p.team.map(t => (
<div key={t.userid}>
<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);
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));

View File

@ -58,6 +58,11 @@ async function fetchProposalDetail(id: number) {
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) {
const { data } = await api.delete('/admin/proposals/' + id);
return data;
@ -202,6 +207,21 @@ const app = store({
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) {
try {
await deleteProposal(id);
@ -213,10 +233,9 @@ const app = store({
async approveProposal(isApprove: boolean, rejectReason?: string) {
if (!app.proposalDetail) {
(x => {
app.generalError.push(x);
console.error(x);
})('store.approveProposal(): Expected proposalDetail to be populated!');
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.proposalDetailApproving = true;

View File

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

View File

@ -124,7 +124,7 @@ def get_proposal(id):
proposal = Proposal.query.filter(Proposal.id == id).first()
if 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'])
@ -134,6 +134,29 @@ def delete_proposal(id):
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'])
@endpoint.api(
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.requests import blockchain_get
from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
# Proposal states
DRAFT = 'DRAFT'
@ -154,6 +155,8 @@ class Proposal(db.Model):
target = db.Column(db.String(255), nullable=False)
payout_address = db.Column(db.String(255), 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
team = db.relationship("User", secondary=proposal_team)
@ -298,13 +301,25 @@ class Proposal(db.Model):
self.date_published = datetime.datetime.now()
self.status = LIVE
def get_amount_funded(self):
@hybrid_property
def contributed(self):
contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=CONFIRMED) \
.all()
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
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 Meta:
@ -321,6 +336,7 @@ class ProposalSchema(ma.Schema):
"brief",
"proposal_id",
"target",
"contributed",
"funded",
"content",
"comments",
@ -330,6 +346,7 @@ class ProposalSchema(ma.Schema):
"team",
"payout_address",
"deadline_duration",
"contribution_matching",
"invites"
)
@ -337,7 +354,6 @@ class ProposalSchema(ma.Schema):
date_approved = ma.Method("get_date_approved")
date_published = ma.Method("get_date_published")
proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded")
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
@ -357,9 +373,6 @@ class ProposalSchema(ma.Schema):
def get_date_published(self, obj):
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()
proposals_schema = ProposalSchema(many=True)
@ -370,6 +383,7 @@ user_fields = [
"brief",
"target",
"funded",
"contribution_matching",
"date_created",
"date_approved",
"date_published",

View File

@ -465,13 +465,13 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'proposal': contribution.proposal,
'contribution': contribution,
'contributor': contribution.user,
'funded': contribution.proposal.get_amount_funded(),
'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
})
# TODO: Once we have a task queuer in place, queue emails to everyone
# on funding target reached.
# on funding target reached.
return None, 200

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
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):
self.login_admin()
# submit for approval (performed by end-user)

View File

@ -1,6 +1,6 @@
import React from 'react';
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 classnames from 'classnames';
import { fromZat } from 'utils/units';
@ -95,6 +95,26 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</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 ? (
<div
className={classnames({

View File

@ -23,6 +23,31 @@
flex-grow: 1;
overflow: hidden;
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,
target,
funded,
contributionMatching,
percentFunded,
} = this.props;
@ -33,6 +34,14 @@ export class ProposalCard extends React.Component<Proposal> {
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
>
<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-raised">
<UnitDisplay value={funded} symbol="ZEC" /> <small>raised</small> of{' '}

View File

@ -1,6 +1,8 @@
@import '~styles/variables.less';
.ProposalCard {
position: relative;
background: white;
border: 1px solid #eee;
padding: 1rem 1rem 0;
border-radius: 2px;
@ -17,6 +19,34 @@
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 {
display: -webkit-box;
font-size: 1rem;
@ -58,7 +88,7 @@
height: 1.8rem;
margin-left: -0.75rem;
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,
target: toZat(draft.target),
funded: Zat('0'),
contributionMatching: 0,
percentFunded: 0,
stage: 'preview',
category: draft.category || PROPOSAL_CATEGORY.DAPP,

View File

@ -22,7 +22,7 @@ export function formatUserFromGet(user: UserState) {
}
user.proposals = user.proposals.map(bnUserProp);
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 user;

View File

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

View File

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