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:
parent
4091deaf2f
commit
f8f3bd1707
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ export enum PROPOSAL_STATUS {
|
|||
REJECTED = 'REJECTED',
|
||||
LIVE = 'LIVE',
|
||||
DELETED = 'DELETED',
|
||||
STAKING = 'STAKING',
|
||||
}
|
||||
export interface Proposal {
|
||||
proposalId: number;
|
||||
|
|
|
@ -52,6 +52,13 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
|||
hint:
|
||||
'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>> = [
|
||||
|
|
|
@ -31,3 +31,6 @@ ADMIN_PASS_HASH=18f97883b93a975deb9e29257a341a447302040da59cdc2d10ff65a5e57cc197
|
|||
# Blockchain explorer to link to. Top for mainnet, bottom for testnet.
|
||||
# EXPLORER_URL="https://explorer.zcha.in/"
|
||||
EXPLORER_URL="https://testnet.zcha.in/"
|
||||
|
||||
# Amount for staking a proposal in ZEC
|
||||
PROPOSAL_STAKING_AMOUNT=0.025
|
||||
|
|
|
@ -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):
|
||||
return {
|
||||
'subject': 'Your contribution has been confirmed!',
|
||||
|
@ -150,6 +165,7 @@ get_info_lookup = {
|
|||
'proposal_rejected': proposal_rejected,
|
||||
'proposal_contribution': proposal_contribution,
|
||||
'proposal_comment': proposal_comment,
|
||||
'staking_contribution_confirmed': staking_contribution_confirmed,
|
||||
'contribution_confirmed': contribution_confirmed,
|
||||
'contribution_update': contribution_update,
|
||||
'comment_reply': comment_reply,
|
||||
|
|
|
@ -10,15 +10,17 @@ from grant.utils.exceptions import ValidationException
|
|||
from grant.utils.misc import dt_to_unix, make_url
|
||||
from grant.utils.requests import blockchain_get
|
||||
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||
|
||||
# Proposal states
|
||||
DRAFT = 'DRAFT'
|
||||
PENDING = 'PENDING'
|
||||
STAKING = 'STAKING'
|
||||
APPROVED = 'APPROVED'
|
||||
REJECTED = 'REJECTED'
|
||||
LIVE = 'LIVE'
|
||||
DELETED = 'DELETED'
|
||||
STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED]
|
||||
STATUSES = [DRAFT, PENDING, STAKING, APPROVED, REJECTED, LIVE, DELETED]
|
||||
|
||||
# Funding stages
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||
|
@ -255,14 +257,44 @@ class Proposal(db.Model):
|
|||
self.deadline_duration = deadline_duration
|
||||
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):
|
||||
self.validate_publishable()
|
||||
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
||||
# specific validation
|
||||
if self.status not in allowed_statuses:
|
||||
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
||||
|
||||
self.status = ProposalStatus.PENDING
|
||||
# set to PENDING if staked, else STAKING
|
||||
if self.is_staked:
|
||||
self.status = ProposalStatus.PENDING
|
||||
else:
|
||||
self.status = ProposalStatus.STAKING
|
||||
|
||||
def approve_pending(self, is_approve, reject_reason=None):
|
||||
self.validate_publishable()
|
||||
|
@ -321,6 +353,10 @@ class Proposal(db.Model):
|
|||
|
||||
return str(funded)
|
||||
|
||||
@hybrid_property
|
||||
def is_staked(self):
|
||||
return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT
|
||||
|
||||
|
||||
class ProposalSchema(ma.Schema):
|
||||
class Meta:
|
||||
|
@ -338,6 +374,7 @@ class ProposalSchema(ma.Schema):
|
|||
"proposal_id",
|
||||
"target",
|
||||
"contributed",
|
||||
"is_staked",
|
||||
"funded",
|
||||
"content",
|
||||
"comments",
|
||||
|
@ -383,6 +420,7 @@ user_fields = [
|
|||
"title",
|
||||
"brief",
|
||||
"target",
|
||||
"is_staked",
|
||||
"funded",
|
||||
"contribution_matching",
|
||||
"date_created",
|
||||
|
|
|
@ -4,7 +4,7 @@ from flask_yoloapi import endpoint, parameter
|
|||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||
from grant.email.send import send_email
|
||||
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.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
|
||||
from grant.utils.exceptions import ValidationException
|
||||
|
@ -131,14 +131,14 @@ def get_proposals(stage):
|
|||
if stage:
|
||||
proposals = (
|
||||
Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage)
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
proposals = (
|
||||
Proposal.query.filter_by(status=ProposalStatus.LIVE)
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
)
|
||||
dumped_proposals = proposals_schema.dump(proposals)
|
||||
return dumped_proposals
|
||||
|
@ -242,6 +242,18 @@ def submit_for_approval_proposal(proposal_id):
|
|||
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"])
|
||||
@requires_team_member_auth
|
||||
@endpoint.api()
|
||||
|
@ -419,13 +431,7 @@ def post_proposal_contribution(proposal_id, amount):
|
|||
|
||||
if not contribution:
|
||||
code = 201
|
||||
contribution = ProposalContribution(
|
||||
proposal_id=proposal_id,
|
||||
user_id=g.current_user.id,
|
||||
amount=amount
|
||||
)
|
||||
db.session.add(contribution)
|
||||
db.session.commit()
|
||||
contribution = proposal.create_contribution(g.current_user.id, amount)
|
||||
|
||||
dumped_contribution = proposal_contribution_schema.dump(contribution)
|
||||
return dumped_contribution, code
|
||||
|
@ -459,26 +465,43 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
db.session.add(contribution)
|
||||
db.session.commit()
|
||||
|
||||
# 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}',
|
||||
})
|
||||
if contribution.proposal.status == ProposalStatus.STAKING:
|
||||
# fully staked, set status PENDING & notify user
|
||||
if contribution.proposal.is_staked: # float(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
|
||||
contribution.proposal.status = ProposalStatus.PENDING
|
||||
db.session.add(contribution.proposal)
|
||||
db.session.commit()
|
||||
|
||||
# Send to the full proposal gang
|
||||
for member in contribution.proposal.team:
|
||||
send_email(member.email_address, 'proposal_contribution', {
|
||||
'proposal': contribution.proposal,
|
||||
# email progress of staking, partial or complete
|
||||
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
|
||||
'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}'),
|
||||
'proposal': contribution.proposal,
|
||||
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
|
||||
'fully_staked': contribution.proposal.is_staked,
|
||||
'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
|
||||
# on funding target reached.
|
||||
# on funding target reached.
|
||||
|
||||
return None, 200
|
||||
|
||||
|
|
|
@ -54,8 +54,10 @@ ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH")
|
|||
|
||||
EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/")
|
||||
|
||||
PROPOSAL_STAKING_AMOUNT = env.float("PROPOSAL_STAKING_AMOUNT")
|
||||
|
||||
UI = {
|
||||
'NAME': 'ZF Grants',
|
||||
'PRIMARY': '#CF8A00',
|
||||
'SECONDARY': '#2D2A26',
|
||||
'NAME': 'ZF Grants',
|
||||
'PRIMARY': '#CF8A00',
|
||||
'SECONDARY': '#2D2A26',
|
||||
}
|
||||
|
|
|
@ -1,20 +1,16 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has
|
||||
been confirmed! <strong>{{ args.proposal.title}}</strong> has been updated
|
||||
to reflect your funding, and your account will now show your contribution.
|
||||
You can view your transaction below:
|
||||
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has been
|
||||
confirmed! <strong>{{ args.proposal.title }}</strong> has been updated to
|
||||
reflect your funding, and your account will now show your contribution. 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>
|
||||
<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.
|
||||
Thank you for your help in improving the Zcash ecosystem.
|
||||
</p>
|
||||
|
|
|
@ -4,4 +4,4 @@ account will now show your contribution. You can view your transaction here:
|
|||
|
||||
{{ args.tx_explorer_url }}
|
||||
|
||||
Thank you for your help in improving the ZCash ecosystem.
|
||||
Thank you for your help in improving the Zcash ecosystem.
|
|
@ -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>
|
||||
|
|
@ -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.
|
|
@ -43,10 +43,10 @@ def get_users(proposal_id):
|
|||
else:
|
||||
users = (
|
||||
User.query
|
||||
.join(proposal_team)
|
||||
.join(Proposal)
|
||||
.filter(proposal_team.c.proposal_id == proposal.id)
|
||||
.all()
|
||||
.join(proposal_team)
|
||||
.join(Proposal)
|
||||
.filter(proposal_team.c.proposal_id == proposal.id)
|
||||
.all()
|
||||
)
|
||||
result = users_schema.dump(users)
|
||||
return result
|
||||
|
@ -88,6 +88,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
|
|||
result["comments"] = comments_dump
|
||||
if with_pending and authed_user and authed_user.id == user.id:
|
||||
pending = Proposal.get_by_user(user, [
|
||||
ProposalStatus.STAKING,
|
||||
ProposalStatus.PENDING,
|
||||
ProposalStatus.APPROVED,
|
||||
ProposalStatus.REJECTED,
|
||||
|
|
|
@ -8,16 +8,21 @@ class CustomEnum():
|
|||
class ProposalStatusEnum(CustomEnum):
|
||||
DRAFT = 'DRAFT'
|
||||
PENDING = 'PENDING'
|
||||
STAKING = 'STAKING'
|
||||
APPROVED = 'APPROVED'
|
||||
REJECTED = 'REJECTED'
|
||||
LIVE = 'LIVE'
|
||||
DELETED = 'DELETED'
|
||||
|
||||
|
||||
ProposalStatus = ProposalStatusEnum()
|
||||
|
||||
|
||||
class ProposalStageEnum(CustomEnum):
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
||||
COMPLETED = 'COMPLETED'
|
||||
|
||||
|
||||
ProposalStage = ProposalStageEnum()
|
||||
|
||||
|
||||
|
@ -28,6 +33,8 @@ class CategoryEnum(CustomEnum):
|
|||
COMMUNITY = 'COMMUNITY'
|
||||
DOCUMENTATION = 'DOCUMENTATION'
|
||||
ACCESSIBILITY = 'ACCESSIBILITY'
|
||||
|
||||
|
||||
Category = CategoryEnum()
|
||||
|
||||
|
||||
|
@ -35,6 +42,8 @@ class ContributionStatusEnum(CustomEnum):
|
|||
PENDING = 'PENDING'
|
||||
CONFIRMED = 'CONFIRMED'
|
||||
DELETED = 'DELETED'
|
||||
|
||||
|
||||
ContributionStatus = ContributionStatusEnum()
|
||||
|
||||
|
||||
|
@ -42,4 +51,6 @@ class RFPStatusEnum(CustomEnum):
|
|||
DRAFT = 'DRAFT'
|
||||
LIVE = 'LIVE'
|
||||
CLOSED = 'CLOSED'
|
||||
|
||||
|
||||
RFPStatus = RFPStatusEnum()
|
||||
|
|
|
@ -3,6 +3,8 @@ from grant.utils.admin import generate_admin_password_hash
|
|||
from mock import patch
|
||||
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
from ..mocks import mock_request
|
||||
|
||||
|
||||
plaintext_mock_password = "p4ssw0rd"
|
||||
mock_admin_auth = {
|
||||
|
@ -96,8 +98,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
|
||||
def test_approve_proposal(self):
|
||||
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
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
||||
|
@ -108,8 +112,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
|
||||
def test_reject_proposal(self):
|
||||
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
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import json
|
||||
from mock import patch
|
||||
|
||||
from flask_testing import TestCase
|
||||
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.user.models import User, SocialMedia, db, Avatar
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||
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):
|
||||
|
@ -32,7 +34,7 @@ class BaseTestConfig(TestCase):
|
|||
"""
|
||||
|
||||
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)
|
||||
|
||||
assert_status = assertStatus
|
||||
|
@ -144,4 +146,17 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
|||
|
||||
def make_proposal_reminder_task(self):
|
||||
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
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import json
|
||||
from mock import patch
|
||||
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||
from grant.proposal.models import Proposal
|
||||
from grant.utils.enums import ProposalStatus
|
||||
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
from ..test_data import test_proposal
|
||||
from ..test_data import test_proposal, mock_contribution_addresses
|
||||
|
||||
|
||||
class TestProposalAPI(BaseProposalCreatorConfig):
|
||||
|
@ -70,6 +72,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
self.login_default_user()
|
||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json['status'], ProposalStatus.STAKING)
|
||||
|
||||
def test_no_auth_proposal_draft_submit_for_approval(self):
|
||||
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))
|
||||
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
|
||||
def test_publish_proposal_approved(self):
|
||||
self.login_default_user()
|
||||
# submit for approval, then approve
|
||||
self.proposal.submit_for_approval()
|
||||
self.proposal.approve_pending(True) # admin action
|
||||
# proposal needs to be APPROVED
|
||||
self.proposal.status = ProposalStatus.APPROVED
|
||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
||||
self.assert200(resp)
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ from mock import patch
|
|||
|
||||
from grant.proposal.models import Proposal
|
||||
from grant.utils.enums import ProposalStatus
|
||||
from ..config import BaseUserConfig
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
from ..test_data import test_proposal
|
||||
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)
|
||||
def test_create_proposal_contribution(self, mock_blockchain_get):
|
||||
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 = {
|
||||
"amount": "1.2345"
|
||||
}
|
||||
|
||||
post_res = self.app.post(
|
||||
"/api/v1/proposals/{}/contributions".format(proposal_id),
|
||||
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
|
||||
data=json.dumps(contribution),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
@ -41,20 +34,13 @@ class TestProposalContributionAPI(BaseUserConfig):
|
|||
@patch('requests.get', side_effect=mock_contribution_addresses)
|
||||
def test_create_duplicate_contribution(self, mock_blockchain_get):
|
||||
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 = {
|
||||
"amount": "1.2345"
|
||||
}
|
||||
|
||||
post_res = self.app.post(
|
||||
"/api/v1/proposals/{}/contributions".format(proposal_id),
|
||||
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
|
||||
data=json.dumps(contribution),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
@ -62,7 +48,7 @@ class TestProposalContributionAPI(BaseUserConfig):
|
|||
self.assertStatus(post_res, 201)
|
||||
|
||||
dupe_res = self.app.post(
|
||||
"/api/v1/proposals/{}/contributions".format(proposal_id),
|
||||
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
|
||||
data=json.dumps(contribution),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
@ -72,27 +58,20 @@ class TestProposalContributionAPI(BaseUserConfig):
|
|||
@patch('requests.get', side_effect=mock_contribution_addresses)
|
||||
def test_get_proposal_contribution(self, mock_blockchain_get):
|
||||
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 = {
|
||||
"amount": "1.2345"
|
||||
}
|
||||
|
||||
post_res = self.app.post(
|
||||
"/api/v1/proposals/{}/contributions".format(proposal_id),
|
||||
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
|
||||
data=json.dumps(contribution),
|
||||
content_type='application/json'
|
||||
)
|
||||
contribution_id = post_res.json['id']
|
||||
|
||||
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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from .mocks import mock_request
|
||||
from grant.utils.enums import Category
|
||||
|
||||
|
||||
|
@ -57,3 +58,9 @@ test_reply = {
|
|||
"comment": "Test reply"
|
||||
# Fill in parentCommentId in test
|
||||
}
|
||||
|
||||
mock_contribution_addresses = mock_request({
|
||||
'transparent': 't123',
|
||||
'sprout': 'z123',
|
||||
'memo': '123',
|
||||
})
|
||||
|
|
|
@ -4,7 +4,7 @@ WEBHOOK_URL="http://localhost:5000/api/v1"
|
|||
# REST Server Config
|
||||
PORT="5051"
|
||||
|
||||
# ZCash Node (Defaults are for regtest)
|
||||
# Zcash Node (Defaults are for regtest)
|
||||
ZCASH_NODE_URL="http://localhost:18232"
|
||||
ZCASH_NODE_USERNAME="zcash_user"
|
||||
ZCASH_NODE_PASSWORD="zcash_password"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# 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.
|
||||
|
||||
## Development
|
||||
|
|
|
@ -15,3 +15,6 @@ BACKEND_URL=http://localhost:5000
|
|||
# Blockchain explorer to link to. Top for mainnet, bottom for testnet.
|
||||
# EXPLORER_URL="https://explorer.zcha.in/"
|
||||
EXPLORER_URL="https://testnet.zcha.in/"
|
||||
|
||||
# Amount for staking a proposal in ZEC
|
||||
PROPOSAL_STAKING_AMOUNT=0.025
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# ZF Grants Frontend
|
||||
|
||||
This is the front-end component of ZCash Grant System.
|
||||
This is the front-end component of Zcash Grant System.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
@ -11,7 +11,12 @@ import {
|
|||
EmailSubscriptions,
|
||||
RFP,
|
||||
} from 'types';
|
||||
import { formatUserForPost, formatProposalFromGet, formatUserFromGet, formatRFPFromGet } from 'utils/api';
|
||||
import {
|
||||
formatUserForPost,
|
||||
formatProposalFromGet,
|
||||
formatUserFromGet,
|
||||
formatRFPFromGet,
|
||||
} from 'utils/api';
|
||||
|
||||
export function getProposals(): Promise<{ data: Proposal[] }> {
|
||||
return axios.get('/api/v1/proposals/').then(res => {
|
||||
|
@ -261,6 +266,12 @@ export function getProposalContribution(
|
|||
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[] }> {
|
||||
return axios.get('/api/v1/rfps/').then(res => {
|
||||
res.data = res.data.map(formatRFPFromGet);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
&-text {
|
||||
margin-top: -0.25rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
&-types {
|
||||
|
@ -23,8 +24,7 @@
|
|||
padding: 0.5rem;
|
||||
margin-right: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.1),
|
||||
0 1px 4px rgba(#000, 0.2);
|
||||
box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2);
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
|
@ -110,4 +110,4 @@
|
|||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Form, Input, Button, Icon, Radio, message } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
||||
|
@ -11,6 +11,7 @@ import './PaymentInfo.less';
|
|||
|
||||
interface Props {
|
||||
contribution?: ContributionWithAddresses | Falsy;
|
||||
text?: ReactNode;
|
||||
}
|
||||
|
||||
type SendType = 'sprout' | 'transparent';
|
||||
|
@ -25,7 +26,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { contribution } = this.props;
|
||||
const { contribution, text } = this.props;
|
||||
const { sendType } = this.state;
|
||||
let address;
|
||||
let memo;
|
||||
|
@ -45,32 +46,27 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<Form className="PaymentInfo" layout="vertical">
|
||||
<p className="PaymentInfo-text">
|
||||
Thank you for contributing! Just send using whichever method works best for you,
|
||||
and we'll let you know when your contribution has been confirmed. Need help
|
||||
sending?
|
||||
{/* TODO: Help / FAQ page for sending */}
|
||||
{' '}
|
||||
<a>Click here</a>.
|
||||
</p>
|
||||
<div className="PaymentInfo-text">
|
||||
{text || (
|
||||
<>
|
||||
Thank you for contributing! Just send using whichever method works best for
|
||||
you, and we'll let you know when your contribution has been confirmed.
|
||||
</>
|
||||
)}
|
||||
{/* TODO: Help / FAQ page for sending */} Need help sending? <a>Click here</a>.
|
||||
</div>
|
||||
|
||||
<Radio.Group
|
||||
className="PaymentInfo-types"
|
||||
onChange={this.handleChangeSendType}
|
||||
value={sendType}
|
||||
>
|
||||
<Radio.Button value="sprout">
|
||||
Z Address (Private)
|
||||
</Radio.Button>
|
||||
<Radio.Button value="transparent">
|
||||
T Address (Public)
|
||||
</Radio.Button>
|
||||
<Radio.Button value="sprout">Z Address (Private)</Radio.Button>
|
||||
<Radio.Button value="transparent">T Address (Public)</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div className="PaymentInfo-uri">
|
||||
<div className={
|
||||
classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
|
||||
}>
|
||||
<div className={classnames('PaymentInfo-uri-qr', !uri && 'is-loading')}>
|
||||
<span style={{ opacity: uri ? 1 : 0 }}>
|
||||
<QRCode value={uri || ''} />
|
||||
</span>
|
||||
|
@ -100,7 +96,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
|
|||
</div>
|
||||
<div className="PaymentInfo-fields-row">
|
||||
<CopyInput
|
||||
label="ZCash CLI command"
|
||||
label="Zcash CLI command"
|
||||
help="Make sure you replace YOUR_ADDRESS with your actual address"
|
||||
value={cli}
|
||||
/>
|
||||
|
@ -123,33 +119,29 @@ interface CopyInputProps {
|
|||
isTextarea?: boolean;
|
||||
}
|
||||
|
||||
const CopyInput: React.SFC<CopyInputProps> = ({ label, value, help, className, isTextarea }) => (
|
||||
const CopyInput: React.SFC<CopyInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
help,
|
||||
className,
|
||||
isTextarea,
|
||||
}) => (
|
||||
<Form.Item
|
||||
className={classnames(
|
||||
'CopyInput',
|
||||
className,
|
||||
isTextarea && 'is-textarea',
|
||||
)}
|
||||
className={classnames('CopyInput', className, isTextarea && 'is-textarea')}
|
||||
label={label}
|
||||
help={help}
|
||||
>
|
||||
{isTextarea ? (
|
||||
<>
|
||||
<Input.TextArea value={value} readOnly rows={3} />
|
||||
<CopyToClipboard
|
||||
text={value || ''}
|
||||
onCopy={() => message.success('Copied!', 2)}
|
||||
>
|
||||
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
|
||||
<Button icon="copy" />
|
||||
</CopyToClipboard>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input value={value} readOnly />
|
||||
<CopyToClipboard
|
||||
text={value || ''}
|
||||
onCopy={() => message.success('Copied!', 2)}
|
||||
>
|
||||
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
|
||||
<Button icon="copy" />
|
||||
</CopyToClipboard>
|
||||
</>
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
@import '~styles/variables.less';
|
||||
|
||||
.CreateFinal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
max-width: 550px;
|
||||
padding: 1rem;
|
||||
margin: 3rem auto;
|
||||
|
||||
&-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.anticon {
|
||||
margin-right: 1rem;
|
||||
font-size: 3.2rem;
|
||||
|
@ -28,4 +27,15 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,10 @@ import { Link } from 'react-router-dom';
|
|||
import Loader from 'components/Loader';
|
||||
import { createActions } from 'modules/create';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { getProposalStakingContribution } from 'api/api';
|
||||
import './Final.less';
|
||||
import PaymentInfo from 'components/ContributionModal/PaymentInfo';
|
||||
import { ContributionWithAddresses } from 'types';
|
||||
|
||||
interface StateProps {
|
||||
form: AppState['create']['form'];
|
||||
|
@ -19,13 +22,34 @@ interface 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() {
|
||||
this.submit();
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
const { submittedProposal } = this.props;
|
||||
if (!prev.submittedProposal && submittedProposal) {
|
||||
if (!submittedProposal.isStaked) {
|
||||
this.getStakingContribution();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { submittedProposal, submitError } = this.props;
|
||||
const { contribution } = this.state;
|
||||
|
||||
const ready = submittedProposal && (submittedProposal.isStaked || contribution);
|
||||
const staked = submittedProposal && submittedProposal.isStaked;
|
||||
|
||||
let content;
|
||||
if (submitError) {
|
||||
content = (
|
||||
|
@ -40,24 +64,57 @@ class CreateFinal extends React.Component<Props> {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (submittedProposal) {
|
||||
} else if (ready) {
|
||||
content = (
|
||||
<div className="CreateFinal-message is-success">
|
||||
<Icon type="check-circle" />
|
||||
<div className="CreateFinal-message-text">
|
||||
Your proposal has been submitted! Check your{' '}
|
||||
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link> to
|
||||
check its status.
|
||||
<>
|
||||
<div className="CreateFinal-message is-success">
|
||||
<Icon type="check-circle" />
|
||||
{staked && (
|
||||
<div className="CreateFinal-message-text">
|
||||
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>
|
||||
{/* TODO - remove or rework depending on design choices */}
|
||||
{/* <div className="CreateFinal-message-text">
|
||||
Your proposal has been submitted!{' '}
|
||||
<Link to={`/proposals/${submittedProposal.proposalUrlId}`}>
|
||||
Click here
|
||||
</Link>
|
||||
{' '}to check it out.
|
||||
</div> */}
|
||||
</div>
|
||||
{!staked && (
|
||||
<>
|
||||
<div className="CreateFinal-contribute">
|
||||
<PaymentInfo
|
||||
text={
|
||||
<>
|
||||
<p>
|
||||
If you cannot send the payment now, you may bring up these
|
||||
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 {
|
||||
content = <Loader size="large" tip="Submitting your proposal..." />;
|
||||
|
@ -71,6 +128,14 @@ class CreateFinal extends React.Component<Props> {
|
|||
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>(
|
||||
|
|
|
@ -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 you’ve
|
||||
done so, you won't be able to edit it.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
.PublishWarningModal {
|
||||
.SubmitWarningModal {
|
||||
.ant-alert {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
|
@ -10,4 +10,4 @@
|
|||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 you’ve
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ import Payment from './Payment';
|
|||
import Review from './Review';
|
||||
import Preview from './Preview';
|
||||
import Final from './Final';
|
||||
import PublishWarningModal from './PublishWarningModal';
|
||||
import SubmitWarningModal from './SubmitWarningModal';
|
||||
import createExampleProposal from './example';
|
||||
import { createActions } from 'modules/create';
|
||||
import { ProposalDraft } from 'types';
|
||||
|
@ -115,8 +115,8 @@ type Props = StateProps & DispatchProps & RouteComponentProps<any>;
|
|||
interface State {
|
||||
step: CREATE_STEP;
|
||||
isPreviewing: boolean;
|
||||
isShowingPublishWarning: boolean;
|
||||
isPublishing: boolean;
|
||||
isShowingSubmitWarning: boolean;
|
||||
isSubmitting: boolean;
|
||||
isExample: boolean;
|
||||
}
|
||||
|
||||
|
@ -134,9 +134,9 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
this.state = {
|
||||
step,
|
||||
isPreviewing: false,
|
||||
isPublishing: false,
|
||||
isSubmitting: false,
|
||||
isExample: false,
|
||||
isShowingPublishWarning: false,
|
||||
isShowingSubmitWarning: false,
|
||||
};
|
||||
this.debouncedUpdateForm = debounce(this.updateForm, 800);
|
||||
this.historyUnlisten = this.props.history.listen(this.handlePop);
|
||||
|
@ -154,7 +154,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
|
||||
render() {
|
||||
const { isSavingDraft } = this.props;
|
||||
const { step, isPreviewing, isPublishing, isShowingPublishWarning } = this.state;
|
||||
const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
|
||||
|
||||
const info = STEP_INFO[step];
|
||||
const currentIndex = STEP_ORDER.indexOf(step);
|
||||
|
@ -163,7 +163,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
|
||||
let content;
|
||||
let showFooter = true;
|
||||
if (isPublishing) {
|
||||
if (isSubmitting) {
|
||||
content = <Final />;
|
||||
showFooter = false;
|
||||
} else if (isPreviewing) {
|
||||
|
@ -241,11 +241,11 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
{isSavingDraft && (
|
||||
<div className="CreateFlow-draftNotification">Saving draft...</div>
|
||||
)}
|
||||
<PublishWarningModal
|
||||
<SubmitWarningModal
|
||||
proposal={this.props.form}
|
||||
isVisible={isShowingPublishWarning}
|
||||
isVisible={isShowingSubmitWarning}
|
||||
handleClose={this.closePublishWarning}
|
||||
handlePublish={this.startPublish}
|
||||
handleSubmit={this.startSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -274,10 +274,10 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
this.setState({ isPreviewing: !this.state.isPreviewing });
|
||||
};
|
||||
|
||||
private startPublish = () => {
|
||||
private startSubmit = () => {
|
||||
this.setState({
|
||||
isPublishing: true,
|
||||
isShowingPublishWarning: false,
|
||||
isSubmitting: true,
|
||||
isShowingSubmitWarning: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -302,11 +302,11 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
private openPublishWarning = () => {
|
||||
this.setState({ isShowingPublishWarning: true });
|
||||
this.setState({ isShowingSubmitWarning: true });
|
||||
};
|
||||
|
||||
private closePublishWarning = () => {
|
||||
this.setState({ isShowingPublishWarning: false });
|
||||
this.setState({ isShowingSubmitWarning: false });
|
||||
};
|
||||
|
||||
private fillInExample = () => {
|
||||
|
|
|
@ -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]: {
|
||||
color: 'orange',
|
||||
tag: 'Pending',
|
||||
|
|
|
@ -142,7 +142,8 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
blurb: (
|
||||
<>
|
||||
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',
|
||||
|
@ -151,11 +152,22 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
blurb: (
|
||||
<>
|
||||
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',
|
||||
},
|
||||
[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'] } };
|
||||
let banner = statusBanner[proposal.status];
|
||||
if (isPreview) {
|
||||
|
@ -196,11 +208,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
['is-expanded']: isBodyExpanded,
|
||||
})}
|
||||
>
|
||||
{proposal ? (
|
||||
<Markdown source={proposal.content} />
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
{proposal ? <Markdown source={proposal.content} /> : <Loader />}
|
||||
</div>
|
||||
{showExpand && (
|
||||
<button
|
||||
|
|
|
@ -174,6 +174,7 @@ export default function createReducer(
|
|||
case types.SUBMIT_PROPOSAL_PENDING:
|
||||
return {
|
||||
...state,
|
||||
submittedProposal: null,
|
||||
isSubmitting: true,
|
||||
submitError: null,
|
||||
};
|
||||
|
|
|
@ -192,6 +192,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
|
|||
stage: 'preview',
|
||||
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
||||
team: draft.team,
|
||||
isStaked: true,
|
||||
milestones: draft.milestones.map((m, idx) => ({
|
||||
index: idx,
|
||||
title: m.title,
|
||||
|
|
|
@ -41,6 +41,8 @@ envProductionRequiredHandler(
|
|||
'http://localhost:' + (process.env.PORT || 3000),
|
||||
);
|
||||
|
||||
envProductionRequiredHandler('PROPOSAL_STAKING_AMOUNT', '0.025');
|
||||
|
||||
const appDirectory = fs.realpathSync(process.cwd());
|
||||
process.env.NODE_PATH = (process.env.NODE_PATH || '')
|
||||
.split(path.delimiter)
|
||||
|
@ -54,6 +56,7 @@ module.exports = () => {
|
|||
EXPLORER_URL: process.env.EXPLORER_URL || 'https://chain.so/zcash/',
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
PORT: process.env.PORT || 3000,
|
||||
PROPOSAL_STAKING_AMOUNT: process.env.PROPOSAL_STAKING_AMOUNT,
|
||||
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN || null,
|
||||
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,
|
||||
|
|
|
@ -159,6 +159,7 @@ export function generateProposal({
|
|||
content: 'body',
|
||||
stage: 'FUNDING_REQUIRED',
|
||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||
isStaked: true,
|
||||
team: [
|
||||
{
|
||||
userid: 123,
|
||||
|
|
|
@ -34,6 +34,7 @@ export interface ProposalDraft {
|
|||
team: User[];
|
||||
invites: TeamInvite[];
|
||||
status: STATUS;
|
||||
isStaked: boolean;
|
||||
}
|
||||
|
||||
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||
|
@ -85,6 +86,7 @@ export interface UserProposal {
|
|||
// NOTE: sync with backend/grant/proposal/models.py STATUSES
|
||||
export enum STATUS {
|
||||
DRAFT = 'DRAFT',
|
||||
STAKING = 'STAKING',
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
|
|
Loading…
Reference in New Issue