Merge pull request #179 from grant-project/rfp-fields
RFP Fields (matching, bounty, dates)
This commit is contained in:
commit
fe5632dc75
|
@ -91,6 +91,9 @@ class RFPDetail extends React.Component<Props> {
|
||||||
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
|
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
|
||||||
{renderDeetItem('status', rfp.status)}
|
{renderDeetItem('status', rfp.status)}
|
||||||
{renderDeetItem('category', rfp.category)}
|
{renderDeetItem('category', rfp.category)}
|
||||||
|
{renderDeetItem('matching', String(rfp.matching))}
|
||||||
|
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
|
||||||
|
{renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* PROPOSALS */}
|
{/* PROPOSALS */}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
.RFPForm {
|
.RFPForm {
|
||||||
|
max-width: 880px;
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
&-preview {
|
&-preview {
|
||||||
font-size: 1rem;
|
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 {
|
&-buttons {
|
||||||
.ant-btn {
|
.ant-btn {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import moment, { Moment } from 'moment';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
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 Exception from 'ant-design-pro/lib/Exception';
|
||||||
import { FormComponentProps } from 'antd/lib/form';
|
import { FormComponentProps } from 'antd/lib/form';
|
||||||
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
|
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
|
||||||
|
@ -34,7 +36,7 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isShowingPreview } = this.state;
|
const { isShowingPreview } = this.state;
|
||||||
const { getFieldDecorator, getFieldValue } = this.props.form;
|
const { getFieldDecorator, getFieldValue, isFieldsTouched } = this.props.form;
|
||||||
|
|
||||||
let defaults: RFPArgs = {
|
let defaults: RFPArgs = {
|
||||||
title: '',
|
title: '',
|
||||||
|
@ -42,6 +44,9 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
content: '',
|
content: '',
|
||||||
category: '',
|
category: '',
|
||||||
status: '',
|
status: '',
|
||||||
|
matching: false,
|
||||||
|
bounty: undefined,
|
||||||
|
dateCloses: undefined,
|
||||||
};
|
};
|
||||||
const rfpId = this.getRFPId();
|
const rfpId = this.getRFPId();
|
||||||
if (rfpId) {
|
if (rfpId) {
|
||||||
|
@ -57,12 +62,20 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
content: rfp.content,
|
content: rfp.content,
|
||||||
category: rfp.category,
|
category: rfp.category,
|
||||||
status: rfp.status,
|
status: rfp.status,
|
||||||
|
matching: rfp.matching,
|
||||||
|
bounty: rfp.bounty,
|
||||||
|
dateCloses: rfp.dateCloses || undefined,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return <Exception type="404" desc="This RFP does not exist" />;
|
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 (
|
return (
|
||||||
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
|
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
|
||||||
<Back to="/rfps" text="RFPs" />
|
<Back to="/rfps" text="RFPs" />
|
||||||
|
@ -85,12 +98,15 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{rfpId && (
|
{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', {
|
{getFieldDecorator('status', {
|
||||||
initialValue: defaults.status,
|
initialValue: defaults.status,
|
||||||
rules: [{ required: true, message: 'Status is required' }],
|
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 => (
|
{typedKeys(RFP_STATUS).map(c => (
|
||||||
<Select.Option value={c} key={c}>
|
<Select.Option value={c} key={c}>
|
||||||
{getStatusById(RFP_STATUSES, c).tagDisplay}
|
{getStatusById(RFP_STATUSES, c).tagDisplay}
|
||||||
|
@ -164,13 +180,57 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</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">
|
<div className="RFPForm-buttons">
|
||||||
<Button type="primary" htmlType="submit" size="large">
|
<Button type="primary" htmlType="submit" size="large">
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link to="/rfps">
|
||||||
<Button type="ghost" size="large">
|
<Button type="ghost" size="large">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
@ -189,10 +249,14 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
|
|
||||||
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.props.form.validateFieldsAndScroll(async (err: any, values: any) => {
|
this.props.form.validateFieldsAndScroll(async (err: any, rawValues: any) => {
|
||||||
if (err) return;
|
if (err) return;
|
||||||
|
|
||||||
const rfpId = this.getRFPId();
|
const rfpId = this.getRFPId();
|
||||||
|
const values = {
|
||||||
|
...rawValues,
|
||||||
|
dateCloses: rawValues.dateCloses && rawValues.dateCloses.unix(),
|
||||||
|
};
|
||||||
let msg;
|
let msg;
|
||||||
if (rfpId) {
|
if (rfpId) {
|
||||||
await store.editRFP(rfpId, values);
|
await store.editRFP(rfpId, values);
|
||||||
|
|
|
@ -36,19 +36,27 @@ export enum RFP_STATUS {
|
||||||
export interface RFP {
|
export interface RFP {
|
||||||
id: number;
|
id: number;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
|
dateOpened: number | null;
|
||||||
|
dateClosed: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: string;
|
category: string;
|
||||||
status: string;
|
status: string;
|
||||||
proposals: Proposal[];
|
proposals: Proposal[];
|
||||||
|
matching: boolean;
|
||||||
|
bounty: string | null;
|
||||||
|
dateCloses: number | null;
|
||||||
}
|
}
|
||||||
export interface RFPArgs {
|
export interface RFPArgs {
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: 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
|
// NOTE: sync with backend/grant/utils/enums.py ProposalArbiterStatus
|
||||||
export enum PROPOSAL_ARBITER_STATUS {
|
export enum PROPOSAL_ARBITER_STATUS {
|
||||||
|
|
|
@ -2,6 +2,7 @@ from functools import reduce
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
from grant.comment.models import Comment, user_comments_schema
|
from grant.comment.models import Comment, user_comments_schema
|
||||||
from grant.email.send import generate_email, send_email
|
from grant.email.send import generate_email, send_email
|
||||||
from grant.extensions import db
|
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.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.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
|
||||||
from grant.utils.misc import make_url
|
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.utils import pagination
|
||||||
from grant.settings import EXPLORER_URL
|
from grant.settings import EXPLORER_URL
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
@ -332,14 +340,15 @@ def get_rfps():
|
||||||
parameter('brief', type=str),
|
parameter('brief', type=str),
|
||||||
parameter('content', type=str),
|
parameter('content', type=str),
|
||||||
parameter('category', type=str),
|
parameter('category', type=str),
|
||||||
|
parameter('bounty', type=str),
|
||||||
|
parameter('matching', type=bool, default=False),
|
||||||
|
parameter('dateCloses', type=int),
|
||||||
)
|
)
|
||||||
@admin_auth_required
|
@admin_auth_required
|
||||||
def create_rfp(title, brief, content, category):
|
def create_rfp(date_closes, **kwargs):
|
||||||
rfp = RFP(
|
rfp = RFP(
|
||||||
title=title,
|
**kwargs,
|
||||||
brief=brief,
|
date_closes=datetime.fromtimestamp(date_closes) if date_closes else None,
|
||||||
content=content,
|
|
||||||
category=category,
|
|
||||||
)
|
)
|
||||||
db.session.add(rfp)
|
db.session.add(rfp)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -363,18 +372,32 @@ def get_rfp(rfp_id):
|
||||||
parameter('brief', type=str),
|
parameter('brief', type=str),
|
||||||
parameter('content', type=str),
|
parameter('content', type=str),
|
||||||
parameter('category', type=str),
|
parameter('category', type=str),
|
||||||
|
parameter('bounty', type=str),
|
||||||
|
parameter('matching', type=bool, default=False),
|
||||||
|
parameter('dateCloses', type=int),
|
||||||
parameter('status', type=str),
|
parameter('status', type=str),
|
||||||
)
|
)
|
||||||
@admin_auth_required
|
@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()
|
rfp = RFP.query.filter(RFP.id == rfp_id).first()
|
||||||
if not rfp:
|
if not rfp:
|
||||||
return {"message": "No RFP matching that id"}, 404
|
return {"message": "No RFP matching that id"}, 404
|
||||||
|
|
||||||
|
# Update fields
|
||||||
rfp.title = title
|
rfp.title = title
|
||||||
rfp.brief = brief
|
rfp.brief = brief
|
||||||
rfp.content = content
|
rfp.content = content
|
||||||
rfp.category = category
|
rfp.category = category
|
||||||
|
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
|
rfp.status = status
|
||||||
|
|
||||||
db.session.add(rfp)
|
db.session.add(rfp)
|
||||||
|
|
|
@ -164,9 +164,9 @@ def make_proposal_draft(rfp_id):
|
||||||
rfp = RFP.query.filter_by(id=rfp_id).first()
|
rfp = RFP.query.filter_by(id=rfp_id).first()
|
||||||
if not rfp:
|
if not rfp:
|
||||||
return {"message": "The request this proposal was made for doesn’t exist"}, 400
|
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
|
proposal.category = rfp.category
|
||||||
|
if rfp.matching:
|
||||||
|
proposal.contribution_matching = 1.0
|
||||||
rfp.proposals.append(proposal)
|
rfp.proposals.append(proposal)
|
||||||
db.session.add(rfp)
|
db.session.add(rfp)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import datetime
|
from datetime import datetime
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.enums import RFPStatus
|
from grant.utils.enums import RFPStatus
|
||||||
from grant.utils.misc import dt_to_unix
|
from grant.utils.misc import dt_to_unix
|
||||||
|
@ -15,6 +15,11 @@ class RFP(db.Model):
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
category = db.Column(db.String(255), nullable=False)
|
category = db.Column(db.String(255), nullable=False)
|
||||||
status = 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
|
# Relationships
|
||||||
proposals = db.relationship(
|
proposals = db.relationship(
|
||||||
|
@ -36,13 +41,19 @@ class RFP(db.Model):
|
||||||
brief: str,
|
brief: str,
|
||||||
content: str,
|
content: str,
|
||||||
category: str,
|
category: str,
|
||||||
|
bounty: str,
|
||||||
|
date_closes: datetime,
|
||||||
|
matching: bool = False,
|
||||||
status: str = RFPStatus.DRAFT,
|
status: str = RFPStatus.DRAFT,
|
||||||
):
|
):
|
||||||
self.date_created = datetime.datetime.now()
|
self.date_created = datetime.now()
|
||||||
self.title = title
|
self.title = title
|
||||||
self.brief = brief
|
self.brief = brief
|
||||||
self.content = content
|
self.content = content
|
||||||
self.category = category
|
self.category = category
|
||||||
|
self.bounty = bounty
|
||||||
|
self.date_closes = date_closes
|
||||||
|
self.matching = matching
|
||||||
self.status = status
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
@ -57,15 +68,35 @@ class RFPSchema(ma.Schema):
|
||||||
"content",
|
"content",
|
||||||
"category",
|
"category",
|
||||||
"status",
|
"status",
|
||||||
|
"matching",
|
||||||
|
"bounty",
|
||||||
"date_created",
|
"date_created",
|
||||||
|
"date_closes",
|
||||||
|
"date_opened",
|
||||||
|
"date_closed",
|
||||||
"accepted_proposals",
|
"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"])
|
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
||||||
|
|
||||||
def get_date_created(self, obj):
|
def get_status(self, obj):
|
||||||
return dt_to_unix(obj.date_created)
|
# 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()
|
rfp_schema = RFPSchema()
|
||||||
rfps_schema = RFPSchema(many=True)
|
rfps_schema = RFPSchema(many=True)
|
||||||
|
@ -82,15 +113,39 @@ class AdminRFPSchema(ma.Schema):
|
||||||
"content",
|
"content",
|
||||||
"category",
|
"category",
|
||||||
"status",
|
"status",
|
||||||
|
"matching",
|
||||||
|
"bounty",
|
||||||
"date_created",
|
"date_created",
|
||||||
|
"date_closes",
|
||||||
|
"date_opened",
|
||||||
|
"date_closed",
|
||||||
"proposals",
|
"proposals",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
status = ma.Method("get_status")
|
||||||
date_created = ma.Method("get_date_created")
|
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"])
|
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):
|
def get_date_created(self, obj):
|
||||||
return dt_to_unix(obj.date_created)
|
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_rfp_schema = AdminRFPSchema()
|
||||||
admin_rfps_schema = AdminRFPSchema(many=True)
|
admin_rfps_schema = AdminRFPSchema(many=True)
|
||||||
|
|
|
@ -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 ###
|
|
@ -32,9 +32,23 @@
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-rules {
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 1px solid rgba(#000, 0.08);
|
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 {
|
&-proposals {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { AppState } from 'store/reducers';
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import ProposalCard from 'components/Proposals/ProposalCard';
|
import ProposalCard from 'components/Proposals/ProposalCard';
|
||||||
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
|
@ -53,11 +54,34 @@ class RFPDetail extends React.Component<Props> {
|
||||||
<Icon type="arrow-left" /> Back to Requests
|
<Icon type="arrow-left" /> Back to Requests
|
||||||
</Link>
|
</Link>
|
||||||
<div className="RFPDetail-top-date">
|
<div className="RFPDetail-top-date">
|
||||||
Opened {moment(rfp.dateCreated * 1000).format('LL')}
|
Opened {moment(rfp.dateOpened * 1000).format('LL')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="RFPDetail-title">{rfp.title}</h1>
|
<h1 className="RFPDetail-title">{rfp.title}</h1>
|
||||||
<Markdown className="RFPDetail-content" source={rfp.content} />
|
<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 && (
|
{!!rfp.acceptedProposals.length && (
|
||||||
<div className="RFPDetail-proposals">
|
<div className="RFPDetail-proposals">
|
||||||
|
|
|
@ -24,6 +24,11 @@
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: @primary-color;
|
color: @primary-color;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
vertical-align: top;
|
||||||
|
margin: 0.5rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-brief {
|
&-brief {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
import { Tag } from 'antd';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import UnitDisplay from 'components/UnitDisplay';
|
||||||
import { RFP } from 'types';
|
import { RFP } from 'types';
|
||||||
import './RFPItem.less';
|
import './RFPItem.less';
|
||||||
|
|
||||||
|
@ -13,19 +15,52 @@ interface Props {
|
||||||
export default class RFPItem extends React.Component<Props> {
|
export default class RFPItem extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { rfp, isSmall } = this.props;
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={classnames('RFPItem', isSmall && 'is-small')}
|
className={classnames('RFPItem', isSmall && 'is-small')}
|
||||||
to={`/requests/${id}`}
|
to={`/requests/${id}`}
|
||||||
>
|
>
|
||||||
<h3 className="RFPItem-title">{title}</h3>
|
<h3 className="RFPItem-title">
|
||||||
|
{title}
|
||||||
|
{tags}
|
||||||
|
</h3>
|
||||||
<p className="RFPItem-brief">{brief}</p>
|
<p className="RFPItem-brief">{brief}</p>
|
||||||
|
|
||||||
<div className="RFPItem-details">
|
<div className="RFPItem-details">
|
||||||
<div className="RFPItem-details-detail">
|
<div className="RFPItem-details-detail">
|
||||||
{moment(dateCreated * 1000).format('LL')}
|
{moment(dateOpened * 1000).format('LL')}
|
||||||
|
{closeDate && <> – {moment(closeDate * 1000).format('LL')}</>}
|
||||||
</div>
|
</div>
|
||||||
<div className="RFPItem-details-detail">
|
<div className="RFPItem-details-detail">
|
||||||
{acceptedProposals.length} proposals approved
|
{acceptedProposals.length} proposals approved
|
||||||
|
|
|
@ -101,6 +101,9 @@ export function formatProposalFromGet(p: any): Proposal {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRFPFromGet(rfp: RFP): RFP {
|
export function formatRFPFromGet(rfp: RFP): RFP {
|
||||||
|
if (rfp.bounty) {
|
||||||
|
rfp.bounty = toZat(rfp.bounty as any);
|
||||||
|
}
|
||||||
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
|
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
|
||||||
return rfp;
|
return rfp;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
import { Proposal } from './proposal';
|
import { Proposal } from './proposal';
|
||||||
import { PROPOSAL_CATEGORY, RFP_STATUS } from 'api/constants';
|
import { PROPOSAL_CATEGORY, RFP_STATUS } from 'api/constants';
|
||||||
|
import { Zat } from 'utils/units';
|
||||||
|
|
||||||
export interface RFP {
|
export interface RFP {
|
||||||
id: number;
|
id: number;
|
||||||
dateCreated: number;
|
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: PROPOSAL_CATEGORY;
|
category: PROPOSAL_CATEGORY;
|
||||||
status: RFP_STATUS;
|
status: RFP_STATUS;
|
||||||
acceptedProposals: Proposal[];
|
acceptedProposals: Proposal[];
|
||||||
|
bounty: Zat | null;
|
||||||
|
matching: boolean;
|
||||||
|
dateOpened: number;
|
||||||
|
dateClosed?: number;
|
||||||
|
dateCloses?: number;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue