Merge branch 'develop' into bitgo

This commit is contained in:
Daniel Ternyak 2019-03-21 12:46:17 -05:00 committed by GitHub
commit ece0648015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 136 additions and 68 deletions

View File

@ -32,5 +32,8 @@ BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
# Amount for staking a proposal in ZEC
# Amount for staking a proposal in ZEC, keep in sync with frontend .env
PROPOSAL_STAKING_AMOUNT=0.025
# Maximum amount for a proposal target, keep in sync with frontend .env
PROPOSAL_TARGET_MAX=10000

View File

@ -30,7 +30,7 @@ class Comment(db.Model):
self.proposal_id = proposal_id
self.user_id = user_id
self.parent_comment_id = parent_comment_id
self.content = content
self.content = content[:1000]
self.date_created = datetime.datetime.now()
@staticmethod

View File

@ -53,11 +53,11 @@ class Milestone(db.Model):
proposal_id=int,
):
self.id = gen_random_id(Milestone)
self.title = title
self.content = content
self.title = title[:255]
self.content = content[:255]
self.stage = stage
self.date_estimated = date_estimated
self.payout_percent = payout_percent
self.payout_percent = payout_percent[:255]
self.immediate_payout = immediate_payout
self.proposal_id = proposal_id
self.date_created = datetime.datetime.now()
@ -70,21 +70,16 @@ class Milestone(db.Model):
[db.session.delete(x) for x in proposal.milestones]
for i, milestone_data in enumerate(milestones_data):
m = Milestone(
title=milestone_data["title"],
content=milestone_data["content"],
title=milestone_data["title"][:255],
content=milestone_data["content"][:255],
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
payout_percent=str(milestone_data["payout_percent"]),
payout_percent=str(milestone_data["payout_percent"])[:255],
immediate_payout=milestone_data["immediate_payout"],
proposal_id=proposal.id,
index=i
)
db.session.add(m)
@staticmethod
def validate(milestone):
if len(milestone.title) > 60:
raise ValidationException("Milestone title must be no more than 60 chars")
def request_payout(self, user_id: int):
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')

View File

@ -11,7 +11,7 @@ from flask import current_app
from grant.comment.models import Comment
from grant.email.send import send_email
from grant.extensions import ma, db
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
from grant.task.jobs import ContributionExpired
from grant.utils.enums import (
ProposalStatus,
@ -45,7 +45,7 @@ class ProposalTeamInvite(db.Model):
def __init__(self, proposal_id: int, address: str, accepted: bool = None):
self.proposal_id = proposal_id
self.address = address
self.address = address[:255]
self.accepted = accepted
self.date_created = datetime.datetime.now()
@ -70,7 +70,7 @@ class ProposalUpdate(db.Model):
def __init__(self, proposal_id: int, title: str, content: str):
self.id = gen_random_id(ProposalUpdate)
self.proposal_id = proposal_id
self.title = title
self.title = title[:255]
self.content = content
self.date_created = datetime.datetime.now()
@ -275,12 +275,11 @@ class Proposal(db.Model):
@staticmethod
def simple_validate(proposal):
title = proposal.get('title')
# Validate fields to be database save-able.
# Stricter validation is done in validate_publishable.
stage = proposal.get('stage')
category = proposal.get('category')
if title and len(title) > 60:
raise ValidationException("Proposal title cannot be longer than 60 characters")
if stage and not ProposalStage.includes(stage):
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
if category and not Category.includes(category):
@ -294,37 +293,59 @@ class Proposal(db.Model):
raise ValidationException("Only the first milestone can have an immediate payout")
if len(milestone.title) > 60:
raise ValidationException("Milestone title must be no more than 60 chars")
raise ValidationException("Milestone title cannot be longer than 60 chars")
if len(milestone.content) > 200:
raise ValidationException("Milestone content must be no more than 200 chars")
raise ValidationException("Milestone content cannot be longer than 200 chars")
payout_total += float(milestone.payout_percent)
try:
p = float(milestone.payout_percent)
if not p.is_integer():
raise ValidationException("Milestone payout percents must be whole numbers, no decimals")
if p <= 0 or p > 100:
raise ValidationException("Milestone payout percent must be greater than zero")
except ValueError:
raise ValidationException("Milestone payout percent must be a number")
payout_total += p
try:
present = datetime.datetime.today().replace(day=1)
if present > milestone.date_estimated:
raise ValidationException("Milestone date_estimated must be in the future ")
raise ValidationException("Milestone date estimate must be in the future ")
except Exception as e:
current_app.logger.warn(
f"Unexpected validation error - client prohibits {e}"
)
raise ValidationException("date_estimated does not convert to a datetime")
raise ValidationException("Date estimate is not a valid datetime")
if payout_total != 100.0:
raise ValidationException("payoutPercent across milestones must sum to exactly 100")
raise ValidationException("Payout percentages of milestones must add up to exactly 100%")
def validate_publishable(self):
self.validate_publishable_milestones()
# Require certain fields
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
for field in required_fields:
if not hasattr(self, field):
raise ValidationException("Proposal must have a {}".format(field))
# Stricter limits on certain fields
if len(self.title) > 60:
raise ValidationException("Proposal title cannot be longer than 60 characters")
if len(self.brief) > 140:
raise ValidationException("Brief cannot be longer than 140 characters")
if len(self.content) > 250000:
raise ValidationException("Content cannot be longer than 250,000 characters")
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
if Decimal(self.target) < 0.0001:
raise ValidationException("Target cannot be less than 0.0001")
if self.deadline_duration > 7776000:
raise ValidationException("Deadline duration cannot be more than 90 days")
# Check with node that the address is kosher
try:
res = blockchain_get('/validate/address', {'address': self.payout_address})
@ -380,12 +401,12 @@ class Proposal(db.Model):
payout_address: str = '',
deadline_duration: int = 5184000 # 60 days
):
self.title = title
self.brief = brief
self.title = title[:255]
self.brief = brief[:255]
self.category = category
self.content = content
self.target = target if target != '' else None
self.payout_address = payout_address
self.content = content[:300000]
self.target = target[:255] if target != '' else None
self.payout_address = payout_address[:255]
self.deadline_duration = deadline_duration
Proposal.simple_validate(vars(self))

View File

@ -4,6 +4,7 @@ from flask import Blueprint, g, request, current_app
from marshmallow import fields, validate
from sqlalchemy import or_
from sentry_sdk import capture_message
from webargs import validate
from grant.extensions import limiter
from grant.comment.models import Comment, comment_schema, comments_schema
@ -98,7 +99,7 @@ def report_proposal_comment(proposal_id, comment_id):
@limiter.limit("30/hour;2/minute")
@requires_email_verified_auth
@body({
"comment": fields.Str(required=True),
"comment": fields.Str(required=True, validate=validate.Length(max=1000)),
"parentCommentId": fields.Int(required=False, missing=None),
})
def post_proposal_comments(proposal_id, comment, parent_comment_id):
@ -122,9 +123,6 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
if g.current_user.silenced:
return {"message": "Your account has been silenced, commenting is disabled."}, 403
if len(comment) > 1000:
return {"message": "Please make sure your comment is less than 1000 characters long"}, 400
# Make the comment
comment = Comment(
proposal_id=proposal_id,
@ -218,6 +216,7 @@ def get_proposal_drafts():
@blueprint.route("/<proposal_id>", methods=["PUT"])
@requires_team_member_auth
@body({
# Length checks are to prevent database errors, not actual user limits imposed
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
@ -226,7 +225,7 @@ def get_proposal_drafts():
"payoutAddress": fields.Str(required=True),
"deadlineDuration": fields.Int(required=True),
"milestones": fields.List(fields.Dict(), required=True),
"rfpOptIn": fields.Bool(required=False, missing=None)
"rfpOptIn": fields.Bool(required=False, missing=None),
})
def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
# Update the base proposal fields
@ -348,8 +347,8 @@ def get_proposal_update(proposal_id, update_id):
@limiter.limit("5/day;1/minute")
@requires_team_member_auth
@body({
"title": fields.Str(required=True, validate=lambda p: 3 <= len(p) <= 30),
"content": fields.Str(required=True, validate=lambda p: 5 <= len(p) <= 10000),
"title": fields.Str(required=True, validate=validate.Length(min=3, max=60)),
"content": fields.Str(required=True, validate=validate.Length(min=5, max=10000)),
})
def post_proposal_update(proposal_id, title, content):
update = ProposalUpdate(
@ -376,7 +375,7 @@ def post_proposal_update(proposal_id, title, content):
@limiter.limit("30/day;10/minute")
@requires_team_member_auth
@body({
"address": fields.Str(required=True),
"address": fields.Str(required=True, validate=validate.Length(max=255)),
})
def post_proposal_team_invite(proposal_id, address):
for u in g.current_proposal.team:
@ -647,7 +646,7 @@ def accept_milestone_payout_request(proposal_id, milestone_id):
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/reject", methods=["PUT"])
@requires_arbiter_auth
@body({
"reason": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
"reason": fields.Str(required=True, validate=validate.Length(min=2, max=200)),
})
def reject_milestone_payout_request(proposal_id, milestone_id, reason):
if not g.current_proposal.is_funded:

View File

@ -64,8 +64,8 @@ class RFP(db.Model):
assert Category.includes(category)
self.id = gen_random_id(RFP)
self.date_created = datetime.now()
self.title = title
self.brief = brief
self.title = title[:255]
self.brief = brief[:255]
self.content = content
self.category = category
self.bounty = bounty

View File

@ -61,6 +61,7 @@ BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
UI = {
'NAME': 'ZF Grants',

View File

@ -46,8 +46,8 @@ class SocialMedia(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
def __init__(self, service: str, username: str, user_id):
self.service = service.upper()
self.username = username.lower()
self.service = service.upper()[:255]
self.username = username.lower()[:255]
self.user_id = user_id
@ -145,8 +145,8 @@ class User(db.Model, UserMixin):
):
self.id = gen_random_id(User)
self.email_address = email_address
self.display_name = display_name
self.title = title
self.display_name = display_name[:255]
self.title = title[:255]
self.password = password
@staticmethod

View File

@ -3,6 +3,7 @@ from animal_case import keys_to_snake_case
from flask import Blueprint, g, current_app
from marshmallow import fields
from validate_email import validate_email
from webargs import validate
import grant.utils.auth as auth
from grant.comment.models import Comment, user_comments_schema
@ -95,8 +96,8 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
@body({
"emailAddress": fields.Str(required=True, validate=lambda e: validate_email(e)),
"password": fields.Str(required=True),
"displayName": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
"title": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
"displayName": fields.Str(required=True, validate=validate.Length(min=2, max=50)),
"title": fields.Str(required=True, validate=validate.Length(min=2, max=50)),
})
def create_user(
email_address,

View File

@ -128,7 +128,6 @@ class TestProposalCommentAPI(BaseUserConfig):
)
self.assertStatus(comment_res, 400)
self.assertIn('less than', comment_res.json['message'])
def test_create_new_proposal_comment_fails_with_silenced_user(self):
self.login_default_user()

View File

@ -16,7 +16,7 @@ BACKEND_URL=http://localhost:5000
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
# Amount for staking a proposal in ZEC
# Amount for staking a proposal in ZEC, keep in sync with backend .env
PROPOSAL_STAKING_AMOUNT=0.025
# Normally production runs with SSL, this disables that
@ -24,3 +24,6 @@ DISABLE_SSL=true
# Uncomment if running on testnet
# TESTNET=true
# Maximum amount for a proposal target, keep in sync with backend .env
PROPOSAL_TARGET_MAX=10000

View File

@ -36,6 +36,7 @@ class SignUp extends React.Component<Props> {
name="name"
placeholder="Non-unique name that others will see you as"
autoComplete="name"
maxLength={50}
/>,
)}
</Form.Item>
@ -47,6 +48,7 @@ class SignUp extends React.Component<Props> {
<Input
name="title"
placeholder="A short description about you, e.g. Core Ethereum Developer"
maxLength={50}
/>,
)}
</Form.Item>
@ -62,6 +64,7 @@ class SignUp extends React.Component<Props> {
name="email"
placeholder="We promise not to spam you or share your email"
autoComplete="username"
maxLength={255}
/>,
)}
</Form.Item>

View File

@ -147,6 +147,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
type="text"
value={title}
onChange={this.handleInputChange}
maxLength={200}
/>
</Form.Item>
@ -161,6 +162,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
value={brief}
onChange={this.handleInputChange}
rows={3}
maxLength={200}
/>
</Form.Item>
@ -196,6 +198,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
value={target}
onChange={this.handleInputChange}
addonAfter="ZEC"
maxLength={16}
/>
</Form.Item>
</Form>

View File

@ -1,7 +1,8 @@
import React from 'react';
import { Form } from 'antd';
import { Form, Alert } from 'antd';
import MarkdownEditor from 'components/MarkdownEditor';
import { ProposalDraft } from 'types';
import { getCreateErrors } from 'modules/create/utils';
interface State {
content: string;
@ -22,6 +23,8 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
}
render() {
const errors = getCreateErrors(this.state, true);
return (
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
<MarkdownEditor
@ -29,6 +32,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
initialMarkdown={this.state.content}
minHeight={200}
/>
{errors.content && <Alert type="error" message={errors.content} showIcon />}
</Form>
);
}

View File

@ -124,6 +124,7 @@ const MilestoneFields = ({
name="title"
value={milestone.title}
onChange={ev => onChange(index, { ...milestone, title: ev.currentTarget.value })}
maxLength={80}
/>
<button
onClick={() => onRemove(index)}
@ -147,6 +148,7 @@ const MilestoneFields = ({
onChange={ev =>
onChange(index, { ...milestone, content: ev.currentTarget.value })
}
maxLength={255}
/>
</div>
@ -190,6 +192,7 @@ const MilestoneFields = ({
}
addonAfter="%"
style={{ maxWidth: '120px', width: '100%' }}
maxLength={6}
/>
{index === 0 && (
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '0.5rem' }}>

View File

@ -138,6 +138,10 @@
font-size: 0.8rem;
opacity: 0.3;
animation: draft-notification-popup 120ms ease 1;
&.is-error {
color: @error-color;
}
}
&-loading {

View File

@ -103,6 +103,7 @@ interface StateProps {
form: AppState['create']['form'];
isSavingDraft: AppState['create']['isSavingDraft'];
hasSavedDraft: AppState['create']['hasSavedDraft'];
saveDraftError: AppState['create']['saveDraftError'];
}
interface DispatchProps {
@ -149,7 +150,7 @@ class CreateFlow extends React.Component<Props, State> {
}
render() {
const { isSavingDraft } = this.props;
const { isSavingDraft, saveDraftError } = this.props;
const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
const info = STEP_INFO[step];
@ -238,8 +239,16 @@ class CreateFlow extends React.Component<Props, State> {
)}
</div>
)}
{isSavingDraft && (
{isSavingDraft ? (
<div className="CreateFlow-draftNotification">Saving draft...</div>
) : (
saveDraftError && (
<div className="CreateFlow-draftNotification is-error">
Failed to save draft!
<br />
{saveDraftError}
</div>
)
)}
<SubmitWarningModal
proposal={this.props.form}
@ -326,13 +335,12 @@ class CreateFlow extends React.Component<Props, State> {
}
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
(state: AppState) => {
return {
form: state.create.form,
isSavingDraft: state.create.isSavingDraft,
hasSavedDraft: state.create.hasSavedDraft,
};
},
(state: AppState) => ({
form: state.create.form,
isSavingDraft: state.create.isSavingDraft,
hasSavedDraft: state.create.hasSavedDraft,
saveDraftError: state.create.saveDraftError,
}),
{
updateForm: createActions.updateForm,
},

View File

@ -13,8 +13,6 @@ import {
PROPOSAL_DETAIL_INITIAL_STATE,
} from 'modules/proposals/reducers';
export const TARGET_ZEC_LIMIT = 1000;
interface CreateFormErrors {
rfpOptIn?: string;
title?: string;
@ -57,7 +55,17 @@ export function getCreateErrors(
skipRequired?: boolean,
): CreateFormErrors {
const errors: CreateFormErrors = {};
const { title, team, milestones, target, payoutAddress, rfp, rfpOptIn, brief } = form;
const {
title,
content,
team,
milestones,
target,
payoutAddress,
rfp,
rfpOptIn,
brief,
} = form;
// Required fields with no extra validation
if (!skipRequired) {
@ -90,10 +98,16 @@ export function getCreateErrors(
errors.brief = 'Brief can only be 140 characters maximum';
}
// Content limit for our database's sake
if (content && content.length > 250000) {
errors.content = 'Details can only be 250,000 characters maximum';
}
// Amount to raise
const targetFloat = target ? parseFloat(target) : 0;
if (target && !Number.isNaN(targetFloat)) {
const targetErr = getAmountError(targetFloat, TARGET_ZEC_LIMIT);
const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string);
const targetErr = getAmountError(targetFloat, limit);
if (targetErr) {
errors.target = targetErr;
}
@ -134,6 +148,12 @@ export function getCreateErrors(
return 'Payout percent is required';
} else if (Number.isNaN(parseInt(ms.payoutPercent, 10))) {
return 'Payout percent must be a valid number';
} else if (parseInt(ms.payoutPercent, 10) !== parseFloat(ms.payoutPercent)) {
return 'Payout percent must be a whole number, no decimals';
} else if (parseInt(ms.payoutPercent, 10) <= 0) {
return 'Payout percent must be greater than 0%';
} else if (parseInt(ms.payoutPercent, 10) > 100) {
return 'Payout percent must be less than or equal to 100%';
}
// Last one shows percentage errors
@ -155,10 +175,10 @@ export function getCreateErrors(
}
export function validateUserProfile(user: User) {
if (user.displayName.length > 30) {
return 'Display name can only be 30 characters maximum';
} else if (user.title.length > 30) {
return 'Title can only be 30 characters maximum';
if (user.displayName.length > 50) {
return 'Display name can only be 50 characters maximum';
} else if (user.title.length > 50) {
return 'Title can only be 50 characters maximum';
}
return '';

View File

@ -57,6 +57,7 @@ module.exports = () => {
NODE_ENV: process.env.NODE_ENV || 'development',
PORT: process.env.PORT || 3000,
PROPOSAL_STAKING_AMOUNT: process.env.PROPOSAL_STAKING_AMOUNT,
PROPOSAL_TARGET_MAX: process.env.PROPOSAL_TARGET_MAX || '10000',
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
SENTRY_DSN: process.env.SENTRY_DSN || null,
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,