diff --git a/admin/src/components/RFPDetail/index.tsx b/admin/src/components/RFPDetail/index.tsx index 56a74a5d..d6c5c8df 100644 --- a/admin/src/components/RFPDetail/index.tsx +++ b/admin/src/components/RFPDetail/index.tsx @@ -91,6 +91,9 @@ class RFPDetail extends React.Component { {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))} {/* PROPOSALS */} diff --git a/admin/src/components/RFPForm/index.less b/admin/src/components/RFPForm/index.less index 40df07c9..ca925f5e 100644 --- a/admin/src/components/RFPForm/index.less +++ b/admin/src/components/RFPForm/index.less @@ -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; diff --git a/admin/src/components/RFPForm/index.tsx b/admin/src/components/RFPForm/index.tsx index 37c03b8f..29fe0dbf 100644 --- a/admin/src/components/RFPForm/index.tsx +++ b/admin/src/components/RFPForm/index.tsx @@ -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 { 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 { content: '', category: '', status: '', + matching: false, + bounty: undefined, + dateCloses: undefined, }; const rfpId = this.getRFPId(); if (rfpId) { @@ -57,11 +62,19 @@ class RFPForm extends React.Component { content: rfp.content, category: rfp.category, status: rfp.status, + matching: rfp.matching, + bounty: rfp.bounty, + dateCloses: rfp.dateCloses || undefined, }; } else { return ; } } + + const dateCloses = isFieldsTouched(['dateCloses']) + ? getFieldValue('dateCloses') + : defaults.dateCloses && moment(defaults.dateCloses * 1000); + const forceClosed = dateCloses && dateCloses.isBefore(moment.now()); return (
@@ -85,12 +98,15 @@ class RFPForm extends React.Component { {rfpId && ( - + {getFieldDecorator('status', { initialValue: defaults.status, rules: [{ required: true, message: 'Status is required' }], })( - {typedKeys(RFP_STATUS).map(c => ( {getStatusById(RFP_STATUSES, c).tagDisplay} @@ -164,13 +180,57 @@ class RFPForm extends React.Component { )} + + + + {getFieldDecorator('bounty', { + initialValue: defaults.bounty, + })( + , + )} + {getFieldDecorator('matching', { + initialValue: defaults.matching, + })( + + Match community contributions for approved proposals + , + )} + + + + + {getFieldDecorator('dateCloses', { + initialValue: defaults.dateCloses ? moment(defaults.dateCloses * 1000) : undefined, + })( + + )} + + + +
- + + +
); @@ -189,10 +249,14 @@ class RFPForm extends React.Component { private handleSubmit = (ev: React.FormEvent) => { 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); diff --git a/admin/src/types.ts b/admin/src/types.ts index 81a63a61..6f22717d 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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 { diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 66de4099..84d31486 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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() diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 5718c41c..27948b80 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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 doesn’t 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) diff --git a/backend/grant/rfp/models.py b/backend/grant/rfp/models.py index 82bd82c7..4861ae50 100644 --- a/backend/grant/rfp/models.py +++ b/backend/grant/rfp/models.py @@ -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) diff --git a/backend/migrations/versions/d39bb526eef4_.py b/backend/migrations/versions/d39bb526eef4_.py new file mode 100644 index 00000000..fecd583e --- /dev/null +++ b/backend/migrations/versions/d39bb526eef4_.py @@ -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 ### diff --git a/frontend/client/components/RFP/index.less b/frontend/client/components/RFP/index.less index d06845a1..b9ae68f2 100644 --- a/frontend/client/components/RFP/index.less +++ b/frontend/client/components/RFP/index.less @@ -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 { diff --git a/frontend/client/components/RFP/index.tsx b/frontend/client/components/RFP/index.tsx index 77b21bd3..3600ac3c 100644 --- a/frontend/client/components/RFP/index.tsx +++ b/frontend/client/components/RFP/index.tsx @@ -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 { Back to Requests
- Opened {moment(rfp.dateCreated * 1000).format('LL')} + Opened {moment(rfp.dateOpened * 1000).format('LL')}

{rfp.title}

+
+
    + {rfp.bounty && ( +
  • + Accepted proposals will be funded up to{' '} + + + +
  • + )} + {rfp.matching && ( +
  • + Contributions will have their funding matched by the + Zcash Foundation +
  • + )} + {rfp.dateCloses && ( +
  • + Proposal submissions end {moment(rfp.dateCloses * 1000).format('LL')} +
  • + )} +
+
{!!rfp.acceptedProposals.length && (
diff --git a/frontend/client/components/RFPs/RFPItem.less b/frontend/client/components/RFPs/RFPItem.less index c77d3af8..16daad19 100644 --- a/frontend/client/components/RFPs/RFPItem.less +++ b/frontend/client/components/RFPs/RFPItem.less @@ -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 { diff --git a/frontend/client/components/RFPs/RFPItem.tsx b/frontend/client/components/RFPs/RFPItem.tsx index ab738d1f..7645b3d5 100644 --- a/frontend/client/components/RFPs/RFPItem.tsx +++ b/frontend/client/components/RFPs/RFPItem.tsx @@ -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 { 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( + + bounty + , + ); + } + if (matching) { + tags.push( + + x2 matching + , + ); + } + } return ( -

{title}

+

+ {title} + {tags} +

{brief}

- {moment(dateCreated * 1000).format('LL')} + {moment(dateOpened * 1000).format('LL')} + {closeDate && <> – {moment(closeDate * 1000).format('LL')}}
{acceptedProposals.length} proposals approved diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 4eb6ab21..8a186704 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -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; } diff --git a/frontend/types/rfp.ts b/frontend/types/rfp.ts index 00cdb79e..76035a54 100644 --- a/frontend/types/rfp.ts +++ b/frontend/types/rfp.ts @@ -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; }