diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts
index b0dafdb7..80752ea9 100644
--- a/admin/src/components/Emails/emails.ts
+++ b/admin/src/components/Emails/emails.ts
@@ -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',
diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx
index 3a0b525f..421b4b8c 100644
--- a/admin/src/components/ProposalDetail/index.tsx
+++ b/admin/src/components/ProposalDetail/index.tsx
@@ -61,6 +61,10 @@ class ProposalDetailNaked extends React.Component {
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 = () => (
{
);
+ const renderCancelControl = () => (
+
+ Are you sure you want to cancel proposal and begin
+
+ the refund process? This cannot be undone.
+
+ )}
+ placement="left"
+ cancelText="cancel"
+ okText="confirm"
+ okButtonProps={{ loading: store.proposalDetailCanceling }}
+ onConfirm={this.handleCancel}
+ >
+
+
+ );
+
const renderArbiterControl = () => (
{
);
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 {
- This proposal failed to reach its funding goal of {p.target} ZEC by{' '}
- {formatDateSeconds(p.datePublished + p.deadlineDuration)}.
- >
+ p.stage === PROPOSAL_STAGE.FAILED ? (
+ <>
+ This proposal failed to reach its funding goal of {p.target} ZEC by{' '}
+ {formatDateSeconds(p.datePublished + p.deadlineDuration)}. All contributors
+ will need to be refunded.
+ >
+ ) : (
+ <>
+ This proposal was canceled by an admin, and will be refunding contributors
+ {' '}{refundablePct}% of their contributions.
+ >
+ )
+
}
/>
);
@@ -371,6 +418,7 @@ class ProposalDetailNaked extends React.Component {
{/* ACTIONS */}
{renderDeleteControl()}
+ {renderCancelControl()}
{renderArbiterControl()}
{renderMatchingControl()}
{/* TODO - other actions */}
@@ -445,6 +493,11 @@ class ProposalDetailNaked extends React.Component {
store.deleteProposal(store.proposalDetail.proposalId);
};
+ private handleCancel = () => {
+ if (!store.proposalDetail) return;
+ store.cancelProposal(store.proposalDetail.proposalId);
+ };
+
private handleApprove = () => {
store.approveProposal(true);
};
diff --git a/admin/src/components/Proposals/ProposalItem.less b/admin/src/components/Proposals/ProposalItem.less
index e7d778bc..ff1052e4 100644
--- a/admin/src/components/Proposals/ProposalItem.less
+++ b/admin/src/components/Proposals/ProposalItem.less
@@ -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;
}
}
diff --git a/admin/src/components/Proposals/ProposalItem.tsx b/admin/src/components/Proposals/ProposalItem.tsx
index 90ea8daa..7c7afa7f 100644
--- a/admin/src/components/Proposals/ProposalItem.tsx
+++ b/admin/src/components/Proposals/ProposalItem.tsx
@@ -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 {
render() {
const p = this.props;
const status = getStatusById(PROPOSAL_STATUSES, p.status);
+ const stage = getStatusById(PROPOSAL_STAGES, p.stage);
const actions = [
{
{status.tagDisplay}
+ {p.status === PROPOSAL_STATUS.LIVE &&
+
+ {stage.tagDisplay}
+
+ }
Created: {formatDateSeconds(p.dateCreated)}
{p.brief}
diff --git a/admin/src/store.ts b/admin/src/store.ts
index 83ea4a45..893e95a8 100644
--- a/admin/src/store.ts
+++ b/admin/src/store.ts
@@ -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) {
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('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 {
diff --git a/admin/src/types.ts b/admin/src/types.ts
index 1a481d11..eae184c1 100644
--- a/admin/src/types.ts
+++ b/admin/src/types.ts
@@ -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;
diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts
index a470c8b6..ef96a633 100644
--- a/admin/src/util/statuses.ts
+++ b/admin/src/util/statuses.ts
@@ -118,6 +118,18 @@ export const PROPOSAL_STAGES: Array> = [
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> = [
diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py
index 2dcebcd1..5d08bf94 100644
--- a/backend/grant/admin/example_emails.py
+++ b/backend/grant/admin/example_emails.py
@@ -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,
diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py
index 3dce4316..0b2b2665 100644
--- a/backend/grant/admin/views.py
+++ b/backend/grant/admin/views.py
@@ -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//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//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//milestone//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)
diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py
index 49458939..6022a23d 100644
--- a/backend/grant/email/send.py
+++ b/backend/grant/email/send.py
@@ -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,
diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py
index 7e025e35..3fccf7cb 100644
--- a/backend/grant/proposal/models.py
+++ b/backend/grant/proposal/models.py
@@ -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
diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py
index 6d632dce..308be669 100644
--- a/backend/grant/proposal/views.py
+++ b/backend/grant/proposal/views.py
@@ -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,
diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py
index 4f763ee9..7a5108f4 100644
--- a/backend/grant/task/jobs.py
+++ b/backend/grant/task/jobs.py
@@ -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()
diff --git a/backend/grant/templates/emails/contribution_proposal_canceled.html b/backend/grant/templates/emails/contribution_proposal_canceled.html
new file mode 100644
index 00000000..dd08ac71
--- /dev/null
+++ b/backend/grant/templates/emails/contribution_proposal_canceled.html
@@ -0,0 +1,30 @@
+
+ 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.
+
+{% else %}
+
+ You'll need to configure a refund address to receive your refund. You can
+ do 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 contribute to improving the Zcash ecosystem!
+
diff --git a/backend/grant/templates/emails/contribution_proposal_canceled.txt b/backend/grant/templates/emails/contribution_proposal_canceled.txt
new file mode 100644
index 00000000..7e35a8a6
--- /dev/null
+++ b/backend/grant/templates/emails/contribution_proposal_canceled.txt
@@ -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!
\ No newline at end of file
diff --git a/backend/grant/templates/emails/contribution_proposal_failed.html b/backend/grant/templates/emails/contribution_proposal_failed.html
index aef7a2f0..fa2cd816 100644
--- a/backend/grant/templates/emails/contribution_proposal_failed.html
+++ b/backend/grant/templates/emails/contribution_proposal_failed.html
@@ -25,6 +25,5 @@
{% 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!
diff --git a/backend/grant/templates/emails/contribution_proposal_failed.txt b/backend/grant/templates/emails/contribution_proposal_failed.txt
index 6da9d975..751abdf3 100644
--- a/backend/grant/templates/emails/contribution_proposal_failed.txt
+++ b/backend/grant/templates/emails/contribution_proposal_failed.txt
@@ -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!
diff --git a/backend/grant/templates/emails/proposal_canceled.html b/backend/grant/templates/emails/proposal_canceled.html
new file mode 100644
index 00000000..18d7fe7e
--- /dev/null
+++ b/backend/grant/templates/emails/proposal_canceled.html
@@ -0,0 +1,12 @@
+
+ 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.
+
+
\ No newline at end of file
diff --git a/backend/grant/templates/emails/proposal_canceled.txt b/backend/grant/templates/emails/proposal_canceled.txt
new file mode 100644
index 00000000..30fe36be
--- /dev/null
+++ b/backend/grant/templates/emails/proposal_canceled.txt
@@ -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 }}
\ No newline at end of file
diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py
index d938dc09..aad33e24 100644
--- a/backend/grant/utils/enums.py
+++ b/backend/grant/utils/enums.py
@@ -37,7 +37,8 @@ class ProposalStageEnum(CustomEnum):
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
WIP = 'WIP'
COMPLETED = 'COMPLETED'
- REFUNDING = 'REFUNDING'
+ FAILED = 'FAILED'
+ CANCELED = 'CANCELED'
ProposalStage = ProposalStageEnum()
diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py
index 18f0397d..fd4e23a9 100644
--- a/backend/grant/utils/pagination.py
+++ b/backend/grant/utils/pagination.py
@@ -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) \
diff --git a/backend/migrations/versions/7c7cecfe5e6c_.py b/backend/migrations/versions/7c7cecfe5e6c_.py
new file mode 100644
index 00000000..33b9aebe
--- /dev/null
+++ b/backend/migrations/versions/7c7cecfe5e6c_.py
@@ -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'")
\ No newline at end of file
diff --git a/blockchain/src/server/index.ts b/blockchain/src/server/index.ts
index 568d501b..83960f10 100644
--- a/blockchain/src/server/index.ts
+++ b/blockchain/src/server/index.ts
@@ -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
diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts
index 49226af4..cc1a22ae 100644
--- a/frontend/client/api/constants.ts
+++ b/frontend/client/api/constants.ts
@@ -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 {
diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx
index ef2f65fd..e6d053ab 100644
--- a/frontend/client/components/Proposal/CampaignBlock/index.tsx
+++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx
@@ -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 {
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 {
['is-success']: isRaiseGoalReached,
})}
>
- {isRaiseGoalReached ? (
+ {proposal.stage === PROPOSAL_STAGE.CANCELED ? (
+ <>
+
+ Proposal was canceled
+ >
+ ) : isRaiseGoalReached ? (
<>
Proposal has been funded
diff --git a/frontend/client/components/Proposal/CancelModal.tsx b/frontend/client/components/Proposal/CancelModal.tsx
index d40e9740..c0bad93f 100644
--- a/frontend/client/components/Proposal/CancelModal.tsx
+++ b/frontend/client/components/Proposal/CancelModal.tsx
@@ -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 {
- componentDidUpdate() {
- // TODO: Close on success of action
- }
-
+export default class CancelModal extends React.Component {
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 (
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}
>
- Are you sure you would like to cancel this proposal?{' '}
- This cannot be undone.
+ Are you sure you would like to cancel this proposal, and refund any
+ contributors? This cannot be undone.
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.
- {hasContributors && (
-
- Should you choose to cancel, we highly recommend posting an update to let your
- contributors know why you’ve decided to do so.
-
- )}
- {refundActionError && (
-
- )}
+
+ If you're sure you'd like to cancel, please{' '}
+ contact support to let us know. Canceling can only be
+ done by site admins.
+
);
}
-
- private closeModal = () => {
- if (!this.props.isRefundActionPending) {
- this.props.handleClose();
- }
- };
-
- private cancelProposal = () => {
- console.warn('TODO - implement cancelProposal');
- };
}
-
-export default connect(state => {
- console.warn('TODO - redux isRefundActionPending/refundActionError?', state);
- return {
- isRefundActionPending: false,
- refundActionError: '',
- };
-})(CancelModal);
diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx
index 292aca90..2fc836da 100644
--- a/frontend/client/components/Proposal/index.tsx
+++ b/frontend/client/components/Proposal/index.tsx
@@ -98,13 +98,7 @@ export class ProposalDetail extends React.Component {
return ;
}
- 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 {
Post an Update
- alert('Sorry, not yet implemented!')}
- disabled={!isProposalActive}
- >
- Edit proposal
-
-
+
Cancel proposal
diff --git a/frontend/client/components/Proposals/Filters/index.tsx b/frontend/client/components/Proposals/Filters/index.tsx
index aa3689ea..26933ef5 100644
--- a/frontend/client/components/Proposals/Filters/index.tsx
+++ b/frontend/client/components/Proposals/Filters/index.tsx
@@ -66,7 +66,14 @@ export default class ProposalFilters extends React.Component {
{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 => (