Proposal staking (#134)

* BE: proposal/<id>/stake end-point basics

* BE: proposal staking tests

* add STAKING to ProposalStatusEnum

* BE: incremental staking related changes

* admin: staking status

* FE: proposal staking first pass

* ZCash -> Zcash spelling

* staking contribution confirmed email

* FE: staking related minor style changes

* FE proposal staking env var

* notify user of partially staked proposal contributions
This commit is contained in:
AMStrix 2019-01-31 16:56:16 -06:00 committed by William O'Beirne
parent 4091deaf2f
commit f8f3bd1707
39 changed files with 533 additions and 234 deletions

View File

@ -1,6 +1,6 @@
# Zcash Grant System # Zcash Grant System
This is a collection of the various services and components that make up the ZCash Grant System. This is a collection of the various services and components that make up the Zcash Grant System.
### Setup ### Setup

View File

@ -21,6 +21,7 @@ export enum PROPOSAL_STATUS {
REJECTED = 'REJECTED', REJECTED = 'REJECTED',
LIVE = 'LIVE', LIVE = 'LIVE',
DELETED = 'DELETED', DELETED = 'DELETED',
STAKING = 'STAKING',
} }
export interface Proposal { export interface Proposal {
proposalId: number; proposalId: number;

View File

@ -52,6 +52,13 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
hint: hint:
'Admin has rejected this proposal. User may adjust it and resubmit for approval.', 'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
}, },
{
id: PROPOSAL_STATUS.STAKING,
filterDisplay: 'Status: staking',
tagDisplay: 'Staking',
tagColor: '#722ed1',
hint: 'This proposal is awaiting a staking contribution.',
},
]; ];
export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [ export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [

View File

@ -31,3 +31,6 @@ ADMIN_PASS_HASH=18f97883b93a975deb9e29257a341a447302040da59cdc2d10ff65a5e57cc197
# Blockchain explorer to link to. Top for mainnet, bottom for testnet. # Blockchain explorer to link to. Top for mainnet, bottom for testnet.
# EXPLORER_URL="https://explorer.zcha.in/" # EXPLORER_URL="https://explorer.zcha.in/"
EXPLORER_URL="https://testnet.zcha.in/" EXPLORER_URL="https://testnet.zcha.in/"
# Amount for staking a proposal in ZEC
PROPOSAL_STAKING_AMOUNT=0.025

View File

@ -106,6 +106,21 @@ def proposal_comment(email_args):
} }
def staking_contribution_confirmed(email_args):
subject = 'Your proposal has been staked!' if \
email_args['fully_staked'] else \
'Partial staking contribution confirmed'
return {
'subject': subject,
'title': 'Staking contribution confirmed',
'preview': 'Your {} ZEC staking contribution to {} has been confirmed!'.format(
email_args['contribution'].amount,
email_args['proposal'].title
),
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
}
def contribution_confirmed(email_args): def contribution_confirmed(email_args):
return { return {
'subject': 'Your contribution has been confirmed!', 'subject': 'Your contribution has been confirmed!',
@ -150,6 +165,7 @@ get_info_lookup = {
'proposal_rejected': proposal_rejected, 'proposal_rejected': proposal_rejected,
'proposal_contribution': proposal_contribution, 'proposal_contribution': proposal_contribution,
'proposal_comment': proposal_comment, 'proposal_comment': proposal_comment,
'staking_contribution_confirmed': staking_contribution_confirmed,
'contribution_confirmed': contribution_confirmed, 'contribution_confirmed': contribution_confirmed,
'contribution_update': contribution_update, 'contribution_update': contribution_update,
'comment_reply': comment_reply, 'comment_reply': comment_reply,

View File

@ -10,15 +10,17 @@ from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url from grant.utils.misc import dt_to_unix, make_url
from grant.utils.requests import blockchain_get from grant.utils.requests import blockchain_get
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus
from grant.settings import PROPOSAL_STAKING_AMOUNT
# Proposal states # Proposal states
DRAFT = 'DRAFT' DRAFT = 'DRAFT'
PENDING = 'PENDING' PENDING = 'PENDING'
STAKING = 'STAKING'
APPROVED = 'APPROVED' APPROVED = 'APPROVED'
REJECTED = 'REJECTED' REJECTED = 'REJECTED'
LIVE = 'LIVE' LIVE = 'LIVE'
DELETED = 'DELETED' DELETED = 'DELETED'
STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED] STATUSES = [DRAFT, PENDING, STAKING, APPROVED, REJECTED, LIVE, DELETED]
# Funding stages # Funding stages
FUNDING_REQUIRED = 'FUNDING_REQUIRED' FUNDING_REQUIRED = 'FUNDING_REQUIRED'
@ -255,14 +257,44 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration self.deadline_duration = deadline_duration
Proposal.validate(vars(self)) Proposal.validate(vars(self))
def create_contribution(self, user_id: int, amount: float):
contribution = ProposalContribution(
proposal_id=self.id,
user_id=user_id,
amount=amount
)
db.session.add(contribution)
db.session.commit()
return contribution
def get_staking_contribution(self, user_id: int):
contribution = None
remaining = PROPOSAL_STAKING_AMOUNT - float(self.contributed)
# check funding
if remaining > 0:
# find pending contribution for any user
# (always use full staking amout so we can find it)
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)
return contribution
def submit_for_approval(self): def submit_for_approval(self):
self.validate_publishable() self.validate_publishable()
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED] allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
# specific validation # specific validation
if self.status not in allowed_statuses: if self.status not in allowed_statuses:
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval") raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
# set to PENDING if staked, else STAKING
self.status = ProposalStatus.PENDING if self.is_staked:
self.status = ProposalStatus.PENDING
else:
self.status = ProposalStatus.STAKING
def approve_pending(self, is_approve, reject_reason=None): def approve_pending(self, is_approve, reject_reason=None):
self.validate_publishable() self.validate_publishable()
@ -321,6 +353,10 @@ class Proposal(db.Model):
return str(funded) return str(funded)
@hybrid_property
def is_staked(self):
return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT
class ProposalSchema(ma.Schema): class ProposalSchema(ma.Schema):
class Meta: class Meta:
@ -338,6 +374,7 @@ class ProposalSchema(ma.Schema):
"proposal_id", "proposal_id",
"target", "target",
"contributed", "contributed",
"is_staked",
"funded", "funded",
"content", "content",
"comments", "comments",
@ -383,6 +420,7 @@ user_fields = [
"title", "title",
"brief", "brief",
"target", "target",
"is_staked",
"funded", "funded",
"contribution_matching", "contribution_matching",
"date_created", "date_created",

View File

@ -4,7 +4,7 @@ from flask_yoloapi import endpoint, parameter
from grant.comment.models import Comment, comment_schema, comments_schema from grant.comment.models import Comment, comment_schema, comments_schema
from grant.email.send import send_email from grant.email.send import send_email
from grant.milestone.models import Milestone from grant.milestone.models import Milestone
from grant.settings import EXPLORER_URL from grant.settings import EXPLORER_URL, PROPOSAL_STAKING_AMOUNT
from grant.user.models import User from grant.user.models import User
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
from grant.utils.exceptions import ValidationException from grant.utils.exceptions import ValidationException
@ -131,14 +131,14 @@ def get_proposals(stage):
if stage: if stage:
proposals = ( proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage) Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage)
.order_by(Proposal.date_created.desc()) .order_by(Proposal.date_created.desc())
.all() .all()
) )
else: else:
proposals = ( proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE) Proposal.query.filter_by(status=ProposalStatus.LIVE)
.order_by(Proposal.date_created.desc()) .order_by(Proposal.date_created.desc())
.all() .all()
) )
dumped_proposals = proposals_schema.dump(proposals) dumped_proposals = proposals_schema.dump(proposals)
return dumped_proposals return dumped_proposals
@ -242,6 +242,18 @@ def submit_for_approval_proposal(proposal_id):
return proposal_schema.dump(g.current_proposal), 200 return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
@requires_team_member_auth
@endpoint.api()
def get_proposal_stake(proposal_id):
if g.current_proposal.status != ProposalStatus.STAKING:
return None, 400
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
if contribution:
return proposal_contribution_schema.dump(contribution)
return None, 404
@blueprint.route("/<proposal_id>/publish", methods=["PUT"]) @blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth @requires_team_member_auth
@endpoint.api() @endpoint.api()
@ -419,13 +431,7 @@ def post_proposal_contribution(proposal_id, amount):
if not contribution: if not contribution:
code = 201 code = 201
contribution = ProposalContribution( contribution = proposal.create_contribution(g.current_user.id, amount)
proposal_id=proposal_id,
user_id=g.current_user.id,
amount=amount
)
db.session.add(contribution)
db.session.commit()
dumped_contribution = proposal_contribution_schema.dump(contribution) dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, code return dumped_contribution, code
@ -459,26 +465,43 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
db.session.add(contribution) db.session.add(contribution)
db.session.commit() db.session.commit()
# Send to the user if contribution.proposal.status == ProposalStatus.STAKING:
send_email(contribution.user.email_address, 'contribution_confirmed', { # fully staked, set status PENDING & notify user
'contribution': contribution, if contribution.proposal.is_staked: # float(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
'proposal': contribution.proposal, contribution.proposal.status = ProposalStatus.PENDING
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', db.session.add(contribution.proposal)
}) db.session.commit()
# Send to the full proposal gang # email progress of staking, partial or complete
for member in contribution.proposal.team: send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
send_email(member.email_address, 'proposal_contribution', {
'proposal': contribution.proposal,
'contribution': contribution, 'contribution': contribution,
'contributor': contribution.user, 'proposal': contribution.proposal,
'funded': contribution.proposal.funded, 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'), 'fully_staked': contribution.proposal.is_staked,
'contributor_url': make_url(f'/profile/{contribution.user.id}'), 'stake_target': PROPOSAL_STAKING_AMOUNT
}) })
else:
# Send to the user
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
})
# Send to the full proposal gang
for member in contribution.proposal.team:
send_email(member.email_address, 'proposal_contribution', {
'proposal': contribution.proposal,
'contribution': contribution,
'contributor': contribution.user,
'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
})
# TODO: Once we have a task queuer in place, queue emails to everyone # TODO: Once we have a task queuer in place, queue emails to everyone
# on funding target reached. # on funding target reached.
return None, 200 return None, 200

View File

@ -54,8 +54,10 @@ ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/") EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/")
PROPOSAL_STAKING_AMOUNT = env.float("PROPOSAL_STAKING_AMOUNT")
UI = { UI = {
'NAME': 'ZF Grants', 'NAME': 'ZF Grants',
'PRIMARY': '#CF8A00', 'PRIMARY': '#CF8A00',
'SECONDARY': '#2D2A26', 'SECONDARY': '#2D2A26',
} }

View File

@ -1,20 +1,16 @@
<p style="margin: 0 0 20px;"> <p style="margin: 0 0 20px;">
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has been
been confirmed! <strong>{{ args.proposal.title}}</strong> has been updated confirmed! <strong>{{ args.proposal.title }}</strong> has been updated to
to reflect your funding, and your account will now show your contribution. reflect your funding, and your account will now show your contribution. You
You can view your transaction below: can view your transaction below:
</p> </p>
<p style="margin: 0 0 20px;"> <p style="margin: 0 0 20px;">
<a <a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
href="{{ args.tx_explorer_url }}" {{ args.tx_explorer_url }}
target="_blank" </a>
rel="nofollow noopener"
>
{{ args.tx_explorer_url }}
</a>
</p> </p>
<p style="margin: 0;"> <p style="margin: 0;">
Thank you for your help in improving the ZCash ecosystem. Thank you for your help in improving the Zcash ecosystem.
</p> </p>

View File

@ -4,4 +4,4 @@ account will now show your contribution. You can view your transaction here:
{{ args.tx_explorer_url }} {{ args.tx_explorer_url }}
Thank you for your help in improving the ZCash ecosystem. Thank you for your help in improving the Zcash ecosystem.

View File

@ -0,0 +1,24 @@
<p style="margin: 0 0 20px;">
{% if args.fully_staked %}
<strong>{{ args.proposal.title }}</strong> has been
staked for <strong>{{ args.contribution.amount }} ZEC</strong>! Your proposal
will now be forwarded to administrators for approval.
{% else %}
<strong>{{ args.proposal.title }}</strong> has been partially staked for
<strong>{{ args.contribution.amount }} ZEC</strong>. This is not enough to
fully stake the proposal. You must send at least
<strong>{{ args.stake_target }} ZEC</strong>.
{% endif %}
You can view your transaction below:
</p>
<p style="margin: 0 0 20px;">
<a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
{{ args.tx_explorer_url }}
</a>
</p>
<p style="margin: 0;">
Thank you for your help in improving the Zcash ecosystem.
</p>

View File

@ -0,0 +1,14 @@
{% if args.fully_staked %}
{{args.proposal.title}} has been staked for {{args.contribution.amount}} ZEC!
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.
{% endif %}
You can view your transaction here:
{{ args.tx_explorer_url }}
Thank you for your help in improving the Zcash ecosystem.

View File

@ -43,10 +43,10 @@ def get_users(proposal_id):
else: else:
users = ( users = (
User.query User.query
.join(proposal_team) .join(proposal_team)
.join(Proposal) .join(Proposal)
.filter(proposal_team.c.proposal_id == proposal.id) .filter(proposal_team.c.proposal_id == proposal.id)
.all() .all()
) )
result = users_schema.dump(users) result = users_schema.dump(users)
return result return result
@ -88,6 +88,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
result["comments"] = comments_dump result["comments"] = comments_dump
if with_pending and authed_user and authed_user.id == user.id: if with_pending and authed_user and authed_user.id == user.id:
pending = Proposal.get_by_user(user, [ pending = Proposal.get_by_user(user, [
ProposalStatus.STAKING,
ProposalStatus.PENDING, ProposalStatus.PENDING,
ProposalStatus.APPROVED, ProposalStatus.APPROVED,
ProposalStatus.REJECTED, ProposalStatus.REJECTED,

View File

@ -8,16 +8,21 @@ class CustomEnum():
class ProposalStatusEnum(CustomEnum): class ProposalStatusEnum(CustomEnum):
DRAFT = 'DRAFT' DRAFT = 'DRAFT'
PENDING = 'PENDING' PENDING = 'PENDING'
STAKING = 'STAKING'
APPROVED = 'APPROVED' APPROVED = 'APPROVED'
REJECTED = 'REJECTED' REJECTED = 'REJECTED'
LIVE = 'LIVE' LIVE = 'LIVE'
DELETED = 'DELETED' DELETED = 'DELETED'
ProposalStatus = ProposalStatusEnum() ProposalStatus = ProposalStatusEnum()
class ProposalStageEnum(CustomEnum): class ProposalStageEnum(CustomEnum):
FUNDING_REQUIRED = 'FUNDING_REQUIRED' FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED' COMPLETED = 'COMPLETED'
ProposalStage = ProposalStageEnum() ProposalStage = ProposalStageEnum()
@ -28,6 +33,8 @@ class CategoryEnum(CustomEnum):
COMMUNITY = 'COMMUNITY' COMMUNITY = 'COMMUNITY'
DOCUMENTATION = 'DOCUMENTATION' DOCUMENTATION = 'DOCUMENTATION'
ACCESSIBILITY = 'ACCESSIBILITY' ACCESSIBILITY = 'ACCESSIBILITY'
Category = CategoryEnum() Category = CategoryEnum()
@ -35,6 +42,8 @@ class ContributionStatusEnum(CustomEnum):
PENDING = 'PENDING' PENDING = 'PENDING'
CONFIRMED = 'CONFIRMED' CONFIRMED = 'CONFIRMED'
DELETED = 'DELETED' DELETED = 'DELETED'
ContributionStatus = ContributionStatusEnum() ContributionStatus = ContributionStatusEnum()
@ -42,4 +51,6 @@ class RFPStatusEnum(CustomEnum):
DRAFT = 'DRAFT' DRAFT = 'DRAFT'
LIVE = 'LIVE' LIVE = 'LIVE'
CLOSED = 'CLOSED' CLOSED = 'CLOSED'
RFPStatus = RFPStatusEnum() RFPStatus = RFPStatusEnum()

View File

@ -3,6 +3,8 @@ from grant.utils.admin import generate_admin_password_hash
from mock import patch from mock import patch
from ..config import BaseProposalCreatorConfig from ..config import BaseProposalCreatorConfig
from ..mocks import mock_request
plaintext_mock_password = "p4ssw0rd" plaintext_mock_password = "p4ssw0rd"
mock_admin_auth = { mock_admin_auth = {
@ -96,8 +98,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def test_approve_proposal(self): def test_approve_proposal(self):
self.login_admin() self.login_admin()
# submit for approval (performed by end-user)
self.proposal.submit_for_approval() # proposal needs to be PENDING
self.proposal.status = ProposalStatus.PENDING
# approve # approve
resp = self.app.put( resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id), "/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
@ -108,8 +112,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def test_reject_proposal(self): def test_reject_proposal(self):
self.login_admin() self.login_admin()
# submit for approval (performed by end-user)
self.proposal.submit_for_approval() # proposal needs to be PENDING
self.proposal.status = ProposalStatus.PENDING
# reject # reject
resp = self.app.put( resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id), "/api/v1/admin/proposals/{}/approve".format(self.proposal.id),

View File

@ -1,13 +1,15 @@
import json import json
from mock import patch
from flask_testing import TestCase from flask_testing import TestCase
from grant.app import create_app from grant.app import create_app
from grant.proposal.models import Proposal from grant.proposal.models import Proposal, ProposalContribution
from grant.task.jobs import ProposalReminder from grant.task.jobs import ProposalReminder
from grant.user.models import User, SocialMedia, db, Avatar from grant.user.models import User, SocialMedia, db, Avatar
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.utils.enums import ProposalStatus from grant.utils.enums import ProposalStatus
from .test_data import test_user, test_other_user, test_proposal from .test_data import test_user, test_other_user, test_proposal, mock_contribution_addresses
class BaseTestConfig(TestCase): class BaseTestConfig(TestCase):
@ -32,7 +34,7 @@ class BaseTestConfig(TestCase):
""" """
message = message or 'HTTP Status %s expected but got %s. Response json: %s' \ message = message or 'HTTP Status %s expected but got %s. Response json: %s' \
% (status_code, response.status_code, response.json or response.data) % (status_code, response.status_code, response.json or response.data)
self.assertEqual(response.status_code, status_code, message) self.assertEqual(response.status_code, status_code, message)
assert_status = assertStatus assert_status = assertStatus
@ -144,4 +146,17 @@ class BaseProposalCreatorConfig(BaseUserConfig):
def make_proposal_reminder_task(self): def make_proposal_reminder_task(self):
proposal_reminder = ProposalReminder(self.proposal.id) proposal_reminder = ProposalReminder(self.proposal.id)
proposal_reminder.make_task() proposal_reminder.make_task()
@patch('requests.get', side_effect=mock_contribution_addresses)
def stake_proposal(self, mock_get):
# 1. submit
self.proposal.submit_for_approval()
# 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))
db.session.add(contribution)
db.session.commit()
contribution = self.proposal.get_staking_contribution(self.user.id)
return contribution

View File

@ -1,10 +1,12 @@
import json import json
from mock import patch
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.proposal.models import Proposal from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus from grant.utils.enums import ProposalStatus
from ..config import BaseProposalCreatorConfig from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal from ..test_data import test_proposal, mock_contribution_addresses
class TestProposalAPI(BaseProposalCreatorConfig): class TestProposalAPI(BaseProposalCreatorConfig):
@ -70,6 +72,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
self.login_default_user() self.login_default_user()
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert200(resp) self.assert200(resp)
self.assertEqual(resp.json['status'], ProposalStatus.STAKING)
def test_no_auth_proposal_draft_submit_for_approval(self): def test_no_auth_proposal_draft_submit_for_approval(self):
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
@ -86,12 +89,45 @@ class TestProposalAPI(BaseProposalCreatorConfig):
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert400(resp) self.assert400(resp)
# /stake
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake(self, mock_get):
self.login_default_user()
self.proposal.status = ProposalStatus.STAKING
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))
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake_no_auth(self, mock_get):
self.proposal.status = ProposalStatus.STAKING
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert401(resp)
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake_bad_status(self, mock_get):
self.login_default_user()
self.proposal.status = ProposalStatus.PENDING # should be staking
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert400(resp)
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake_funded(self, mock_get):
self.login_default_user()
# fake stake contribution with confirmation
self.stake_proposal()
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert404(resp)
# /publish # /publish
def test_publish_proposal_approved(self): def test_publish_proposal_approved(self):
self.login_default_user() self.login_default_user()
# submit for approval, then approve # proposal needs to be APPROVED
self.proposal.submit_for_approval() self.proposal.status = ProposalStatus.APPROVED
self.proposal.approve_pending(True) # admin action
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert200(resp) self.assert200(resp)

View File

@ -3,7 +3,7 @@ from mock import patch
from grant.proposal.models import Proposal from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal from ..test_data import test_proposal
from ..mocks import mock_request from ..mocks import mock_request
@ -14,24 +14,17 @@ mock_contribution_addresses = mock_request({
}) })
class TestProposalContributionAPI(BaseUserConfig): class TestProposalContributionAPI(BaseProposalCreatorConfig):
@patch('requests.get', side_effect=mock_contribution_addresses) @patch('requests.get', side_effect=mock_contribution_addresses)
def test_create_proposal_contribution(self, mock_blockchain_get): def test_create_proposal_contribution(self, mock_blockchain_get):
self.login_default_user() self.login_default_user()
proposal_res = self.app.post(
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = { contribution = {
"amount": "1.2345" "amount": "1.2345"
} }
post_res = self.app.post( post_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id), "/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution), data=json.dumps(contribution),
content_type='application/json' content_type='application/json'
) )
@ -41,20 +34,13 @@ class TestProposalContributionAPI(BaseUserConfig):
@patch('requests.get', side_effect=mock_contribution_addresses) @patch('requests.get', side_effect=mock_contribution_addresses)
def test_create_duplicate_contribution(self, mock_blockchain_get): def test_create_duplicate_contribution(self, mock_blockchain_get):
self.login_default_user() self.login_default_user()
proposal_res = self.app.post(
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = { contribution = {
"amount": "1.2345" "amount": "1.2345"
} }
post_res = self.app.post( post_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id), "/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution), data=json.dumps(contribution),
content_type='application/json' content_type='application/json'
) )
@ -62,7 +48,7 @@ class TestProposalContributionAPI(BaseUserConfig):
self.assertStatus(post_res, 201) self.assertStatus(post_res, 201)
dupe_res = self.app.post( dupe_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id), "/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution), data=json.dumps(contribution),
content_type='application/json' content_type='application/json'
) )
@ -72,27 +58,20 @@ class TestProposalContributionAPI(BaseUserConfig):
@patch('requests.get', side_effect=mock_contribution_addresses) @patch('requests.get', side_effect=mock_contribution_addresses)
def test_get_proposal_contribution(self, mock_blockchain_get): def test_get_proposal_contribution(self, mock_blockchain_get):
self.login_default_user() self.login_default_user()
proposal_res = self.app.post(
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = { contribution = {
"amount": "1.2345" "amount": "1.2345"
} }
post_res = self.app.post( post_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id), "/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution), data=json.dumps(contribution),
content_type='application/json' content_type='application/json'
) )
contribution_id = post_res.json['id'] contribution_id = post_res.json['id']
contribution_res = self.app.get( contribution_res = self.app.get(
f'/api/v1/proposals/{proposal_id}/contributions/{contribution_id}' f'/api/v1/proposals/{self.proposal.id}/contributions/{contribution_id}'
) )
contribution = contribution_res.json contribution = contribution_res.json

View File

@ -1,3 +1,4 @@
from .mocks import mock_request
from grant.utils.enums import Category from grant.utils.enums import Category
@ -57,3 +58,9 @@ test_reply = {
"comment": "Test reply" "comment": "Test reply"
# Fill in parentCommentId in test # Fill in parentCommentId in test
} }
mock_contribution_addresses = mock_request({
'transparent': 't123',
'sprout': 'z123',
'memo': '123',
})

View File

@ -4,7 +4,7 @@ WEBHOOK_URL="http://localhost:5000/api/v1"
# REST Server Config # REST Server Config
PORT="5051" PORT="5051"
# ZCash Node (Defaults are for regtest) # Zcash Node (Defaults are for regtest)
ZCASH_NODE_URL="http://localhost:18232" ZCASH_NODE_URL="http://localhost:18232"
ZCASH_NODE_USERNAME="zcash_user" ZCASH_NODE_USERNAME="zcash_user"
ZCASH_NODE_PASSWORD="zcash_password" ZCASH_NODE_PASSWORD="zcash_password"

View File

@ -1,6 +1,6 @@
# Blockchain Watcher # Blockchain Watcher
Creates a websocket server that reads and reports on the activity of the ZCash Creates a websocket server that reads and reports on the activity of the Zcash
blockchain. Communicates with a node over RPC. blockchain. Communicates with a node over RPC.
## Development ## Development

View File

@ -15,3 +15,6 @@ BACKEND_URL=http://localhost:5000
# Blockchain explorer to link to. Top for mainnet, bottom for testnet. # Blockchain explorer to link to. Top for mainnet, bottom for testnet.
# EXPLORER_URL="https://explorer.zcha.in/" # EXPLORER_URL="https://explorer.zcha.in/"
EXPLORER_URL="https://testnet.zcha.in/" EXPLORER_URL="https://testnet.zcha.in/"
# Amount for staking a proposal in ZEC
PROPOSAL_STAKING_AMOUNT=0.025

View File

@ -1,6 +1,6 @@
# ZF Grants Frontend # ZF Grants Frontend
This is the front-end component of ZCash Grant System. This is the front-end component of Zcash Grant System.
## Setup ## Setup

View File

@ -11,7 +11,12 @@ import {
EmailSubscriptions, EmailSubscriptions,
RFP, RFP,
} from 'types'; } from 'types';
import { formatUserForPost, formatProposalFromGet, formatUserFromGet, formatRFPFromGet } from 'utils/api'; import {
formatUserForPost,
formatProposalFromGet,
formatUserFromGet,
formatRFPFromGet,
} from 'utils/api';
export function getProposals(): Promise<{ data: Proposal[] }> { export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/v1/proposals/').then(res => { return axios.get('/api/v1/proposals/').then(res => {
@ -261,6 +266,12 @@ export function getProposalContribution(
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`); return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
} }
export function getProposalStakingContribution(
proposalId: number,
): Promise<{ data: ContributionWithAddresses }> {
return axios.get(`/api/v1/proposals/${proposalId}/stake`);
}
export function getRFPs(): Promise<{ data: RFP[] }> { export function getRFPs(): Promise<{ data: RFP[] }> {
return axios.get('/api/v1/rfps/').then(res => { return axios.get('/api/v1/rfps/').then(res => {
res.data = res.data.map(formatRFPFromGet); res.data = res.data.map(formatRFPFromGet);

View File

@ -4,6 +4,7 @@
&-text { &-text {
margin-top: -0.25rem; margin-top: -0.25rem;
font-size: 0.95rem; font-size: 0.95rem;
margin-bottom: 1em;
} }
&-types { &-types {
@ -23,8 +24,7 @@
padding: 0.5rem; padding: 0.5rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 1px 2px rgba(#000, 0.1), box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2);
0 1px 4px rgba(#000, 0.2);
canvas { canvas {
display: block; display: block;
@ -110,4 +110,4 @@
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.8; opacity: 0.8;
} }
} }

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ReactNode } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Form, Input, Button, Icon, Radio, message } from 'antd'; import { Form, Input, Button, Icon, Radio, message } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio'; import { RadioChangeEvent } from 'antd/lib/radio';
@ -11,6 +11,7 @@ import './PaymentInfo.less';
interface Props { interface Props {
contribution?: ContributionWithAddresses | Falsy; contribution?: ContributionWithAddresses | Falsy;
text?: ReactNode;
} }
type SendType = 'sprout' | 'transparent'; type SendType = 'sprout' | 'transparent';
@ -25,7 +26,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
}; };
render() { render() {
const { contribution } = this.props; const { contribution, text } = this.props;
const { sendType } = this.state; const { sendType } = this.state;
let address; let address;
let memo; let memo;
@ -45,32 +46,27 @@ export default class PaymentInfo extends React.Component<Props, State> {
return ( return (
<Form className="PaymentInfo" layout="vertical"> <Form className="PaymentInfo" layout="vertical">
<p className="PaymentInfo-text"> <div className="PaymentInfo-text">
Thank you for contributing! Just send using whichever method works best for you, {text || (
and we'll let you know when your contribution has been confirmed. Need help <>
sending? Thank you for contributing! Just send using whichever method works best for
{/* TODO: Help / FAQ page for sending */} you, and we'll let you know when your contribution has been confirmed.
{' '} </>
<a>Click here</a>. )}
</p> {/* TODO: Help / FAQ page for sending */} Need help sending? <a>Click here</a>.
</div>
<Radio.Group <Radio.Group
className="PaymentInfo-types" className="PaymentInfo-types"
onChange={this.handleChangeSendType} onChange={this.handleChangeSendType}
value={sendType} value={sendType}
> >
<Radio.Button value="sprout"> <Radio.Button value="sprout">Z Address (Private)</Radio.Button>
Z Address (Private) <Radio.Button value="transparent">T Address (Public)</Radio.Button>
</Radio.Button>
<Radio.Button value="transparent">
T Address (Public)
</Radio.Button>
</Radio.Group> </Radio.Group>
<div className="PaymentInfo-uri"> <div className="PaymentInfo-uri">
<div className={ <div className={classnames('PaymentInfo-uri-qr', !uri && 'is-loading')}>
classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
}>
<span style={{ opacity: uri ? 1 : 0 }}> <span style={{ opacity: uri ? 1 : 0 }}>
<QRCode value={uri || ''} /> <QRCode value={uri || ''} />
</span> </span>
@ -100,7 +96,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
</div> </div>
<div className="PaymentInfo-fields-row"> <div className="PaymentInfo-fields-row">
<CopyInput <CopyInput
label="ZCash CLI command" label="Zcash CLI command"
help="Make sure you replace YOUR_ADDRESS with your actual address" help="Make sure you replace YOUR_ADDRESS with your actual address"
value={cli} value={cli}
/> />
@ -123,33 +119,29 @@ interface CopyInputProps {
isTextarea?: boolean; isTextarea?: boolean;
} }
const CopyInput: React.SFC<CopyInputProps> = ({ label, value, help, className, isTextarea }) => ( const CopyInput: React.SFC<CopyInputProps> = ({
label,
value,
help,
className,
isTextarea,
}) => (
<Form.Item <Form.Item
className={classnames( className={classnames('CopyInput', className, isTextarea && 'is-textarea')}
'CopyInput',
className,
isTextarea && 'is-textarea',
)}
label={label} label={label}
help={help} help={help}
> >
{isTextarea ? ( {isTextarea ? (
<> <>
<Input.TextArea value={value} readOnly rows={3} /> <Input.TextArea value={value} readOnly rows={3} />
<CopyToClipboard <CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
text={value || ''}
onCopy={() => message.success('Copied!', 2)}
>
<Button icon="copy" /> <Button icon="copy" />
</CopyToClipboard> </CopyToClipboard>
</> </>
) : ( ) : (
<> <>
<Input value={value} readOnly /> <Input value={value} readOnly />
<CopyToClipboard <CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
text={value || ''}
onCopy={() => message.success('Copied!', 2)}
>
<Button icon="copy" /> <Button icon="copy" />
</CopyToClipboard> </CopyToClipboard>
</> </>

View File

@ -1,17 +1,16 @@
@import '~styles/variables.less'; @import '~styles/variables.less';
.CreateFinal { .CreateFinal {
position: absolute; max-width: 550px;
top: 50%; padding: 1rem;
left: 50%; margin: 3rem auto;
transform: translate(-50%, -50%);
text-align: center;
&-message { &-message {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-bottom: 2rem;
.anticon { .anticon {
margin-right: 1rem; margin-right: 1rem;
font-size: 3.2rem; font-size: 3.2rem;
@ -28,4 +27,15 @@
text-align: left; text-align: left;
} }
} }
}
&-contribute {
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 1.5rem;
}
&-staked {
margin-top: 1rem;
font-size: 1.1rem;
text-align: center;
}
}

View File

@ -5,7 +5,10 @@ import { Link } from 'react-router-dom';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import { createActions } from 'modules/create'; import { createActions } from 'modules/create';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { getProposalStakingContribution } from 'api/api';
import './Final.less'; import './Final.less';
import PaymentInfo from 'components/ContributionModal/PaymentInfo';
import { ContributionWithAddresses } from 'types';
interface StateProps { interface StateProps {
form: AppState['create']['form']; form: AppState['create']['form'];
@ -19,13 +22,34 @@ interface DispatchProps {
type Props = StateProps & DispatchProps; type Props = StateProps & DispatchProps;
class CreateFinal extends React.Component<Props> { const STATE = {
contribution: null as null | ContributionWithAddresses,
};
type State = typeof STATE;
class CreateFinal extends React.Component<Props, State> {
state = STATE;
componentDidMount() { componentDidMount() {
this.submit(); this.submit();
} }
componentDidUpdate(prev: Props) {
const { submittedProposal } = this.props;
if (!prev.submittedProposal && submittedProposal) {
if (!submittedProposal.isStaked) {
this.getStakingContribution();
}
}
}
render() { render() {
const { submittedProposal, submitError } = this.props; const { submittedProposal, submitError } = this.props;
const { contribution } = this.state;
const ready = submittedProposal && (submittedProposal.isStaked || contribution);
const staked = submittedProposal && submittedProposal.isStaked;
let content; let content;
if (submitError) { if (submitError) {
content = ( content = (
@ -40,24 +64,57 @@ class CreateFinal extends React.Component<Props> {
</div> </div>
</div> </div>
); );
} else if (submittedProposal) { } else if (ready) {
content = ( content = (
<div className="CreateFinal-message is-success"> <>
<Icon type="check-circle" /> <div className="CreateFinal-message is-success">
<div className="CreateFinal-message-text"> <Icon type="check-circle" />
Your proposal has been submitted! Check your{' '} {staked && (
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link> to <div className="CreateFinal-message-text">
check its status. Your proposal has been staked and submitted! Check your{' '}
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link>{' '}
to check its status.
</div>
)}
{!staked && (
<div className="CreateFinal-message-text">
Your proposal has been submitted! Please send the staking contribution of{' '}
<b>{contribution && contribution.amount} ZEC</b> using the instructions
below.
</div>
)}
</div> </div>
{/* TODO - remove or rework depending on design choices */} {!staked && (
{/* <div className="CreateFinal-message-text"> <>
Your proposal has been submitted!{' '} <div className="CreateFinal-contribute">
<Link to={`/proposals/${submittedProposal.proposalUrlId}`}> <PaymentInfo
Click here text={
</Link> <>
{' '}to check it out. <p>
</div> */} If you cannot send the payment now, you may bring up these
</div> instructions again by visiting your{' '}
<Link to={`/profile?tab=funded`}>profile's funded tab</Link>.
</p>
<p>
Once your payment has been sent and confirmed, you will receive an
email. Visit your{' '}
<Link to={`/profile?tab=pending`}>
profile's pending proposals tab
</Link>{' '}
at any time to check its status.
</p>
</>
}
contribution={contribution}
/>
</div>
<p className="CreateFinal-staked">
I'm finished, take me to{' '}
<Link to="/profile?tab=pending">my pending proposals</Link>!
</p>
</>
)}
</>
); );
} else { } else {
content = <Loader size="large" tip="Submitting your proposal..." />; content = <Loader size="large" tip="Submitting your proposal..." />;
@ -71,6 +128,14 @@ class CreateFinal extends React.Component<Props> {
this.props.submitProposal(this.props.form); this.props.submitProposal(this.props.form);
} }
}; };
private getStakingContribution = async () => {
const { submittedProposal } = this.props;
if (submittedProposal) {
const res = await getProposalStakingContribution(submittedProposal.proposalId);
this.setState({ contribution: res.data });
}
};
} }
export default connect<StateProps, DispatchProps, {}, AppState>( export default connect<StateProps, DispatchProps, {}, AppState>(

View File

@ -1,54 +0,0 @@
import React from 'react';
import { Modal, Alert } from 'antd';
import { getCreateWarnings } from 'modules/create/utils';
import { ProposalDraft } from 'types';
import './PublishWarningModal.less';
interface Props {
proposal: ProposalDraft | null;
isVisible: boolean;
handleClose(): void;
handlePublish(): void;
}
export default class PublishWarningModal extends React.Component<Props> {
render() {
const { proposal, isVisible, handleClose, handlePublish } = this.props;
const warnings = proposal ? getCreateWarnings(proposal) : [];
return (
<Modal
title={<>Confirm submit for approval</>}
visible={isVisible}
okText="Confirm submit"
cancelText="Never mind"
onOk={handlePublish}
onCancel={handleClose}
>
<div className="PublishWarningModal">
{!!warnings.length && (
<Alert
type="warning"
showIcon
message="Some fields have warnings"
description={
<>
<ul>
{warnings.map(w => (
<li key={w}>{w}</li>
))}
</ul>
<p>You can still submit, despite these warnings.</p>
</>
}
/>
)}
<p>
Are you sure you're ready to submit your proposal for approval? Once youve
done so, you won't be able to edit it.
</p>
</div>
</Modal>
);
}
}

View File

@ -1,4 +1,4 @@
.PublishWarningModal { .SubmitWarningModal {
.ant-alert { .ant-alert {
margin-bottom: 1rem; margin-bottom: 1rem;
@ -10,4 +10,4 @@
margin-bottom: 0; margin-bottom: 0;
} }
} }
} }

View File

@ -0,0 +1,66 @@
import React from 'react';
import { Modal, Alert } from 'antd';
import { getCreateWarnings } from 'modules/create/utils';
import { ProposalDraft } from 'types';
import './SubmitWarningModal.less';
interface Props {
proposal: ProposalDraft | null;
isVisible: boolean;
handleClose(): void;
handleSubmit(): void;
}
export default class SubmitWarningModal extends React.Component<Props> {
render() {
const { proposal, isVisible, handleClose, handleSubmit } = this.props;
const warnings = proposal ? getCreateWarnings(proposal) : [];
const staked = proposal && proposal.isStaked;
return (
<Modal
title={<>Confirm submission</>}
visible={isVisible}
okText={staked ? 'Submit' : `I'm ready to stake`}
cancelText="Never mind"
onOk={handleSubmit}
onCancel={handleClose}
>
<div className="SubmitWarningModal">
{!!warnings.length && (
<Alert
type="warning"
showIcon
message="Some fields have warnings"
description={
<>
<ul>
{warnings.map(w => (
<li key={w}>{w}</li>
))}
</ul>
<p>You can still submit, despite these warnings.</p>
</>
}
/>
)}
{staked && (
<p>
Are you sure you're ready to submit your proposal for approval? Once youve
done so, you won't be able to edit it.
</p>
)}
{!staked && (
<p>
Are you sure you're ready to submit your proposal? You will be asked to send
a staking contribution of <b>{process.env.PROPOSAL_STAKING_AMOUNT} ZEC</b>.
Once confirmed, the proposal will be submitted for approval by site
administrators.
</p>
)}
</div>
</Modal>
);
}
}

View File

@ -14,7 +14,7 @@ import Payment from './Payment';
import Review from './Review'; import Review from './Review';
import Preview from './Preview'; import Preview from './Preview';
import Final from './Final'; import Final from './Final';
import PublishWarningModal from './PublishWarningModal'; import SubmitWarningModal from './SubmitWarningModal';
import createExampleProposal from './example'; import createExampleProposal from './example';
import { createActions } from 'modules/create'; import { createActions } from 'modules/create';
import { ProposalDraft } from 'types'; import { ProposalDraft } from 'types';
@ -115,8 +115,8 @@ type Props = StateProps & DispatchProps & RouteComponentProps<any>;
interface State { interface State {
step: CREATE_STEP; step: CREATE_STEP;
isPreviewing: boolean; isPreviewing: boolean;
isShowingPublishWarning: boolean; isShowingSubmitWarning: boolean;
isPublishing: boolean; isSubmitting: boolean;
isExample: boolean; isExample: boolean;
} }
@ -134,9 +134,9 @@ class CreateFlow extends React.Component<Props, State> {
this.state = { this.state = {
step, step,
isPreviewing: false, isPreviewing: false,
isPublishing: false, isSubmitting: false,
isExample: false, isExample: false,
isShowingPublishWarning: false, isShowingSubmitWarning: false,
}; };
this.debouncedUpdateForm = debounce(this.updateForm, 800); this.debouncedUpdateForm = debounce(this.updateForm, 800);
this.historyUnlisten = this.props.history.listen(this.handlePop); this.historyUnlisten = this.props.history.listen(this.handlePop);
@ -154,7 +154,7 @@ class CreateFlow extends React.Component<Props, State> {
render() { render() {
const { isSavingDraft } = this.props; const { isSavingDraft } = this.props;
const { step, isPreviewing, isPublishing, isShowingPublishWarning } = this.state; const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
const info = STEP_INFO[step]; const info = STEP_INFO[step];
const currentIndex = STEP_ORDER.indexOf(step); const currentIndex = STEP_ORDER.indexOf(step);
@ -163,7 +163,7 @@ class CreateFlow extends React.Component<Props, State> {
let content; let content;
let showFooter = true; let showFooter = true;
if (isPublishing) { if (isSubmitting) {
content = <Final />; content = <Final />;
showFooter = false; showFooter = false;
} else if (isPreviewing) { } else if (isPreviewing) {
@ -241,11 +241,11 @@ class CreateFlow extends React.Component<Props, State> {
{isSavingDraft && ( {isSavingDraft && (
<div className="CreateFlow-draftNotification">Saving draft...</div> <div className="CreateFlow-draftNotification">Saving draft...</div>
)} )}
<PublishWarningModal <SubmitWarningModal
proposal={this.props.form} proposal={this.props.form}
isVisible={isShowingPublishWarning} isVisible={isShowingSubmitWarning}
handleClose={this.closePublishWarning} handleClose={this.closePublishWarning}
handlePublish={this.startPublish} handleSubmit={this.startSubmit}
/> />
</div> </div>
); );
@ -274,10 +274,10 @@ class CreateFlow extends React.Component<Props, State> {
this.setState({ isPreviewing: !this.state.isPreviewing }); this.setState({ isPreviewing: !this.state.isPreviewing });
}; };
private startPublish = () => { private startSubmit = () => {
this.setState({ this.setState({
isPublishing: true, isSubmitting: true,
isShowingPublishWarning: false, isShowingSubmitWarning: false,
}); });
}; };
@ -302,11 +302,11 @@ class CreateFlow extends React.Component<Props, State> {
}; };
private openPublishWarning = () => { private openPublishWarning = () => {
this.setState({ isShowingPublishWarning: true }); this.setState({ isShowingSubmitWarning: true });
}; };
private closePublishWarning = () => { private closePublishWarning = () => {
this.setState({ isShowingPublishWarning: false }); this.setState({ isShowingSubmitWarning: false });
}; };
private fillInExample = () => { private fillInExample = () => {

View File

@ -55,6 +55,17 @@ class ProfilePending extends React.Component<Props, State> {
</> </>
), ),
}, },
[STATUS.STAKING]: {
color: 'purple',
tag: 'Staking',
blurb: (
<div>
Awaiting staking contribution, you will recieve an email when staking has been
confirmed. If you staked this proposal you may check its status under the
"funded" tab.
</div>
),
},
[STATUS.PENDING]: { [STATUS.PENDING]: {
color: 'orange', color: 'orange',
tag: 'Pending', tag: 'Pending',

View File

@ -142,7 +142,8 @@ export class ProposalDetail extends React.Component<Props, State> {
blurb: ( blurb: (
<> <>
Your proposal has been approved! It is currently only visible to the team. Your proposal has been approved! It is currently only visible to the team.
Visit your <Link to="/profile?tab=pending">profile's pending tab</Link> to publish. Visit your <Link to="/profile?tab=pending">profile's pending tab</Link> to
publish.
</> </>
), ),
type: 'success', type: 'success',
@ -151,11 +152,22 @@ export class ProposalDetail extends React.Component<Props, State> {
blurb: ( blurb: (
<> <>
Your proposal was rejected and is only visible to the team. Visit your{' '} Your proposal was rejected and is only visible to the team. Visit your{' '}
<Link to="/profile?tab=pending">profile's pending tab</Link> for more information. <Link to="/profile?tab=pending">profile's pending tab</Link> for more
information.
</> </>
), ),
type: 'error', type: 'error',
}, },
[STATUS.STAKING]: {
blurb: (
<>
Your proposal is awaiting a staking contribution. Visit your{' '}
<Link to="/profile?tab=pending">profile's pending tab</Link> for more
information.
</>
),
type: 'warning',
},
} as { [key in STATUS]: { blurb: ReactNode; type: AlertProps['type'] } }; } as { [key in STATUS]: { blurb: ReactNode; type: AlertProps['type'] } };
let banner = statusBanner[proposal.status]; let banner = statusBanner[proposal.status];
if (isPreview) { if (isPreview) {
@ -196,11 +208,7 @@ export class ProposalDetail extends React.Component<Props, State> {
['is-expanded']: isBodyExpanded, ['is-expanded']: isBodyExpanded,
})} })}
> >
{proposal ? ( {proposal ? <Markdown source={proposal.content} /> : <Loader />}
<Markdown source={proposal.content} />
) : (
<Loader />
)}
</div> </div>
{showExpand && ( {showExpand && (
<button <button

View File

@ -174,6 +174,7 @@ export default function createReducer(
case types.SUBMIT_PROPOSAL_PENDING: case types.SUBMIT_PROPOSAL_PENDING:
return { return {
...state, ...state,
submittedProposal: null,
isSubmitting: true, isSubmitting: true,
submitError: null, submitError: null,
}; };

View File

@ -192,6 +192,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
stage: 'preview', stage: 'preview',
category: draft.category || PROPOSAL_CATEGORY.DAPP, category: draft.category || PROPOSAL_CATEGORY.DAPP,
team: draft.team, team: draft.team,
isStaked: true,
milestones: draft.milestones.map((m, idx) => ({ milestones: draft.milestones.map((m, idx) => ({
index: idx, index: idx,
title: m.title, title: m.title,

View File

@ -41,6 +41,8 @@ envProductionRequiredHandler(
'http://localhost:' + (process.env.PORT || 3000), 'http://localhost:' + (process.env.PORT || 3000),
); );
envProductionRequiredHandler('PROPOSAL_STAKING_AMOUNT', '0.025');
const appDirectory = fs.realpathSync(process.cwd()); const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '') process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter) .split(path.delimiter)
@ -54,6 +56,7 @@ module.exports = () => {
EXPLORER_URL: process.env.EXPLORER_URL || 'https://chain.so/zcash/', EXPLORER_URL: process.env.EXPLORER_URL || 'https://chain.so/zcash/',
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
PORT: process.env.PORT || 3000, PORT: process.env.PORT || 3000,
PROPOSAL_STAKING_AMOUNT: process.env.PROPOSAL_STAKING_AMOUNT,
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL, PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
SENTRY_DSN: process.env.SENTRY_DSN || null, SENTRY_DSN: process.env.SENTRY_DSN || null,
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined, SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,

View File

@ -159,6 +159,7 @@ export function generateProposal({
content: 'body', content: 'body',
stage: 'FUNDING_REQUIRED', stage: 'FUNDING_REQUIRED',
category: PROPOSAL_CATEGORY.COMMUNITY, category: PROPOSAL_CATEGORY.COMMUNITY,
isStaked: true,
team: [ team: [
{ {
userid: 123, userid: 123,

View File

@ -34,6 +34,7 @@ export interface ProposalDraft {
team: User[]; team: User[];
invites: TeamInvite[]; invites: TeamInvite[];
status: STATUS; status: STATUS;
isStaked: boolean;
} }
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> { export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
@ -85,6 +86,7 @@ export interface UserProposal {
// NOTE: sync with backend/grant/proposal/models.py STATUSES // NOTE: sync with backend/grant/proposal/models.py STATUSES
export enum STATUS { export enum STATUS {
DRAFT = 'DRAFT', DRAFT = 'DRAFT',
STAKING = 'STAKING',
PENDING = 'PENDING', PENDING = 'PENDING',
APPROVED = 'APPROVED', APPROVED = 'APPROVED',
REJECTED = 'REJECTED', REJECTED = 'REJECTED',