From a1283f24ebcd83b983e846121238cc1e36985d13 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 20:45:57 -0500 Subject: [PATCH] Allow topping off of partial stake contributions. Fix float innaccuracy errors in python. Allow staking proposals to be deleted. --- backend/grant/proposal/models.py | 19 +++-- backend/grant/proposal/views.py | 5 +- backend/grant/settings.py | 3 +- .../staking_contribution_confirmed.html | 7 +- .../emails/staking_contribution_confirmed.txt | 6 +- backend/tests/config.py | 2 +- backend/tests/proposal/test_api.py | 2 +- .../components/ContributionModal/index.tsx | 40 +++++----- .../components/Profile/ProfilePending.tsx | 74 ++++++++++++++++--- 9 files changed, 109 insertions(+), 49 deletions(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 7a348e06..3c1db9f5 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -2,6 +2,7 @@ import datetime from functools import reduce from sqlalchemy import func, or_ from sqlalchemy.ext.hybrid import hybrid_property +from decimal import Decimal from grant.comment.models import Comment from grant.email.send import send_email @@ -263,7 +264,7 @@ class Proposal(db.Model): self.deadline_duration = deadline_duration Proposal.validate(vars(self)) - def create_contribution(self, user_id: int, amount: float): + def create_contribution(self, user_id: int, amount): contribution = ProposalContribution( proposal_id=self.id, user_id=user_id, @@ -275,18 +276,16 @@ class Proposal(db.Model): def get_staking_contribution(self, user_id: int): contribution = None - remaining = PROPOSAL_STAKING_AMOUNT - float(self.contributed) + remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.contributed) # check funding if remaining > 0: - # find pending contribution for any user - # (always use full staking amout so we can find it) + # find pending contribution for any user of remaining amount contribution = ProposalContribution.query.filter_by( proposal_id=self.id, - amount=str(PROPOSAL_STAKING_AMOUNT), status=PENDING, ).first() if not contribution: - contribution = self.create_contribution(user_id, PROPOSAL_STAKING_AMOUNT) + contribution = self.create_contribution(user_id, str(remaining.normalize())) return contribution @@ -345,14 +344,14 @@ class Proposal(db.Model): contributions = ProposalContribution.query \ .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \ .all() - funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0) + funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) return str(funded) @hybrid_property def funded(self): - target = float(self.target) + target = Decimal(self.target) # apply matching multiplier - funded = float(self.contributed) * (1 + self.contribution_matching) + funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching) # if funded > target, just set as target if funded > target: return str(target) @@ -361,7 +360,7 @@ class Proposal(db.Model): @hybrid_property def is_staked(self): - return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT + return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT class ProposalSchema(ma.Schema): diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index a872aafb..16aeb148 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -237,6 +237,7 @@ def delete_proposal(proposal_id): ProposalStatus.PENDING, ProposalStatus.APPROVED, ProposalStatus.REJECTED, + ProposalStatus.STAKING, ] status = g.current_proposal.status if status not in deleteable_statuses: @@ -482,7 +483,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): if contribution.proposal.status == ProposalStatus.STAKING: # fully staked, set status PENDING & notify user - if contribution.proposal.is_staked: # float(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT: + if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT: contribution.proposal.status = ProposalStatus.PENDING db.session.add(contribution.proposal) db.session.commit() @@ -493,7 +494,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): 'proposal': contribution.proposal, 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', 'fully_staked': contribution.proposal.is_staked, - 'stake_target': PROPOSAL_STAKING_AMOUNT + 'stake_target': str(PROPOSAL_STAKING_AMOUNT.normalize()), }) else: diff --git a/backend/grant/settings.py b/backend/grant/settings.py index d91908f3..47a1c820 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -7,6 +7,7 @@ For local development, use a .env file to set environment variables. """ from environs import Env +from decimal import Decimal env = Env() env.read_env() @@ -54,7 +55,7 @@ ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH") EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/") -PROPOSAL_STAKING_AMOUNT = env.float("PROPOSAL_STAKING_AMOUNT") +PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT")) UI = { 'NAME': 'ZF Grants', diff --git a/backend/grant/templates/emails/staking_contribution_confirmed.html b/backend/grant/templates/emails/staking_contribution_confirmed.html index e742e005..a1b7fe93 100644 --- a/backend/grant/templates/emails/staking_contribution_confirmed.html +++ b/backend/grant/templates/emails/staking_contribution_confirmed.html @@ -5,9 +5,10 @@ will now be forwarded to administrators for approval. {% else %} {{ args.proposal.title }} has been partially staked for - {{ args.contribution.amount }} ZEC. This is not enough to - fully stake the proposal. You must send at least - {{ args.stake_target }} ZEC. + {{ args.contribution.amount }} ZEC of the required + {{ args.stake_target}} ZEC. + You can send the remaining amount by going to your profile's "Pending" tab, + and clicking the "Stake" button next to the proposal. {% endif %} You can view your transaction below:

diff --git a/backend/grant/templates/emails/staking_contribution_confirmed.txt b/backend/grant/templates/emails/staking_contribution_confirmed.txt index 54afffd0..d562fc3d 100644 --- a/backend/grant/templates/emails/staking_contribution_confirmed.txt +++ b/backend/grant/templates/emails/staking_contribution_confirmed.txt @@ -3,9 +3,9 @@ Your proposal will now be forwarded to administrators for approval. {% else %} {{ args.proposal.title }} has been partially staked for -{{ args.contribution.amount }} ZEC. This is not enough to -fully stake the proposal. You must send at least -{{ args.stake_target }} ZEC. +{{ args.contribution.amount }} ZEC of the required {{ args.stake_target}} ZEC. +You can send the remaining amount by going to your profile's "Pending" tab, +and clicking the "Stake" button next to the proposal. {% endif %} You can view your transaction here: diff --git a/backend/tests/config.py b/backend/tests/config.py index d72f45c0..c9b6bcfc 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -155,7 +155,7 @@ class BaseProposalCreatorConfig(BaseUserConfig): # 2. get staking contribution contribution = self.proposal.get_staking_contribution(self.user.id) # 3. fake a confirmation - contribution.confirm(tx_id='tx', amount=str(PROPOSAL_STAKING_AMOUNT)) + contribution.confirm(tx_id='tx', amount=str(PROPOSAL_STAKING_AMOUNT.normalize())) db.session.add(contribution) db.session.commit() contribution = self.proposal.get_staking_contribution(self.user.id) diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index db2228c8..dff7a2c9 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -115,7 +115,7 @@ class TestProposalAPI(BaseProposalCreatorConfig): resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake") print(resp) self.assert200(resp) - self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT)) + self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT.normalize())) @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_proposal_stake_no_auth(self, mock_get): diff --git a/frontend/client/components/ContributionModal/index.tsx b/frontend/client/components/ContributionModal/index.tsx index 6155bb38..fe327a86 100644 --- a/frontend/client/components/ContributionModal/index.tsx +++ b/frontend/client/components/ContributionModal/index.tsx @@ -8,10 +8,12 @@ import PaymentInfo from './PaymentInfo'; interface OwnProps { isVisible: boolean; + contribution?: ContributionWithAddresses | Falsy; proposalId?: number; contributionId?: number; amount?: string; hasNoButtons?: boolean; + text?: React.ReactNode; handleClose(): void; } @@ -30,22 +32,32 @@ export default class ContributionModal extends React.Component { error: null, }; + constructor(props: Props) { + super(props); + if (props.contribution) { + this.state = { + ...this.state, + contribution: props.contribution, + }; + } + } + componentWillUpdate(nextProps: Props) { - const { isVisible, proposalId, contributionId } = nextProps; + const { isVisible, proposalId, contributionId, contribution } = nextProps; // When modal is opened and proposalId is provided or changed if (isVisible && proposalId) { - if ( - this.props.isVisible !== isVisible || - proposalId !== this.props.proposalId - ) { + if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) { this.fetchAddresses(proposalId, contributionId); } } - + // If contribution is provided + if (contribution !== this.props.contribution) { + this.setState({ contribution: contribution || null }); + } } render() { - const { isVisible, handleClose, hasNoButtons } = this.props; + const { isVisible, handleClose, hasNoButtons, text } = this.props; const { hasSent, contribution, error } = this.state; let content; @@ -68,7 +80,7 @@ export default class ContributionModal extends React.Component { if (error) { content = error; } else { - content = ; + content = ; } } @@ -89,22 +101,16 @@ export default class ContributionModal extends React.Component { ); } - private async fetchAddresses( - proposalId: number, - contributionId?: number, - ) { + private async fetchAddresses(proposalId: number, contributionId?: number) { try { let res; if (contributionId) { res = await getProposalContribution(proposalId, contributionId); } else { - res = await postProposalContribution( - proposalId, - this.props.amount || '0', - ); + res = await postProposalContribution(proposalId, this.props.amount || '0'); } this.setState({ contribution: res.data }); - } catch(err) { + } catch (err) { this.setState({ error: err.message }); } } diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx index 460c3972..972a3ccb 100644 --- a/frontend/client/components/Profile/ProfilePending.tsx +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -1,15 +1,17 @@ import React, { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Button, Popconfirm, message, Tag } from 'antd'; -import { UserProposal, STATUS } from 'types'; +import { UserProposal, STATUS, ContributionWithAddresses } from 'types'; +import ContributionModal from 'components/ContributionModal'; +import { getProposalStakingContribution } from 'api/api'; import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions'; -import './ProfilePending.less'; import { connect } from 'react-redux'; import { AppState } from 'store/reducers'; +import './ProfilePending.less'; interface OwnProps { proposal: UserProposal; - onPublish: (id: UserProposal['proposalId']) => void; + onPublish(id: UserProposal['proposalId']): void; } interface StateProps { @@ -23,18 +25,24 @@ interface DispatchProps { type Props = OwnProps & StateProps & DispatchProps; -const STATE = { - isDeleting: false, - isPublishing: false, -}; - -type State = typeof STATE; +interface State { + isDeleting: boolean; + isPublishing: boolean; + isLoadingStake: boolean; + stakeContribution: ContributionWithAddresses | null; +} class ProfilePending extends React.Component { - state = STATE; + state: State = { + isDeleting: false, + isPublishing: false, + isLoadingStake: false, + stakeContribution: null, + }; + render() { const { status, title, proposalId, rejectReason } = this.props.proposal; - const { isDeleting, isPublishing } = this.state; + const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state; const isDisableActions = isDeleting || isPublishing; @@ -105,6 +113,15 @@ class ProfilePending extends React.Component { )} + {STATUS.STAKING === status && ( + + )} { + + {STATUS.STAKING && ( + + Please send the staking contribution of{' '} + {stakeContribution && stakeContribution.amount} ZEC using the + instructions below. Once your payment has been sent and confirmed, you + will receive an email. +

+ } + /> + )} ); } @@ -152,6 +185,25 @@ class ProfilePending extends React.Component { this.setState({ isDeleting: false }); } }; + + private openStakingModal = async () => { + try { + this.setState({ isLoadingStake: true }); + const res = await getProposalStakingContribution(this.props.proposal.proposalId); + this.setState({ stakeContribution: res.data }, () => { + this.setState({ isLoadingStake: false }); + }); + } catch (err) { + message.error(err.message, 3); + this.setState({ isLoadingStake: false }); + } + }; + + private closeStakingModal = () => + this.setState({ + isLoadingStake: false, + stakeContribution: null, + }); } export default connect(