From eb47e3e0aae80ddab677cc65d7ff8bbf3f0c761b Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Fri, 22 Feb 2019 15:51:46 -0500 Subject: [PATCH 01/11] Allow for no user with contributions in admin. --- admin/src/components/ContributionDetail/index.tsx | 9 +++++---- admin/src/components/ContributionForm/index.tsx | 11 ++++++----- .../src/components/Contributions/ContributionItem.tsx | 2 +- admin/src/types.ts | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/admin/src/components/ContributionDetail/index.tsx b/admin/src/components/ContributionDetail/index.tsx index 869275a5..61f9894f 100644 --- a/admin/src/components/ContributionDetail/index.tsx +++ b/admin/src/components/ContributionDetail/index.tsx @@ -46,7 +46,7 @@ class ContributionDetail extends React.Component { ); const renderSendRefund = () => { - if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed) { + if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed || !c.user) { return; } const percent = c.proposal.milestones.reduce((prev, m) => { @@ -98,9 +98,10 @@ class ContributionDetail extends React.Component {
{JSON.stringify(c.addresses, null, 4)}
- - - + + + {c.user ? : Anonymous contribution} + diff --git a/admin/src/components/ContributionForm/index.tsx b/admin/src/components/ContributionForm/index.tsx index a12fd4a1..506be031 100644 --- a/admin/src/components/ContributionForm/index.tsx +++ b/admin/src/components/ContributionForm/index.tsx @@ -40,7 +40,7 @@ class ContributionForm extends React.Component { } defaults = { proposalId: contribution.proposal.proposalId, - userId: contribution.user.userid, + userId: contribution.user ? contribution.user.userid : '', amount: contribution.amount, txId: contribution.txId || '', }; @@ -68,14 +68,11 @@ class ContributionForm extends React.Component { {getFieldDecorator('userId', { initialValue: defaults.userId, - rules: [ - { required: true, message: 'User ID is required' }, - ], })( , )} @@ -152,6 +149,10 @@ class ContributionForm extends React.Component { }; let msg; if (id) { + // Explicitly make it zero of omitted to indicate to remove user + if (!args.userId) { + args.userId = 0; + } await store.editContribution(id, args); msg = 'Successfully updated contribution'; } else { diff --git a/admin/src/components/Contributions/ContributionItem.tsx b/admin/src/components/Contributions/ContributionItem.tsx index 3cbcbb8f..f0f99d3f 100644 --- a/admin/src/components/Contributions/ContributionItem.tsx +++ b/admin/src/components/Contributions/ContributionItem.tsx @@ -22,7 +22,7 @@ export default class ContributionItem extends React.PureComponent { >

- {user.displayName} for {proposal.title} + {user ? user.displayName : Anonymous} for {proposal.title} {status.tagDisplay} diff --git a/admin/src/types.ts b/admin/src/types.ts index 8359345b..8a9824e8 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -139,7 +139,7 @@ export interface Contribution { txId: null | string; amount: string; dateCreated: number; - user: User; + user: User | null; proposal: Proposal; addresses: { transparent: string; From 115b1279623750360778e37b04ac2905dc04c19d Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Sat, 23 Feb 2019 15:31:07 -0500 Subject: [PATCH 02/11] Initial pass at anon contributions on frontend. --- backend/grant/admin/views.py | 17 ++-- backend/grant/proposal/models.py | 20 ++-- backend/grant/proposal/views.py | 21 ++-- backend/grant/utils/stubs.py | 9 ++ frontend/client/api/api.ts | 10 +- .../ContributionModal/PaymentInfo.less | 5 + .../components/ContributionModal/index.tsx | 96 +++++++++++++++---- .../Proposal/CampaignBlock/index.tsx | 31 ++++-- .../Proposal/CampaignBlock/style.less | 26 +++++ frontend/types/contribution.ts | 3 + 10 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 backend/grant/utils/stubs.py diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index fb6422b9..bf1b9d77 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -498,7 +498,7 @@ def get_contributions(page, filters, search, sort): @blueprint.route('/contributions', methods=['POST']) @endpoint.api( parameter('proposalId', type=int, required=True), - parameter('userId', type=int, required=True), + parameter('userId', type=int, required=False, default=None), parameter('status', type=str, required=True), parameter('amount', type=str, required=True), parameter('txId', type=str, required=False), @@ -561,12 +561,15 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_ if not proposal: return {"message": "No proposal matching that id"}, 400 contribution.proposal_id = proposal_id - # User ID (must belong to an existing user) - if user_id: - user = User.query.filter(User.id == user_id).first() - if not user: - return {"message": "No user matching that id"}, 400 - contribution.user_id = user_id + # User ID (must belong to an existing user or 0 to unset) + if user_id is not None: + if user_id == 0: + contribution.user_id = None + else: + user = User.query.filter(User.id == user_id).first() + if not user: + return {"message": "No user matching that id"}, 400 + contribution.user_id = user_id # Status (must be in list of statuses) if status: if not ContributionStatus.includes(status): diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index ca3d88a1..732fd3e5 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -3,6 +3,7 @@ from functools import reduce from sqlalchemy import func, or_ from sqlalchemy.ext.hybrid import hybrid_property from decimal import Decimal +from marshmallow import post_dump from grant.comment.models import Comment from grant.email.send import send_email @@ -19,6 +20,7 @@ from grant.utils.enums import ( ProposalArbiterStatus, MilestoneStage ) +from grant.utils.stubs import anonymous_user proposal_team = db.Table( 'proposal_team', db.Model.metadata, @@ -87,13 +89,13 @@ class ProposalContribution(db.Model): def __init__( self, proposal_id: int, - user_id: int, amount: str, + user_id: int = None, staking: bool = False, ): self.proposal_id = proposal_id - self.user_id = user_id self.amount = amount + self.user_id = user_id self.staking = staking self.date_created = datetime.datetime.now() self.status = ContributionStatus.PENDING @@ -163,7 +165,7 @@ class ProposalContribution(db.Model): @hybrid_property def refund_address(self): - return self.user.settings.refund_address + return self.user.settings.refund_address if self.user else None class ProposalArbiter(db.Model): @@ -337,11 +339,11 @@ class Proposal(db.Model): self.deadline_duration = deadline_duration Proposal.validate(vars(self)) - def create_contribution(self, user_id: int, amount, staking: bool = False): + def create_contribution(self, amount, user_id: int = None, staking: bool = False): contribution = ProposalContribution( proposal_id=self.id, - user_id=user_id, amount=amount, + user_id=user_id, staking=staking, ) db.session.add(contribution) @@ -692,7 +694,7 @@ class ProposalContributionSchema(ma.Schema): ) proposal = ma.Nested("ProposalSchema") - user = ma.Nested("UserSchema") + user = ma.Nested("UserSchema", default=anonymous_user) date_created = ma.Method("get_date_created") addresses = ma.Method("get_addresses") @@ -702,6 +704,12 @@ class ProposalContributionSchema(ma.Schema): def get_addresses(self, obj): return blockchain_get('/contribution/addresses', {'contributionId': obj.id}) + @post_dump + def stub_anonymous_user(self, data): + if not data['user']: + data['user'] = anonymous_user + return data + proposal_contribution_schema = ProposalContributionSchema() proposal_contributions_schema = ProposalContributionSchema(many=True) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 6d632dce..3690279d 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -478,22 +478,31 @@ def get_proposal_contribution(proposal_id, contribution_id): @blueprint.route("//contributions", methods=["POST"]) -@requires_auth @endpoint.api( - parameter('amount', type=str, required=True) + parameter('amount', type=str, required=True), + parameter('anonymous', type=bool, required=False) ) -def post_proposal_contribution(proposal_id, amount): +def post_proposal_contribution(proposal_id, amount, anonymous): proposal = Proposal.query.filter_by(id=proposal_id).first() if not proposal: return {"message": "No proposal matching id"}, 404 code = 200 - contribution = ProposalContribution \ - .get_existing_contribution(g.current_user.id, proposal_id, amount) + user = None + contribution = None + + if not anonymous: + user = get_authed_user() + + if user: + contribution = ProposalContribution.get_existing_contribution(user.id, proposal_id, amount) if not contribution: code = 201 - contribution = proposal.create_contribution(g.current_user.id, amount) + contribution = proposal.create_contribution( + amount=amount, + user_id=user.id if user else None, + ) dumped_contribution = proposal_contribution_schema.dump(contribution) return dumped_contribution, code diff --git a/backend/grant/utils/stubs.py b/backend/grant/utils/stubs.py new file mode 100644 index 00000000..a5c34b1e --- /dev/null +++ b/backend/grant/utils/stubs.py @@ -0,0 +1,9 @@ +# This should match UserSchema in grant.user.models +anonymous_user = { + 'userid': 0, + 'display_name': 'Anonymous', + 'title': 'Anonymous', + 'avatar': None, + 'social_medias': [], + 'email_verified': True, +} diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index c719e4a7..37641f65 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -8,7 +8,7 @@ import { TeamInvite, TeamInviteWithProposal, SOCIAL_SERVICE, - ContributionWithAddresses, + ContributionWithAddressesAndUser, EmailSubscriptions, RFP, ProposalPageParams, @@ -309,9 +309,11 @@ export function putInviteResponse( export function postProposalContribution( proposalId: number, amount: string, -): Promise<{ data: ContributionWithAddresses }> { + anonymous?: boolean, +): Promise<{ data: ContributionWithAddressesAndUser }> { return axios.post(`/api/v1/proposals/${proposalId}/contributions`, { amount, + anonymous, }); } @@ -331,13 +333,13 @@ export function deleteProposalContribution(contributionId: string | number) { export function getProposalContribution( proposalId: number, contributionId: number, -): Promise<{ data: ContributionWithAddresses }> { +): Promise<{ data: ContributionWithAddressesAndUser }> { return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`); } export function getProposalStakingContribution( proposalId: number, -): Promise<{ data: ContributionWithAddresses }> { +): Promise<{ data: ContributionWithAddressesAndUser }> { return axios.get(`/api/v1/proposals/${proposalId}/stake`); } diff --git a/frontend/client/components/ContributionModal/PaymentInfo.less b/frontend/client/components/ContributionModal/PaymentInfo.less index d819671b..5c10f935 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.less +++ b/frontend/client/components/ContributionModal/PaymentInfo.less @@ -1,6 +1,11 @@ @import '~styles/variables.less'; .PaymentInfo { + &-anonymous { + margin-top: -0.5rem; + margin-bottom: 1.25rem; + } + &-text { margin-top: -0.25rem; font-size: 0.95rem; diff --git a/frontend/client/components/ContributionModal/index.tsx b/frontend/client/components/ContributionModal/index.tsx index fe327a86..7c7b79f9 100644 --- a/frontend/client/components/ContributionModal/index.tsx +++ b/frontend/client/components/ContributionModal/index.tsx @@ -1,17 +1,18 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { Modal } from 'antd'; +import { Modal, Alert } from 'antd'; import Result from 'ant-design-pro/lib/Result'; import { postProposalContribution, getProposalContribution } from 'api/api'; -import { ContributionWithAddresses } from 'types'; +import { ContributionWithAddressesAndUser } from 'types'; import PaymentInfo from './PaymentInfo'; interface OwnProps { isVisible: boolean; - contribution?: ContributionWithAddresses | Falsy; + contribution?: ContributionWithAddressesAndUser | Falsy; proposalId?: number; contributionId?: number; amount?: string; + isAnonymous?: boolean; hasNoButtons?: boolean; text?: React.ReactNode; handleClose(): void; @@ -20,13 +21,15 @@ interface OwnProps { type Props = OwnProps; interface State { + hasConfirmedAnonymous: boolean; hasSent: boolean; - contribution: ContributionWithAddresses | null; + contribution: ContributionWithAddressesAndUser | null; error: string | null; } export default class ContributionModal extends React.Component { state: State = { + hasConfirmedAnonymous: true, hasSent: false, contribution: null, error: null, @@ -34,34 +37,87 @@ export default class ContributionModal extends React.Component { constructor(props: Props) { super(props); + if (props.isAnonymous) { + this.state = { + ...this.state, + hasConfirmedAnonymous: false, + }; + } if (props.contribution) { this.state = { ...this.state, contribution: props.contribution, + hasConfirmedAnonymous: !!props.contribution.user.userid, }; } } - componentWillUpdate(nextProps: Props) { - const { isVisible, proposalId, contributionId, contribution } = nextProps; - // When modal is opened and proposalId is provided or changed - if (isVisible && proposalId) { + componentWillUpdate(nextProps: Props, nextState: State) { + const { + isVisible, + proposalId, + contributionId, + contribution, + isAnonymous, + } = nextProps; + let { hasConfirmedAnonymous } = nextState; + // If we're opening the modal, set hasConfirmedAnonymous based on isAnonymous + if (isVisible && !this.props.isVisible && isAnonymous && !hasConfirmedAnonymous) { + hasConfirmedAnonymous = false; + this.setState({ hasConfirmedAnonymous: false }); + } + // When modal is opened and proposalId is provided or changed and we've confirmed anonymity + if (isVisible && proposalId && hasConfirmedAnonymous) { if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) { this.fetchAddresses(proposalId, contributionId); } } - // If contribution is provided + // If contribution is provided, update it if (contribution !== this.props.contribution) { - this.setState({ contribution: contribution || null }); + this.setState({ + contribution: contribution || null, + hasConfirmedAnonymous: contribution + ? !!contribution.user.userid + : nextState.hasConfirmedAnonymous, + }); } } render() { const { isVisible, handleClose, hasNoButtons, text } = this.props; - const { hasSent, contribution, error } = this.state; + const { hasSent, hasConfirmedAnonymous, contribution, error } = this.state; + let okText; + let onOk; let content; - if (hasSent) { + if (!hasConfirmedAnonymous) { + okText = 'I accept'; + onOk = this.confirmAnonymous; + content = ( + + You are about to contribute anonymously. Your contribution will show up + without attribution, and even if you're logged in,{' '} + will not appear anywhere on your account after you close + this modal. +

+ In the case of a refund, your contribution will be treated as a donation to + the Zcash Foundation instead. +

+ If you would like to have your contribution attached to an account, just + close this, make sure you're logged in, and don't check the "Contribute + anonymously" checkbox. + + } + /> + ); + } else if (hasSent) { + okText = 'Done'; + onOk = handleClose; content = ( { ); } else { if (error) { + okText = 'Done'; + onOk = handleClose; content = error; } else { + okText = 'I’ve sent it'; + onOk = this.confirmSend; content = ; } } @@ -90,8 +150,8 @@ export default class ContributionModal extends React.Component { visible={isVisible} closable={hasSent || hasNoButtons} maskClosable={hasSent || hasNoButtons} - okText={hasSent ? 'Done' : 'I’ve sent it'} - onOk={hasSent ? handleClose : this.confirmSend} + okText={okText} + onOk={onOk} onCancel={handleClose} footer={hasNoButtons ? '' : undefined} centered @@ -103,11 +163,12 @@ export default class ContributionModal extends React.Component { private async fetchAddresses(proposalId: number, contributionId?: number) { try { + const { amount, isAnonymous } = this.props; let res; if (contributionId) { res = await getProposalContribution(proposalId, contributionId); } else { - res = await postProposalContribution(proposalId, this.props.amount || '0'); + res = await postProposalContribution(proposalId, amount || '0', isAnonymous); } this.setState({ contribution: res.data }); } catch (err) { @@ -115,8 +176,11 @@ export default class ContributionModal extends React.Component { } } + private confirmAnonymous = () => { + this.setState({ hasConfirmedAnonymous: true }); + }; + private confirmSend = () => { - // TODO: Mark on backend this.setState({ hasSent: true }); }; } diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index ef2f65fd..b657be55 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import moment from 'moment'; -import { Form, Input, Button, Icon, Popover } from 'antd'; +import { Form, Input, Checkbox, Button, Icon, Popover, Tooltip } from 'antd'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; import { fromZat } from 'utils/units'; @@ -21,7 +22,7 @@ interface OwnProps { } interface StateProps { - sendLoading: boolean; + authUser: AppState['auth']['user']; } type Props = OwnProps & StateProps; @@ -29,6 +30,7 @@ type Props = OwnProps & StateProps; interface State { amountToRaise: string; amountError: string | null; + isAnonymous: boolean; isContributing: boolean; } @@ -38,13 +40,14 @@ export class ProposalCampaignBlock extends React.Component { this.state = { amountToRaise: '', amountError: null, + isAnonymous: false, isContributing: false, }; } render() { - const { proposal, sendLoading, isPreview } = this.props; - const { amountToRaise, amountError, isContributing } = this.state; + const { proposal, isPreview, authUser } = this.props; + const { amountToRaise, amountError, isAnonymous, isContributing } = this.state; const amountFloat = parseFloat(amountToRaise) || 0; let content; if (proposal) { @@ -160,7 +163,7 @@ export class ProposalCampaignBlock extends React.Component { /> -
+ { disabled={isPreview} /> + {amountToRaise && + !!authUser && ( + + Contribute anonymously + + + + + )}