Merge pull request #179 from grant-project/rfp-fields

RFP Fields (matching, bounty, dates)
This commit is contained in:
Daniel Ternyak 2019-02-15 12:05:59 -06:00 committed by GitHub
commit fe5632dc75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 339 additions and 29 deletions

View File

@ -91,6 +91,9 @@ class RFPDetail extends React.Component<Props> {
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
{renderDeetItem('status', rfp.status)}
{renderDeetItem('category', rfp.category)}
{renderDeetItem('matching', String(rfp.matching))}
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
{renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
</Card>
{/* PROPOSALS */}

View File

@ -1,4 +1,6 @@
.RFPForm {
max-width: 880px;
&-content {
&-preview {
font-size: 1rem;
@ -13,6 +15,24 @@
}
}
&-bounty {
&-matching.ant-checkbox-wrapper {
margin-top: 0.4rem;
}
}
&-date {
padding-left: 1.5rem;
.ant-calendar-picker {
width: 100%;
}
.ant-form-explain {
margin-top: 0.4rem;
}
}
&-buttons {
.ant-btn {
margin-right: 0.5rem;

View File

@ -1,7 +1,9 @@
import React from 'react';
import moment, { Moment } from 'moment';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Form, Input, Select, Icon, Button, message, Spin } from 'antd';
import { Link } from 'react-router-dom';
import { Form, Input, Select, Icon, Button, message, Spin, Checkbox, Row, Col, DatePicker } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
@ -34,7 +36,7 @@ class RFPForm extends React.Component<Props, State> {
render() {
const { isShowingPreview } = this.state;
const { getFieldDecorator, getFieldValue } = this.props.form;
const { getFieldDecorator, getFieldValue, isFieldsTouched } = this.props.form;
let defaults: RFPArgs = {
title: '',
@ -42,6 +44,9 @@ class RFPForm extends React.Component<Props, State> {
content: '',
category: '',
status: '',
matching: false,
bounty: undefined,
dateCloses: undefined,
};
const rfpId = this.getRFPId();
if (rfpId) {
@ -57,11 +62,19 @@ class RFPForm extends React.Component<Props, State> {
content: rfp.content,
category: rfp.category,
status: rfp.status,
matching: rfp.matching,
bounty: rfp.bounty,
dateCloses: rfp.dateCloses || undefined,
};
} else {
return <Exception type="404" desc="This RFP does not exist" />;
}
}
const dateCloses = isFieldsTouched(['dateCloses'])
? getFieldValue('dateCloses')
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
return (
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
@ -85,12 +98,15 @@ class RFPForm extends React.Component<Props, State> {
</Form.Item>
{rfpId && (
<Form.Item label="Status">
<Form.Item
label="Status"
help={forceClosed && 'Status is forced to "Closed" when close date is in the past'}
>
{getFieldDecorator('status', {
initialValue: defaults.status,
rules: [{ required: true, message: 'Status is required' }],
})(
<Select size="large" placeholder="Select a status">
<Select size="large" placeholder="Select a status" disabled={forceClosed}>
{typedKeys(RFP_STATUS).map(c => (
<Select.Option value={c} key={c}>
{getStatusById(RFP_STATUSES, c).tagDisplay}
@ -164,13 +180,57 @@ class RFPForm extends React.Component<Props, State> {
)}
</Form.Item>
<Row>
<Col sm={12} xs={12}>
<Form.Item className="RFPForm-bounty" label="Bounty">
{getFieldDecorator('bounty', {
initialValue: defaults.bounty,
})(
<Input
autoComplete="off"
name="bounty"
placeholder="100"
addonAfter="ZEC"
size="large"
/>,
)}
{getFieldDecorator('matching', {
initialValue: defaults.matching,
})(
<Checkbox
className="RFPForm-bounty-matching"
name="matching"
defaultChecked={defaults.matching}
>
Match community contributions for approved proposals
</Checkbox>,
)}
</Form.Item>
</Col>
<Col sm={12} xs={24}>
<Form.Item
className="RFPForm-date"
label="Close date"
help="Date that proposals will stop being submittable by"
>
{getFieldDecorator('dateCloses', {
initialValue: defaults.dateCloses ? moment(defaults.dateCloses * 1000) : undefined,
})(
<DatePicker size="large" />
)}
</Form.Item>
</Col>
</Row>
<div className="RFPForm-buttons">
<Button type="primary" htmlType="submit" size="large">
Submit
</Button>
<Button type="ghost" size="large">
Cancel
</Button>
<Link to="/rfps">
<Button type="ghost" size="large">
Cancel
</Button>
</Link>
</div>
</Form>
);
@ -189,10 +249,14 @@ class RFPForm extends React.Component<Props, State> {
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
this.props.form.validateFieldsAndScroll(async (err: any, values: any) => {
this.props.form.validateFieldsAndScroll(async (err: any, rawValues: any) => {
if (err) return;
const rfpId = this.getRFPId();
const values = {
...rawValues,
dateCloses: rawValues.dateCloses && rawValues.dateCloses.unix(),
};
let msg;
if (rfpId) {
await store.editRFP(rfpId, values);

View File

@ -36,19 +36,27 @@ export enum RFP_STATUS {
export interface RFP {
id: number;
dateCreated: number;
dateOpened: number | null;
dateClosed: number | null;
title: string;
brief: string;
content: string;
category: string;
status: string;
proposals: Proposal[];
matching: boolean;
bounty: string | null;
dateCloses: number | null;
}
export interface RFPArgs {
title: string;
brief: string;
content: string;
category: string;
status?: string;
matching: boolean;
dateCloses: number | null | undefined;
bounty: string | null | undefined;
status: string;
}
// NOTE: sync with backend/grant/utils/enums.py ProposalArbiterStatus
export enum PROPOSAL_ARBITER_STATUS {

View File

@ -2,6 +2,7 @@ from functools import reduce
from flask import Blueprint, request
from flask_yoloapi import endpoint, parameter
from decimal import Decimal
from datetime import datetime
from grant.comment.models import Comment, user_comments_schema
from grant.email.send import generate_email, send_email
from grant.extensions import db
@ -19,7 +20,14 @@ from grant.user.models import User, admin_users_schema, admin_user_schema
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
from grant.utils.misc import make_url
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, ProposalArbiterStatus, MilestoneStage
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
ContributionStatus,
ProposalArbiterStatus,
MilestoneStage,
RFPStatus,
)
from grant.utils import pagination
from grant.settings import EXPLORER_URL
from sqlalchemy import func, or_
@ -332,14 +340,15 @@ def get_rfps():
parameter('brief', type=str),
parameter('content', type=str),
parameter('category', type=str),
parameter('bounty', type=str),
parameter('matching', type=bool, default=False),
parameter('dateCloses', type=int),
)
@admin_auth_required
def create_rfp(title, brief, content, category):
def create_rfp(date_closes, **kwargs):
rfp = RFP(
title=title,
brief=brief,
content=content,
category=category,
**kwargs,
date_closes=datetime.fromtimestamp(date_closes) if date_closes else None,
)
db.session.add(rfp)
db.session.commit()
@ -363,19 +372,33 @@ def get_rfp(rfp_id):
parameter('brief', type=str),
parameter('content', type=str),
parameter('category', type=str),
parameter('bounty', type=str),
parameter('matching', type=bool, default=False),
parameter('dateCloses', type=int),
parameter('status', type=str),
)
@admin_auth_required
def update_rfp(rfp_id, title, brief, content, category, status):
def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
# Update fields
rfp.title = title
rfp.brief = brief
rfp.content = content
rfp.category = category
rfp.status = status
rfp.bounty = bounty
rfp.matching = matching
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
# Update timestamps if status changed
if rfp.status != status:
if status == RFPStatus.LIVE and not rfp.date_opened:
rfp.date_opened = datetime.now()
if status == RFPStatus.CLOSED:
rfp.date_closed = datetime.now()
rfp.status = status
db.session.add(rfp)
db.session.commit()

View File

@ -164,9 +164,9 @@ def make_proposal_draft(rfp_id):
rfp = RFP.query.filter_by(id=rfp_id).first()
if not rfp:
return {"message": "The request this proposal was made for doesnt exist"}, 400
proposal.title = rfp.title
proposal.brief = rfp.brief
proposal.category = rfp.category
if rfp.matching:
proposal.contribution_matching = 1.0
rfp.proposals.append(proposal)
db.session.add(rfp)

View File

@ -1,4 +1,4 @@
import datetime
from datetime import datetime
from grant.extensions import ma, db
from grant.utils.enums import RFPStatus
from grant.utils.misc import dt_to_unix
@ -15,6 +15,11 @@ class RFP(db.Model):
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(255), nullable=False)
matching = db.Column(db.Boolean, default=False, nullable=False)
bounty = db.Column(db.String(255), nullable=True)
date_closes = db.Column(db.DateTime, nullable=True)
date_opened = db.Column(db.DateTime, nullable=True)
date_closed = db.Column(db.DateTime, nullable=True)
# Relationships
proposals = db.relationship(
@ -36,13 +41,19 @@ class RFP(db.Model):
brief: str,
content: str,
category: str,
bounty: str,
date_closes: datetime,
matching: bool = False,
status: str = RFPStatus.DRAFT,
):
self.date_created = datetime.datetime.now()
self.date_created = datetime.now()
self.title = title
self.brief = brief
self.content = content
self.category = category
self.bounty = bounty
self.date_closes = date_closes
self.matching = matching
self.status = status
@ -57,15 +68,35 @@ class RFPSchema(ma.Schema):
"content",
"category",
"status",
"matching",
"bounty",
"date_created",
"date_closes",
"date_opened",
"date_closed",
"accepted_proposals",
)
date_created = ma.Method("get_date_created")
status = ma.Method("get_status")
date_closes = ma.Method("get_date_closes")
date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed")
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_status(self, obj):
# Force it into closed state if date_closes is in the past
if obj.date_closes and obj.date_closes <= datetime.today():
return RFPStatus.CLOSED
return obj.status
def get_date_closes(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None
def get_date_opened(self, obj):
return dt_to_unix(obj.date_opened) if obj.date_opened else None
def get_date_closed(self, obj):
return dt_to_unix(obj.date_closed) if obj.date_closed else None
rfp_schema = RFPSchema()
rfps_schema = RFPSchema(many=True)
@ -82,15 +113,39 @@ class AdminRFPSchema(ma.Schema):
"content",
"category",
"status",
"matching",
"bounty",
"date_created",
"date_closes",
"date_opened",
"date_closed",
"proposals",
)
status = ma.Method("get_status")
date_created = ma.Method("get_date_created")
date_closes = ma.Method("get_date_closes")
date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed")
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
def get_status(self, obj):
# Force it into closed state if date_closes is in the past
if obj.date_closes and obj.date_closes <= datetime.today():
return RFPStatus.CLOSED
return obj.status
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_date_closes(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None
def get_date_opened(self, obj):
return dt_to_unix(obj.date_opened) if obj.date_opened else None
def get_date_closed(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None
admin_rfp_schema = AdminRFPSchema()
admin_rfps_schema = AdminRFPSchema(many=True)

View File

@ -0,0 +1,51 @@
"""Adds RFP bounty, matching, and date fields
Revision ID: d39bb526eef4
Revises: 3793d9a71e27
Create Date: 2019-02-07 15:09:11.548655
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import expression
from datetime import datetime, timedelta
# revision identifiers, used by Alembic.
revision = 'd39bb526eef4'
down_revision = '3793d9a71e27'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('rfp', sa.Column('bounty', sa.String(length=255), nullable=True))
op.add_column('rfp', sa.Column('date_closed', sa.DateTime(), nullable=True))
op.add_column('rfp', sa.Column('date_closes', sa.DateTime(), nullable=True))
op.add_column('rfp', sa.Column('date_opened', sa.DateTime(), nullable=True))
op.add_column('rfp', sa.Column('matching', sa.Boolean(), nullable=False, server_default=expression.false()))
# ### end Alembic commands ###
# Set columns for times based on status.
connection = op.get_bind()
connection.execute("UPDATE rfp SET date_opened = now() - INTERVAL '1 DAY' WHERE status = 'LIVE' OR status = 'CLOSED'")
connection.execute("UPDATE rfp SET date_closed = now() WHERE status = 'CLOSED'")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rfp', 'matching')
op.drop_column('rfp', 'date_opened')
op.drop_column('rfp', 'date_closes')
op.drop_column('rfp', 'date_closed')
op.drop_column('rfp', 'bounty')
op.create_table('rfp_proposal',
sa.Column('rfp_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('proposal_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name='rfp_proposal_proposal_id_fkey'),
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], name='rfp_proposal_rfp_id_fkey'),
sa.UniqueConstraint('proposal_id', name='rfp_proposal_proposal_id_key')
)
# ### end Alembic commands ###

View File

@ -32,9 +32,23 @@
&-content {
font-size: 1.15rem;
margin-bottom: 2rem;
}
&-rules {
padding-bottom: 2rem;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(#000, 0.08);
ul {
margin: 0;
padding: 0 0 0 1rem;
li {
font-size: 0.9rem;
line-height: 2rem;
}
}
}
&-proposals {

View File

@ -11,6 +11,7 @@ import { AppState } from 'store/reducers';
import Loader from 'components/Loader';
import Markdown from 'components/Markdown';
import ProposalCard from 'components/Proposals/ProposalCard';
import UnitDisplay from 'components/UnitDisplay';
import './index.less';
interface OwnProps {
@ -53,11 +54,34 @@ class RFPDetail extends React.Component<Props> {
<Icon type="arrow-left" /> Back to Requests
</Link>
<div className="RFPDetail-top-date">
Opened {moment(rfp.dateCreated * 1000).format('LL')}
Opened {moment(rfp.dateOpened * 1000).format('LL')}
</div>
</div>
<h1 className="RFPDetail-title">{rfp.title}</h1>
<Markdown className="RFPDetail-content" source={rfp.content} />
<div className="RFPDetail-rules">
<ul>
{rfp.bounty && (
<li>
Accepted proposals will be funded up to{' '}
<strong>
<UnitDisplay value={rfp.bounty} symbol="ZEC" />
</strong>
</li>
)}
{rfp.matching && (
<li>
Contributions will have their <strong>funding matched</strong> by the
Zcash Foundation
</li>
)}
{rfp.dateCloses && (
<li>
Proposal submissions end {moment(rfp.dateCloses * 1000).format('LL')}
</li>
)}
</ul>
</div>
{!!rfp.acceptedProposals.length && (
<div className="RFPDetail-proposals">

View File

@ -24,6 +24,11 @@
font-size: 1.4rem;
margin-bottom: 0.5rem;
color: @primary-color;
.ant-tag {
vertical-align: top;
margin: 0.5rem 0 0 0.5rem;
}
}
&-brief {

View File

@ -1,7 +1,9 @@
import React from 'react';
import moment from 'moment';
import classnames from 'classnames';
import { Tag } from 'antd';
import { Link } from 'react-router-dom';
import UnitDisplay from 'components/UnitDisplay';
import { RFP } from 'types';
import './RFPItem.less';
@ -13,19 +15,52 @@ interface Props {
export default class RFPItem extends React.Component<Props> {
render() {
const { rfp, isSmall } = this.props;
const { id, title, brief, acceptedProposals, dateCreated } = rfp;
const {
id,
title,
brief,
acceptedProposals,
dateOpened,
dateCloses,
dateClosed,
bounty,
matching,
} = rfp;
const closeDate = dateCloses || dateClosed;
const tags = [];
if (!isSmall) {
if (bounty) {
tags.push(
<Tag key="bounty" color="#CF8A00">
<UnitDisplay value={bounty} symbol="ZEC" /> bounty
</Tag>,
);
}
if (matching) {
tags.push(
<Tag key="matching" color="#1890ff">
x2 matching
</Tag>,
);
}
}
return (
<Link
className={classnames('RFPItem', isSmall && 'is-small')}
to={`/requests/${id}`}
>
<h3 className="RFPItem-title">{title}</h3>
<h3 className="RFPItem-title">
{title}
{tags}
</h3>
<p className="RFPItem-brief">{brief}</p>
<div className="RFPItem-details">
<div className="RFPItem-details-detail">
{moment(dateCreated * 1000).format('LL')}
{moment(dateOpened * 1000).format('LL')}
{closeDate && <> {moment(closeDate * 1000).format('LL')}</>}
</div>
<div className="RFPItem-details-detail">
{acceptedProposals.length} proposals approved

View File

@ -101,6 +101,9 @@ export function formatProposalFromGet(p: any): Proposal {
}
export function formatRFPFromGet(rfp: RFP): RFP {
if (rfp.bounty) {
rfp.bounty = toZat(rfp.bounty as any);
}
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
return rfp;
}

View File

@ -1,13 +1,18 @@
import { Proposal } from './proposal';
import { PROPOSAL_CATEGORY, RFP_STATUS } from 'api/constants';
import { Zat } from 'utils/units';
export interface RFP {
id: number;
dateCreated: number;
title: string;
brief: string;
content: string;
category: PROPOSAL_CATEGORY;
status: RFP_STATUS;
acceptedProposals: Proposal[];
bounty: Zat | null;
matching: boolean;
dateOpened: number;
dateClosed?: number;
dateCloses?: number;
}