Cancel proposal (#253)
* Backend setup for proposal canceling. * Cancelable in admin, update all states to properly reflect a canceled proposal. * Replace REFUNDING stage with CANCELED and FAILED to distinguish between the 2 * Fix pending contributions showing up as needing refunds. * Fix some refund cases. * Dont show failed & canceled proposals on list view. Hide their filters. * Show when proposal was canceled. * Remove edit action, make cancel an explanation to contact support. * Fix PR comments * Fix issues from develop merge.
This commit is contained in:
parent
4c026f5645
commit
8bf7013b0f
|
@ -62,6 +62,11 @@ export default [
|
|||
title: 'Proposal failed',
|
||||
description: 'Sent to the proposal team when the deadline is reached and it didn’t get fully funded',
|
||||
},
|
||||
{
|
||||
id: 'proposal_canceled',
|
||||
title: 'Proposal canceled',
|
||||
description: 'Sent to the proposal team when an admin cancels the proposal after funding',
|
||||
},
|
||||
{
|
||||
id: 'contribution_confirmed',
|
||||
title: 'Contribution confirmed',
|
||||
|
@ -82,6 +87,11 @@ export default [
|
|||
title: 'Contribution proposal failed',
|
||||
description: 'Sent to contributors when the deadline is reached and the proposal didn’t get fully funded',
|
||||
},
|
||||
{
|
||||
id: 'contribution_proposal_canceled',
|
||||
title: 'Contribution proposal canceled',
|
||||
description: 'Sent to contributors when an admin cancels the proposal after funding',
|
||||
},
|
||||
{
|
||||
id: 'comment_reply',
|
||||
title: 'Comment reply',
|
||||
|
|
|
@ -61,6 +61,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||
p.status === PROPOSAL_STATUS.LIVE &&
|
||||
!p.isFailed;
|
||||
const refundablePct = p.milestones.reduce((prev, m) => {
|
||||
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
||||
}, 100);
|
||||
|
||||
|
||||
const renderDeleteControl = () => (
|
||||
<Popconfirm
|
||||
|
@ -75,6 +79,37 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
</Popconfirm>
|
||||
);
|
||||
|
||||
const renderCancelControl = () => (
|
||||
<Popconfirm
|
||||
title={(
|
||||
<p>
|
||||
Are you sure you want to cancel proposal and begin
|
||||
<br />
|
||||
the refund process? This cannot be undone.
|
||||
</p>
|
||||
)}
|
||||
placement="left"
|
||||
cancelText="cancel"
|
||||
okText="confirm"
|
||||
okButtonProps={{ loading: store.proposalDetailCanceling }}
|
||||
onConfirm={this.handleCancel}
|
||||
>
|
||||
<Button
|
||||
icon="close-circle"
|
||||
className="ProposalDetail-controls-control"
|
||||
loading={store.proposalDetailCanceling}
|
||||
disabled={
|
||||
p.status !== PROPOSAL_STATUS.LIVE ||
|
||||
p.stage === PROPOSAL_STAGE.FAILED ||
|
||||
p.stage === PROPOSAL_STAGE.CANCELED
|
||||
}
|
||||
block
|
||||
>
|
||||
Cancel & refund
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
|
||||
const renderArbiterControl = () => (
|
||||
<ArbiterControl
|
||||
{...p}
|
||||
|
@ -267,6 +302,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
|
||||
const renderMilestoneAccepted = () => {
|
||||
if (p.stage === PROPOSAL_STAGE.FAILED || p.stage === PROPOSAL_STAGE.CANCELED) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(
|
||||
p.status === PROPOSAL_STATUS.LIVE &&
|
||||
|
@ -319,12 +357,21 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="Funding failed"
|
||||
message={p.stage === PROPOSAL_STAGE.FAILED ? 'Proposal failed' : 'Proposal canceled'}
|
||||
description={
|
||||
<>
|
||||
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
|
||||
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>.
|
||||
</>
|
||||
p.stage === PROPOSAL_STAGE.FAILED ? (
|
||||
<>
|
||||
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
|
||||
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>. All contributors
|
||||
will need to be refunded.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This proposal was canceled by an admin, and will be refunding contributors
|
||||
{' '}<b>{refundablePct}%</b> of their contributions.
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
@ -371,6 +418,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{/* ACTIONS */}
|
||||
<Card size="small" className="ProposalDetail-controls">
|
||||
{renderDeleteControl()}
|
||||
{renderCancelControl()}
|
||||
{renderArbiterControl()}
|
||||
{renderMatchingControl()}
|
||||
{/* TODO - other actions */}
|
||||
|
@ -445,6 +493,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
store.deleteProposal(store.proposalDetail.proposalId);
|
||||
};
|
||||
|
||||
private handleCancel = () => {
|
||||
if (!store.proposalDetail) return;
|
||||
store.cancelProposal(store.proposalDetail.proposalId);
|
||||
};
|
||||
|
||||
private handleApprove = () => {
|
||||
store.approveProposal(true);
|
||||
};
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
|
||||
& .ant-tag {
|
||||
vertical-align: text-top;
|
||||
margin-top: 0.2rem;
|
||||
margin-left: 0.5rem;
|
||||
margin: 0.2rem 0 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,8 +3,8 @@ import { view } from 'react-easy-state';
|
|||
import { Popconfirm, Tag, Tooltip, List } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import store from 'src/store';
|
||||
import { Proposal } from 'src/types';
|
||||
import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses';
|
||||
import { Proposal, PROPOSAL_STATUS } from 'src/types';
|
||||
import { PROPOSAL_STATUSES, PROPOSAL_STAGES, getStatusById } from 'util/statuses';
|
||||
import { formatDateSeconds } from 'util/time';
|
||||
import './ProposalItem.less';
|
||||
|
||||
|
@ -15,6 +15,7 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
|||
render() {
|
||||
const p = this.props;
|
||||
const status = getStatusById(PROPOSAL_STATUSES, p.status);
|
||||
const stage = getStatusById(PROPOSAL_STAGES, p.stage);
|
||||
const actions = [
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
|
@ -36,6 +37,11 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
|||
<Tooltip title={status.hint}>
|
||||
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
|
||||
</Tooltip>
|
||||
{p.status === PROPOSAL_STATUS.LIVE &&
|
||||
<Tooltip title={stage.hint}>
|
||||
<Tag color={stage.tagColor}>{stage.tagDisplay}</Tag>
|
||||
</Tooltip>
|
||||
}
|
||||
</h2>
|
||||
<p>Created: {formatDateSeconds(p.dateCreated)}</p>
|
||||
<p>{p.brief}</p>
|
||||
|
|
|
@ -132,6 +132,11 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st
|
|||
return data;
|
||||
}
|
||||
|
||||
async function cancelProposal(id: number) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/cancel`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchComments(params: Partial<PageQuery>) {
|
||||
const { data } = await api.get('/admin/comments', { params });
|
||||
return data;
|
||||
|
@ -243,6 +248,7 @@ const app = store({
|
|||
proposalDetailFetching: false,
|
||||
proposalDetailApproving: false,
|
||||
proposalDetailMarkingMilestonePaid: false,
|
||||
proposalDetailCanceling: false,
|
||||
|
||||
comments: {
|
||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||
|
@ -500,6 +506,17 @@ const app = store({
|
|||
app.proposalDetailApproving = false;
|
||||
},
|
||||
|
||||
async cancelProposal(id: number) {
|
||||
app.proposalDetailCanceling = true;
|
||||
try {
|
||||
const res = await cancelProposal(id);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.proposalDetailCanceling = false;
|
||||
},
|
||||
|
||||
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
||||
app.proposalDetailMarkingMilestonePaid = true;
|
||||
try {
|
||||
|
|
|
@ -85,6 +85,8 @@ export enum PROPOSAL_STAGE {
|
|||
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
||||
WIP = 'WIP',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
CANCELED = 'CANCELED',
|
||||
}
|
||||
export interface Proposal {
|
||||
proposalId: number;
|
||||
|
|
|
@ -118,6 +118,18 @@ export const PROPOSAL_STAGES: Array<StatusSoT<PROPOSAL_STAGE>> = [
|
|||
tagColor: '#108ee9',
|
||||
hint: 'Proposal was accepted, published, funded and all funds paid out.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STAGE.FAILED,
|
||||
tagDisplay: 'Failed',
|
||||
tagColor: '#eb4118',
|
||||
hint: 'Proposal failed to meet target and is currently refunding all contributors.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STAGE.CANCELED,
|
||||
tagDisplay: 'Canceled',
|
||||
tagColor: '#eb4118',
|
||||
hint: 'Proposal was canceled by an admin and is currently refunding all contributors.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROPOSAL_ARBITER_STATUSES: Array<StatusSoT<PROPOSAL_ARBITER_STATUS>> = [
|
||||
|
|
|
@ -93,6 +93,10 @@ example_email_args = {
|
|||
'proposal_failed': {
|
||||
'proposal': proposal,
|
||||
},
|
||||
'proposal_canceled': {
|
||||
'proposal': proposal,
|
||||
'support_url': 'http://linktosupport.com',
|
||||
},
|
||||
'contribution_confirmed': {
|
||||
'proposal': proposal,
|
||||
'contribution': contribution,
|
||||
|
@ -114,6 +118,12 @@ example_email_args = {
|
|||
'refund_address': 'ztqdzvnK2SE27FCWg69EdissCBn7twnfd1XWLrftiZaT4rSFCkp7eQGQDSWXBF43sM5cyA4c8qyVjBP9Cf4zTcFJxf71ve8',
|
||||
'account_settings_url': 'http://accountsettingsurl.com/',
|
||||
},
|
||||
'contribution_proposal_canceled': {
|
||||
'proposal': proposal,
|
||||
'contribution': contribution,
|
||||
'refund_address': 'ztqdzvnK2SE27FCWg69EdissCBn7twnfd1XWLrftiZaT4rSFCkp7eQGQDSWXBF43sM5cyA4c8qyVjBP9Cf4zTcFJxf71ve8',
|
||||
'account_settings_url': 'http://accountsettingsurl.com/',
|
||||
},
|
||||
'comment_reply': {
|
||||
'author': user,
|
||||
'proposal': proposal,
|
||||
|
|
|
@ -159,8 +159,12 @@ def stats():
|
|||
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
|
||||
.filter(ProposalContribution.refund_tx_id == None) \
|
||||
.filter(ProposalContribution.staking == False) \
|
||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(Proposal.stage == ProposalStage.REFUNDING) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user) \
|
||||
.join(UserSettings) \
|
||||
.filter(UserSettings.refund_address != None) \
|
||||
|
@ -372,14 +376,16 @@ def delete_proposal(id):
|
|||
@admin.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:
|
||||
proposal.set_contribution_matching(contribution_matching)
|
||||
if not proposal:
|
||||
return {"message": f"Could not find proposal with id {id}"}, 404
|
||||
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
if contribution_matching is not None:
|
||||
proposal.set_contribution_matching(contribution_matching)
|
||||
|
||||
return {"message": f"Could not find proposal with id {id}"}, 404
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
|
||||
|
@ -398,6 +404,20 @@ def approve_proposal(id, is_approve, reject_reason=None):
|
|||
return {"message": "No proposal found."}, 404
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
|
||||
@endpoint.api()
|
||||
@admin.admin_auth_required
|
||||
def cancel_proposal(id):
|
||||
proposal = Proposal.query.filter_by(id=id).first()
|
||||
if not proposal:
|
||||
return {"message": "No proposal found."}, 404
|
||||
|
||||
proposal.cancel()
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route("/proposals/<id>/milestone/<mid>/paid", methods=["PUT"])
|
||||
@endpoint.api(
|
||||
parameter('txId', type=str, required=True),
|
||||
|
@ -623,8 +643,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
return {"message": "No contribution matching that id"}, 404
|
||||
had_refund = contribution.refund_tx_id
|
||||
|
||||
# do not allow editing contributions once a proposal has become funded
|
||||
if contribution.proposal.is_funded:
|
||||
# do not allow editing certain fields on contributions once a proposal has become funded
|
||||
if (proposal_id or user_id or status or amount or tx_id) and contribution.proposal.is_funded:
|
||||
return {"message": "Cannot edit contributions to fully-funded proposals"}, 400
|
||||
|
||||
# Proposal ID (must belong to an existing proposal)
|
||||
|
|
|
@ -117,6 +117,16 @@ def proposal_failed(email_args):
|
|||
}
|
||||
|
||||
|
||||
def proposal_canceled(email_args):
|
||||
return {
|
||||
'subject': 'Your proposal has been canceled',
|
||||
'title': 'Proposal canceled',
|
||||
'preview': 'Your proposal entitled {} has been canceled, and your contributors will be refunded'.format(
|
||||
email_args['proposal'].title,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def staking_contribution_confirmed(email_args):
|
||||
subject = 'Your proposal has been staked!' if \
|
||||
email_args['fully_staked'] else \
|
||||
|
@ -178,6 +188,17 @@ def contribution_proposal_failed(email_args):
|
|||
}
|
||||
|
||||
|
||||
def contribution_proposal_canceled(email_args):
|
||||
return {
|
||||
'subject': 'A proposal you contributed to has been canceled',
|
||||
'title': 'Proposal canceled',
|
||||
'preview': 'The proposal entitled {} has been canceled, here’s how to get a refund'.format(
|
||||
email_args['proposal'].title,
|
||||
),
|
||||
'subscription': EmailSubscription.FUNDED_PROPOSAL_FUNDED,
|
||||
}
|
||||
|
||||
|
||||
def comment_reply(email_args):
|
||||
return {
|
||||
'subject': 'New reply from {}'.format(email_args['author'].display_name),
|
||||
|
@ -254,11 +275,13 @@ get_info_lookup = {
|
|||
'proposal_contribution': proposal_contribution,
|
||||
'proposal_comment': proposal_comment,
|
||||
'proposal_failed': proposal_failed,
|
||||
'proposal_canceled': proposal_canceled,
|
||||
'staking_contribution_confirmed': staking_contribution_confirmed,
|
||||
'contribution_confirmed': contribution_confirmed,
|
||||
'contribution_update': contribution_update,
|
||||
'contribution_refunded': contribution_refunded,
|
||||
'contribution_proposal_failed': contribution_proposal_failed,
|
||||
'contribution_proposal_canceled': contribution_proposal_canceled,
|
||||
'comment_reply': comment_reply,
|
||||
'proposal_arbiter': proposal_arbiter,
|
||||
'milestone_request': milestone_request,
|
||||
|
|
|
@ -438,7 +438,7 @@ class Proposal(db.Model):
|
|||
self.set_funded_when_ready()
|
||||
|
||||
def set_funded_when_ready(self):
|
||||
if self.status == ProposalStatus.LIVE and self.is_funded:
|
||||
if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded:
|
||||
self.set_funded()
|
||||
|
||||
# state: stage FUNDING_REQUIRED -> WIP
|
||||
|
@ -468,6 +468,28 @@ class Proposal(db.Model):
|
|||
else:
|
||||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
||||
|
||||
def cancel(self):
|
||||
print(self.status)
|
||||
if self.status != ProposalStatus.LIVE:
|
||||
raise ValidationException("Cannot cancel a proposal until it's live")
|
||||
|
||||
self.stage = ProposalStage.CANCELED
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
# Send emails to team & contributors
|
||||
for u in self.team:
|
||||
send_email(u.email_address, 'proposal_canceled', {
|
||||
'proposal': self,
|
||||
'support_url': make_url('/contact'),
|
||||
})
|
||||
for c in self.contributions:
|
||||
send_email(c.user.email_address, 'contribution_proposal_canceled', {
|
||||
'contribution': c,
|
||||
'proposal': self,
|
||||
'refund_address': c.user.settings.refund_address,
|
||||
'account_settings_url': make_url('/profile/settings?tab=account')
|
||||
})
|
||||
|
||||
@hybrid_property
|
||||
def contributed(self):
|
||||
contributions = ProposalContribution.query \
|
||||
|
@ -507,6 +529,8 @@ class Proposal(db.Model):
|
|||
def is_failed(self):
|
||||
if not self.status == ProposalStatus.LIVE or not self.date_published:
|
||||
return False
|
||||
if self.stage == ProposalStage.FAILED or self.stage == ProposalStage.CANCELED:
|
||||
return True
|
||||
deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration)
|
||||
passed = deadline < datetime.datetime.now()
|
||||
return passed and not self.is_funded
|
||||
|
|
|
@ -166,9 +166,12 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
|||
)
|
||||
def get_proposals(page, filters, search, sort):
|
||||
filters_workaround = request.args.getlist('filters[]')
|
||||
query = Proposal.query.filter_by(status=ProposalStatus.LIVE) \
|
||||
.filter(Proposal.stage != ProposalStage.CANCELED) \
|
||||
.filter(Proposal.stage != ProposalStage.FAILED)
|
||||
page = pagination.proposal(
|
||||
schema=proposals_schema,
|
||||
query=Proposal.query.filter_by(status=ProposalStatus.LIVE),
|
||||
query=query,
|
||||
page=page,
|
||||
filters=filters_workaround,
|
||||
search=search,
|
||||
|
|
|
@ -67,8 +67,8 @@ class ProposalDeadline:
|
|||
if not proposal or proposal.is_funded:
|
||||
return
|
||||
|
||||
# Otherwise, mark it as refunding and inform everyone
|
||||
proposal.stage = ProposalStage.REFUNDING
|
||||
# Otherwise, mark it as failed and inform everyone
|
||||
proposal.stage = ProposalStage.FAILED
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
We're sorry to inform you that a proposal you contributed to,
|
||||
<strong>{{ args.proposal.title }}</strong>, has been canceled.
|
||||
Contributors will be refunded the remaining amount of money that
|
||||
hadn't already been paid out to the team.
|
||||
</p>
|
||||
|
||||
{% if args.refund_address %}
|
||||
<p style="margin: 0 0 20px;">
|
||||
It looks like you've configured refunds to be sent to:
|
||||
</p>
|
||||
<p style="margin: 0 0 20px">
|
||||
<code style="word-break: break-all; margin: 10px 0;">{{ args.refund_address }}</code>
|
||||
</p>
|
||||
<p style="margin: 0 0 20px;">
|
||||
If you would like to change it, you
|
||||
can configure your refund address on your
|
||||
<a href="{{ args.account_settings_url }}">account settings page</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="margin: 0 0 20px;">
|
||||
You'll need to configure a refund address to receive your refund. You can
|
||||
do that from the <a href="{{ args.account_settings_url }}">account settings page</a>.
|
||||
You should expect to receive the refund within a few days of setting an address.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 0;">
|
||||
We hope you continue to contribute to improving the Zcash ecosystem!
|
||||
</p>
|
|
@ -0,0 +1,22 @@
|
|||
We're sorry to inform you that a proposal you contributed to, "{{ args.proposal.title }}",
|
||||
has been canceled. Contributors will be refunded the remaining amount of money that
|
||||
hadn't already been paid out to the team.
|
||||
|
||||
{% if args.refund_address %}
|
||||
It looks like you've configured refunds to be sent to:
|
||||
|
||||
{{ args.refund_address }}
|
||||
|
||||
If you would like to change it, you can configure your refund address on your account settings page:
|
||||
|
||||
{{ args.account_settings_url }}
|
||||
{% else %}
|
||||
You'll need to configure a refund address to receive your refund. You can do
|
||||
that from the account settings page:
|
||||
|
||||
{{ args.account_settings_url }}
|
||||
|
||||
You should expect to receive the refund within a few days of setting an address.
|
||||
{% endif %}
|
||||
|
||||
We hope you continue to contribute to improving the Zcash ecosystem!
|
|
@ -25,6 +25,5 @@
|
|||
{% endif %}
|
||||
|
||||
<p style="margin: 0;">
|
||||
We hope you continue to try to improve the Zcash ecosystem, and that your next
|
||||
proposal gets fully funded!
|
||||
We hope you continue to contribute to improving the Zcash ecosystem!
|
||||
</p>
|
||||
|
|
|
@ -19,5 +19,4 @@ that from the account settings page:
|
|||
You should expect to receive the refund within a few days of setting an address.
|
||||
{% endif %}
|
||||
|
||||
We hope you continue to try to improve the Zcash ecosystem, and that your next
|
||||
proposal gets fully funded!
|
||||
We hope you continue to contribute to improving the Zcash ecosystem!
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
||||
shortly.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0;">
|
||||
If you have any further questions, please
|
||||
<a href="{{ args.support_url }}">contact support</a>
|
||||
for more information.
|
||||
</p>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
||||
shortly.
|
||||
|
||||
If you have any further questions, please contact support for more information:
|
||||
{{ args.support_url }}
|
|
@ -37,7 +37,8 @@ class ProposalStageEnum(CustomEnum):
|
|||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||
WIP = 'WIP'
|
||||
COMPLETED = 'COMPLETED'
|
||||
REFUNDING = 'REFUNDING'
|
||||
FAILED = 'FAILED'
|
||||
CANCELED = 'CANCELED'
|
||||
|
||||
|
||||
ProposalStage = ProposalStageEnum()
|
||||
|
|
|
@ -153,8 +153,12 @@ class ContributionPagination(Pagination):
|
|||
if 'REFUNDABLE' in filters:
|
||||
query = query.filter(ProposalContribution.refund_tx_id == None) \
|
||||
.filter(ProposalContribution.staking == False) \
|
||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(Proposal.stage == ProposalStage.REFUNDING) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user) \
|
||||
.join(UserSettings) \
|
||||
.filter(UserSettings.refund_address != None) \
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
"""Convert REFUNDING stage to FAILED
|
||||
|
||||
Revision ID: 7c7cecfe5e6c
|
||||
Revises: 9ad68ecf85aa
|
||||
Create Date: 2019-02-22 13:15:44.997884
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7c7cecfe5e6c'
|
||||
down_revision = '9ad68ecf85aa'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
connection.execute("UPDATE proposal SET stage = 'FAILED' WHERE stage = 'REFUNDING'")
|
||||
|
||||
|
||||
def downgrade():
|
||||
connection = op.get_bind()
|
||||
connection.execute("UPDATE proposal SET stage = 'REFUNDING' WHERE stage = 'FAILED'")
|
|
@ -19,10 +19,11 @@ import log from '../log';
|
|||
|
||||
// Configure server
|
||||
const app = express();
|
||||
const limit = '50mb';
|
||||
app.set('port', env.PORT);
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.use(bodyParser.json({ limit }));
|
||||
app.use(bodyParser.urlencoded({ extended: true, limit }));
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Routes
|
||||
|
|
|
@ -55,6 +55,8 @@ export enum PROPOSAL_STAGE {
|
|||
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
||||
WIP = 'WIP',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
CANCELED = 'CANCELED',
|
||||
}
|
||||
|
||||
interface StageUI {
|
||||
|
@ -79,6 +81,15 @@ export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
|
|||
label: 'Completed',
|
||||
color: '#27ae60',
|
||||
},
|
||||
// Never used
|
||||
FAILED: {
|
||||
label: 'Failed',
|
||||
color: '#000',
|
||||
},
|
||||
CANCELED: {
|
||||
label: 'Canceled',
|
||||
color: '#000',
|
||||
},
|
||||
};
|
||||
|
||||
export enum RFP_STATUS {
|
||||
|
|
|
@ -12,7 +12,7 @@ import UnitDisplay from 'components/UnitDisplay';
|
|||
import ContributionModal from 'components/ContributionModal';
|
||||
import Loader from 'components/Loader';
|
||||
import { getAmountError } from 'utils/validators';
|
||||
import { CATEGORY_UI } from 'api/constants';
|
||||
import { CATEGORY_UI, PROPOSAL_STAGE } from 'api/constants';
|
||||
import './style.less';
|
||||
|
||||
interface OwnProps {
|
||||
|
@ -52,9 +52,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
const datePublished = proposal.datePublished || Date.now() / 1000;
|
||||
const isRaiseGoalReached = funded.gte(target);
|
||||
const deadline = (datePublished + proposal.deadlineDuration) * 1000;
|
||||
// TODO: Get values from proposal
|
||||
console.warn('TODO: Get isFrozen from proposal data');
|
||||
const isFrozen = false;
|
||||
const isFrozen =
|
||||
proposal.stage === PROPOSAL_STAGE.FAILED ||
|
||||
proposal.stage === PROPOSAL_STAGE.CANCELED;
|
||||
const isLive = proposal.status === STATUS.LIVE;
|
||||
|
||||
const isFundingOver = isRaiseGoalReached || deadline < Date.now() || isFrozen;
|
||||
|
@ -137,7 +137,12 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
['is-success']: isRaiseGoalReached,
|
||||
})}
|
||||
>
|
||||
{isRaiseGoalReached ? (
|
||||
{proposal.stage === PROPOSAL_STAGE.CANCELED ? (
|
||||
<>
|
||||
<Icon type="close-circle-o" />
|
||||
<span>Proposal was canceled</span>
|
||||
</>
|
||||
) : isRaiseGoalReached ? (
|
||||
<>
|
||||
<Icon type="check-circle-o" />
|
||||
<span>Proposal has been funded</span>
|
||||
|
|
|
@ -1,85 +1,42 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Modal, Alert } from 'antd';
|
||||
import { Modal } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Proposal } from 'types';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
interface OwnProps {
|
||||
interface Props {
|
||||
proposal: Proposal;
|
||||
isVisible: boolean;
|
||||
handleClose(): void;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
isRefundActionPending: boolean;
|
||||
refundActionError: string;
|
||||
}
|
||||
|
||||
type Props = StateProps & OwnProps;
|
||||
|
||||
class CancelModal extends React.Component<Props> {
|
||||
componentDidUpdate() {
|
||||
// TODO: Close on success of action
|
||||
}
|
||||
|
||||
export default class CancelModal extends React.Component<Props> {
|
||||
render() {
|
||||
const { isVisible, isRefundActionPending, refundActionError } = this.props;
|
||||
const hasContributors = false; // TODO: Determine if it has contributors from proposal
|
||||
const disabled = isRefundActionPending;
|
||||
const { isVisible, handleClose } = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<>Cancel proposal</>}
|
||||
visible={isVisible}
|
||||
okText="Confirm"
|
||||
cancelText="Never mind"
|
||||
onOk={this.cancelProposal}
|
||||
onCancel={this.closeModal}
|
||||
okButtonProps={{ type: 'danger', loading: disabled }}
|
||||
cancelButtonProps={{ disabled }}
|
||||
okText="OK"
|
||||
cancelText="Cancel"
|
||||
onOk={handleClose}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<p>
|
||||
Are you sure you would like to cancel this proposal?{' '}
|
||||
<strong>This cannot be undone</strong>.
|
||||
Are you sure you would like to cancel this proposal, and refund any
|
||||
contributors? <strong>This cannot be undone</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Canceled proposals cannot be deleted and will still be viewable by contributors
|
||||
or anyone with a direct link. However, they will be de-listed everywhere else on
|
||||
ZF Grants.
|
||||
</p>
|
||||
{hasContributors && (
|
||||
<p>
|
||||
Should you choose to cancel, we highly recommend posting an update to let your
|
||||
contributors know why you’ve decided to do so.
|
||||
</p>
|
||||
)}
|
||||
{refundActionError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Failed to cancel proposal"
|
||||
description={refundActionError}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
If you're sure you'd like to cancel, please{' '}
|
||||
<Link to="/contact">contact support</Link> to let us know. Canceling can only be
|
||||
done by site admins.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
private closeModal = () => {
|
||||
if (!this.props.isRefundActionPending) {
|
||||
this.props.handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
private cancelProposal = () => {
|
||||
console.warn('TODO - implement cancelProposal');
|
||||
};
|
||||
}
|
||||
|
||||
export default connect<StateProps, {}, OwnProps, AppState>(state => {
|
||||
console.warn('TODO - redux isRefundActionPending/refundActionError?', state);
|
||||
return {
|
||||
isRefundActionPending: false,
|
||||
refundActionError: '',
|
||||
};
|
||||
})(CancelModal);
|
||||
|
|
|
@ -98,13 +98,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
return <Loader size="large" />;
|
||||
}
|
||||
|
||||
const deadline = 0; // TODO: Use actual date for deadline
|
||||
// TODO: isTrustee - determine rework to isAdmin?
|
||||
// for now: check if authed user in member of proposal team
|
||||
const isTrustee = !!proposal.team.find(tm => tm.userid === (user && user.userid));
|
||||
const hasBeenFunded = false; // TODO: deterimne if proposal has reached funding
|
||||
const isProposalActive = !hasBeenFunded && deadline > Date.now();
|
||||
const canCancel = false; // TODO: Allow canceling if proposal hasn't gone live yet
|
||||
const isLive = proposal.status === STATUS.LIVE;
|
||||
|
||||
const adminMenu = (
|
||||
|
@ -112,17 +106,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
<Menu.Item disabled={!isLive} onClick={this.openUpdateModal}>
|
||||
Post an Update
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => alert('Sorry, not yet implemented!')}
|
||||
disabled={!isProposalActive}
|
||||
>
|
||||
Edit proposal
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
style={{ color: canCancel ? '#e74c3c' : undefined }}
|
||||
onClick={this.openCancelModal}
|
||||
disabled={!canCancel}
|
||||
>
|
||||
<Menu.Item disabled={!isLive} onClick={this.openCancelModal}>
|
||||
Cancel proposal
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
|
|
|
@ -66,7 +66,14 @@ export default class ProposalFilters extends React.Component<Props> {
|
|||
</Radio>
|
||||
</div>
|
||||
{typedKeys(PROPOSAL_STAGE)
|
||||
.filter(s => s !== PROPOSAL_STAGE.PREVIEW) // skip this one
|
||||
.filter(
|
||||
s =>
|
||||
![
|
||||
PROPOSAL_STAGE.PREVIEW,
|
||||
PROPOSAL_STAGE.FAILED,
|
||||
PROPOSAL_STAGE.CANCELED,
|
||||
].includes(s as PROPOSAL_STAGE),
|
||||
) // skip a few
|
||||
.map(s => (
|
||||
<div key={s} style={{ marginBottom: '0.25rem' }}>
|
||||
<Radio
|
||||
|
|
Loading…
Reference in New Issue