From b0d16ace7df322941f9e548bfefc70613cb00975 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Tue, 29 Jan 2019 17:50:27 -0600 Subject: [PATCH] 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 --- admin/src/components/Info/index.less | 10 +++ admin/src/components/Info/index.tsx | 14 ++++ .../src/components/ProposalDetail/index.less | 12 +++ admin/src/components/ProposalDetail/index.tsx | 73 +++++++++++++++++-- admin/src/store.ts | 27 ++++++- admin/src/types.ts | 3 + backend/grant/admin/views.py | 25 ++++++- backend/grant/proposal/models.py | 24 ++++-- backend/grant/proposal/views.py | 4 +- backend/migrations/versions/eddbe541cff1_.py | 29 ++++++++ backend/tests/admin/test_api.py | 19 +++++ .../Proposal/CampaignBlock/index.tsx | 22 +++++- .../Proposal/CampaignBlock/style.less | 25 +++++++ .../Proposals/ProposalCard/index.tsx | 9 +++ .../Proposals/ProposalCard/style.less | 32 +++++++- frontend/client/modules/create/utils.ts | 1 + frontend/client/utils/api.ts | 2 +- frontend/stories/props.tsx | 1 + frontend/types/proposal.ts | 1 + 19 files changed, 313 insertions(+), 20 deletions(-) create mode 100644 admin/src/components/Info/index.less create mode 100644 admin/src/components/Info/index.tsx create mode 100644 backend/migrations/versions/eddbe541cff1_.py diff --git a/admin/src/components/Info/index.less b/admin/src/components/Info/index.less new file mode 100644 index 00000000..75ad6230 --- /dev/null +++ b/admin/src/components/Info/index.less @@ -0,0 +1,10 @@ +.Info { + position: relative; + &-overlay { + max-width: 400px; + } + + & .anticon { + color: #1890ff; + } +} diff --git a/admin/src/components/Info/index.tsx b/admin/src/components/Info/index.tsx new file mode 100644 index 00000000..23f7caa1 --- /dev/null +++ b/admin/src/components/Info/index.tsx @@ -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 = p => ( + + + {p.children} + + +); + +export default Info; diff --git a/admin/src/components/ProposalDetail/index.less b/admin/src/components/ProposalDetail/index.less index 51e645e8..cef74417 100644 --- a/admin/src/components/ProposalDetail/index.less +++ b/admin/src/components/ProposalDetail/index.less @@ -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; + } + } } diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 37e602c8..80ef5c91 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -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 { okText="delete" cancelText="cancel" > - ); + const renderMatching = () => ( +
+ +
+ Turn {p.contributionMatching ? 'off' : 'on'} contribution matching? +
+ {p.status === PROPOSAL_STATUS.LIVE && ( +
+ This is a LIVE proposal, this will alter the funding state of the + proposal! +
+ )} + + } + okText="ok" + cancelText="cancel" + > + {' '} +
+ + matching{' '} + + Contribution matching +
Funded amount will be multiplied by 2. +
+ } + /> + +
+ ); + const renderApproved = () => p.status === PROPOSAL_STATUS.APPROVED && ( { const renderDeetItem = (name: string, val: any) => (
{name} - {val} + {val}  
); @@ -183,8 +233,9 @@ class ProposalDetailNaked extends React.Component { {/* RIGHT SIDE */} {/* ACTIONS */} - + {renderDelete()} + {renderMatching()} {/* TODO - other actions */} @@ -195,10 +246,13 @@ class ProposalDetailNaked extends React.Component { {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)} {/* TEAM */} - + {p.team.map(t => (
{t.displayName} @@ -233,6 +287,15 @@ class ProposalDetailNaked extends React.Component { 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)); diff --git a/admin/src/store.ts b/admin/src/store.ts index 816493b3..a9942d95 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -58,6 +58,11 @@ async function fetchProposalDetail(id: number) { return data; } +async function updateProposal(p: Partial) { + 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) { + 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; diff --git a/admin/src/types.ts b/admin/src/types.ts index 06597c35..cf34ca36 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 019bcefc..cdcb78c9 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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/', methods=['DELETE']) @@ -134,6 +134,29 @@ def delete_proposal(id): return {"message": "Not implemented."}, 400 +@blueprint.route('/proposals/', 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//approve', methods=['PUT']) @endpoint.api( parameter('isApprove', type=bool, required=True), diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 57ea6a1b..c3586e87 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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", diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 4a8beb81..b1239bdb 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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 diff --git a/backend/migrations/versions/eddbe541cff1_.py b/backend/migrations/versions/eddbe541cff1_.py new file mode 100644 index 00000000..115215ba --- /dev/null +++ b/backend/migrations/versions/eddbe541cff1_.py @@ -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 ### diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index b35743bb..c8b6f1e3 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -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) diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index eee0b59a..c30eff3b 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -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 {
+ {proposal.contributionMatching > 0 && ( +
+ Funds are being matched x{proposal.contributionMatching + 1} + + Matching +
+ Increase your impact! Contributions to this proposal are being + matched by the Zcash Foundation, up to the target amount. + + } + > + +
+
+ )} + {isFundingOver ? (
{ team, target, funded, + contributionMatching, percentFunded, } = this.props; @@ -33,6 +34,14 @@ export class ProposalCard extends React.Component { onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })} >

{title}

+ {contributionMatching > 0 && ( +
+ + x2 + matching + +
+ )}
raised of{' '} diff --git a/frontend/client/components/Proposals/ProposalCard/style.less b/frontend/client/components/Proposals/ProposalCard/style.less index 60a40794..beb6c04f 100644 --- a/frontend/client/components/Proposals/ProposalCard/style.less +++ b/frontend/client/components/Proposals/ProposalCard/style.less @@ -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; } } } diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index ea6a2332..8d36ffc0 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -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, diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index e9b06897..b6c90838 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -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; diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 0fd71cb0..5cf56159 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -153,6 +153,7 @@ export function generateProposal({ target: amountBn, funded: fundedBn, percentFunded, + contributionMatching: 0, title: 'Crowdfund Title', brief: 'A cool test crowdfund', content: 'body', diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index a60a2c67..10609c35 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -42,6 +42,7 @@ export interface Proposal extends Omit { target: Zat; funded: Zat; percentFunded: number; + contributionMatching: number; milestones: ProposalMilestone[]; datePublished: number; }