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(