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:
parent
73bbd191d9
commit
b0d16ace7d
|
@ -0,0 +1,10 @@
|
|||
.Info {
|
||||
position: relative;
|
||||
&-overlay {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
& .anticon {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
</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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -465,7 +465,7 @@ 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}'),
|
||||
})
|
||||
|
|
|
@ -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 ###
|
|
@ -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)
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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{' '}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -153,6 +153,7 @@ export function generateProposal({
|
|||
target: amountBn,
|
||||
funded: fundedBn,
|
||||
percentFunded,
|
||||
contributionMatching: 0,
|
||||
title: 'Crowdfund Title',
|
||||
brief: 'A cool test crowdfund',
|
||||
content: 'body',
|
||||
|
|
|
@ -42,6 +42,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
|||
target: Zat;
|
||||
funded: Zat;
|
||||
percentFunded: number;
|
||||
contributionMatching: number;
|
||||
milestones: ProposalMilestone[];
|
||||
datePublished: number;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue