commit
452637cc28
|
@ -13,6 +13,8 @@ import UserDetail from 'components/UserDetail';
|
||||||
import Emails from 'components/Emails';
|
import Emails from 'components/Emails';
|
||||||
import Proposals from 'components/Proposals';
|
import Proposals from 'components/Proposals';
|
||||||
import ProposalDetail from 'components/ProposalDetail';
|
import ProposalDetail from 'components/ProposalDetail';
|
||||||
|
import CCRs from 'components/CCRs';
|
||||||
|
import CCRDetail from 'components/CCRDetail';
|
||||||
import RFPs from 'components/RFPs';
|
import RFPs from 'components/RFPs';
|
||||||
import RFPForm from 'components/RFPForm';
|
import RFPForm from 'components/RFPForm';
|
||||||
import RFPDetail from 'components/RFPDetail';
|
import RFPDetail from 'components/RFPDetail';
|
||||||
|
@ -47,6 +49,8 @@ class Routes extends React.Component<Props> {
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
<Route path="/proposals/:id" component={ProposalDetail} />
|
<Route path="/proposals/:id" component={ProposalDetail} />
|
||||||
<Route path="/proposals" component={Proposals} />
|
<Route path="/proposals" component={Proposals} />
|
||||||
|
<Route path="/ccrs/:id" component={CCRDetail} />
|
||||||
|
<Route path="/ccrs" component={CCRs} />
|
||||||
<Route path="/rfps/new" component={RFPForm} />
|
<Route path="/rfps/new" component={RFPForm} />
|
||||||
<Route path="/rfps/:id/edit" component={RFPForm} />
|
<Route path="/rfps/:id/edit" component={RFPForm} />
|
||||||
<Route path="/rfps/:id" component={RFPDetail} />
|
<Route path="/rfps/:id" component={RFPDetail} />
|
||||||
|
|
|
@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { arbiter } = this.props;
|
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
|
||||||
const { showSearch, searching } = this.state;
|
const { showSearch, searching } = this.state;
|
||||||
const { results, search, error } = store.arbitersSearch;
|
const { results, search, error } = store.arbitersSearch;
|
||||||
const showEmpty = !results.length && !searching;
|
const showEmpty = !results.length && !searching;
|
||||||
|
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
|
||||||
|
|
||||||
const disp = {
|
const disp = {
|
||||||
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
||||||
|
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={this.handleShowSearch}
|
onClick={this.handleShowSearch}
|
||||||
{...this.props.buttonProps}
|
{...this.props.buttonProps}
|
||||||
|
disabled={buttonDisabled}
|
||||||
>
|
>
|
||||||
{disp[arbiter.status]}
|
{disp[arbiter.status]}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
.CCRDetail {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
&-control + &-control {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-deet {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.8;
|
||||||
|
bottom: -0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ant-card,
|
||||||
|
.ant-alert,
|
||||||
|
.ant-collapse {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-popover {
|
||||||
|
&-overlay {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-alert {
|
||||||
|
& pre {
|
||||||
|
margin: 1rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-review {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,221 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Alert, Button, Card, Col, Collapse, message, Row } from 'antd';
|
||||||
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import { CCR_STATUS } from 'src/types';
|
||||||
|
import Back from 'components/Back';
|
||||||
|
import Markdown from 'components/Markdown';
|
||||||
|
import FeedbackModal from '../FeedbackModal';
|
||||||
|
import './index.less';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
paidTxId: '',
|
||||||
|
showCancelAndRefundPopover: false,
|
||||||
|
showChangeToAcceptedWithFundingPopover: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = typeof STATE;
|
||||||
|
|
||||||
|
class CCRDetailNaked extends React.Component<Props, State> {
|
||||||
|
state = STATE;
|
||||||
|
rejectInput: null | TextArea = null;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const id = this.getIdFromQuery();
|
||||||
|
const { ccrDetail: c, ccrDetailFetching } = store;
|
||||||
|
|
||||||
|
if (!c || (c && c.ccrId !== id) || ccrDetailFetching) {
|
||||||
|
return 'loading ccr...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderApproved = () =>
|
||||||
|
c.status === CCR_STATUS.APPROVED && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="success"
|
||||||
|
message={`Approved on ${formatDateSeconds(c.dateApproved)}`}
|
||||||
|
description={`
|
||||||
|
This ccr has been approved.
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderReview = () =>
|
||||||
|
c.status === CCR_STATUS.PENDING && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="Review Pending"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Please review this Community Created Request and render your judgment.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="CCRDetail-review"
|
||||||
|
loading={store.ccrDetailApproving}
|
||||||
|
icon="check"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => this.handleApprove()}
|
||||||
|
>
|
||||||
|
Generate RFP from CCR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="CCRDetail-review"
|
||||||
|
loading={store.ccrDetailApproving}
|
||||||
|
icon="close"
|
||||||
|
type="danger"
|
||||||
|
onClick={() => {
|
||||||
|
FeedbackModal.open({
|
||||||
|
title: 'Request changes for this Request?',
|
||||||
|
label: 'Please provide a reason:',
|
||||||
|
okText: 'Request changes',
|
||||||
|
onOk: this.handleReject,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRejected = () =>
|
||||||
|
c.status === CCR_STATUS.REJECTED && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="error"
|
||||||
|
message="Changes requested"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This CCR has changes requested. The team will be able to re-submit it for
|
||||||
|
approval should they desire to do so.
|
||||||
|
</p>
|
||||||
|
<b>Reason:</b>
|
||||||
|
<br />
|
||||||
|
<i>{c.rejectReason}</i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
|
<div className="CCRDetail-deet">
|
||||||
|
<span>{name}</span>
|
||||||
|
{val}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CCRDetail">
|
||||||
|
<Back to="/ccrs" text="CCRs" />
|
||||||
|
<h1>{c.title}</h1>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* MAIN */}
|
||||||
|
<Col span={18}>
|
||||||
|
{renderApproved()}
|
||||||
|
{renderReview()}
|
||||||
|
{renderRejected()}
|
||||||
|
|
||||||
|
<Collapse defaultActiveKey={['brief', 'content', 'target']}>
|
||||||
|
<Collapse.Panel key="brief" header="brief">
|
||||||
|
{c.brief}
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="content" header="content">
|
||||||
|
<Markdown source={c.content} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="target" header="target">
|
||||||
|
<Markdown source={c.target} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="json" header="json">
|
||||||
|
<pre>{JSON.stringify(c, null, 4)}</pre>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* RIGHT SIDE */}
|
||||||
|
<Col span={6}>
|
||||||
|
{c.rfp && (
|
||||||
|
<Alert
|
||||||
|
message="Linked to RFP"
|
||||||
|
description={
|
||||||
|
<React.Fragment>
|
||||||
|
This CCR has been accepted and is instantiated as an RFP{' '}
|
||||||
|
<Link to={`/rfps/${c.rfp.id}`}>here</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DETAILS */}
|
||||||
|
<Card title="Details" size="small">
|
||||||
|
{renderDeetItem('id', c.ccrId)}
|
||||||
|
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
|
||||||
|
{renderDeetItem(
|
||||||
|
'published',
|
||||||
|
c.datePublished ? formatDateSeconds(c.datePublished) : 'n/a',
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderDeetItem(
|
||||||
|
'status',
|
||||||
|
c.status === CCR_STATUS.LIVE ? 'Accepted/Generated RFP' : c.status,
|
||||||
|
)}
|
||||||
|
{renderDeetItem('target', c.target)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Author" size="small">
|
||||||
|
<div key={c.author.userid}>
|
||||||
|
<Link to={`/users/${c.author.userid}`}>{c.author.displayName}</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIdFromQuery = () => {
|
||||||
|
return Number(this.props.match.params.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadDetail = () => {
|
||||||
|
store.fetchCCRDetail(this.getIdFromQuery());
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleApprove = async () => {
|
||||||
|
await store.approveCCR(true);
|
||||||
|
if (store.ccrCreatedRFPId) {
|
||||||
|
message.success('Successfully created RFP from CCR!', 1);
|
||||||
|
setTimeout(
|
||||||
|
() => this.props.history.replace(`/rfps/${store.ccrCreatedRFPId}/edit`),
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleReject = async (reason: string) => {
|
||||||
|
await store.approveCCR(false, reason);
|
||||||
|
message.info('CCR changes requested');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CCRDetail = withRouter(view(CCRDetailNaked));
|
||||||
|
export default CCRDetail;
|
|
@ -0,0 +1,16 @@
|
||||||
|
.CCRItem {
|
||||||
|
& h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
& .ant-tag {
|
||||||
|
vertical-align: text-top;
|
||||||
|
margin: 0.2rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
color: rgba(#000, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Tag, Tooltip, List } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CCR } from 'src/types';
|
||||||
|
import { CCR_STATUSES, getStatusById } from 'util/statuses';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import './CCRItem.less';
|
||||||
|
|
||||||
|
class CCRItemNaked extends React.Component<CCR> {
|
||||||
|
render() {
|
||||||
|
const props = this.props;
|
||||||
|
const status = getStatusById(CCR_STATUSES, props.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item key={props.ccrId} className="CCRItem">
|
||||||
|
<Link to={`/ccrs/${props.ccrId}`}>
|
||||||
|
<h2>
|
||||||
|
{props.title || '(no title)'}
|
||||||
|
<Tooltip title={status.hint}>
|
||||||
|
<Tag color={status.tagColor}>
|
||||||
|
{status.tagDisplay === 'Live'
|
||||||
|
? 'Accepted/Generated RFP'
|
||||||
|
: status.tagDisplay}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</h2>
|
||||||
|
<p>Created: {formatDateSeconds(props.dateCreated)}</p>
|
||||||
|
<p>{props.brief}</p>
|
||||||
|
</Link>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CCRItem = view(CCRItemNaked);
|
||||||
|
export default CCRItem;
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import store from 'src/store';
|
||||||
|
import CCRItem from './CCRItem';
|
||||||
|
import Pageable from 'components/Pageable';
|
||||||
|
import { CCR } from 'src/types';
|
||||||
|
import { ccrFilters } from 'util/filters';
|
||||||
|
|
||||||
|
class CCRs extends React.Component<{}> {
|
||||||
|
render() {
|
||||||
|
const { page } = store.ccrs;
|
||||||
|
// NOTE: sync with /backend ... pagination.py CCRPagination.SORT_MAP
|
||||||
|
const sorts = ['CREATED:DESC', 'CREATED:ASC'];
|
||||||
|
return (
|
||||||
|
<Pageable
|
||||||
|
page={page}
|
||||||
|
filters={ccrFilters}
|
||||||
|
sorts={sorts}
|
||||||
|
searchPlaceholder="Search CCR titles"
|
||||||
|
renderItem={(c: CCR) => <CCRItem key={c.ccrId} {...c} />}
|
||||||
|
handleSearch={store.fetchCCRs}
|
||||||
|
handleChangeQuery={store.setCCRPageQuery}
|
||||||
|
handleResetQuery={store.resetCCRPageQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(CCRs);
|
|
@ -43,8 +43,8 @@ export default [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proposal_rejected',
|
id: 'proposal_rejected',
|
||||||
title: 'Proposal rejected',
|
title: 'Proposal changes requested',
|
||||||
description: 'Sent when an admin rejects your submitted proposal',
|
description: 'Sent when an admin requests changes for your submitted proposal',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proposal_contribution',
|
id: 'proposal_contribution',
|
||||||
|
@ -130,6 +130,11 @@ export default [
|
||||||
title: 'Milestone paid',
|
title: 'Milestone paid',
|
||||||
description: 'Sent when milestone is paid',
|
description: 'Sent when milestone is paid',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'milestone_deadline',
|
||||||
|
title: 'Milestone deadline',
|
||||||
|
description: 'Sent when the estimated deadline for milestone has been reached',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'admin_approval',
|
id: 'admin_approval',
|
||||||
title: 'Admin Approval',
|
title: 'Admin Approval',
|
||||||
|
@ -145,4 +150,15 @@ export default [
|
||||||
title: 'Admin Payout',
|
title: 'Admin Payout',
|
||||||
description: 'Sent when milestone payout has been approved',
|
description: 'Sent when milestone payout has been approved',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'followed_proposal_milestone',
|
||||||
|
title: 'Followed Proposal Milestone',
|
||||||
|
description:
|
||||||
|
'Sent to followers of a proposal when one of its milestones has been approved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'followed_proposal_update',
|
||||||
|
title: 'Followed Proposal Update',
|
||||||
|
description: 'Sent to followers of a proposal when it has a new update',
|
||||||
|
},
|
||||||
] as Email[];
|
] as Email[];
|
||||||
|
|
|
@ -14,6 +14,7 @@ class Home extends React.Component {
|
||||||
const {
|
const {
|
||||||
userCount,
|
userCount,
|
||||||
proposalCount,
|
proposalCount,
|
||||||
|
ccrPendingCount,
|
||||||
proposalPendingCount,
|
proposalPendingCount,
|
||||||
proposalNoArbiterCount,
|
proposalNoArbiterCount,
|
||||||
proposalMilestonePayoutsCount,
|
proposalMilestonePayoutsCount,
|
||||||
|
@ -21,6 +22,13 @@ class Home extends React.Component {
|
||||||
} = store.stats;
|
} = store.stats;
|
||||||
|
|
||||||
const actionItems = [
|
const actionItems = [
|
||||||
|
!!ccrPendingCount && (
|
||||||
|
<div>
|
||||||
|
<Icon type="exclamation-circle" /> There are <b>{ccrPendingCount}</b> community
|
||||||
|
created requests <b>waiting for review</b>.{' '}
|
||||||
|
<Link to="/ccrs?filters[]=STATUS_PENDING">Click here</Link> to view them.
|
||||||
|
</div>
|
||||||
|
),
|
||||||
!!proposalPendingCount && (
|
!!proposalPendingCount && (
|
||||||
<div>
|
<div>
|
||||||
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
||||||
|
@ -32,7 +40,7 @@ class Home extends React.Component {
|
||||||
<div>
|
<div>
|
||||||
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
|
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
|
||||||
live proposals <b>without an arbiter</b>.{' '}
|
live proposals <b>without an arbiter</b>.{' '}
|
||||||
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING&filters[]=STAGE_NOT_CANCELED">
|
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING&filters[]=STAGE_NOT_CANCELED&filters[]=ACCEPTED_WITH_FUNDING">
|
||||||
Click here
|
Click here
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
to view them.
|
to view them.
|
||||||
|
|
|
@ -26,10 +26,6 @@
|
||||||
.ant-alert,
|
.ant-alert,
|
||||||
.ant-collapse {
|
.ant-collapse {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
||||||
button + button {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-popover {
|
&-popover {
|
||||||
|
@ -46,4 +42,9 @@
|
||||||
white-space: inherit;
|
white-space: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-review {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import {
|
||||||
Collapse,
|
Collapse,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Input,
|
Input,
|
||||||
Switch,
|
|
||||||
Tag,
|
Tag,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
@ -26,11 +25,11 @@ import {
|
||||||
} from 'src/types';
|
} from 'src/types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Info from 'components/Info';
|
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import ArbiterControl from 'components/ArbiterControl';
|
import ArbiterControl from 'components/ArbiterControl';
|
||||||
import { toZat, fromZat } from 'src/util/units';
|
import { toZat, fromZat } from 'src/util/units';
|
||||||
import FeedbackModal from '../FeedbackModal';
|
import FeedbackModal from '../FeedbackModal';
|
||||||
|
import { formatUsd } from 'util/formatters';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
type Props = RouteComponentProps<any>;
|
type Props = RouteComponentProps<any>;
|
||||||
|
@ -38,6 +37,7 @@ type Props = RouteComponentProps<any>;
|
||||||
const STATE = {
|
const STATE = {
|
||||||
paidTxId: '',
|
paidTxId: '',
|
||||||
showCancelAndRefundPopover: false,
|
showCancelAndRefundPopover: false,
|
||||||
|
showChangeToAcceptedWithFundingPopover: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = typeof STATE;
|
type State = typeof STATE;
|
||||||
|
@ -65,17 +65,32 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
const { isVersionTwo } = p;
|
||||||
|
const shouldShowArbiter =
|
||||||
|
!isVersionTwo || (isVersionTwo && p.acceptedWithFunding === true);
|
||||||
|
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund';
|
||||||
|
const shouldShowChangeToAcceptedWithFunding =
|
||||||
|
isVersionTwo && p.acceptedWithFunding === false;
|
||||||
|
|
||||||
const renderCancelControl = () => {
|
const renderCancelControl = () => {
|
||||||
const disabled = this.getCancelAndRefundDisabled();
|
const disabled = this.getCancelAndRefundDisabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={
|
title={
|
||||||
<p>
|
isVersionTwo ? (
|
||||||
Are you sure you want to cancel proposal and begin
|
<p>
|
||||||
<br />
|
Are you sure you want to cancel proposal?
|
||||||
the refund process? This cannot be undone.
|
<br />
|
||||||
</p>
|
This cannot be undone.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Are you sure you want to cancel proposal and begin
|
||||||
|
<br />
|
||||||
|
the refund process? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
placement="left"
|
placement="left"
|
||||||
cancelText="cancel"
|
cancelText="cancel"
|
||||||
|
@ -95,7 +110,40 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
Cancel & refund
|
{cancelButtonText}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderChangeToAcceptedWithFundingControl = () => {
|
||||||
|
return (
|
||||||
|
<Popconfirm
|
||||||
|
title={
|
||||||
|
<p>
|
||||||
|
Are you sure you want to accept the proposal
|
||||||
|
<br />
|
||||||
|
with funding? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
placement="left"
|
||||||
|
cancelText="cancel"
|
||||||
|
okText="confirm"
|
||||||
|
visible={this.state.showChangeToAcceptedWithFundingPopover}
|
||||||
|
okButtonProps={{
|
||||||
|
loading: store.proposalDetailCanceling,
|
||||||
|
}}
|
||||||
|
onCancel={this.handleChangeToAcceptWithFundingCancel}
|
||||||
|
onConfirm={this.handleChangeToAcceptWithFundingConfirm}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon="close-circle"
|
||||||
|
className="ProposalDetail-controls-control"
|
||||||
|
loading={store.proposalDetailChangingToAcceptedWithFunding}
|
||||||
|
onClick={this.handleChangeToAcceptedWithFunding}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Accept With Funding
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
);
|
);
|
||||||
|
@ -116,69 +164,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderMatchingControl = () => (
|
|
||||||
<div className="ProposalDetail-controls-control">
|
|
||||||
<Popconfirm
|
|
||||||
overlayClassName="ProposalDetail-popover-overlay"
|
|
||||||
onConfirm={this.handleToggleMatching}
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
|
|
||||||
</div>
|
|
||||||
{p.status === PROPOSAL_STATUS.LIVE && (
|
|
||||||
<div>
|
|
||||||
This is a LIVE proposal, this will alter the funding state of the
|
|
||||||
proposal!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
okText="ok"
|
|
||||||
cancelText="cancel"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={p.contributionMatching === 1}
|
|
||||||
loading={store.proposalDetailUpdating}
|
|
||||||
disabled={
|
|
||||||
p.isFailed ||
|
|
||||||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
|
||||||
}
|
|
||||||
/>{' '}
|
|
||||||
</Popconfirm>
|
|
||||||
<span>
|
|
||||||
matching{' '}
|
|
||||||
<Info
|
|
||||||
placement="right"
|
|
||||||
content={
|
|
||||||
<span>
|
|
||||||
<b>Contribution matching</b>
|
|
||||||
<br /> Funded amount will be multiplied by 2.
|
|
||||||
<br /> <i>Disabled after proposal is fully-funded.</i>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBountyControl = () => (
|
|
||||||
<div className="ProposalDetail-controls-control">
|
|
||||||
<Button
|
|
||||||
icon="dollar"
|
|
||||||
className="ProposalDetail-controls-control"
|
|
||||||
loading={store.proposalDetailUpdating}
|
|
||||||
onClick={this.handleSetBounty}
|
|
||||||
disabled={
|
|
||||||
p.isFailed || [PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
|
||||||
}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Set bounty
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderApproved = () =>
|
const renderApproved = () =>
|
||||||
p.status === PROPOSAL_STATUS.APPROVED && (
|
p.status === PROPOSAL_STATUS.APPROVED && (
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -194,39 +179,76 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
|
|
||||||
const renderReview = () =>
|
const renderReview = () =>
|
||||||
p.status === PROPOSAL_STATUS.PENDING && (
|
p.status === PROPOSAL_STATUS.PENDING && (
|
||||||
<Alert
|
<>
|
||||||
showIcon
|
<Row gutter={16}>
|
||||||
type="warning"
|
<Col span={isVersionTwo ? 16 : 24}>
|
||||||
message="Review Pending"
|
<Alert
|
||||||
description={
|
showIcon
|
||||||
<div>
|
type="warning"
|
||||||
<p>Please review this proposal and render your judgment.</p>
|
message="Review Pending"
|
||||||
<Button
|
description={
|
||||||
loading={store.proposalDetailApproving}
|
<div>
|
||||||
icon="check"
|
<p>Please review this proposal and render your judgment.</p>
|
||||||
type="primary"
|
<Button
|
||||||
onClick={this.handleApprove}
|
className="ProposalDetail-review"
|
||||||
>
|
loading={store.proposalDetailApproving}
|
||||||
Approve
|
icon="check"
|
||||||
</Button>
|
type="primary"
|
||||||
<Button
|
onClick={() => this.handleApprove(true)}
|
||||||
loading={store.proposalDetailApproving}
|
>
|
||||||
icon="close"
|
Approve With Funding
|
||||||
type="danger"
|
</Button>
|
||||||
onClick={() => {
|
<Button
|
||||||
FeedbackModal.open({
|
className="ProposalDetail-review"
|
||||||
title: 'Reject this proposal?',
|
loading={store.proposalDetailApproving}
|
||||||
label: 'Please provide a reason:',
|
icon="check"
|
||||||
okText: 'Reject',
|
type="default"
|
||||||
onOk: this.handleReject,
|
onClick={() => this.handleApprove(false)}
|
||||||
});
|
>
|
||||||
}}
|
Approve Without Funding
|
||||||
>
|
</Button>
|
||||||
Reject
|
<Button
|
||||||
</Button>
|
className="ProposalDetail-review"
|
||||||
</div>
|
loading={store.proposalDetailApproving}
|
||||||
}
|
icon="close"
|
||||||
/>
|
type="danger"
|
||||||
|
onClick={() => {
|
||||||
|
FeedbackModal.open({
|
||||||
|
title: 'Request changes to this proposal?',
|
||||||
|
label: 'Please provide a reason:',
|
||||||
|
okText: 'Request changes',
|
||||||
|
onOk: this.handleReject,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
{p.isVersionTwo && (
|
||||||
|
<Col span={8}>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type={p.rfpOptIn ? 'success' : 'error'}
|
||||||
|
message={p.rfpOptIn ? 'KYC accepted' : 'KYC rejected'}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
{p.rfpOptIn ? (
|
||||||
|
<p>KYC has been accepted by the proposer.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
KYC has been rejected. Recommend against approving with funding.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRejected = () =>
|
const renderRejected = () =>
|
||||||
|
@ -234,12 +256,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="error"
|
type="error"
|
||||||
message="Rejected"
|
message="Changes requested"
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
This proposal has been rejected. The team will be able to re-submit it for
|
This proposal has changes requested. The team will be able to re-submit it
|
||||||
approval should they desire to do so.
|
for approval should they desire to do so.
|
||||||
</p>
|
</p>
|
||||||
<b>Reason:</b>
|
<b>Reason:</b>
|
||||||
<br />
|
<br />
|
||||||
|
@ -250,7 +272,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderNominateArbiter = () =>
|
const renderNominateArbiter = () =>
|
||||||
needsArbiter && (
|
needsArbiter &&
|
||||||
|
shouldShowArbiter && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
|
@ -297,11 +320,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ms = p.currentMilestone;
|
const ms = p.currentMilestone;
|
||||||
const amount = fromZat(
|
|
||||||
toZat(p.target)
|
let paymentMsg;
|
||||||
.mul(new BN(ms.payoutPercent))
|
if (p.isVersionTwo) {
|
||||||
.divn(100),
|
const target = parseFloat(p.target.toString());
|
||||||
);
|
const payoutPercent = parseFloat(ms.payoutPercent);
|
||||||
|
const amountNum = (target * payoutPercent) / 100;
|
||||||
|
const amount = formatUsd(amountNum, true, 2);
|
||||||
|
paymentMsg = `${amount} in ZEC`;
|
||||||
|
} else {
|
||||||
|
const amount = fromZat(
|
||||||
|
toZat(p.target)
|
||||||
|
.mul(new BN(ms.payoutPercent))
|
||||||
|
.divn(100),
|
||||||
|
);
|
||||||
|
paymentMsg = `${amount} ZEC`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
className="ProposalDetail-alert"
|
className="ProposalDetail-alert"
|
||||||
|
@ -318,7 +353,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{' '}
|
{' '}
|
||||||
Please make a payment of <b>{amount.toString()} ZEC</b> to:
|
Please make a payment of <b>{paymentMsg}</b> to:
|
||||||
</p>{' '}
|
</p>{' '}
|
||||||
<pre>{p.payoutAddress}</pre>
|
<pre>{p.payoutAddress}</pre>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
|
@ -381,7 +416,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderMilestoneAccepted()}
|
{renderMilestoneAccepted()}
|
||||||
{renderFailed()}
|
{renderFailed()}
|
||||||
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
||||||
|
|
||||||
<Collapse.Panel key="brief" header="brief">
|
<Collapse.Panel key="brief" header="brief">
|
||||||
{p.brief}
|
{p.brief}
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
@ -391,24 +425,35 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="milestones" header="milestones">
|
<Collapse.Panel key="milestones" header="milestones">
|
||||||
{
|
{p.milestones.map((milestone, i) => (
|
||||||
p.milestones.map((milestone, i) =>
|
<Card
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{milestone.title + ' '}
|
||||||
|
{milestone.immediatePayout && (
|
||||||
|
<Tag color="magenta">Immediate Payout</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
extra={`${milestone.payoutPercent}% Payout`}
|
||||||
|
key={i}
|
||||||
|
>
|
||||||
|
{p.isVersionTwo && (
|
||||||
|
<p>
|
||||||
|
<b>Estimated Days to Complete:</b>{' '}
|
||||||
|
{milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<b>Estimated Date:</b>{' '}
|
||||||
|
{milestone.dateEstimated
|
||||||
|
? formatDateSeconds(milestone.dateEstimated)
|
||||||
|
: 'N/A'}{' '}
|
||||||
|
</p>
|
||||||
|
|
||||||
<Card title={
|
<p>{milestone.content}</p>
|
||||||
<>
|
</Card>
|
||||||
{milestone.title + ' '}
|
))}
|
||||||
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
extra={`${milestone.payoutPercent}% Payout`}
|
|
||||||
key={i}
|
|
||||||
>
|
|
||||||
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p>
|
|
||||||
<p>{milestone.content}</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="json" header="json">
|
<Collapse.Panel key="json" header="json">
|
||||||
|
@ -419,12 +464,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
|
|
||||||
{/* RIGHT SIDE */}
|
{/* RIGHT SIDE */}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
|
{p.isVersionTwo &&
|
||||||
|
!p.acceptedWithFunding &&
|
||||||
|
p.stage === PROPOSAL_STAGE.WIP && (
|
||||||
|
<Alert
|
||||||
|
message="Accepted without funding"
|
||||||
|
description="This proposal has been posted publicly, but isn't being funded by the Zcash Foundation."
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<Card size="small" className="ProposalDetail-controls">
|
<Card size="small" className="ProposalDetail-controls">
|
||||||
{renderCancelControl()}
|
{renderCancelControl()}
|
||||||
{renderArbiterControl()}
|
{renderArbiterControl()}
|
||||||
{renderBountyControl()}
|
{shouldShowChangeToAcceptedWithFunding &&
|
||||||
{renderMatchingControl()}
|
renderChangeToAcceptedWithFundingControl()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* DETAILS */}
|
{/* DETAILS */}
|
||||||
|
@ -447,13 +503,19 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
|
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
|
||||||
{renderDeetItem('status', p.status)}
|
{renderDeetItem('status', p.status)}
|
||||||
{renderDeetItem('stage', p.stage)}
|
{renderDeetItem('stage', p.stage)}
|
||||||
{renderDeetItem('category', p.category)}
|
{renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)}
|
||||||
{renderDeetItem('target', p.target)}
|
|
||||||
{renderDeetItem('contributed', p.contributed)}
|
{renderDeetItem('contributed', p.contributed)}
|
||||||
{renderDeetItem('funded (inc. matching)', p.funded)}
|
{renderDeetItem(
|
||||||
|
'funded (inc. matching)',
|
||||||
|
p.isVersionTwo ? formatUsd(p.funded) : p.funded,
|
||||||
|
)}
|
||||||
{renderDeetItem('matching', p.contributionMatching)}
|
{renderDeetItem('matching', p.contributionMatching)}
|
||||||
{renderDeetItem('bounty', p.contributionBounty)}
|
{renderDeetItem('bounty', p.contributionBounty)}
|
||||||
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
||||||
|
{renderDeetItem(
|
||||||
|
'acceptedWithFunding',
|
||||||
|
JSON.stringify(p.acceptedWithFunding),
|
||||||
|
)}
|
||||||
{renderDeetItem(
|
{renderDeetItem(
|
||||||
'arbiter',
|
'arbiter',
|
||||||
<>
|
<>
|
||||||
|
@ -508,6 +570,20 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleChangeToAcceptedWithFunding = () => {
|
||||||
|
this.setState({ showChangeToAcceptedWithFundingPopover: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleChangeToAcceptWithFundingCancel = () => {
|
||||||
|
this.setState({ showChangeToAcceptedWithFundingPopover: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleChangeToAcceptWithFundingConfirm = () => {
|
||||||
|
if (!store.proposalDetail) return;
|
||||||
|
store.changeProposalToAcceptedWithFunding(store.proposalDetail.proposalId);
|
||||||
|
this.setState({ showChangeToAcceptedWithFundingPopover: false });
|
||||||
|
};
|
||||||
|
|
||||||
private getIdFromQuery = () => {
|
private getIdFromQuery = () => {
|
||||||
return Number(this.props.match.params.id);
|
return Number(this.props.match.params.id);
|
||||||
};
|
};
|
||||||
|
@ -526,44 +602,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
this.setState({ showCancelAndRefundPopover: false });
|
this.setState({ showCancelAndRefundPopover: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleApprove = () => {
|
private handleApprove = (withFunding: boolean) => {
|
||||||
store.approveProposal(true);
|
store.approveProposal(true, withFunding);
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleReject = async (reason: string) => {
|
private handleReject = async (reason: string) => {
|
||||||
await store.approveProposal(false, reason);
|
await store.approveProposal(false, false, reason);
|
||||||
message.info('Proposal rejected');
|
message.info('Proposal changes requested');
|
||||||
};
|
|
||||||
|
|
||||||
private handleToggleMatching = async () => {
|
|
||||||
if (store.proposalDetail) {
|
|
||||||
// we lock this to be 1 or 0 for now, we may support more values later on
|
|
||||||
const contributionMatching =
|
|
||||||
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
|
|
||||||
await store.updateProposalDetail({ contributionMatching });
|
|
||||||
message.success('Updated matching');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSetBounty = async () => {
|
|
||||||
if (store.proposalDetail) {
|
|
||||||
FeedbackModal.open({
|
|
||||||
title: 'Set bounty?',
|
|
||||||
content:
|
|
||||||
'Set the bounty for this proposal. The bounty will count towards the funding goal.',
|
|
||||||
type: 'input',
|
|
||||||
inputProps: {
|
|
||||||
addonBefore: 'Amount',
|
|
||||||
addonAfter: 'ZEC',
|
|
||||||
placeholder: '1.5',
|
|
||||||
},
|
|
||||||
okText: 'Set bounty',
|
|
||||||
onOk: async contributionBounty => {
|
|
||||||
await store.updateProposalDetail({ contributionBounty });
|
|
||||||
message.success('Updated bounty');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private handlePaidMilestone = async () => {
|
private handlePaidMilestone = async () => {
|
||||||
|
|
|
@ -2,13 +2,14 @@ import React from 'react';
|
||||||
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 { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin } from 'antd';
|
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin, Alert } from 'antd';
|
||||||
import Exception from 'ant-design-pro/lib/Exception';
|
import Exception from 'ant-design-pro/lib/Exception';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import { formatDateSeconds } from 'util/time';
|
import { formatDateSeconds } from 'util/time';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { PROPOSAL_STATUS } from 'src/types';
|
import { PROPOSAL_STATUS } from 'src/types';
|
||||||
|
import { formatUsd } from 'src/util/formatters';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
type Props = RouteComponentProps<{ id?: string }>;
|
type Props = RouteComponentProps<{ id?: string }>;
|
||||||
|
@ -37,9 +38,11 @@ class RFPDetail extends React.Component<Props> {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING);
|
const pendingProposals = rfp.proposals.filter(
|
||||||
const acceptedProposals = rfp.proposals.filter(p =>
|
p => p.status === PROPOSAL_STATUS.PENDING,
|
||||||
p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED
|
);
|
||||||
|
const acceptedProposals = rfp.proposals.filter(
|
||||||
|
p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -66,6 +69,20 @@ class RFPDetail extends React.Component<Props> {
|
||||||
|
|
||||||
{/* RIGHT SIDE */}
|
{/* RIGHT SIDE */}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
|
{rfp.ccr && (
|
||||||
|
<Alert
|
||||||
|
message="Linked CCR"
|
||||||
|
description={
|
||||||
|
<React.Fragment>
|
||||||
|
This RFP has been generated from a CCR{' '}
|
||||||
|
<Link to={`/ccrs/${rfp.ccr.ccrId}`}>here</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<Card className="RFPDetail-actions" size="small">
|
<Card className="RFPDetail-actions" size="small">
|
||||||
<Link to={`/rfps/${rfp.id}/edit`}>
|
<Link to={`/rfps/${rfp.id}/edit`}>
|
||||||
|
@ -90,10 +107,15 @@ class RFPDetail extends React.Component<Props> {
|
||||||
{renderDeetItem('id', rfp.id)}
|
{renderDeetItem('id', rfp.id)}
|
||||||
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
|
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
|
||||||
{renderDeetItem('status', rfp.status)}
|
{renderDeetItem('status', rfp.status)}
|
||||||
{renderDeetItem('category', rfp.category)}
|
|
||||||
{renderDeetItem('matching', String(rfp.matching))}
|
{renderDeetItem('matching', String(rfp.matching))}
|
||||||
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
|
{renderDeetItem(
|
||||||
{renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
|
'bounty',
|
||||||
|
rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`,
|
||||||
|
)}
|
||||||
|
{renderDeetItem(
|
||||||
|
'dateCloses',
|
||||||
|
rfp.dateCloses && formatDateSeconds(rfp.dateCloses),
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* PROPOSALS */}
|
{/* PROPOSALS */}
|
||||||
|
|
|
@ -3,23 +3,10 @@ import 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 { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd';
|
||||||
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 { RFP_STATUS, RFPArgs } from 'src/types';
|
||||||
import { CATEGORY_UI } from 'util/ui';
|
|
||||||
import { typedKeys } from 'util/ts';
|
import { typedKeys } from 'util/ts';
|
||||||
import { RFP_STATUSES, getStatusById } from 'util/statuses';
|
import { RFP_STATUSES, getStatusById } from 'util/statuses';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
|
@ -54,13 +41,14 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
title: '',
|
title: '',
|
||||||
brief: '',
|
brief: '',
|
||||||
content: '',
|
content: '',
|
||||||
category: '',
|
|
||||||
status: '',
|
status: '',
|
||||||
matching: false,
|
matching: false,
|
||||||
bounty: undefined,
|
bounty: undefined,
|
||||||
dateCloses: undefined,
|
dateCloses: undefined,
|
||||||
};
|
};
|
||||||
const rfpId = this.getRFPId();
|
const rfpId = this.getRFPId();
|
||||||
|
let isVersionTwo = true;
|
||||||
|
|
||||||
if (rfpId) {
|
if (rfpId) {
|
||||||
if (!store.rfpsFetched) {
|
if (!store.rfpsFetched) {
|
||||||
return <Spin />;
|
return <Spin />;
|
||||||
|
@ -72,12 +60,12 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
title: rfp.title,
|
title: rfp.title,
|
||||||
brief: rfp.brief,
|
brief: rfp.brief,
|
||||||
content: rfp.content,
|
content: rfp.content,
|
||||||
category: rfp.category,
|
|
||||||
status: rfp.status,
|
status: rfp.status,
|
||||||
matching: rfp.matching,
|
matching: rfp.matching,
|
||||||
bounty: rfp.bounty,
|
bounty: rfp.bounty,
|
||||||
dateCloses: rfp.dateCloses || undefined,
|
dateCloses: rfp.dateCloses || undefined,
|
||||||
};
|
};
|
||||||
|
isVersionTwo = rfp.isVersionTwo;
|
||||||
} else {
|
} else {
|
||||||
return <Exception type="404" desc="This RFP does not exist" />;
|
return <Exception type="404" desc="This RFP does not exist" />;
|
||||||
}
|
}
|
||||||
|
@ -88,6 +76,10 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
|
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
|
||||||
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
|
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
|
||||||
|
|
||||||
|
const bountyMatchRule = isVersionTwo
|
||||||
|
? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' }
|
||||||
|
: {};
|
||||||
|
|
||||||
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" />
|
||||||
|
@ -131,28 +123,6 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form.Item label="Category">
|
|
||||||
{getFieldDecorator('category', {
|
|
||||||
initialValue: defaults.category,
|
|
||||||
rules: [
|
|
||||||
{ required: true, message: 'Category is required' },
|
|
||||||
{ max: 60, message: 'Max 60 chars' },
|
|
||||||
],
|
|
||||||
})(
|
|
||||||
<Select size="large" placeholder="Select a category">
|
|
||||||
{typedKeys(PROPOSAL_CATEGORY).map(c => (
|
|
||||||
<Select.Option value={c} key={c}>
|
|
||||||
<Icon
|
|
||||||
type={CATEGORY_UI[c].icon}
|
|
||||||
style={{ color: CATEGORY_UI[c].color }}
|
|
||||||
/>{' '}
|
|
||||||
{CATEGORY_UI[c].label}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="Brief description">
|
<Form.Item label="Brief description">
|
||||||
{getFieldDecorator('brief', {
|
{getFieldDecorator('brief', {
|
||||||
initialValue: defaults.brief,
|
initialValue: defaults.brief,
|
||||||
|
@ -199,26 +169,20 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
<Form.Item className="RFPForm-bounty" label="Bounty">
|
<Form.Item className="RFPForm-bounty" label="Bounty">
|
||||||
{getFieldDecorator('bounty', {
|
{getFieldDecorator('bounty', {
|
||||||
initialValue: defaults.bounty,
|
initialValue: defaults.bounty,
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'Bounty is required' },
|
||||||
|
bountyMatchRule,
|
||||||
|
],
|
||||||
})(
|
})(
|
||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
name="bounty"
|
name="bounty"
|
||||||
placeholder="100"
|
placeholder="1000"
|
||||||
addonAfter="ZEC"
|
addonBefore={isVersionTwo ? '$' : undefined}
|
||||||
|
addonAfter={isVersionTwo ? undefined : 'ZEC'}
|
||||||
size="large"
|
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>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col sm={12} xs={24}>
|
<Col sm={12} xs={24}>
|
||||||
|
|
|
@ -51,6 +51,12 @@ class Template extends React.Component<Props> {
|
||||||
<span className="nav-text">Proposals</span>
|
<span className="nav-text">Proposals</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="ccrs">
|
||||||
|
<Link to="/ccrs">
|
||||||
|
<Icon type="solution" />
|
||||||
|
<span className="nav-text">CCRs</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item key="rfps">
|
<Menu.Item key="rfps">
|
||||||
<Link to="/rfps">
|
<Link to="/rfps">
|
||||||
<Icon type="notification" />
|
<Icon type="notification" />
|
||||||
|
|
|
@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Proposal,
|
Proposal,
|
||||||
|
CCR,
|
||||||
Contribution,
|
Contribution,
|
||||||
ContributionArgs,
|
ContributionArgs,
|
||||||
RFP,
|
RFP,
|
||||||
|
@ -129,9 +130,15 @@ async function deleteProposal(id: number) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
|
async function approveProposal(
|
||||||
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
|
id: number,
|
||||||
isApprove,
|
isAccepted: boolean,
|
||||||
|
withFunding: boolean,
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
|
||||||
|
isAccepted,
|
||||||
|
withFunding,
|
||||||
rejectReason,
|
rejectReason,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
|
@ -142,6 +149,11 @@ async function cancelProposal(id: number) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeProposalToAcceptedWithFunding(id: number) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchComments(params: Partial<PageQuery>) {
|
async function fetchComments(params: Partial<PageQuery>) {
|
||||||
const { data } = await api.get('/admin/comments', { params });
|
const { data } = await api.get('/admin/comments', { params });
|
||||||
return data;
|
return data;
|
||||||
|
@ -165,6 +177,28 @@ async function getEmailExample(type: string) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCCRDetail(id: number) {
|
||||||
|
const { data } = await api.get(`/admin/ccrs/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string) {
|
||||||
|
const { data } = await api.put(`/admin/ccrs/${id}/accept`, {
|
||||||
|
isAccepted,
|
||||||
|
rejectReason,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCCRs(params: Partial<PageQuery>) {
|
||||||
|
const { data } = await api.get(`/admin/ccrs`, { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCCR(id: number) {
|
||||||
|
await api.delete(`/admin/ccrs/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function getRFPs() {
|
async function getRFPs() {
|
||||||
const { data } = await api.get(`/admin/rfps`);
|
const { data } = await api.get(`/admin/rfps`);
|
||||||
return data;
|
return data;
|
||||||
|
@ -218,6 +252,7 @@ const app = store({
|
||||||
stats: {
|
stats: {
|
||||||
userCount: 0,
|
userCount: 0,
|
||||||
proposalCount: 0,
|
proposalCount: 0,
|
||||||
|
ccrPendingCount: 0,
|
||||||
proposalPendingCount: 0,
|
proposalPendingCount: 0,
|
||||||
proposalNoArbiterCount: 0,
|
proposalNoArbiterCount: 0,
|
||||||
proposalMilestonePayoutsCount: 0,
|
proposalMilestonePayoutsCount: 0,
|
||||||
|
@ -282,6 +317,25 @@ const app = store({
|
||||||
proposalDetailCanceling: false,
|
proposalDetailCanceling: false,
|
||||||
proposalDetailUpdating: false,
|
proposalDetailUpdating: false,
|
||||||
proposalDetailUpdated: false,
|
proposalDetailUpdated: false,
|
||||||
|
proposalDetailChangingToAcceptedWithFunding: false,
|
||||||
|
|
||||||
|
ccrs: {
|
||||||
|
page: createDefaultPageData<CCR>('CREATED:DESC'),
|
||||||
|
},
|
||||||
|
ccrSaving: false,
|
||||||
|
ccrSaved: false,
|
||||||
|
ccrDeleting: false,
|
||||||
|
ccrDeleted: false,
|
||||||
|
|
||||||
|
ccrDetail: null as null | CCR,
|
||||||
|
ccrDetailFetching: false,
|
||||||
|
ccrDetailApproving: false,
|
||||||
|
ccrDetailMarkingMilestonePaid: false,
|
||||||
|
ccrDetailCanceling: false,
|
||||||
|
ccrDetailUpdating: false,
|
||||||
|
ccrDetailUpdated: false,
|
||||||
|
ccrDetailChangingToAcceptedWithFunding: false,
|
||||||
|
ccrCreatedRFPId: null,
|
||||||
|
|
||||||
comments: {
|
comments: {
|
||||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||||
|
@ -482,6 +536,53 @@ const app = store({
|
||||||
app.arbiterSaving = false;
|
app.arbiterSaving = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// CCRS
|
||||||
|
|
||||||
|
async fetchCCRs() {
|
||||||
|
return await pageFetch(app.ccrs, fetchCCRs);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCCRPageQuery(params: Partial<PageQuery>) {
|
||||||
|
setPageParams(app.ccrs, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetCCRPageQuery() {
|
||||||
|
resetPageParams(app.ccrs);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCCRDetail(id: number) {
|
||||||
|
app.ccrDetailFetching = true;
|
||||||
|
try {
|
||||||
|
app.ccrDetail = await fetchCCRDetail(id);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.ccrDetailFetching = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveCCR(isAccepted: boolean, rejectReason?: string) {
|
||||||
|
if (!app.ccrDetail) {
|
||||||
|
const m = 'store.approveCCR(): Expected ccrDetail to be populated!';
|
||||||
|
app.generalError.push(m);
|
||||||
|
console.error(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.ccrCreatedRFPId = null;
|
||||||
|
app.ccrDetailApproving = true;
|
||||||
|
try {
|
||||||
|
const { ccrId } = app.ccrDetail;
|
||||||
|
const res = await approveCCR(ccrId, isAccepted, rejectReason);
|
||||||
|
await app.fetchCCRs();
|
||||||
|
await app.fetchRFPs();
|
||||||
|
if (isAccepted) {
|
||||||
|
app.ccrCreatedRFPId = res.rfpId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.ccrDetailApproving = false;
|
||||||
|
},
|
||||||
|
|
||||||
// Proposals
|
// Proposals
|
||||||
|
|
||||||
async fetchProposals() {
|
async fetchProposals() {
|
||||||
|
@ -536,7 +637,11 @@ const app = store({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async approveProposal(isApprove: boolean, rejectReason?: string) {
|
async approveProposal(
|
||||||
|
isAccepted: boolean,
|
||||||
|
withFunding: boolean,
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
if (!app.proposalDetail) {
|
if (!app.proposalDetail) {
|
||||||
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||||
app.generalError.push(m);
|
app.generalError.push(m);
|
||||||
|
@ -546,7 +651,12 @@ const app = store({
|
||||||
app.proposalDetailApproving = true;
|
app.proposalDetailApproving = true;
|
||||||
try {
|
try {
|
||||||
const { proposalId } = app.proposalDetail;
|
const { proposalId } = app.proposalDetail;
|
||||||
const res = await approveProposal(proposalId, isApprove, rejectReason);
|
const res = await approveProposal(
|
||||||
|
proposalId,
|
||||||
|
isAccepted,
|
||||||
|
withFunding,
|
||||||
|
rejectReason,
|
||||||
|
);
|
||||||
app.updateProposalInStore(res);
|
app.updateProposalInStore(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e);
|
handleApiError(e);
|
||||||
|
@ -565,6 +675,19 @@ const app = store({
|
||||||
app.proposalDetailCanceling = false;
|
app.proposalDetailCanceling = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changeProposalToAcceptedWithFunding(id: number) {
|
||||||
|
app.proposalDetailChangingToAcceptedWithFunding = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await changeProposalToAcceptedWithFunding(id);
|
||||||
|
app.updateProposalInStore(res);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.proposalDetailChangingToAcceptedWithFunding = false;
|
||||||
|
},
|
||||||
|
|
||||||
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
||||||
app.proposalDetailMarkingMilestonePaid = true;
|
app.proposalDetailMarkingMilestonePaid = true;
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -17,7 +17,8 @@ export interface Milestone {
|
||||||
index: number;
|
index: number;
|
||||||
content: string;
|
content: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
dateEstimated: number;
|
dateEstimated?: number;
|
||||||
|
daysEstimated?: string;
|
||||||
dateRequested: number;
|
dateRequested: number;
|
||||||
dateAccepted: number;
|
dateAccepted: number;
|
||||||
dateRejected: number;
|
dateRejected: number;
|
||||||
|
@ -41,18 +42,18 @@ export interface RFP {
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
proposals: Proposal[];
|
proposals: Proposal[];
|
||||||
matching: boolean;
|
matching: boolean;
|
||||||
bounty: string | null;
|
bounty: string | null;
|
||||||
dateCloses: number | null;
|
dateCloses: number | null;
|
||||||
|
isVersionTwo: boolean;
|
||||||
|
ccr?: CCR;
|
||||||
}
|
}
|
||||||
export interface RFPArgs {
|
export interface RFPArgs {
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: string;
|
|
||||||
matching: boolean;
|
matching: boolean;
|
||||||
dateCloses: number | null | undefined;
|
dateCloses: number | null | undefined;
|
||||||
bounty: string | null | undefined;
|
bounty: string | null | undefined;
|
||||||
|
@ -102,7 +103,6 @@ export interface Proposal {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
stage: PROPOSAL_STAGE;
|
stage: PROPOSAL_STAGE;
|
||||||
category: string;
|
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
currentMilestone?: Milestone;
|
currentMilestone?: Milestone;
|
||||||
team: User[];
|
team: User[];
|
||||||
|
@ -116,6 +116,8 @@ export interface Proposal {
|
||||||
rfpOptIn: null | boolean;
|
rfpOptIn: null | boolean;
|
||||||
rfp?: RFP;
|
rfp?: RFP;
|
||||||
arbiter: ProposalArbiter;
|
arbiter: ProposalArbiter;
|
||||||
|
acceptedWithFunding: boolean | null;
|
||||||
|
isVersionTwo: boolean;
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -199,6 +201,30 @@ export enum PROPOSAL_CATEGORY {
|
||||||
ACCESSIBILITY = 'ACCESSIBILITY',
|
ACCESSIBILITY = 'ACCESSIBILITY',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CCR_STATUS {
|
||||||
|
DRAFT = 'DRAFT',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
APPROVED = 'APPROVED',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
LIVE = 'LIVE',
|
||||||
|
DELETED = 'DELETED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCR {
|
||||||
|
ccrId: number;
|
||||||
|
brief: string;
|
||||||
|
status: CCR_STATUS;
|
||||||
|
dateCreated: number;
|
||||||
|
dateApproved: number;
|
||||||
|
datePublished: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
target: string;
|
||||||
|
rejectReason: string;
|
||||||
|
rfp?: RFP;
|
||||||
|
author: User;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageQuery {
|
export interface PageQuery {
|
||||||
page: number;
|
page: number;
|
||||||
filters: string[];
|
filters: string[];
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
PROPOSAL_ARBITER_STATUSES,
|
PROPOSAL_ARBITER_STATUSES,
|
||||||
MILESTONE_STAGES,
|
MILESTONE_STAGES,
|
||||||
PROPOSAL_STAGES,
|
PROPOSAL_STAGES,
|
||||||
|
CCR_STATUSES,
|
||||||
} from './statuses';
|
} from './statuses';
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
@ -59,7 +60,21 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
|
||||||
color: s.tagColor,
|
color: s.tagColor,
|
||||||
group: 'Milestone',
|
group: 'Milestone',
|
||||||
})),
|
})),
|
||||||
);
|
)
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
id: 'ACCEPTED_WITH_FUNDING',
|
||||||
|
display: 'Accepted With Funding',
|
||||||
|
color: '#2D2A26',
|
||||||
|
group: 'Funding',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACCEPTED_WITHOUT_FUNDING',
|
||||||
|
display: 'Accepted Without Funding',
|
||||||
|
color: '#108ee9',
|
||||||
|
group: 'Funding',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const proposalFilters: Filters = {
|
export const proposalFilters: Filters = {
|
||||||
list: PROPOSAL_FILTERS,
|
list: PROPOSAL_FILTERS,
|
||||||
|
@ -80,6 +95,20 @@ export const rfpFilters: Filters = {
|
||||||
getById: getFilterById(RFP_FILTERS),
|
getById: getFilterById(RFP_FILTERS),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CCR
|
||||||
|
|
||||||
|
const CCR_FILTERS = CCR_STATUSES.map(c => ({
|
||||||
|
id: `STATUS_${c.id}`,
|
||||||
|
display: `Status: ${c.tagDisplay}`,
|
||||||
|
color: c.tagColor,
|
||||||
|
group: 'Status',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ccrFilters: Filters = {
|
||||||
|
list: CCR_FILTERS,
|
||||||
|
getById: getFilterById(CCR_FILTERS),
|
||||||
|
};
|
||||||
|
|
||||||
// Contribution
|
// Contribution
|
||||||
|
|
||||||
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
||||||
|
@ -87,17 +116,20 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
||||||
display: `Status: ${s.tagDisplay}`,
|
display: `Status: ${s.tagDisplay}`,
|
||||||
color: s.tagColor,
|
color: s.tagColor,
|
||||||
group: 'Status',
|
group: 'Status',
|
||||||
})).concat([{
|
})).concat([
|
||||||
id: 'REFUNDABLE',
|
{
|
||||||
display: 'Refundable',
|
id: 'REFUNDABLE',
|
||||||
color: '#afd500',
|
display: 'Refundable',
|
||||||
group: 'Refundable',
|
color: '#afd500',
|
||||||
}, {
|
group: 'Refundable',
|
||||||
id: 'DONATION',
|
},
|
||||||
display: 'Donations',
|
{
|
||||||
color: '#afd500',
|
id: 'DONATION',
|
||||||
group: 'Donations',
|
display: 'Donations',
|
||||||
}]);
|
color: '#afd500',
|
||||||
|
group: 'Donations',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const contributionFilters: Filters = {
|
export const contributionFilters: Filters = {
|
||||||
list: CONTRIBUTION_FILTERS,
|
list: CONTRIBUTION_FILTERS,
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
|
||||||
|
const toFixed = (num: string, digits: number = 3) => {
|
||||||
|
const [integerPart, fractionPart = ''] = num.split('.');
|
||||||
|
if (fractionPart.length === digits) {
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
if (fractionPart.length < digits) {
|
||||||
|
return `${integerPart}.${fractionPart.padEnd(digits, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decimalPoint = integerPart.length;
|
||||||
|
|
||||||
|
const formattedFraction = fractionPart.slice(0, digits);
|
||||||
|
|
||||||
|
const integerArr = `${integerPart}${formattedFraction}`.split('').map(str => +str);
|
||||||
|
|
||||||
|
let carryOver = Math.floor((+fractionPart[digits] + 5) / 10);
|
||||||
|
|
||||||
|
// grade school addition / rounding
|
||||||
|
for (let i = integerArr.length - 1; i >= 0; i--) {
|
||||||
|
const currVal = integerArr[i] + carryOver;
|
||||||
|
const newVal = currVal % 10;
|
||||||
|
carryOver = Math.floor(currVal / 10);
|
||||||
|
integerArr[i] = newVal;
|
||||||
|
if (i === 0 && carryOver > 0) {
|
||||||
|
integerArr.unshift(0);
|
||||||
|
decimalPoint++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strArr = integerArr.map(n => n.toString());
|
||||||
|
|
||||||
|
strArr.splice(decimalPoint, 0, '.');
|
||||||
|
|
||||||
|
if (strArr[strArr.length - 1] === '.') {
|
||||||
|
strArr.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return strArr.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatNumber(num: string, digits?: number): string {
|
||||||
|
const parts = toFixed(num, digits).split('.');
|
||||||
|
|
||||||
|
// Remove trailing zeroes on decimal (If there is a decimal)
|
||||||
|
if (parts[1]) {
|
||||||
|
parts[1] = parts[1].replace(/0+$/, '');
|
||||||
|
|
||||||
|
// If there's nothing left, remove decimal altogether
|
||||||
|
if (!parts[1]) {
|
||||||
|
parts.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commafy the whole numbers
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|
||||||
|
return parts.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsd(
|
||||||
|
amount: number | string | undefined | null,
|
||||||
|
includeDollarSign: boolean = true,
|
||||||
|
digits: number = 0,
|
||||||
|
) {
|
||||||
|
if (!amount) return includeDollarSign ? '$0' : '0';
|
||||||
|
const a = typeof amount === 'number' ? amount.toString() : amount;
|
||||||
|
const str = formatNumber(a, digits);
|
||||||
|
return includeDollarSign ? `$${str}` : str;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
PROPOSAL_STATUS,
|
PROPOSAL_STATUS,
|
||||||
|
CCR_STATUS,
|
||||||
RFP_STATUS,
|
RFP_STATUS,
|
||||||
CONTRIBUTION_STATUS,
|
CONTRIBUTION_STATUS,
|
||||||
PROPOSAL_ARBITER_STATUS,
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
@ -48,6 +49,46 @@ export const MILESTONE_STAGES: Array<StatusSoT<MILESTONE_STAGE>> = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const CCR_STATUSES: Array<StatusSoT<CCR_STATUS>> = [
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.APPROVED,
|
||||||
|
tagDisplay: 'Approved',
|
||||||
|
tagColor: '#afd500',
|
||||||
|
hint: 'Request has been approved and is awaiting being published by user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.DELETED,
|
||||||
|
tagDisplay: 'Deleted',
|
||||||
|
tagColor: '#bebebe',
|
||||||
|
hint: 'Request has been deleted and is not visible on the platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.DRAFT,
|
||||||
|
tagDisplay: 'Draft',
|
||||||
|
tagColor: '#8d8d8d',
|
||||||
|
hint: 'Request is being created by the user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.LIVE,
|
||||||
|
tagDisplay: 'Live',
|
||||||
|
tagColor: '#108ee9',
|
||||||
|
hint: 'Request is live on the platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.PENDING,
|
||||||
|
tagDisplay: 'Awaiting Approval',
|
||||||
|
tagColor: '#ffaa00',
|
||||||
|
hint: 'User is waiting for admin to approve or request changes to this Request.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.REJECTED,
|
||||||
|
tagDisplay: 'Changes Requested',
|
||||||
|
tagColor: '#eb4118',
|
||||||
|
hint:
|
||||||
|
'Admin has requested changes for this Request. User may adjust it and resubmit for approval.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.APPROVED,
|
id: PROPOSAL_STATUS.APPROVED,
|
||||||
|
@ -77,14 +118,14 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
id: PROPOSAL_STATUS.PENDING,
|
id: PROPOSAL_STATUS.PENDING,
|
||||||
tagDisplay: 'Awaiting Approval',
|
tagDisplay: 'Awaiting Approval',
|
||||||
tagColor: '#ffaa00',
|
tagColor: '#ffaa00',
|
||||||
hint: 'User is waiting for admin to approve or reject this Proposal.',
|
hint: 'User is waiting for admin to approve or request changes to this Proposal.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.REJECTED,
|
id: PROPOSAL_STATUS.REJECTED,
|
||||||
tagDisplay: 'Approval Rejected',
|
tagDisplay: 'Changes Requested',
|
||||||
tagColor: '#eb4118',
|
tagColor: '#eb4118',
|
||||||
hint:
|
hint:
|
||||||
'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
|
'Admin has requested changes for this proposal. User may adjust it and resubmit for approval.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.STAKING,
|
id: PROPOSAL_STATUS.STAKING,
|
||||||
|
|
|
@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
|
||||||
PROPOSAL_STAKING_AMOUNT=0.025
|
PROPOSAL_STAKING_AMOUNT=0.025
|
||||||
|
|
||||||
# Maximum amount for a proposal target, keep in sync with frontend .env
|
# Maximum amount for a proposal target, keep in sync with frontend .env
|
||||||
PROPOSAL_TARGET_MAX=10000
|
PROPOSAL_TARGET_MAX=999999
|
||||||
|
|
|
@ -69,6 +69,10 @@ To run all tests, run
|
||||||
|
|
||||||
flask test
|
flask test
|
||||||
|
|
||||||
|
To run only select test, Flask allows you to match against the test filename with ``-t` like so:
|
||||||
|
|
||||||
|
flask test -t proposal
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Whenever a database migration needs to be made. Run the following commands
|
Whenever a database migration needs to be made. Run the following commands
|
||||||
|
|
|
@ -149,6 +149,10 @@ example_email_args = {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
},
|
},
|
||||||
|
'milestone_deadline': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
|
},
|
||||||
'milestone_reject': {
|
'milestone_reject': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
|
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
|
||||||
|
@ -178,4 +182,13 @@ example_email_args = {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||||
},
|
},
|
||||||
|
'followed_proposal_milestone': {
|
||||||
|
"proposal": proposal,
|
||||||
|
"milestone": milestone,
|
||||||
|
"proposal_url": "http://someproposal.com",
|
||||||
|
},
|
||||||
|
'followed_proposal_update': {
|
||||||
|
"proposal": proposal,
|
||||||
|
"proposal_url": "http://someproposal.com",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ from sqlalchemy import func, or_, text
|
||||||
|
|
||||||
import grant.utils.admin as admin
|
import grant.utils.admin as admin
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
|
from grant.ccr.models import CCR, ccrs_schema, ccr_schema
|
||||||
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
|
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_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
|
||||||
|
@ -26,7 +27,6 @@ from grant.proposal.models import (
|
||||||
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.user.models import User, UserSettings, admin_users_schema, admin_user_schema
|
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from grant.utils.enums import Category
|
|
||||||
from grant.utils.enums import (
|
from grant.utils.enums import (
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
ProposalStage,
|
ProposalStage,
|
||||||
|
@ -34,6 +34,7 @@ from grant.utils.enums import (
|
||||||
ProposalArbiterStatus,
|
ProposalArbiterStatus,
|
||||||
MilestoneStage,
|
MilestoneStage,
|
||||||
RFPStatus,
|
RFPStatus,
|
||||||
|
CCRStatus
|
||||||
)
|
)
|
||||||
from grant.utils.misc import make_url, make_explore_url
|
from grant.utils.misc import make_url, make_explore_url
|
||||||
from .example_emails import example_email_args
|
from .example_emails import example_email_args
|
||||||
|
@ -137,6 +138,9 @@ def logout():
|
||||||
def stats():
|
def stats():
|
||||||
user_count = db.session.query(func.count(User.id)).scalar()
|
user_count = db.session.query(func.count(User.id)).scalar()
|
||||||
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
||||||
|
ccr_pending_count = db.session.query(func.count(CCR.id)) \
|
||||||
|
.filter(CCR.status == CCRStatus.PENDING) \
|
||||||
|
.scalar()
|
||||||
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
|
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
|
||||||
.filter(Proposal.status == ProposalStatus.PENDING) \
|
.filter(Proposal.status == ProposalStatus.PENDING) \
|
||||||
.scalar()
|
.scalar()
|
||||||
|
@ -145,6 +149,7 @@ def stats():
|
||||||
.filter(Proposal.status == ProposalStatus.LIVE) \
|
.filter(Proposal.status == ProposalStatus.LIVE) \
|
||||||
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
||||||
.filter(Proposal.stage != ProposalStage.CANCELED) \
|
.filter(Proposal.stage != ProposalStage.CANCELED) \
|
||||||
|
.filter(Proposal.accepted_with_funding == True) \
|
||||||
.scalar()
|
.scalar()
|
||||||
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
|
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
|
||||||
.join(Proposal.milestones) \
|
.join(Proposal.milestones) \
|
||||||
|
@ -159,15 +164,16 @@ def stats():
|
||||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||||
.join(Proposal) \
|
.join(Proposal) \
|
||||||
.filter(or_(
|
.filter(or_(
|
||||||
Proposal.stage == ProposalStage.FAILED,
|
Proposal.stage == ProposalStage.FAILED,
|
||||||
Proposal.stage == ProposalStage.CANCELED,
|
Proposal.stage == ProposalStage.CANCELED,
|
||||||
)) \
|
)) \
|
||||||
.join(ProposalContribution.user) \
|
.join(ProposalContribution.user) \
|
||||||
.join(UserSettings) \
|
.join(UserSettings) \
|
||||||
.filter(UserSettings.refund_address != None) \
|
.filter(UserSettings.refund_address != None) \
|
||||||
.scalar()
|
.scalar()
|
||||||
return {
|
return {
|
||||||
"userCount": user_count,
|
"userCount": user_count,
|
||||||
|
"ccrPendingCount": ccr_pending_count,
|
||||||
"proposalCount": proposal_count,
|
"proposalCount": proposal_count,
|
||||||
"proposalPendingCount": proposal_pending_count,
|
"proposalPendingCount": proposal_pending_count,
|
||||||
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
||||||
|
@ -313,9 +319,9 @@ def set_arbiter(proposal_id, user_id):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'proposal': proposal_schema.dump(proposal),
|
'proposal': proposal_schema.dump(proposal),
|
||||||
'user': admin_user_schema.dump(user)
|
'user': admin_user_schema.dump(user)
|
||||||
}, 200
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
# PROPOSALS
|
# PROPOSALS
|
||||||
|
@ -352,45 +358,48 @@ def delete_proposal(id):
|
||||||
return {"message": "Not implemented."}, 400
|
return {"message": "Not implemented."}, 400
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>', methods=['PUT'])
|
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
|
||||||
@body({
|
@body({
|
||||||
"contributionMatching": fields.Int(required=False, missing=None),
|
"isAccepted": fields.Bool(required=True),
|
||||||
"contributionBounty": fields.Str(required=False, missing=None)
|
"withFunding": fields.Bool(required=True),
|
||||||
})
|
|
||||||
@admin.admin_auth_required
|
|
||||||
def update_proposal(id, contribution_matching, contribution_bounty):
|
|
||||||
proposal = Proposal.query.filter(Proposal.id == id).first()
|
|
||||||
if not proposal:
|
|
||||||
return {"message": f"Could not find proposal with id {id}"}, 404
|
|
||||||
|
|
||||||
if contribution_matching is not None:
|
|
||||||
proposal.set_contribution_matching(contribution_matching)
|
|
||||||
|
|
||||||
if contribution_bounty is not None:
|
|
||||||
proposal.set_contribution_bounty(contribution_bounty)
|
|
||||||
|
|
||||||
db.session.add(proposal)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return proposal_schema.dump(proposal)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
|
|
||||||
@body({
|
|
||||||
"isApprove": fields.Bool(required=True),
|
|
||||||
"rejectReason": fields.Str(required=False, missing=None)
|
"rejectReason": fields.Str(required=False, missing=None)
|
||||||
})
|
})
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def approve_proposal(id, is_approve, reject_reason=None):
|
def approve_proposal(id, is_accepted, with_funding, reject_reason=None):
|
||||||
proposal = Proposal.query.filter_by(id=id).first()
|
proposal = Proposal.query.filter_by(id=id).first()
|
||||||
if proposal:
|
if proposal:
|
||||||
proposal.approve_pending(is_approve, reject_reason)
|
proposal.approve_pending(is_accepted, with_funding, reject_reason)
|
||||||
|
|
||||||
|
if is_accepted and with_funding:
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return proposal_schema.dump(proposal)
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
return {"message": "No proposal found."}, 404
|
return {"message": "No proposal found."}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/proposals/<id>/accept/fund', methods=['PUT'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def change_proposal_to_accepted_with_funding(id):
|
||||||
|
proposal = Proposal.query.filter_by(id=id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal found."}, 404
|
||||||
|
if proposal.accepted_with_funding:
|
||||||
|
return {"message": "Proposal already accepted with funding."}, 404
|
||||||
|
if proposal.version != '2':
|
||||||
|
return {"message": "Only version two proposals can be accepted with funding"}, 404
|
||||||
|
if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.APPROVED:
|
||||||
|
return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404
|
||||||
|
|
||||||
|
proposal.update_proposal_with_funding()
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
|
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def cancel_proposal(id):
|
def cancel_proposal(id):
|
||||||
|
@ -417,12 +426,14 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
||||||
return {"message": "Proposal is not fully funded"}, 400
|
return {"message": "Proposal is not fully funded"}, 400
|
||||||
for ms in proposal.milestones:
|
for ms in proposal.milestones:
|
||||||
if ms.id == int(mid):
|
if ms.id == int(mid):
|
||||||
|
is_final_milestone = False
|
||||||
ms.mark_paid(tx_id)
|
ms.mark_paid(tx_id)
|
||||||
db.session.add(ms)
|
db.session.add(ms)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
# check if this is the final ms, and update proposal.stage
|
# check if this is the final ms, and update proposal.stage
|
||||||
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
|
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
|
||||||
if num_paid == len(proposal.milestones):
|
if num_paid == len(proposal.milestones):
|
||||||
|
is_final_milestone = True
|
||||||
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
|
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
|
||||||
db.session.add(proposal)
|
db.session.add(proposal)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
@ -437,6 +448,18 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
||||||
'tx_explorer_url': make_explore_url(tx_id),
|
'tx_explorer_url': make_explore_url(tx_id),
|
||||||
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# email FOLLOWERS that milestone was accepted
|
||||||
|
proposal.send_follower_email(
|
||||||
|
"followed_proposal_milestone",
|
||||||
|
email_args={"milestone": ms},
|
||||||
|
url_suffix="?tab=milestones",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_final_milestone:
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
return proposal_schema.dump(proposal), 200
|
return proposal_schema.dump(proposal), 200
|
||||||
|
|
||||||
return {"message": "No milestone matching id"}, 404
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
@ -455,6 +478,64 @@ def get_email_example(type):
|
||||||
return email
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
# CCRs
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/ccrs", methods=["GET"])
|
||||||
|
@query(paginated_fields)
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def get_ccrs(page, filters, search, sort):
|
||||||
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
|
page = pagination.ccr(
|
||||||
|
schema=ccrs_schema,
|
||||||
|
query=CCR.query,
|
||||||
|
page=page,
|
||||||
|
filters=filters_workaround,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<ccr_id>', methods=['DELETE'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def delete_ccr(ccr_id):
|
||||||
|
ccr = CCR.query.filter(CCR.id == ccr_id).first()
|
||||||
|
if not ccr:
|
||||||
|
return {"message": "No CCR matching that id"}, 404
|
||||||
|
|
||||||
|
db.session.delete(ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<id>', methods=['GET'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def get_ccr(id):
|
||||||
|
ccr = CCR.query.filter(CCR.id == id).first()
|
||||||
|
if ccr:
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
return {"message": f"Could not find ccr with id {id}"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<ccr_id>/accept', methods=['PUT'])
|
||||||
|
@body({
|
||||||
|
"isAccepted": fields.Bool(required=True),
|
||||||
|
"rejectReason": fields.Str(required=False, missing=None)
|
||||||
|
})
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def approve_ccr(ccr_id, is_accepted, reject_reason=None):
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if ccr:
|
||||||
|
rfp_id = ccr.approve_pending(is_accepted, reject_reason)
|
||||||
|
if is_accepted:
|
||||||
|
return {"rfpId": rfp_id}, 201
|
||||||
|
else:
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
|
||||||
|
return {"message": "No CCR found."}, 404
|
||||||
|
|
||||||
|
|
||||||
# Requests for Proposal
|
# Requests for Proposal
|
||||||
|
|
||||||
|
|
||||||
|
@ -470,7 +551,6 @@ def get_rfps():
|
||||||
"title": fields.Str(required=True),
|
"title": fields.Str(required=True),
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"content": fields.Str(required=True),
|
"content": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
|
|
||||||
"bounty": fields.Str(required=False, missing=0),
|
"bounty": fields.Str(required=False, missing=0),
|
||||||
"matching": fields.Bool(required=False, missing=False),
|
"matching": fields.Bool(required=False, missing=False),
|
||||||
"dateCloses": fields.Int(required=False, missing=None)
|
"dateCloses": fields.Int(required=False, missing=None)
|
||||||
|
@ -502,13 +582,12 @@ def get_rfp(rfp_id):
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
|
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
|
||||||
"content": fields.Str(required=True),
|
"content": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
|
|
||||||
"bounty": fields.Str(required=False, allow_none=True, missing=None),
|
"bounty": fields.Str(required=False, allow_none=True, missing=None),
|
||||||
"matching": fields.Bool(required=False, default=False, missing=False),
|
"matching": fields.Bool(required=False, default=False, missing=False),
|
||||||
"dateCloses": fields.Int(required=False, missing=None),
|
"dateCloses": fields.Int(required=False, missing=None),
|
||||||
})
|
})
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status):
|
def update_rfp(rfp_id, title, brief, content, 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
|
||||||
|
@ -517,7 +596,6 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c
|
||||||
rfp.title = title
|
rfp.title = title
|
||||||
rfp.brief = brief
|
rfp.brief = brief
|
||||||
rfp.content = content
|
rfp.content = content
|
||||||
rfp.category = category
|
|
||||||
rfp.matching = matching
|
rfp.matching = matching
|
||||||
rfp.bounty = bounty
|
rfp.bounty = bounty
|
||||||
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
|
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
|
||||||
|
@ -587,8 +665,8 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||||
|
@ -660,8 +738,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||||
|
@ -711,7 +789,6 @@ def edit_comment(comment_id, hidden, reported):
|
||||||
@blueprint.route("/financials", methods=["GET"])
|
@blueprint.route("/financials", methods=["GET"])
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def financials():
|
def financials():
|
||||||
|
|
||||||
nfmt = '999999.99999999' # smallest unit of ZEC
|
nfmt = '999999.99999999' # smallest unit of ZEC
|
||||||
|
|
||||||
def sql_pc(where: str):
|
def sql_pc(where: str):
|
||||||
|
@ -743,7 +820,8 @@ def financials():
|
||||||
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
||||||
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
|
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
|
||||||
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
|
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
|
||||||
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
|
'funded': str(
|
||||||
|
ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
|
||||||
# should have a refund_address
|
# should have a refund_address
|
||||||
'refunding': str(ex(sql_pc_p(
|
'refunding': str(ex(sql_pc_p(
|
||||||
'''
|
'''
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""The app module, containing the app factory function."""
|
"""The app module, containing the app factory function."""
|
||||||
import sentry_sdk
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
from animal_case import animalify
|
from animal_case import animalify
|
||||||
from flask import Flask, Response, jsonify, request, current_app, g
|
from flask import Flask, Response, jsonify, request, current_app, g
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore
|
||||||
from flask_sslify import SSLify
|
from flask_sslify import SSLify
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e
|
from grant import (
|
||||||
|
commands,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
ccr,
|
||||||
|
comment,
|
||||||
|
milestone,
|
||||||
|
admin,
|
||||||
|
email,
|
||||||
|
blockchain,
|
||||||
|
task,
|
||||||
|
rfp,
|
||||||
|
e2e,
|
||||||
|
home
|
||||||
|
)
|
||||||
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
||||||
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
||||||
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
||||||
|
@ -129,6 +144,7 @@ def register_extensions(app):
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register Flask blueprints."""
|
"""Register Flask blueprints."""
|
||||||
|
app.register_blueprint(ccr.views.blueprint)
|
||||||
app.register_blueprint(comment.views.blueprint)
|
app.register_blueprint(comment.views.blueprint)
|
||||||
app.register_blueprint(proposal.views.blueprint)
|
app.register_blueprint(proposal.views.blueprint)
|
||||||
app.register_blueprint(user.views.blueprint)
|
app.register_blueprint(user.views.blueprint)
|
||||||
|
@ -138,6 +154,7 @@ def register_blueprints(app):
|
||||||
app.register_blueprint(blockchain.views.blueprint)
|
app.register_blueprint(blockchain.views.blueprint)
|
||||||
app.register_blueprint(task.views.blueprint)
|
app.register_blueprint(task.views.blueprint)
|
||||||
app.register_blueprint(rfp.views.blueprint)
|
app.register_blueprint(rfp.views.blueprint)
|
||||||
|
app.register_blueprint(home.views.blueprint)
|
||||||
if E2E_TESTING and DEBUG:
|
if E2E_TESTING and DEBUG:
|
||||||
print('Warning: e2e end-points are open, this should only be the case for development or testing')
|
print('Warning: e2e end-points are open, this should only be the case for development or testing')
|
||||||
app.register_blueprint(e2e.views.blueprint)
|
app.register_blueprint(e2e.views.blueprint)
|
||||||
|
@ -162,5 +179,7 @@ def register_commands(app):
|
||||||
app.cli.add_command(commands.reset_db_chain_data)
|
app.cli.add_command(commands.reset_db_chain_data)
|
||||||
app.cli.add_command(proposal.commands.create_proposal)
|
app.cli.add_command(proposal.commands.create_proposal)
|
||||||
app.cli.add_command(proposal.commands.create_proposals)
|
app.cli.add_command(proposal.commands.create_proposals)
|
||||||
|
app.cli.add_command(proposal.commands.retire_v1_proposals)
|
||||||
app.cli.add_command(user.commands.set_admin)
|
app.cli.add_command(user.commands.set_admin)
|
||||||
|
app.cli.add_command(user.commands.mangle_users)
|
||||||
app.cli.add_command(task.commands.create_task)
|
app.cli.add_command(task.commands.create_task)
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import models
|
||||||
|
from . import views
|
|
@ -0,0 +1,230 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
|
||||||
|
from grant.email.send import send_email
|
||||||
|
from grant.extensions import ma, db
|
||||||
|
from grant.utils.enums import CCRStatus
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from grant.utils.misc import make_admin_url, gen_random_id, dt_to_unix
|
||||||
|
|
||||||
|
|
||||||
|
def default_content():
|
||||||
|
return """# Overview
|
||||||
|
|
||||||
|
What you think should be accomplished
|
||||||
|
|
||||||
|
|
||||||
|
# Approach
|
||||||
|
|
||||||
|
How you expect a proposing team to accomplish your request
|
||||||
|
|
||||||
|
|
||||||
|
# Deliverable
|
||||||
|
|
||||||
|
The end result of a proposal the fulfills this request
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CCR(db.Model):
|
||||||
|
__tablename__ = "ccr"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
date_created = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
title = db.Column(db.String(255), nullable=True)
|
||||||
|
brief = db.Column(db.String(255), nullable=True)
|
||||||
|
content = db.Column(db.Text, nullable=True)
|
||||||
|
status = db.Column(db.String(255), nullable=False)
|
||||||
|
_target = db.Column("target", db.String(255), nullable=True)
|
||||||
|
reject_reason = db.Column(db.String())
|
||||||
|
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||||
|
author = db.relationship("User", back_populates="ccrs")
|
||||||
|
|
||||||
|
rfp_id = db.Column(db.Integer, db.ForeignKey("rfp.id"), nullable=True)
|
||||||
|
rfp = db.relationship("RFP", back_populates="ccr")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_user(user, statuses=[CCRStatus.LIVE]):
|
||||||
|
status_filter = or_(CCR.status == v for v in statuses)
|
||||||
|
return CCR.query \
|
||||||
|
.filter(CCR.user_id == user.id) \
|
||||||
|
.filter(status_filter) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(**kwargs):
|
||||||
|
ccr = CCR(
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
db.session.add(ccr)
|
||||||
|
db.session.flush()
|
||||||
|
return ccr
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def target(self):
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
@target.setter
|
||||||
|
def target(self, target: str):
|
||||||
|
if target and Decimal(target) > 0:
|
||||||
|
self._target = target
|
||||||
|
else:
|
||||||
|
self._target = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
content: str = default_content(),
|
||||||
|
target: str = '0',
|
||||||
|
status: str = CCRStatus.DRAFT,
|
||||||
|
):
|
||||||
|
assert CCRStatus.includes(status)
|
||||||
|
self.id = gen_random_id(CCR)
|
||||||
|
self.date_created = datetime.now()
|
||||||
|
self.title = title[:255]
|
||||||
|
self.brief = brief[:255]
|
||||||
|
self.content = content
|
||||||
|
self.target = target
|
||||||
|
self.status = status
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
content: str = '',
|
||||||
|
target: str = '0',
|
||||||
|
):
|
||||||
|
self.title = title[:255]
|
||||||
|
self.brief = brief[:255]
|
||||||
|
self.content = content[:300000]
|
||||||
|
self._target = target[:255] if target != '' and target else '0'
|
||||||
|
|
||||||
|
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
||||||
|
def submit_for_approval(self):
|
||||||
|
self.validate_publishable()
|
||||||
|
allowed_statuses = [CCRStatus.DRAFT, CCRStatus.REJECTED]
|
||||||
|
# specific validation
|
||||||
|
if self.status not in allowed_statuses:
|
||||||
|
raise ValidationException(f"CCR status must be draft or rejected to submit for approval")
|
||||||
|
self.set_pending()
|
||||||
|
|
||||||
|
def send_admin_email(self, type: str):
|
||||||
|
from grant.user.models import User
|
||||||
|
admins = User.get_admins()
|
||||||
|
for a in admins:
|
||||||
|
send_email(a.email_address, type, {
|
||||||
|
'user': a,
|
||||||
|
'ccr': self,
|
||||||
|
'ccr_url': make_admin_url(f'/ccrs/{self.id}'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# state: status DRAFT -> PENDING
|
||||||
|
def set_pending(self):
|
||||||
|
self.send_admin_email('admin_approval_ccr')
|
||||||
|
self.status = CCRStatus.PENDING
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def validate_publishable(self):
|
||||||
|
# Require certain fields
|
||||||
|
required_fields = ['title', 'content', 'brief', 'target']
|
||||||
|
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")
|
||||||
|
|
||||||
|
# state: status PENDING -> (LIVE || REJECTED)
|
||||||
|
def approve_pending(self, is_approve, reject_reason=None):
|
||||||
|
from grant.rfp.models import RFP
|
||||||
|
self.validate_publishable()
|
||||||
|
# specific validation
|
||||||
|
if not self.status == CCRStatus.PENDING:
|
||||||
|
raise ValidationException(f"CCR must be pending to approve or reject")
|
||||||
|
|
||||||
|
if is_approve:
|
||||||
|
self.status = CCRStatus.LIVE
|
||||||
|
rfp = RFP(
|
||||||
|
title=self.title,
|
||||||
|
brief=self.brief,
|
||||||
|
content=self.content,
|
||||||
|
bounty=self._target,
|
||||||
|
date_closes=datetime.now() + timedelta(days=90),
|
||||||
|
)
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.flush()
|
||||||
|
self.rfp_id = rfp.id
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# for emails
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
send_email(self.author.email_address, 'ccr_approved', {
|
||||||
|
'user': self.author,
|
||||||
|
'ccr': self,
|
||||||
|
'admin_note': f'Congratulations! Your Request has been accepted. There may be a delay between acceptance and final posting as required by the Zcash Foundation.'
|
||||||
|
})
|
||||||
|
return rfp.id
|
||||||
|
else:
|
||||||
|
if not reject_reason:
|
||||||
|
raise ValidationException("Please provide a reason for rejecting the ccr")
|
||||||
|
self.status = CCRStatus.REJECTED
|
||||||
|
self.reject_reason = reject_reason
|
||||||
|
# for emails
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
send_email(self.author.email_address, 'ccr_rejected', {
|
||||||
|
'user': self.author,
|
||||||
|
'ccr': self,
|
||||||
|
'admin_note': reject_reason
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CCRSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = CCR
|
||||||
|
# Fields to expose
|
||||||
|
fields = (
|
||||||
|
"author",
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"brief",
|
||||||
|
"ccr_id",
|
||||||
|
"content",
|
||||||
|
"status",
|
||||||
|
"target",
|
||||||
|
"date_created",
|
||||||
|
"reject_reason",
|
||||||
|
"rfp"
|
||||||
|
)
|
||||||
|
|
||||||
|
rfp = ma.Nested("RFPSchema")
|
||||||
|
date_created = ma.Method("get_date_created")
|
||||||
|
author = ma.Nested("UserSchema")
|
||||||
|
ccr_id = ma.Method("get_ccr_id")
|
||||||
|
|
||||||
|
def get_date_created(self, obj):
|
||||||
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
def get_ccr_id(self, obj):
|
||||||
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
|
ccr_schema = CCRSchema()
|
||||||
|
ccrs_schema = CCRSchema(many=True)
|
|
@ -0,0 +1,113 @@
|
||||||
|
from flask import Blueprint, g
|
||||||
|
from marshmallow import fields
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.extensions import limiter
|
||||||
|
from grant.parser import body
|
||||||
|
from grant.utils.auth import (
|
||||||
|
requires_auth,
|
||||||
|
requires_email_verified_auth,
|
||||||
|
get_authed_user
|
||||||
|
)
|
||||||
|
from grant.utils.auth import requires_ccr_owner_auth
|
||||||
|
from grant.utils.enums import CCRStatus
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from .models import CCR, ccr_schema, ccrs_schema, db
|
||||||
|
|
||||||
|
blueprint = Blueprint("ccr", __name__, url_prefix="/api/v1/ccrs")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["GET"])
|
||||||
|
def get_ccr(ccr_id):
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if ccr:
|
||||||
|
if ccr.status != CCRStatus.LIVE:
|
||||||
|
if CCR.status == CCRStatus.DELETED:
|
||||||
|
return {"message": "CCR was deleted"}, 404
|
||||||
|
authed_user = get_authed_user()
|
||||||
|
|
||||||
|
if authed_user.id != ccr.author.id:
|
||||||
|
return {"message": "User cannot view this CCR"}, 404
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
else:
|
||||||
|
return {"message": "No CCR matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/drafts", methods=["POST"])
|
||||||
|
@limiter.limit("10/hour;3/minute")
|
||||||
|
@requires_email_verified_auth
|
||||||
|
def make_ccr_draft():
|
||||||
|
user = g.current_user
|
||||||
|
ccr = CCR.create(status=CCRStatus.DRAFT, user_id=user.id)
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(ccr), 201
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/drafts", methods=["GET"])
|
||||||
|
@requires_auth
|
||||||
|
def get_ccr_drafts():
|
||||||
|
ccrs = (
|
||||||
|
CCR.query
|
||||||
|
.filter_by(user_id=g.current_user.id)
|
||||||
|
.filter(or_(
|
||||||
|
CCR.status == CCRStatus.DRAFT,
|
||||||
|
CCR.status == CCRStatus.REJECTED,
|
||||||
|
))
|
||||||
|
.order_by(CCR.date_created.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return ccrs_schema.dump(ccrs), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["DELETE"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
def delete_ccr(ccr_id):
|
||||||
|
deleteable_statuses = [
|
||||||
|
CCRStatus.DRAFT,
|
||||||
|
CCRStatus.PENDING,
|
||||||
|
CCRStatus.APPROVED,
|
||||||
|
CCRStatus.REJECTED,
|
||||||
|
]
|
||||||
|
status = g.current_ccr.status
|
||||||
|
if status not in deleteable_statuses:
|
||||||
|
return {"message": "Cannot delete CCRs with %s status" % status}, 400
|
||||||
|
db.session.delete(g.current_ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 202
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["PUT"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
@body({
|
||||||
|
"title": fields.Str(required=True),
|
||||||
|
"brief": fields.Str(required=True),
|
||||||
|
"content": fields.Str(required=True),
|
||||||
|
"target": fields.Str(required=True, allow_none=True),
|
||||||
|
})
|
||||||
|
def update_ccr(ccr_id, **kwargs):
|
||||||
|
try:
|
||||||
|
if g.current_ccr.status not in [CCRStatus.DRAFT,
|
||||||
|
CCRStatus.REJECTED]:
|
||||||
|
raise ValidationException(
|
||||||
|
f"CCR with status: {g.current_ccr.status} are not authorized for updates"
|
||||||
|
)
|
||||||
|
g.current_ccr.update(**kwargs)
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "{}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_ccr)
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(g.current_ccr), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>/submit_for_approval", methods=["PUT"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
def submit_for_approval_ccr(ccr_id):
|
||||||
|
try:
|
||||||
|
g.current_ccr.submit_for_approval()
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "{}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(g.current_ccr), 200
|
|
@ -4,10 +4,19 @@ from functools import reduce
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.ma_fields import UnixDate
|
from grant.utils.ma_fields import UnixDate
|
||||||
from grant.utils.misc import gen_random_id
|
from grant.utils.misc import gen_random_id
|
||||||
from sqlalchemy.orm import raiseload
|
from sqlalchemy.orm import raiseload, column_property
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
HIDDEN_CONTENT = '~~comment removed by admin~~'
|
HIDDEN_CONTENT = '~~comment removed by admin~~'
|
||||||
|
|
||||||
|
comment_liker = db.Table(
|
||||||
|
"comment_liker",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("comment_id", db.Integer, db.ForeignKey("comment.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Comment(db.Model):
|
class Comment(db.Model):
|
||||||
__tablename__ = "comment"
|
__tablename__ = "comment"
|
||||||
|
@ -25,6 +34,15 @@ class Comment(db.Model):
|
||||||
author = db.relationship("User", back_populates="comments")
|
author = db.relationship("User", back_populates="comments")
|
||||||
replies = db.relationship("Comment")
|
replies = db.relationship("Comment")
|
||||||
|
|
||||||
|
likes = db.relationship(
|
||||||
|
"User", secondary=comment_liker, back_populates="liked_comments"
|
||||||
|
)
|
||||||
|
likes_count = column_property(
|
||||||
|
select([func.count(comment_liker.c.comment_id)])
|
||||||
|
.where(comment_liker.c.comment_id == id)
|
||||||
|
.correlate_except(comment_liker)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, proposal_id, user_id, parent_comment_id, content):
|
def __init__(self, proposal_id, user_id, parent_comment_id, content):
|
||||||
self.id = gen_random_id(Comment)
|
self.id = gen_random_id(Comment)
|
||||||
self.proposal_id = proposal_id
|
self.proposal_id = proposal_id
|
||||||
|
@ -49,6 +67,28 @@ class Comment(db.Model):
|
||||||
self.hidden = hidden
|
self.hidden = hidden
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_liked(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(comment_liker)
|
||||||
|
.filter_by(user_id=authed.id, comment_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def like(self, user, is_liked):
|
||||||
|
if is_liked:
|
||||||
|
self.likes.append(user)
|
||||||
|
else:
|
||||||
|
self.likes.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# are all of the replies hidden?
|
# are all of the replies hidden?
|
||||||
def all_hidden(replies):
|
def all_hidden(replies):
|
||||||
|
@ -74,6 +114,8 @@ class CommentSchema(ma.Schema):
|
||||||
"replies",
|
"replies",
|
||||||
"reported",
|
"reported",
|
||||||
"hidden",
|
"hidden",
|
||||||
|
"authed_liked",
|
||||||
|
"likes_count"
|
||||||
)
|
)
|
||||||
|
|
||||||
content = ma.Method("get_content")
|
content = ma.Method("get_content")
|
||||||
|
|
|
@ -1,4 +1,26 @@
|
||||||
from flask import Blueprint
|
from flask import Blueprint, g
|
||||||
|
|
||||||
|
from grant.utils.auth import requires_auth
|
||||||
|
from grant.parser import body
|
||||||
|
from marshmallow import fields
|
||||||
|
from .models import Comment, db, comment_schema
|
||||||
|
|
||||||
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
|
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<comment_id>/like", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isLiked": fields.Bool(required=True)})
|
||||||
|
def like_comment(comment_id, is_liked):
|
||||||
|
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure comment exists
|
||||||
|
comment = Comment.query.filter_by(id=comment_id).first()
|
||||||
|
if not comment:
|
||||||
|
return {"message": "No comment matching id"}, 404
|
||||||
|
|
||||||
|
comment.like(user, is_liked)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return comment_schema.dump(comment), 201
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
from .subscription_settings import EmailSubscription, is_subscribed
|
|
||||||
from sendgrid.helpers.mail import Email, Mail, Content
|
|
||||||
from python_http_client import HTTPError
|
|
||||||
from grant.utils.misc import make_url
|
|
||||||
from sentry_sdk import capture_exception
|
|
||||||
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI
|
|
||||||
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
|
|
||||||
import sendgrid
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from flask import render_template, Markup, current_app, g
|
|
||||||
|
|
||||||
|
import sendgrid
|
||||||
|
from flask import render_template, Markup, current_app, g
|
||||||
|
from python_http_client import HTTPError
|
||||||
|
from sendgrid.helpers.mail import Email, Mail, Content
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
|
||||||
|
from grant.settings import SENDGRID_DEFAULT_FROMNAME
|
||||||
|
from grant.utils.misc import make_url
|
||||||
|
from .subscription_settings import EmailSubscription, is_subscribed
|
||||||
|
|
||||||
default_template_args = {
|
default_template_args = {
|
||||||
'home_url': make_url('/'),
|
'home_url': make_url('/'),
|
||||||
|
@ -68,18 +69,34 @@ def change_password_info(email_args):
|
||||||
|
|
||||||
def proposal_approved(email_args):
|
def proposal_approved(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has been approved!',
|
'subject': 'Your proposal has been reviewed',
|
||||||
'title': 'Your proposal has been approved',
|
'title': 'Your proposal has been reviewed',
|
||||||
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
|
'preview': '{} is now live on ZF Grants.'.format(email_args['proposal'].title),
|
||||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ccr_approved(email_args):
|
||||||
|
return {
|
||||||
|
'subject': 'Your request has been approved!',
|
||||||
|
'title': 'Your request has been approved',
|
||||||
|
'preview': '{} will soon be live on ZF Grants!'.format(email_args['ccr'].title),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ccr_rejected(email_args):
|
||||||
|
return {
|
||||||
|
'subject': 'Your request has changes requested',
|
||||||
|
'title': 'Your request has changes requested',
|
||||||
|
'preview': '{} has changes requested'.format(email_args['ccr'].title),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def proposal_rejected(email_args):
|
def proposal_rejected(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has been rejected',
|
'subject': 'Your proposal has changes requested',
|
||||||
'title': 'Your proposal has been rejected',
|
'title': 'Your proposal has changes requested',
|
||||||
'preview': '{} has been rejected'.format(email_args['proposal'].title),
|
'preview': '{} has changes requested'.format(email_args['proposal'].title),
|
||||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,6 +262,17 @@ def milestone_request(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def milestone_deadline(email_args):
|
||||||
|
p = email_args['proposal']
|
||||||
|
ms = p.current_milestone
|
||||||
|
return {
|
||||||
|
'subject': f'Milestone deadline reached for {p.title} - {ms.title}',
|
||||||
|
'title': f'Milestone deadline reached',
|
||||||
|
'preview': f'The estimated deadline for milestone {ms.title} has been reached.',
|
||||||
|
'subscription': EmailSubscription.ARBITER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def milestone_reject(email_args):
|
def milestone_reject(email_args):
|
||||||
p = email_args['proposal']
|
p = email_args['proposal']
|
||||||
ms = p.current_milestone
|
ms = p.current_milestone
|
||||||
|
@ -289,6 +317,15 @@ def admin_approval(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_approval_ccr(email_args):
|
||||||
|
return {
|
||||||
|
'subject': f'Review needed for {email_args["ccr"].title}',
|
||||||
|
'title': f'CCR Review',
|
||||||
|
'preview': f'{email_args["ccr"].title} needs review, as an admin you can help.',
|
||||||
|
'subscription': EmailSubscription.ADMIN_APPROVAL_CCR,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def admin_arbiter(email_args):
|
def admin_arbiter(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
||||||
|
@ -307,6 +344,27 @@ def admin_payout(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def followed_proposal_milestone(email_args):
|
||||||
|
p = email_args["proposal"]
|
||||||
|
ms = email_args["milestone"]
|
||||||
|
return {
|
||||||
|
"subject": f"Milestone accepted for {p.title}",
|
||||||
|
"title": f"Milestone Accepted",
|
||||||
|
"preview": f"Followed proposal {p.title} has passed a milestone",
|
||||||
|
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def followed_proposal_update(email_args):
|
||||||
|
p = email_args["proposal"]
|
||||||
|
return {
|
||||||
|
"subject": f"Proposal update for {p.title}",
|
||||||
|
"title": f"Proposal Update",
|
||||||
|
"preview": f"Followed proposal {p.title} has an update",
|
||||||
|
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
get_info_lookup = {
|
get_info_lookup = {
|
||||||
'signup': signup_info,
|
'signup': signup_info,
|
||||||
'team_invite': team_invite_info,
|
'team_invite': team_invite_info,
|
||||||
|
@ -314,6 +372,8 @@ get_info_lookup = {
|
||||||
'change_email': change_email_info,
|
'change_email': change_email_info,
|
||||||
'change_email_old': change_email_old_info,
|
'change_email_old': change_email_old_info,
|
||||||
'change_password': change_password_info,
|
'change_password': change_password_info,
|
||||||
|
'ccr_rejected': ccr_rejected,
|
||||||
|
'ccr_approved': ccr_approved,
|
||||||
'proposal_approved': proposal_approved,
|
'proposal_approved': proposal_approved,
|
||||||
'proposal_rejected': proposal_rejected,
|
'proposal_rejected': proposal_rejected,
|
||||||
'proposal_contribution': proposal_contribution,
|
'proposal_contribution': proposal_contribution,
|
||||||
|
@ -330,12 +390,16 @@ get_info_lookup = {
|
||||||
'comment_reply': comment_reply,
|
'comment_reply': comment_reply,
|
||||||
'proposal_arbiter': proposal_arbiter,
|
'proposal_arbiter': proposal_arbiter,
|
||||||
'milestone_request': milestone_request,
|
'milestone_request': milestone_request,
|
||||||
|
'milestone_deadline': milestone_deadline,
|
||||||
'milestone_reject': milestone_reject,
|
'milestone_reject': milestone_reject,
|
||||||
'milestone_accept': milestone_accept,
|
'milestone_accept': milestone_accept,
|
||||||
'milestone_paid': milestone_paid,
|
'milestone_paid': milestone_paid,
|
||||||
'admin_approval': admin_approval,
|
'admin_approval': admin_approval,
|
||||||
|
'admin_approval_ccr': admin_approval_ccr,
|
||||||
'admin_arbiter': admin_arbiter,
|
'admin_arbiter': admin_arbiter,
|
||||||
'admin_payout': admin_payout
|
'admin_payout': admin_payout,
|
||||||
|
'followed_proposal_milestone': followed_proposal_milestone,
|
||||||
|
'followed_proposal_update': followed_proposal_update
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,14 @@ class EmailSubscription(Enum):
|
||||||
'bit': 14,
|
'bit': 14,
|
||||||
'key': 'admin_payout'
|
'key': 'admin_payout'
|
||||||
}
|
}
|
||||||
|
FOLLOWED_PROPOSAL = {
|
||||||
|
'bit': 15,
|
||||||
|
'key': 'followed_proposal'
|
||||||
|
}
|
||||||
|
ADMIN_APPROVAL_CCR = {
|
||||||
|
'bit': 16,
|
||||||
|
'key': 'admin_approval_ccr'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_email_sub_key(k: str):
|
def is_email_sub_key(k: str):
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
from . import views
|
|
@ -0,0 +1,34 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.proposal.models import Proposal, proposals_schema
|
||||||
|
from grant.rfp.models import RFP, rfps_schema
|
||||||
|
from grant.utils.enums import ProposalStatus, ProposalStage, RFPStatus
|
||||||
|
|
||||||
|
blueprint = Blueprint("home", __name__, url_prefix="/api/v1/home")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/latest", methods=["GET"])
|
||||||
|
def get_home_content():
|
||||||
|
latest_proposals = (
|
||||||
|
Proposal.query.filter_by(status=ProposalStatus.LIVE)
|
||||||
|
.filter(Proposal.stage != ProposalStage.CANCELED)
|
||||||
|
.filter(Proposal.stage != ProposalStage.FAILED)
|
||||||
|
.order_by(Proposal.date_created.desc())
|
||||||
|
.limit(3)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
latest_rfps = (
|
||||||
|
RFP.query.filter_by(status=RFPStatus.LIVE)
|
||||||
|
.filter(or_(RFP.date_closes == None, RFP.date_closes > datetime.now()))
|
||||||
|
.order_by(RFP.date_opened)
|
||||||
|
.limit(3)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latest_proposals": proposals_schema.dump(latest_proposals),
|
||||||
|
"latest_rfps": rfps_schema.dump(latest_rfps),
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ from grant.utils.enums import MilestoneStage
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.ma_fields import UnixDate
|
from grant.utils.ma_fields import UnixDate
|
||||||
from grant.utils.misc import gen_random_id
|
from grant.utils.misc import gen_random_id
|
||||||
|
from grant.task.jobs import MilestoneDeadline
|
||||||
|
|
||||||
|
|
||||||
class MilestoneException(Exception):
|
class MilestoneException(Exception):
|
||||||
|
@ -22,7 +23,8 @@ class Milestone(db.Model):
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
payout_percent = db.Column(db.String(255), nullable=False)
|
payout_percent = db.Column(db.String(255), nullable=False)
|
||||||
immediate_payout = db.Column(db.Boolean)
|
immediate_payout = db.Column(db.Boolean)
|
||||||
date_estimated = db.Column(db.DateTime, nullable=False)
|
date_estimated = db.Column(db.DateTime, nullable=True)
|
||||||
|
days_estimated = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
@ -46,7 +48,7 @@ class Milestone(db.Model):
|
||||||
index: int,
|
index: int,
|
||||||
title: str,
|
title: str,
|
||||||
content: str,
|
content: str,
|
||||||
date_estimated: datetime,
|
days_estimated: str,
|
||||||
payout_percent: str,
|
payout_percent: str,
|
||||||
immediate_payout: bool,
|
immediate_payout: bool,
|
||||||
stage: str = MilestoneStage.IDLE,
|
stage: str = MilestoneStage.IDLE,
|
||||||
|
@ -56,13 +58,14 @@ class Milestone(db.Model):
|
||||||
self.title = title[:255]
|
self.title = title[:255]
|
||||||
self.content = content[:255]
|
self.content = content[:255]
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
self.date_estimated = date_estimated
|
self.days_estimated = days_estimated[:255]
|
||||||
self.payout_percent = payout_percent[:255]
|
self.payout_percent = payout_percent[:255]
|
||||||
self.immediate_payout = immediate_payout
|
self.immediate_payout = immediate_payout
|
||||||
self.proposal_id = proposal_id
|
self.proposal_id = proposal_id
|
||||||
self.date_created = datetime.datetime.now()
|
self.date_created = datetime.datetime.now()
|
||||||
self.index = index
|
self.index = index
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make(milestones_data, proposal):
|
def make(milestones_data, proposal):
|
||||||
if milestones_data:
|
if milestones_data:
|
||||||
|
@ -72,7 +75,7 @@ class Milestone(db.Model):
|
||||||
m = Milestone(
|
m = Milestone(
|
||||||
title=milestone_data["title"][:255],
|
title=milestone_data["title"][:255],
|
||||||
content=milestone_data["content"][:255],
|
content=milestone_data["content"][:255],
|
||||||
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
|
days_estimated=str(milestone_data["days_estimated"])[:255],
|
||||||
payout_percent=str(milestone_data["payout_percent"])[:255],
|
payout_percent=str(milestone_data["payout_percent"])[:255],
|
||||||
immediate_payout=milestone_data["immediate_payout"],
|
immediate_payout=milestone_data["immediate_payout"],
|
||||||
proposal_id=proposal.id,
|
proposal_id=proposal.id,
|
||||||
|
@ -80,6 +83,55 @@ class Milestone(db.Model):
|
||||||
)
|
)
|
||||||
db.session.add(m)
|
db.session.add(m)
|
||||||
|
|
||||||
|
# The purpose of this method is to set the `date_estimated` property on all milestones in a proposal. This works
|
||||||
|
# by figuring out a starting point for each milestone (the `base_date` below) and adding `days_estimated`.
|
||||||
|
#
|
||||||
|
# As proposal creators now estimate their milestones in days (instead of picking months), this method allows us to
|
||||||
|
# keep `date_estimated` in sync throughout the lifecycle of a proposal. For example, if a user misses their
|
||||||
|
# first milestone deadline by a week, this method would take the actual completion date of that milestone and
|
||||||
|
# adjust the `date_estimated` of the remaining milestones accordingly.
|
||||||
|
#
|
||||||
|
@staticmethod
|
||||||
|
def set_v2_date_estimates(proposal):
|
||||||
|
if not proposal.date_approved:
|
||||||
|
raise MilestoneException(f'Cannot estimate milestone dates because proposal has no date_approved set')
|
||||||
|
|
||||||
|
# The milestone being actively worked on
|
||||||
|
current_milestone = proposal.current_milestone
|
||||||
|
|
||||||
|
if current_milestone.stage == MilestoneStage.PAID:
|
||||||
|
raise MilestoneException(f'Cannot estimate milestone dates because they are all completed')
|
||||||
|
|
||||||
|
# The starting point for `date_estimated` calculation for each uncompleted milestone
|
||||||
|
# We add `days_estimated` to `base_date` to calculate `date_estimated`
|
||||||
|
base_date = None
|
||||||
|
|
||||||
|
for index, milestone in enumerate(proposal.milestones):
|
||||||
|
if index == 0:
|
||||||
|
# If it's the first milestone, use the proposal approval date as a `base_date`
|
||||||
|
base_date = proposal.date_approved
|
||||||
|
|
||||||
|
if milestone.date_paid:
|
||||||
|
# If milestone has been paid, set `base_date` for the next milestone and noop out
|
||||||
|
base_date = milestone.date_paid
|
||||||
|
continue
|
||||||
|
|
||||||
|
days_estimated = milestone.days_estimated if not milestone.immediate_payout else "0"
|
||||||
|
date_estimated = base_date + datetime.timedelta(days=int(days_estimated))
|
||||||
|
milestone.date_estimated = date_estimated
|
||||||
|
|
||||||
|
# Set the `base_date` for the next milestone using the estimate completion date of the current milestone
|
||||||
|
base_date = date_estimated
|
||||||
|
db.session.add(milestone)
|
||||||
|
|
||||||
|
# Skip task creation if current milestone has an immediate payout
|
||||||
|
if current_milestone.immediate_payout:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create MilestoneDeadline task for the current milestone so arbiters will be alerted if the deadline is missed
|
||||||
|
task = MilestoneDeadline(proposal, current_milestone)
|
||||||
|
task.make_task()
|
||||||
|
|
||||||
def request_payout(self, user_id: int):
|
def request_payout(self, user_id: int):
|
||||||
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
||||||
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
||||||
|
@ -140,6 +192,7 @@ class MilestoneSchema(ma.Schema):
|
||||||
"date_rejected",
|
"date_rejected",
|
||||||
"date_accepted",
|
"date_accepted",
|
||||||
"date_paid",
|
"date_paid",
|
||||||
|
"days_estimated"
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = UnixDate(attribute='date_created')
|
date_created = UnixDate(attribute='date_created')
|
||||||
|
|
|
@ -7,7 +7,7 @@ from flask.cli import with_appcontext
|
||||||
from .models import Proposal, db
|
from .models import Proposal, db
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.utils.enums import ProposalStatus, Category, ProposalStageEnum
|
from grant.utils.enums import ProposalStatus, Category, ProposalStage
|
||||||
from grant.user.models import User
|
from grant.user.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,9 +35,9 @@ def create_proposals(count):
|
||||||
user = User.query.filter_by().first()
|
user = User.query.filter_by().first()
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
if i < 5:
|
if i < 5:
|
||||||
stage = ProposalStageEnum.FUNDING_REQUIRED
|
stage = ProposalStage.WIP
|
||||||
else:
|
else:
|
||||||
stage = ProposalStageEnum.COMPLETED
|
stage = ProposalStage.COMPLETED
|
||||||
p = Proposal.create(
|
p = Proposal.create(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
status=ProposalStatus.LIVE,
|
status=ProposalStatus.LIVE,
|
||||||
|
@ -51,6 +51,10 @@ def create_proposals(count):
|
||||||
)
|
)
|
||||||
p.date_published = datetime.datetime.now()
|
p.date_published = datetime.datetime.now()
|
||||||
p.team.append(user)
|
p.team.append(user)
|
||||||
|
p.date_approved = datetime.datetime.now()
|
||||||
|
p.accepted_with_funding = True
|
||||||
|
p.version = '2'
|
||||||
|
p.fully_fund_contibution_bounty()
|
||||||
db.session.add(p)
|
db.session.add(p)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
num_ms = randint(1, 9)
|
num_ms = randint(1, 9)
|
||||||
|
@ -58,7 +62,7 @@ def create_proposals(count):
|
||||||
m = Milestone(
|
m = Milestone(
|
||||||
title=f'Fake MS {j}',
|
title=f'Fake MS {j}',
|
||||||
content=f'Fake milestone #{j} on fake proposal #{i}!',
|
content=f'Fake milestone #{j} on fake proposal #{i}!',
|
||||||
date_estimated=datetime.datetime.now(),
|
days_estimated='10',
|
||||||
payout_percent=str(floor(1 / num_ms * 100)),
|
payout_percent=str(floor(1 / num_ms * 100)),
|
||||||
immediate_payout=j == 0,
|
immediate_payout=j == 0,
|
||||||
proposal_id=p.id,
|
proposal_id=p.id,
|
||||||
|
@ -74,5 +78,119 @@ def create_proposals(count):
|
||||||
)
|
)
|
||||||
db.session.add(c)
|
db.session.add(c)
|
||||||
|
|
||||||
|
Milestone.set_v2_date_estimates(p)
|
||||||
|
db.session.add(p)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f'Added {count} LIVE fake proposals')
|
print(f'Added {count} LIVE fake proposals')
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument('dry', required=False)
|
||||||
|
@with_appcontext
|
||||||
|
def retire_v1_proposals(dry):
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
proposals_funding_required = Proposal.query.filter_by(stage="FUNDING_REQUIRED").all()
|
||||||
|
proposals_draft = Proposal.query.filter_by(status=ProposalStatus.DRAFT).all()
|
||||||
|
proposals_pending = Proposal.query.filter_by(status=ProposalStatus.PENDING).all()
|
||||||
|
proposals_staking = Proposal.query.filter_by(status=ProposalStatus.STAKING).all()
|
||||||
|
modified_funding_required_count = 0
|
||||||
|
modified_draft_count = 0
|
||||||
|
modified_pending_count = 0
|
||||||
|
modified_staking_count = 0
|
||||||
|
deleted_draft_count = 0
|
||||||
|
|
||||||
|
if not proposals_funding_required and not proposals_draft and not proposals_pending and not proposals_staking:
|
||||||
|
print("No proposals found. Exiting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Found {len(proposals_funding_required)} 'FUNDING_REQUIRED' proposals to modify")
|
||||||
|
print(f"Found {len(proposals_draft)} 'DRAFT' proposals to modify")
|
||||||
|
print(f"Found {len(proposals_pending)} 'PENDING' proposals to modify")
|
||||||
|
print(f"Found {len(proposals_staking)} 'STAKING' proposals to modify")
|
||||||
|
|
||||||
|
if dry:
|
||||||
|
print(f"This is a dry run. Changes will not be committed to the database")
|
||||||
|
|
||||||
|
confirm = input("Continue? (y/n) ")
|
||||||
|
|
||||||
|
if confirm != "y":
|
||||||
|
print("Exiting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
# move 'FUNDING_REQUIRED' proposals to a failed state
|
||||||
|
for p in proposals_funding_required:
|
||||||
|
if not dry:
|
||||||
|
new_deadline = (now - p.date_published).total_seconds()
|
||||||
|
p.stage = ProposalStage.FAILED
|
||||||
|
p.deadline_duration = int(new_deadline)
|
||||||
|
db.session.add(p)
|
||||||
|
modified_funding_required_count += 1
|
||||||
|
|
||||||
|
print(f"Modified 'FUNDING_REQUIRED' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
# reset proposal to draft state
|
||||||
|
def convert_proposal_to_v2_draft(proposal):
|
||||||
|
milestones = Milestone.query.filter_by(proposal_id=proposal.id).all()
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
# reset target because v2 estimates are in USD
|
||||||
|
proposal.target = '0'
|
||||||
|
proposal.version = '2'
|
||||||
|
proposal.stage = ProposalStage.PREVIEW
|
||||||
|
proposal.status = ProposalStatus.DRAFT
|
||||||
|
db.session.add(proposal)
|
||||||
|
|
||||||
|
for m in milestones:
|
||||||
|
# clear date estimated because v2 proposals use days_estimated (date_estimated is dynamically set)
|
||||||
|
m.date_estimated = None
|
||||||
|
db.session.add(m)
|
||||||
|
|
||||||
|
print(f"Modified {len(milestones)} milestones on proposal {p.id}")
|
||||||
|
|
||||||
|
# delete drafts that have no content
|
||||||
|
def delete_stale_draft(proposal):
|
||||||
|
if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if proposal.payout_address or proposal.milestones:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
db.session.delete(proposal)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
for p in proposals_draft:
|
||||||
|
is_stale = delete_stale_draft(p)
|
||||||
|
if is_stale:
|
||||||
|
deleted_draft_count += 1
|
||||||
|
print(f"Deleted stale 'DRAFT' proposal {p.id} - {p.title}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
convert_proposal_to_v2_draft(p)
|
||||||
|
modified_draft_count += 1
|
||||||
|
print(f"Modified 'DRAFT' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
for p in proposals_pending:
|
||||||
|
convert_proposal_to_v2_draft(p)
|
||||||
|
modified_pending_count += 1
|
||||||
|
print(f"Modified 'PENDING' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
for p in proposals_staking:
|
||||||
|
convert_proposal_to_v2_draft(p)
|
||||||
|
modified_staking_count += 1
|
||||||
|
print(f"Modified 'STAKING' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
print(f"Committing changes to database")
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(f"Modified {modified_funding_required_count} 'FUNDING_REQUIRED' proposals")
|
||||||
|
print(f"Modified {modified_draft_count} 'DRAFT' proposals")
|
||||||
|
print(f"Modified {modified_pending_count} 'PENDING' proposals")
|
||||||
|
print(f"Modified {modified_staking_count} 'STAKING' proposals")
|
||||||
|
print(f"Deleted {deleted_draft_count} stale 'DRAFT' proposals")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
from typing import Optional
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from marshmallow import post_dump
|
from marshmallow import post_dump
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy.orm import column_property
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
|
@ -32,6 +32,20 @@ proposal_team = db.Table(
|
||||||
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
proposal_follower = db.Table(
|
||||||
|
"proposal_follower",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_liker = db.Table(
|
||||||
|
"proposal_liker",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProposalTeamInvite(db.Model):
|
class ProposalTeamInvite(db.Model):
|
||||||
__tablename__ = "proposal_team_invite"
|
__tablename__ = "proposal_team_invite"
|
||||||
|
@ -145,6 +159,8 @@ class ProposalContribution(db.Model):
|
||||||
raise ValidationException('Proposal ID is required')
|
raise ValidationException('Proposal ID is required')
|
||||||
# User ID (must belong to an existing user)
|
# User ID (must belong to an existing user)
|
||||||
if user_id:
|
if user_id:
|
||||||
|
from grant.user.models import User
|
||||||
|
|
||||||
user = User.query.filter(User.id == user_id).first()
|
user = User.query.filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise ValidationException('No user matching that ID')
|
raise ValidationException('No user matching that ID')
|
||||||
|
@ -212,32 +228,72 @@ class ProposalArbiter(db.Model):
|
||||||
raise ValidationException('User is not arbiter')
|
raise ValidationException('User is not arbiter')
|
||||||
|
|
||||||
|
|
||||||
|
def default_proposal_content():
|
||||||
|
return """# Applicant background
|
||||||
|
|
||||||
|
Summarize you and/or your team’s background and experience. Demonstrate that you have the skills and expertise necessary for the project that you’re proposing. Institutional bona fides are not required, but we want to hear about your track record.
|
||||||
|
|
||||||
|
# Motivation and overview
|
||||||
|
|
||||||
|
What are your high-level goals? Why are they important? How is your project connected to [ZF’s mission](https://www.zfnd.org/about/#mission) and priorities? Whose needs will it serve?
|
||||||
|
|
||||||
|
# Technical approach
|
||||||
|
|
||||||
|
Dive into the _how_ of your project. Describe your approaches, components, workflows, methodology, etc. Bullet points and diagrams are appreciated!
|
||||||
|
|
||||||
|
# Execution risks
|
||||||
|
|
||||||
|
What obstacles do you expect? What is most likely to go wrong? Which unknown factors or dependencies could jeopardize success? What are your contingency plans? Will subsequent activities be required to maximize impact?
|
||||||
|
|
||||||
|
# Downsides
|
||||||
|
|
||||||
|
What are the negative ramifications if your project is successful? Consider usability, stability, privacy, integrity, availability, decentralization, interoperability, maintainability, technical debt, requisite education, etc.
|
||||||
|
|
||||||
|
# Evaluation plan
|
||||||
|
|
||||||
|
What will your project look like if successful? How will we be able to tell? Include quantifiable metrics if possible.
|
||||||
|
|
||||||
|
# Tasks and schedule
|
||||||
|
|
||||||
|
What is your timeline for the project? Include concrete milestones and the major tasks required to complete each milestone.
|
||||||
|
|
||||||
|
# Budget and justification
|
||||||
|
|
||||||
|
How much funding do you need, and how will it be allocated (e.g., compensation for your effort, specific equipment, specific external services)? Specify a total cost, break it up into budget items, and explain the rationale for each. Feel free to present multiple options in terms of scope and cost.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Proposal(db.Model):
|
class Proposal(db.Model):
|
||||||
__tablename__ = "proposal"
|
__tablename__ = "proposal"
|
||||||
|
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
date_created = db.Column(db.DateTime)
|
date_created = db.Column(db.DateTime)
|
||||||
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
|
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
|
||||||
|
version = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
# Content info
|
# Content info
|
||||||
status = db.Column(db.String(255), nullable=False)
|
status = db.Column(db.String(255), nullable=False)
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
brief = db.Column(db.String(255), nullable=False)
|
brief = db.Column(db.String(255), nullable=False)
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False, default=default_proposal_content())
|
||||||
category = db.Column(db.String(255), nullable=False)
|
category = db.Column(db.String(255), nullable=True)
|
||||||
date_approved = db.Column(db.DateTime)
|
date_approved = db.Column(db.DateTime)
|
||||||
date_published = db.Column(db.DateTime)
|
date_published = db.Column(db.DateTime)
|
||||||
reject_reason = db.Column(db.String())
|
reject_reason = db.Column(db.String())
|
||||||
|
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
|
||||||
|
|
||||||
# Payment info
|
# Payment info
|
||||||
target = db.Column(db.String(255), nullable=False)
|
target = db.Column(db.String(255), nullable=False)
|
||||||
payout_address = db.Column(db.String(255), nullable=False)
|
payout_address = db.Column(db.String(255), nullable=False)
|
||||||
deadline_duration = db.Column(db.Integer(), nullable=False)
|
deadline_duration = db.Column(db.Integer(), nullable=True)
|
||||||
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
||||||
contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
|
contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
|
||||||
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
|
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
|
||||||
contributed = db.column_property()
|
contributed = db.column_property()
|
||||||
|
tip_jar_address = db.Column(db.String(255), nullable=True)
|
||||||
|
tip_jar_view_key = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
# Relations
|
# Relations
|
||||||
team = db.relationship("User", secondary=proposal_team)
|
team = db.relationship("User", secondary=proposal_team)
|
||||||
|
@ -248,13 +304,29 @@ class Proposal(db.Model):
|
||||||
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
||||||
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
||||||
|
followers = db.relationship(
|
||||||
|
"User", secondary=proposal_follower, back_populates="followed_proposals"
|
||||||
|
)
|
||||||
|
followers_count = column_property(
|
||||||
|
select([func.count(proposal_follower.c.proposal_id)])
|
||||||
|
.where(proposal_follower.c.proposal_id == id)
|
||||||
|
.correlate_except(proposal_follower)
|
||||||
|
)
|
||||||
|
likes = db.relationship(
|
||||||
|
"User", secondary=proposal_liker, back_populates="liked_proposals"
|
||||||
|
)
|
||||||
|
likes_count = column_property(
|
||||||
|
select([func.count(proposal_liker.c.proposal_id)])
|
||||||
|
.where(proposal_liker.c.proposal_id == id)
|
||||||
|
.correlate_except(proposal_liker)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
status: str = ProposalStatus.DRAFT,
|
status: str = ProposalStatus.DRAFT,
|
||||||
title: str = '',
|
title: str = '',
|
||||||
brief: str = '',
|
brief: str = '',
|
||||||
content: str = '',
|
content: str = default_proposal_content(),
|
||||||
stage: str = ProposalStage.PREVIEW,
|
stage: str = ProposalStage.PREVIEW,
|
||||||
target: str = '0',
|
target: str = '0',
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
|
@ -272,18 +344,16 @@ class Proposal(db.Model):
|
||||||
self.payout_address = payout_address
|
self.payout_address = payout_address
|
||||||
self.deadline_duration = deadline_duration
|
self.deadline_duration = deadline_duration
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
|
self.version = '2'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def simple_validate(proposal):
|
def simple_validate(proposal):
|
||||||
# Validate fields to be database save-able.
|
# Validate fields to be database save-able.
|
||||||
# Stricter validation is done in validate_publishable.
|
# Stricter validation is done in validate_publishable.
|
||||||
stage = proposal.get('stage')
|
stage = proposal.get('stage')
|
||||||
category = proposal.get('category')
|
|
||||||
|
|
||||||
if stage and not ProposalStage.includes(stage):
|
if stage and not ProposalStage.includes(stage):
|
||||||
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
||||||
if category and not Category.includes(category):
|
|
||||||
raise ValidationException("Category {} not a valid category".format(category))
|
|
||||||
|
|
||||||
def validate_publishable_milestones(self):
|
def validate_publishable_milestones(self):
|
||||||
payout_total = 0.0
|
payout_total = 0.0
|
||||||
|
@ -316,7 +386,7 @@ class Proposal(db.Model):
|
||||||
self.validate_publishable_milestones()
|
self.validate_publishable_milestones()
|
||||||
|
|
||||||
# Require certain fields
|
# Require certain fields
|
||||||
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
required_fields = ['title', 'content', 'brief', 'target', 'payout_address']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if not hasattr(self, field):
|
if not hasattr(self, field):
|
||||||
raise ValidationException("Proposal must have a {}".format(field))
|
raise ValidationException("Proposal must have a {}".format(field))
|
||||||
|
@ -329,13 +399,15 @@ class Proposal(db.Model):
|
||||||
if len(self.content) > 250000:
|
if len(self.content) > 250000:
|
||||||
raise ValidationException("Content cannot be longer than 250,000 characters")
|
raise ValidationException("Content cannot be longer than 250,000 characters")
|
||||||
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
|
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
|
||||||
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
|
raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX))
|
||||||
if Decimal(self.target) < 0.0001:
|
if Decimal(self.target) < 0:
|
||||||
raise ValidationException("Target cannot be less than 0.0001")
|
raise ValidationException("Target cannot be less than 0")
|
||||||
|
if not self.target.isdigit():
|
||||||
|
raise ValidationException("Target must be a whole number")
|
||||||
if self.deadline_duration > 7776000:
|
if self.deadline_duration > 7776000:
|
||||||
raise ValidationException("Deadline duration cannot be more than 90 days")
|
raise ValidationException("Deadline duration cannot be more than 90 days")
|
||||||
|
|
||||||
# Check with node that the address is kosher
|
# Check with node that the payout address is kosher
|
||||||
try:
|
try:
|
||||||
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
||||||
except:
|
except:
|
||||||
|
@ -344,16 +416,37 @@ class Proposal(db.Model):
|
||||||
if not res['valid']:
|
if not res['valid']:
|
||||||
raise ValidationException("Payout address is not a valid Zcash address")
|
raise ValidationException("Payout address is not a valid Zcash address")
|
||||||
|
|
||||||
|
if self.tip_jar_address:
|
||||||
|
# Check with node that the tip jar address is kosher
|
||||||
|
try:
|
||||||
|
res = blockchain_get('/validate/address', {'address': self.tip_jar_address})
|
||||||
|
except:
|
||||||
|
raise ValidationException(
|
||||||
|
"Could not validate your tipping address due to an internal server error, please try again later")
|
||||||
|
if not res['valid']:
|
||||||
|
raise ValidationException("Tipping address is not a valid Zcash address")
|
||||||
|
|
||||||
# Then run through regular validation
|
# Then run through regular validation
|
||||||
Proposal.simple_validate(vars(self))
|
Proposal.simple_validate(vars(self))
|
||||||
|
|
||||||
# only do this when user submits for approval, there is a chance the dates will
|
|
||||||
# be passed by the time admin approval / user publishing occurs
|
def validate_milestone_days(self):
|
||||||
def validate_milestone_dates(self):
|
|
||||||
present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
for milestone in self.milestones:
|
for milestone in self.milestones:
|
||||||
if present > milestone.date_estimated:
|
if milestone.immediate_payout:
|
||||||
raise ValidationException("Milestone date estimate must be in the future ")
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
p = float(milestone.days_estimated)
|
||||||
|
if not p.is_integer():
|
||||||
|
raise ValidationException("Milestone days estimated must be whole numbers, no decimals")
|
||||||
|
if p <= 0:
|
||||||
|
raise ValidationException("Milestone days estimated must be greater than zero")
|
||||||
|
if p > 365:
|
||||||
|
raise ValidationException("Milestone days estimated must be less than 365")
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationException("Milestone days estimated must be a number")
|
||||||
|
return
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(**kwargs):
|
def create(**kwargs):
|
||||||
|
@ -396,6 +489,7 @@ class Proposal(db.Model):
|
||||||
content: str = '',
|
content: str = '',
|
||||||
target: str = '0',
|
target: str = '0',
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
|
tip_jar_address: Optional[str] = None,
|
||||||
deadline_duration: int = 5184000 # 60 days
|
deadline_duration: int = 5184000 # 60 days
|
||||||
):
|
):
|
||||||
self.title = title[:255]
|
self.title = title[:255]
|
||||||
|
@ -404,18 +498,12 @@ class Proposal(db.Model):
|
||||||
self.content = content[:300000]
|
self.content = content[:300000]
|
||||||
self.target = target[:255] if target != '' else '0'
|
self.target = target[:255] if target != '' else '0'
|
||||||
self.payout_address = payout_address[:255]
|
self.payout_address = payout_address[:255]
|
||||||
|
self.tip_jar_address = tip_jar_address[:255] if tip_jar_address is not None else None
|
||||||
self.deadline_duration = deadline_duration
|
self.deadline_duration = deadline_duration
|
||||||
Proposal.simple_validate(vars(self))
|
Proposal.simple_validate(vars(self))
|
||||||
|
|
||||||
def update_rfp_opt_in(self, opt_in: bool):
|
def update_rfp_opt_in(self, opt_in: bool):
|
||||||
self.rfp_opt_in = opt_in
|
self.rfp_opt_in = opt_in
|
||||||
# add/remove matching and/or bounty values from RFP
|
|
||||||
if opt_in and self.rfp:
|
|
||||||
self.set_contribution_matching(1 if self.rfp.matching else 0)
|
|
||||||
self.set_contribution_bounty(self.rfp.bounty or '0')
|
|
||||||
else:
|
|
||||||
self.set_contribution_matching(0)
|
|
||||||
self.set_contribution_bounty('0')
|
|
||||||
|
|
||||||
def create_contribution(
|
def create_contribution(
|
||||||
self,
|
self,
|
||||||
|
@ -469,19 +557,15 @@ class Proposal(db.Model):
|
||||||
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
|
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
|
||||||
})
|
})
|
||||||
|
|
||||||
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
# state: status (DRAFT || REJECTED) -> (PENDING)
|
||||||
def submit_for_approval(self):
|
def submit_for_approval(self):
|
||||||
self.validate_publishable()
|
self.validate_publishable()
|
||||||
self.validate_milestone_dates()
|
self.validate_milestone_days()
|
||||||
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
||||||
# specific validation
|
# specific validation
|
||||||
if self.status not in allowed_statuses:
|
if self.status not in allowed_statuses:
|
||||||
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
||||||
# set to PENDING if staked, else STAKING
|
self.set_pending()
|
||||||
if self.is_staked:
|
|
||||||
self.status = ProposalStatus.PENDING
|
|
||||||
else:
|
|
||||||
self.status = ProposalStatus.STAKING
|
|
||||||
|
|
||||||
def set_pending_when_ready(self):
|
def set_pending_when_ready(self):
|
||||||
if self.status == ProposalStatus.STAKING and self.is_staked:
|
if self.status == ProposalStatus.STAKING and self.is_staked:
|
||||||
|
@ -489,31 +573,44 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
# state: status STAKING -> PENDING
|
# state: status STAKING -> PENDING
|
||||||
def set_pending(self):
|
def set_pending(self):
|
||||||
if self.status != ProposalStatus.STAKING:
|
|
||||||
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
|
|
||||||
if not self.is_staked:
|
|
||||||
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
|
|
||||||
self.send_admin_email('admin_approval')
|
self.send_admin_email('admin_approval')
|
||||||
self.status = ProposalStatus.PENDING
|
self.status = ProposalStatus.PENDING
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# state: status PENDING -> (APPROVED || REJECTED)
|
# state: status PENDING -> (LIVE || REJECTED)
|
||||||
def approve_pending(self, is_approve, reject_reason=None):
|
def approve_pending(self, is_approve, with_funding, reject_reason=None):
|
||||||
self.validate_publishable()
|
self.validate_publishable()
|
||||||
# specific validation
|
# specific validation
|
||||||
if not self.status == ProposalStatus.PENDING:
|
if not self.status == ProposalStatus.PENDING:
|
||||||
raise ValidationException(f"Proposal must be pending to approve or reject")
|
raise ValidationException(f"Proposal must be pending to approve or reject")
|
||||||
|
|
||||||
if is_approve:
|
if is_approve:
|
||||||
self.status = ProposalStatus.APPROVED
|
self.status = ProposalStatus.LIVE
|
||||||
self.date_approved = datetime.datetime.now()
|
self.date_approved = datetime.datetime.now()
|
||||||
|
self.accepted_with_funding = with_funding
|
||||||
|
|
||||||
|
# also update date_published and stage since publish() is no longer called by user
|
||||||
|
self.date_published = datetime.datetime.now()
|
||||||
|
self.stage = ProposalStage.WIP
|
||||||
|
|
||||||
|
if with_funding:
|
||||||
|
self.fully_fund_contibution_bounty()
|
||||||
for t in self.team:
|
for t in self.team:
|
||||||
|
admin_note = ''
|
||||||
|
if with_funding:
|
||||||
|
admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.'
|
||||||
|
else:
|
||||||
|
admin_note = '''
|
||||||
|
We've chosen to list your proposal on ZF Grants, but we won't be funding your proposal at this time.
|
||||||
|
Your proposal can still receive funding from the community in the form of tips if you have set a tip address for your proposal.
|
||||||
|
If you have not yet done so, you can do this from the actions dropdown at your proposal.
|
||||||
|
'''
|
||||||
send_email(t.email_address, 'proposal_approved', {
|
send_email(t.email_address, 'proposal_approved', {
|
||||||
'user': t,
|
'user': t,
|
||||||
'proposal': self,
|
'proposal': self,
|
||||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||||
'admin_note': 'Congratulations! Your proposal has been approved.'
|
'admin_note': admin_note
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
if not reject_reason:
|
if not reject_reason:
|
||||||
|
@ -528,6 +625,10 @@ class Proposal(db.Model):
|
||||||
'admin_note': reject_reason
|
'admin_note': reject_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def update_proposal_with_funding(self):
|
||||||
|
self.accepted_with_funding = True
|
||||||
|
self.fully_fund_contibution_bounty()
|
||||||
|
|
||||||
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
||||||
def publish(self):
|
def publish(self):
|
||||||
self.validate_publishable()
|
self.validate_publishable()
|
||||||
|
@ -536,28 +637,7 @@ class Proposal(db.Model):
|
||||||
raise ValidationException(f"Proposal status must be approved")
|
raise ValidationException(f"Proposal status must be approved")
|
||||||
self.date_published = datetime.datetime.now()
|
self.date_published = datetime.datetime.now()
|
||||||
self.status = ProposalStatus.LIVE
|
self.status = ProposalStatus.LIVE
|
||||||
self.stage = ProposalStage.FUNDING_REQUIRED
|
|
||||||
# If we had a bounty that pushed us into funding, skip straight into WIP
|
|
||||||
self.set_funded_when_ready()
|
|
||||||
|
|
||||||
def set_funded_when_ready(self):
|
|
||||||
if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded:
|
|
||||||
self.set_funded()
|
|
||||||
|
|
||||||
# state: stage FUNDING_REQUIRED -> WIP
|
|
||||||
def set_funded(self):
|
|
||||||
if self.status != ProposalStatus.LIVE:
|
|
||||||
raise ValidationException(f"Proposal status must be live in order transition to funded state")
|
|
||||||
if self.stage != ProposalStage.FUNDING_REQUIRED:
|
|
||||||
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
|
|
||||||
if not self.is_funded:
|
|
||||||
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
|
|
||||||
self.send_admin_email('admin_arbiter')
|
|
||||||
self.stage = ProposalStage.WIP
|
self.stage = ProposalStage.WIP
|
||||||
db.session.add(self)
|
|
||||||
db.session.flush()
|
|
||||||
# check the first step, if immediate payout bump it to accepted
|
|
||||||
self.current_milestone.accept_immediate()
|
|
||||||
|
|
||||||
def set_contribution_bounty(self, bounty: str):
|
def set_contribution_bounty(self, bounty: str):
|
||||||
# do not allow changes on funded/WIP proposals
|
# do not allow changes on funded/WIP proposals
|
||||||
|
@ -567,20 +647,9 @@ class Proposal(db.Model):
|
||||||
self.contribution_bounty = str(Decimal(bounty))
|
self.contribution_bounty = str(Decimal(bounty))
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
self.set_funded_when_ready()
|
|
||||||
|
|
||||||
def set_contribution_matching(self, matching: float):
|
def fully_fund_contibution_bounty(self):
|
||||||
# do not allow on funded/WIP proposals
|
self.set_contribution_bounty(self.target)
|
||||||
if self.is_funded:
|
|
||||||
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
|
|
||||||
# enforce 1 or 0 for now
|
|
||||||
if matching == 0.0 or matching == 1.0:
|
|
||||||
self.contribution_matching = matching
|
|
||||||
db.session.add(self)
|
|
||||||
db.session.flush()
|
|
||||||
self.set_funded_when_ready()
|
|
||||||
else:
|
|
||||||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
if self.status != ProposalStatus.LIVE:
|
if self.status != ProposalStatus.LIVE:
|
||||||
|
@ -603,6 +672,33 @@ class Proposal(db.Model):
|
||||||
'account_settings_url': make_url('/profile/settings?tab=account')
|
'account_settings_url': make_url('/profile/settings?tab=account')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def follow(self, user, is_follow):
|
||||||
|
if is_follow:
|
||||||
|
self.followers.append(user)
|
||||||
|
else:
|
||||||
|
self.followers.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def like(self, user, is_liked):
|
||||||
|
if is_liked:
|
||||||
|
self.likes.append(user)
|
||||||
|
else:
|
||||||
|
self.likes.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def send_follower_email(self, type: str, email_args={}, url_suffix=""):
|
||||||
|
for u in self.followers:
|
||||||
|
send_email(
|
||||||
|
u.email_address,
|
||||||
|
type,
|
||||||
|
{
|
||||||
|
"user": u,
|
||||||
|
"proposal": self,
|
||||||
|
"proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"),
|
||||||
|
**email_args,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def contributed(self):
|
def contributed(self):
|
||||||
contributions = ProposalContribution.query \
|
contributions = ProposalContribution.query \
|
||||||
|
@ -635,12 +731,7 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def is_staked(self):
|
def is_staked(self):
|
||||||
# Don't use self.contributed since that ignores stake contributions
|
return True
|
||||||
contributions = ProposalContribution.query \
|
|
||||||
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
|
|
||||||
.all()
|
|
||||||
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
|
|
||||||
return Decimal(funded) >= PROPOSAL_STAKING_AMOUNT
|
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def is_funded(self):
|
def is_funded(self):
|
||||||
|
@ -670,6 +761,48 @@ class Proposal(db.Model):
|
||||||
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
|
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
|
||||||
return d.values()
|
return d.values()
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_follows(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(proposal_follower)
|
||||||
|
.filter_by(user_id=authed.id, proposal_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_liked(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(proposal_liker)
|
||||||
|
.filter_by(user_id=authed.id, proposal_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def get_tip_jar_view_key(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if authed not in self.team:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self.tip_jar_view_key
|
||||||
|
|
||||||
|
|
||||||
class ProposalSchema(ma.Schema):
|
class ProposalSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -694,7 +827,6 @@ class ProposalSchema(ma.Schema):
|
||||||
"updates",
|
"updates",
|
||||||
"milestones",
|
"milestones",
|
||||||
"current_milestone",
|
"current_milestone",
|
||||||
"category",
|
|
||||||
"team",
|
"team",
|
||||||
"payout_address",
|
"payout_address",
|
||||||
"deadline_duration",
|
"deadline_duration",
|
||||||
|
@ -703,13 +835,23 @@ class ProposalSchema(ma.Schema):
|
||||||
"invites",
|
"invites",
|
||||||
"rfp",
|
"rfp",
|
||||||
"rfp_opt_in",
|
"rfp_opt_in",
|
||||||
"arbiter"
|
"arbiter",
|
||||||
|
"accepted_with_funding",
|
||||||
|
"is_version_two",
|
||||||
|
"authed_follows",
|
||||||
|
"followers_count",
|
||||||
|
"authed_liked",
|
||||||
|
"likes_count",
|
||||||
|
"tip_jar_address",
|
||||||
|
"tip_jar_view_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
date_approved = ma.Method("get_date_approved")
|
date_approved = ma.Method("get_date_approved")
|
||||||
date_published = ma.Method("get_date_published")
|
date_published = ma.Method("get_date_published")
|
||||||
proposal_id = ma.Method("get_proposal_id")
|
proposal_id = ma.Method("get_proposal_id")
|
||||||
|
is_version_two = ma.Method("get_is_version_two")
|
||||||
|
tip_jar_view_key = ma.Method("get_tip_jar_view_key")
|
||||||
|
|
||||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
team = ma.Nested("UserSchema", many=True)
|
team = ma.Nested("UserSchema", many=True)
|
||||||
|
@ -731,6 +873,11 @@ class ProposalSchema(ma.Schema):
|
||||||
def get_date_published(self, obj):
|
def get_date_published(self, obj):
|
||||||
return dt_to_unix(obj.date_published) if obj.date_published else None
|
return dt_to_unix(obj.date_published) if obj.date_published else None
|
||||||
|
|
||||||
|
def get_is_version_two(self, obj):
|
||||||
|
return True if obj.version == '2' else False
|
||||||
|
|
||||||
|
def get_tip_jar_view_key(self, obj):
|
||||||
|
return obj.get_tip_jar_view_key
|
||||||
|
|
||||||
proposal_schema = ProposalSchema()
|
proposal_schema = ProposalSchema()
|
||||||
proposals_schema = ProposalSchema(many=True)
|
proposals_schema = ProposalSchema(many=True)
|
||||||
|
@ -748,6 +895,10 @@ user_fields = [
|
||||||
"date_published",
|
"date_published",
|
||||||
"reject_reason",
|
"reject_reason",
|
||||||
"team",
|
"team",
|
||||||
|
"accepted_with_funding",
|
||||||
|
"is_version_two",
|
||||||
|
"authed_follows",
|
||||||
|
"authed_liked"
|
||||||
]
|
]
|
||||||
user_proposal_schema = ProposalSchema(only=user_fields)
|
user_proposal_schema = ProposalSchema(only=user_fields)
|
||||||
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, g, request, current_app
|
from flask import Blueprint, g, request, current_app
|
||||||
from marshmallow import fields, validate
|
from marshmallow import fields, validate
|
||||||
|
@ -13,7 +14,7 @@ from grant.milestone.models import Milestone
|
||||||
from grant.parser import body, query, paginated_fields
|
from grant.parser import body, query, paginated_fields
|
||||||
from grant.rfp.models import RFP
|
from grant.rfp.models import RFP
|
||||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||||
from grant.task.jobs import ProposalDeadline
|
from grant.task.jobs import ProposalDeadline, PruneDraft
|
||||||
from grant.user.models import User
|
from grant.user.models import User
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from grant.utils.auth import (
|
from grant.utils.auth import (
|
||||||
|
@ -24,8 +25,9 @@ from grant.utils.auth import (
|
||||||
get_authed_user,
|
get_authed_user,
|
||||||
internal_webhook
|
internal_webhook
|
||||||
)
|
)
|
||||||
|
from grant.utils.requests import validate_blockchain_get
|
||||||
from grant.utils.enums import Category
|
from grant.utils.enums import Category
|
||||||
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, RFPStatus
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import is_email, make_url, from_zat, make_explore_url
|
from grant.utils.misc import is_email, make_url, from_zat, make_explore_url
|
||||||
from .models import (
|
from .models import (
|
||||||
|
@ -108,6 +110,9 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
||||||
if not proposal:
|
if not proposal:
|
||||||
return {"message": "No proposal matching id"}, 404
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
if proposal.status != ProposalStatus.LIVE:
|
||||||
|
return {"message": "Proposal must be live to comment"}, 400
|
||||||
|
|
||||||
# Make sure the parent comment exists
|
# Make sure the parent comment exists
|
||||||
parent = None
|
parent = None
|
||||||
if parent_comment_id:
|
if parent_comment_id:
|
||||||
|
@ -187,10 +192,16 @@ 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.category = rfp.category
|
if datetime.now() > rfp.date_closes:
|
||||||
|
return {"message": "The request this proposal was made for has expired"}, 400
|
||||||
|
if rfp.status == RFPStatus.CLOSED:
|
||||||
|
return {"message": "The request this proposal was made for has been closed"}, 400
|
||||||
rfp.proposals.append(proposal)
|
rfp.proposals.append(proposal)
|
||||||
db.session.add(rfp)
|
db.session.add(rfp)
|
||||||
|
|
||||||
|
task = PruneDraft(proposal)
|
||||||
|
task.make_task()
|
||||||
|
|
||||||
db.session.add(proposal)
|
db.session.add(proposal)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return proposal_schema.dump(proposal), 201
|
return proposal_schema.dump(proposal), 201
|
||||||
|
@ -219,11 +230,10 @@ def get_proposal_drafts():
|
||||||
# Length checks are to prevent database errors, not actual user limits imposed
|
# Length checks are to prevent database errors, not actual user limits imposed
|
||||||
"title": fields.Str(required=True),
|
"title": fields.Str(required=True),
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
|
|
||||||
"content": fields.Str(required=True),
|
"content": fields.Str(required=True),
|
||||||
"target": fields.Str(required=True),
|
"target": fields.Str(required=True),
|
||||||
"payoutAddress": fields.Str(required=True),
|
"payoutAddress": fields.Str(required=True),
|
||||||
"deadlineDuration": fields.Int(required=True),
|
"tipJarAddress": fields.Str(required=False, missing=None),
|
||||||
"milestones": fields.List(fields.Dict(), required=True),
|
"milestones": fields.List(fields.Dict(), required=True),
|
||||||
"rfpOptIn": fields.Bool(required=False, missing=None),
|
"rfpOptIn": fields.Bool(required=False, missing=None),
|
||||||
})
|
})
|
||||||
|
@ -251,6 +261,26 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/tips", methods=["PUT"])
|
||||||
|
@requires_team_member_auth
|
||||||
|
@body({
|
||||||
|
"address": fields.Str(required=False, missing=None),
|
||||||
|
"viewKey": fields.Str(required=False, missing=None)
|
||||||
|
})
|
||||||
|
def update_proposal_tip_jar(proposal_id, address, view_key):
|
||||||
|
if address is not None:
|
||||||
|
if address is not '':
|
||||||
|
validate_blockchain_get('/validate/address', {'address': address})
|
||||||
|
|
||||||
|
g.current_proposal.tip_jar_address = address
|
||||||
|
if view_key is not None:
|
||||||
|
g.current_proposal.tip_jar_view_key = view_key
|
||||||
|
|
||||||
|
db.session.add(g.current_proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/rfp", methods=["DELETE"])
|
@blueprint.route("/<proposal_id>/rfp", methods=["DELETE"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
def unlink_proposal_from_rfp(proposal_id):
|
def unlink_proposal_from_rfp(proposal_id):
|
||||||
|
@ -293,17 +323,6 @@ def submit_for_approval_proposal(proposal_id):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
|
|
||||||
@requires_team_member_auth
|
|
||||||
def get_proposal_stake(proposal_id):
|
|
||||||
if g.current_proposal.status != ProposalStatus.STAKING:
|
|
||||||
return {"message": "ok"}, 400
|
|
||||||
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
|
|
||||||
if contribution:
|
|
||||||
return proposal_contribution_schema.dump(contribution)
|
|
||||||
return {"message": "ok"}, 404
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
def publish_proposal(proposal_id):
|
def publish_proposal(proposal_id):
|
||||||
|
@ -367,6 +386,11 @@ def post_proposal_update(proposal_id, title, content):
|
||||||
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
|
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Send email to all followers
|
||||||
|
g.current_proposal.send_follower_email(
|
||||||
|
"followed_proposal_update", url_suffix="?tab=updates"
|
||||||
|
)
|
||||||
|
|
||||||
dumped_update = proposal_update_schema.dump(update)
|
dumped_update = proposal_update_schema.dump(update)
|
||||||
return dumped_update, 201
|
return dumped_update, 201
|
||||||
|
|
||||||
|
@ -566,9 +590,6 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
||||||
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
|
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
|
||||||
})
|
})
|
||||||
|
|
||||||
# on funding target reached.
|
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {"message": "ok"}, 200
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
@ -662,3 +683,37 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
return {"message": "No milestone matching id"}, 404
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/follow", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isFollow": fields.Bool(required=True)})
|
||||||
|
def follow_proposal(proposal_id, is_follow):
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure proposal exists
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
proposal.follow(user, is_follow)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/like", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isLiked": fields.Bool(required=True)})
|
||||||
|
def like_proposal(proposal_id, is_liked):
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure proposal exists
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
if not proposal.status == ProposalStatus.LIVE:
|
||||||
|
return {"message": "Cannot like a proposal that's not live"}, 404
|
||||||
|
|
||||||
|
proposal.like(user, is_liked)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,19 @@ from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import column_property
|
||||||
from grant.utils.enums import RFPStatus
|
from grant.utils.enums import RFPStatus
|
||||||
from grant.utils.misc import dt_to_unix, gen_random_id
|
from grant.utils.misc import dt_to_unix, gen_random_id
|
||||||
from grant.utils.enums import Category
|
from grant.utils.enums import Category
|
||||||
|
|
||||||
|
rfp_liker = db.Table(
|
||||||
|
"rfp_liker",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("rfp_id", db.Integer, db.ForeignKey("rfp.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RFP(db.Model):
|
class RFP(db.Model):
|
||||||
__tablename__ = "rfp"
|
__tablename__ = "rfp"
|
||||||
|
@ -16,13 +25,16 @@ class RFP(db.Model):
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
brief = db.Column(db.String(255), nullable=False)
|
brief = db.Column(db.String(255), nullable=False)
|
||||||
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=True)
|
||||||
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)
|
matching = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
_bounty = db.Column("bounty", db.String(255), nullable=True)
|
_bounty = db.Column("bounty", db.String(255), nullable=True)
|
||||||
date_closes = db.Column(db.DateTime, nullable=True)
|
date_closes = db.Column(db.DateTime, nullable=True)
|
||||||
date_opened = db.Column(db.DateTime, nullable=True)
|
date_opened = db.Column(db.DateTime, nullable=True)
|
||||||
date_closed = db.Column(db.DateTime, nullable=True)
|
date_closed = db.Column(db.DateTime, nullable=True)
|
||||||
|
version = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
ccr = db.relationship("CCR", uselist=False, back_populates="rfp")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
proposals = db.relationship(
|
proposals = db.relationship(
|
||||||
|
@ -38,6 +50,15 @@ class RFP(db.Model):
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
likes = db.relationship(
|
||||||
|
"User", secondary=rfp_liker, back_populates="liked_rfps"
|
||||||
|
)
|
||||||
|
likes_count = column_property(
|
||||||
|
select([func.count(rfp_liker.c.rfp_id)])
|
||||||
|
.where(rfp_liker.c.rfp_id == id)
|
||||||
|
.correlate_except(rfp_liker)
|
||||||
|
)
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def bounty(self):
|
def bounty(self):
|
||||||
return self._bounty
|
return self._bounty
|
||||||
|
@ -49,29 +70,50 @@ class RFP(db.Model):
|
||||||
else:
|
else:
|
||||||
self._bounty = None
|
self._bounty = None
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_liked(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(rfp_liker)
|
||||||
|
.filter_by(user_id=authed.id, rfp_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def like(self, user, is_liked):
|
||||||
|
if is_liked:
|
||||||
|
self.likes.append(user)
|
||||||
|
else:
|
||||||
|
self.likes.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
title: str,
|
title: str,
|
||||||
brief: str,
|
brief: str,
|
||||||
content: str,
|
content: str,
|
||||||
category: str,
|
|
||||||
bounty: str,
|
bounty: str,
|
||||||
date_closes: datetime,
|
date_closes: datetime,
|
||||||
matching: bool = False,
|
matching: bool = False,
|
||||||
status: str = RFPStatus.DRAFT,
|
status: str = RFPStatus.DRAFT,
|
||||||
):
|
):
|
||||||
assert RFPStatus.includes(status)
|
assert RFPStatus.includes(status)
|
||||||
assert Category.includes(category)
|
|
||||||
self.id = gen_random_id(RFP)
|
self.id = gen_random_id(RFP)
|
||||||
self.date_created = datetime.now()
|
self.date_created = datetime.now()
|
||||||
self.title = title[:255]
|
self.title = title[:255]
|
||||||
self.brief = brief[:255]
|
self.brief = brief[:255]
|
||||||
self.content = content
|
self.content = content
|
||||||
self.category = category
|
|
||||||
self.bounty = bounty
|
self.bounty = bounty
|
||||||
self.date_closes = date_closes
|
self.date_closes = date_closes
|
||||||
self.matching = matching
|
self.matching = matching
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.version = '2'
|
||||||
|
|
||||||
|
|
||||||
class RFPSchema(ma.Schema):
|
class RFPSchema(ma.Schema):
|
||||||
|
@ -83,7 +125,6 @@ class RFPSchema(ma.Schema):
|
||||||
"title",
|
"title",
|
||||||
"brief",
|
"brief",
|
||||||
"content",
|
"content",
|
||||||
"category",
|
|
||||||
"status",
|
"status",
|
||||||
"matching",
|
"matching",
|
||||||
"bounty",
|
"bounty",
|
||||||
|
@ -92,13 +133,19 @@ class RFPSchema(ma.Schema):
|
||||||
"date_opened",
|
"date_opened",
|
||||||
"date_closed",
|
"date_closed",
|
||||||
"accepted_proposals",
|
"accepted_proposals",
|
||||||
|
"authed_liked",
|
||||||
|
"likes_count",
|
||||||
|
"is_version_two",
|
||||||
|
"ccr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||||
status = ma.Method("get_status")
|
status = ma.Method("get_status")
|
||||||
date_closes = ma.Method("get_date_closes")
|
date_closes = ma.Method("get_date_closes")
|
||||||
date_opened = ma.Method("get_date_opened")
|
date_opened = ma.Method("get_date_opened")
|
||||||
date_closed = ma.Method("get_date_closed")
|
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"])
|
||||||
|
is_version_two = ma.Method("get_is_version_two")
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
# Force it into closed state if date_closes is in the past
|
# Force it into closed state if date_closes is in the past
|
||||||
|
@ -115,6 +162,9 @@ class RFPSchema(ma.Schema):
|
||||||
def get_date_closed(self, obj):
|
def get_date_closed(self, obj):
|
||||||
return dt_to_unix(obj.date_closed) if obj.date_closed else None
|
return dt_to_unix(obj.date_closed) if obj.date_closed else None
|
||||||
|
|
||||||
|
def get_is_version_two(self, obj):
|
||||||
|
return True if obj.version == '2' else False
|
||||||
|
|
||||||
|
|
||||||
rfp_schema = RFPSchema()
|
rfp_schema = RFPSchema()
|
||||||
rfps_schema = RFPSchema(many=True)
|
rfps_schema = RFPSchema(many=True)
|
||||||
|
@ -129,7 +179,6 @@ class AdminRFPSchema(ma.Schema):
|
||||||
"title",
|
"title",
|
||||||
"brief",
|
"brief",
|
||||||
"content",
|
"content",
|
||||||
"category",
|
|
||||||
"status",
|
"status",
|
||||||
"matching",
|
"matching",
|
||||||
"bounty",
|
"bounty",
|
||||||
|
@ -138,14 +187,18 @@ class AdminRFPSchema(ma.Schema):
|
||||||
"date_opened",
|
"date_opened",
|
||||||
"date_closed",
|
"date_closed",
|
||||||
"proposals",
|
"proposals",
|
||||||
|
"is_version_two",
|
||||||
|
"ccr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||||
status = ma.Method("get_status")
|
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_closes = ma.Method("get_date_closes")
|
||||||
date_opened = ma.Method("get_date_opened")
|
date_opened = ma.Method("get_date_opened")
|
||||||
date_closed = ma.Method("get_date_closed")
|
date_closed = ma.Method("get_date_closed")
|
||||||
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
||||||
|
is_version_two = ma.Method("get_is_version_two")
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
# Force it into closed state if date_closes is in the past
|
# Force it into closed state if date_closes is in the past
|
||||||
|
@ -165,6 +218,9 @@ class AdminRFPSchema(ma.Schema):
|
||||||
def get_date_closed(self, obj):
|
def get_date_closed(self, obj):
|
||||||
return dt_to_unix(obj.date_closes) if obj.date_closes else None
|
return dt_to_unix(obj.date_closes) if obj.date_closes else None
|
||||||
|
|
||||||
|
def get_is_version_two(self, obj):
|
||||||
|
return True if obj.version == '2' else False
|
||||||
|
|
||||||
|
|
||||||
admin_rfp_schema = AdminRFPSchema()
|
admin_rfp_schema = AdminRFPSchema()
|
||||||
admin_rfps_schema = AdminRFPSchema(many=True)
|
admin_rfps_schema = AdminRFPSchema(many=True)
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
from flask import Blueprint
|
from flask import Blueprint, g
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from grant.utils.enums import RFPStatus
|
from grant.utils.enums import RFPStatus
|
||||||
from .models import RFP, rfp_schema, rfps_schema
|
from grant.utils.auth import requires_auth
|
||||||
|
from grant.parser import body
|
||||||
|
from .models import RFP, rfp_schema, rfps_schema, db
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
|
blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
|
||||||
|
|
||||||
|
@ -25,3 +28,20 @@ def get_rfp(rfp_id):
|
||||||
if not rfp or rfp.status == RFPStatus.DRAFT:
|
if not rfp or rfp.status == RFPStatus.DRAFT:
|
||||||
return {"message": "No RFP with that ID"}, 404
|
return {"message": "No RFP with that ID"}, 404
|
||||||
return rfp_schema.dump(rfp)
|
return rfp_schema.dump(rfp)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<rfp_id>/like", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isLiked": fields.Bool(required=True)})
|
||||||
|
def like_rfp(rfp_id, is_liked):
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure rfp exists
|
||||||
|
rfp = RFP.query.filter_by(id=rfp_id).first()
|
||||||
|
if not rfp:
|
||||||
|
return {"message": "No RFP matching id"}, 404
|
||||||
|
if not rfp.status == RFPStatus.LIVE:
|
||||||
|
return {"message": "RFP is not live"}, 404
|
||||||
|
|
||||||
|
rfp.like(user, is_liked)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
|
@ -60,11 +60,14 @@ LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
|
||||||
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
|
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
|
||||||
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
||||||
|
|
||||||
|
STAGING_PASSWORD = env.str("STAGING_PASSWORD", default=None)
|
||||||
|
|
||||||
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
|
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
|
||||||
|
|
||||||
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
||||||
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
|
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
|
||||||
|
|
||||||
|
|
||||||
UI = {
|
UI = {
|
||||||
'NAME': 'ZF Grants',
|
'NAME': 'ZF Grants',
|
||||||
'PRIMARY': '#CF8A00',
|
'PRIMARY': '#CF8A00',
|
||||||
|
|
|
@ -2,7 +2,7 @@ from datetime import datetime, timedelta
|
||||||
|
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.utils.enums import ProposalStage, ContributionStatus
|
from grant.utils.enums import ProposalStage, ContributionStatus, ProposalStatus
|
||||||
from grant.utils.misc import make_url
|
from grant.utils.misc import make_url
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
@ -126,8 +126,117 @@ class ContributionExpired:
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class PruneDraft:
|
||||||
|
JOB_TYPE = 4
|
||||||
|
PRUNE_TIME = 259200 # 72 hours in seconds
|
||||||
|
|
||||||
|
def __init__(self, proposal):
|
||||||
|
self.proposal = proposal
|
||||||
|
|
||||||
|
def blobify(self):
|
||||||
|
return {
|
||||||
|
"proposal_id": self.proposal.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_task(self):
|
||||||
|
from .models import Task
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
job_type=self.JOB_TYPE,
|
||||||
|
blob=self.blobify(),
|
||||||
|
execute_after=self.proposal.date_created + timedelta(seconds=self.PRUNE_TIME),
|
||||||
|
)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_task(task):
|
||||||
|
from grant.proposal.models import Proposal, default_proposal_content
|
||||||
|
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
|
||||||
|
|
||||||
|
# If it was deleted or moved out of a draft, noop out
|
||||||
|
if not proposal or proposal.status != ProposalStatus.DRAFT:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If proposal content deviates from the default, noop out
|
||||||
|
if proposal.content != default_proposal_content():
|
||||||
|
return
|
||||||
|
|
||||||
|
# If any of the remaining proposal fields are filled, noop out
|
||||||
|
if proposal.title or proposal.brief or proposal.category or proposal.target != "0":
|
||||||
|
return
|
||||||
|
|
||||||
|
if proposal.payout_address or proposal.milestones:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Otherwise, delete the empty proposal
|
||||||
|
db.session.delete(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class MilestoneDeadline:
|
||||||
|
JOB_TYPE = 5
|
||||||
|
|
||||||
|
def __init__(self, proposal, milestone):
|
||||||
|
self.proposal = proposal
|
||||||
|
self.milestone = milestone
|
||||||
|
|
||||||
|
def blobify(self):
|
||||||
|
from grant.proposal.models import ProposalUpdate
|
||||||
|
|
||||||
|
update_count = len(ProposalUpdate.query.filter_by(proposal_id=self.proposal.id).all())
|
||||||
|
return {
|
||||||
|
"proposal_id": self.proposal.id,
|
||||||
|
"milestone_id": self.milestone.id,
|
||||||
|
"update_count": update_count
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_task(self):
|
||||||
|
from .models import Task
|
||||||
|
task = Task(
|
||||||
|
job_type=self.JOB_TYPE,
|
||||||
|
blob=self.blobify(),
|
||||||
|
execute_after=self.milestone.date_estimated,
|
||||||
|
)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_task(task):
|
||||||
|
from grant.proposal.models import Proposal, ProposalUpdate
|
||||||
|
from grant.milestone.models import Milestone
|
||||||
|
|
||||||
|
proposal_id = task.blob["proposal_id"]
|
||||||
|
milestone_id = task.blob["milestone_id"]
|
||||||
|
update_count = task.blob["update_count"]
|
||||||
|
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
milestone = Milestone.query.filter_by(id=milestone_id).first()
|
||||||
|
current_update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all())
|
||||||
|
|
||||||
|
# if proposal was deleted or cancelled, noop out
|
||||||
|
if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED:
|
||||||
|
return
|
||||||
|
|
||||||
|
# if milestone was deleted, noop out
|
||||||
|
if not milestone:
|
||||||
|
return
|
||||||
|
|
||||||
|
# if milestone payout has been requested or an update has been posted, noop out
|
||||||
|
if current_update_count > update_count or milestone.date_requested:
|
||||||
|
return
|
||||||
|
|
||||||
|
# send email to arbiter notifying milestone deadline has been missed
|
||||||
|
send_email(proposal.arbiter.user.email_address, 'milestone_deadline', {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
JOBS = {
|
JOBS = {
|
||||||
1: ProposalReminder.process_task,
|
1: ProposalReminder.process_task,
|
||||||
2: ProposalDeadline.process_task,
|
2: ProposalDeadline.process_task,
|
||||||
3: ContributionExpired.process_task,
|
3: ContributionExpired.process_task,
|
||||||
|
4: PruneDraft.process_task,
|
||||||
|
5: MilestoneDeadline.process_task
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
<a href="{{ args.ccr_url }}" target="_blank">
|
||||||
|
{{ args.ccr.title }}</a
|
||||||
|
>
|
||||||
|
is awaiting approval. As an admin you can help out by reviewing it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.ccr_url }}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
Review Request
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{ args.ccr.title }} is awaiting approval. As an admin you can help out by reviewing it.
|
||||||
|
|
||||||
|
Visit the request and review: {{ args.ccr_url }}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the admin team was attached to your approval:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the admin team was attached to your approval:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ args.proposal_url }}
|
|
@ -0,0 +1,19 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your request has changes requested. You're free to modify it
|
||||||
|
and try submitting again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the admin team was attached to your rejection:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
|
||||||
|
Please note that repeated submissions without significant changes or with
|
||||||
|
content that doesn't match the platform guidelines may result in a removal
|
||||||
|
of your submission privileges.
|
||||||
|
</p>
|
|
@ -0,0 +1,12 @@
|
||||||
|
Your request has changes requested. You're free to modify it
|
||||||
|
and try submitting again.
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the admin team was attached to your rejection:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Please note that repeated submissions without significant changes or with
|
||||||
|
content that doesn't match the platform guidelines may result in a removal
|
||||||
|
of your submission privileges.
|
|
@ -0,0 +1,31 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has had its
|
||||||
|
{{ args.milestone.title }}
|
||||||
|
milestone accepted!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has an update!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has an update!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -0,0 +1,32 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
The estimated deadline has been reached for proposal milestone
|
||||||
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_milestones_url }}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
View the milestone
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
The estimated deadline has been reached for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}".
|
||||||
|
|
||||||
|
View the milestone: {{ args.proposal_milestones_url }}
|
|
@ -1,7 +1,5 @@
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
Congratulations on your approval! We look forward to seeing the support your
|
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||||
proposal receives. To get your campaign started, click below and follow the
|
|
||||||
instructions to publish your proposal.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
|
@ -13,22 +11,3 @@
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="border-radius: 3px;" bgcolor="{{ UI.PRIMARY }}">
|
|
||||||
<a
|
|
||||||
href="{{ args.proposal_url }}"
|
|
||||||
target="_blank"
|
|
||||||
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{ UI.PRIMARY }}; display: inline-block;"
|
|
||||||
>
|
|
||||||
Publish your proposal
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
|
@ -1,6 +1,5 @@
|
||||||
Congratulations on your approval! We look forward to seeing the support your
|
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||||
proposal receives. To start the fundraising (and the clock) go to the URL
|
|
||||||
below and publish your proposal.
|
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
A note from the admin team was attached to your approval:
|
A note from the admin team was attached to your approval:
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<p style="margin: 0 0 20px;">
|
<p style="margin: 0 0 20px;">
|
||||||
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
||||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
has been canceled.
|
||||||
shortly.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
||||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
has been canceled.
|
||||||
shortly.
|
|
||||||
|
|
||||||
If you have any further questions, please contact support for more information:
|
If you have any further questions, please contact support for more information:
|
||||||
{{ args.support_url }}
|
{{ args.support_url }}
|
|
@ -1,5 +1,5 @@
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
Your proposal has unfortunately been rejected. You're free to modify it
|
Your proposal has changes requested. You're free to modify it
|
||||||
and try submitting again.
|
and try submitting again.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
Your proposal has unfortunately been rejected. You're free to modify it
|
Your proposal has changes requested. You're free to modify it
|
||||||
and try submitting again.
|
and try submitting again.
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from .models import User, db
|
from .models import User, db, SocialMedia
|
||||||
|
from grant.task.models import Task
|
||||||
|
from grant.settings import STAGING_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
# @click.command()
|
# @click.command()
|
||||||
|
@ -23,7 +25,6 @@ from .models import User, db
|
||||||
# 'account address, or email address of an ' \
|
# 'account address, or email address of an ' \
|
||||||
# 'existing user.')
|
# 'existing user.')
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument('identity')
|
@click.argument('identity')
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
|
@ -36,6 +37,7 @@ def set_admin(identity):
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
user.set_admin(True)
|
user.set_admin(True)
|
||||||
|
user.email_verification.has_verified = True
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
|
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
|
||||||
|
@ -43,3 +45,28 @@ def set_admin(identity):
|
||||||
raise click.BadParameter('''Invalid user identity. Must be a userid,
|
raise click.BadParameter('''Invalid user identity. Must be a userid,
|
||||||
'account address, or email address of an
|
'account address, or email address of an
|
||||||
'existing user.''')
|
'existing user.''')
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@with_appcontext
|
||||||
|
def mangle_users():
|
||||||
|
if STAGING_PASSWORD:
|
||||||
|
print("Mangling all users")
|
||||||
|
for i, user in enumerate(User.query.all()):
|
||||||
|
user.email_address = "random" + str(i) + "@grant.io"
|
||||||
|
user.password = STAGING_PASSWORD
|
||||||
|
# DELETE TOTP SECRET
|
||||||
|
user.totp_secret = None
|
||||||
|
# DELETE BACKUP CODES
|
||||||
|
user.backup_codes = None
|
||||||
|
db.session.add(user)
|
||||||
|
|
||||||
|
# DELETE ALL TASKS
|
||||||
|
for task in Task.query.all():
|
||||||
|
db.session.delete(task)
|
||||||
|
|
||||||
|
# REMOVE ALL SOCIAL MEDIA
|
||||||
|
for social in SocialMedia.query.all():
|
||||||
|
db.session.delete(social)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
|
@ -3,6 +3,7 @@ from flask_security.core import current_user
|
||||||
from flask_security.utils import hash_password, verify_and_update_password, login_user
|
from flask_security.utils import hash_password, verify_and_update_password, login_user
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.email.models import EmailVerification, EmailRecovery
|
from grant.email.models import EmailVerification, EmailRecovery
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.email.subscription_settings import (
|
from grant.email.subscription_settings import (
|
||||||
|
@ -58,6 +59,8 @@ class UserSettings(db.Model):
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||||
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
|
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
|
||||||
refund_address = db.Column(db.String(255), unique=False, nullable=True)
|
refund_address = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
tip_jar_address = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
tip_jar_view_key = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
|
||||||
user = db.relationship("User", back_populates="settings")
|
user = db.relationship("User", back_populates="settings")
|
||||||
|
|
||||||
|
@ -123,6 +126,7 @@ class User(db.Model, UserMixin):
|
||||||
# relations
|
# relations
|
||||||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
||||||
comments = db.relationship(Comment, backref="user", lazy=True)
|
comments = db.relationship(Comment, backref="user", lazy=True)
|
||||||
|
ccrs = db.relationship(CCR, back_populates="author", lazy=True, cascade="all, delete-orphan")
|
||||||
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
||||||
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
||||||
lazy=True, cascade="all, delete-orphan")
|
lazy=True, cascade="all, delete-orphan")
|
||||||
|
@ -133,6 +137,18 @@ class User(db.Model, UserMixin):
|
||||||
roles = db.relationship('Role', secondary='roles_users',
|
roles = db.relationship('Role', secondary='roles_users',
|
||||||
backref=db.backref('users', lazy='dynamic'))
|
backref=db.backref('users', lazy='dynamic'))
|
||||||
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
||||||
|
followed_proposals = db.relationship(
|
||||||
|
"Proposal", secondary="proposal_follower", back_populates="followers"
|
||||||
|
)
|
||||||
|
liked_proposals = db.relationship(
|
||||||
|
"Proposal", secondary="proposal_liker", back_populates="likes"
|
||||||
|
)
|
||||||
|
liked_comments = db.relationship(
|
||||||
|
"Comment", secondary="comment_liker", back_populates="likes"
|
||||||
|
)
|
||||||
|
liked_rfps = db.relationship(
|
||||||
|
"RFP", secondary="rfp_liker", back_populates="likes"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -343,13 +359,15 @@ class UserSchema(ma.Schema):
|
||||||
"avatar",
|
"avatar",
|
||||||
"display_name",
|
"display_name",
|
||||||
"userid",
|
"userid",
|
||||||
"email_verified"
|
"email_verified",
|
||||||
|
"tip_jar_address"
|
||||||
)
|
)
|
||||||
|
|
||||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||||
avatar = ma.Nested("AvatarSchema")
|
avatar = ma.Nested("AvatarSchema")
|
||||||
userid = ma.Method("get_userid")
|
userid = ma.Method("get_userid")
|
||||||
email_verified = ma.Method("get_email_verified")
|
email_verified = ma.Method("get_email_verified")
|
||||||
|
tip_jar_address = ma.Method("get_tip_jar_address")
|
||||||
|
|
||||||
def get_userid(self, obj):
|
def get_userid(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
@ -357,6 +375,9 @@ class UserSchema(ma.Schema):
|
||||||
def get_email_verified(self, obj):
|
def get_email_verified(self, obj):
|
||||||
return obj.email_verification.has_verified
|
return obj.email_verification.has_verified
|
||||||
|
|
||||||
|
def get_tip_jar_address(self, obj):
|
||||||
|
return obj.settings.tip_jar_address
|
||||||
|
|
||||||
|
|
||||||
user_schema = UserSchema()
|
user_schema = UserSchema()
|
||||||
users_schema = UserSchema(many=True)
|
users_schema = UserSchema(many=True)
|
||||||
|
@ -399,6 +420,8 @@ class UserSettingsSchema(ma.Schema):
|
||||||
fields = (
|
fields = (
|
||||||
"email_subscriptions",
|
"email_subscriptions",
|
||||||
"refund_address",
|
"refund_address",
|
||||||
|
"tip_jar_address",
|
||||||
|
"tip_jar_view_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -8,17 +8,18 @@ from webargs import validate
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
from grant.comment.models import Comment, user_comments_schema
|
from grant.comment.models import Comment, user_comments_schema
|
||||||
from grant.email.models import EmailRecovery
|
from grant.email.models import EmailRecovery
|
||||||
|
from grant.ccr.models import CCR, ccrs_schema
|
||||||
from grant.extensions import limiter
|
from grant.extensions import limiter
|
||||||
from grant.parser import query, body
|
from grant.parser import query, body
|
||||||
from grant.proposal.models import (
|
from grant.proposal.models import (
|
||||||
Proposal,
|
Proposal,
|
||||||
ProposalTeamInvite,
|
ProposalTeamInvite,
|
||||||
invites_with_proposal_schema,
|
invites_with_proposal_schema,
|
||||||
ProposalContribution,
|
|
||||||
user_proposal_contributions_schema,
|
user_proposal_contributions_schema,
|
||||||
user_proposals_schema,
|
user_proposals_schema,
|
||||||
user_proposal_arbiters_schema
|
user_proposal_arbiters_schema
|
||||||
)
|
)
|
||||||
|
from grant.proposal.models import ProposalContribution
|
||||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ContributionStatus
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.requests import validate_blockchain_get
|
from grant.utils.requests import validate_blockchain_get
|
||||||
|
@ -50,14 +51,20 @@ def get_me():
|
||||||
"withComments": fields.Bool(required=False, missing=None),
|
"withComments": fields.Bool(required=False, missing=None),
|
||||||
"withFunded": fields.Bool(required=False, missing=None),
|
"withFunded": fields.Bool(required=False, missing=None),
|
||||||
"withPending": fields.Bool(required=False, missing=None),
|
"withPending": fields.Bool(required=False, missing=None),
|
||||||
"withArbitrated": fields.Bool(required=False, missing=None)
|
"withArbitrated": fields.Bool(required=False, missing=None),
|
||||||
|
"withRequests": fields.Bool(required=False, missing=None)
|
||||||
|
|
||||||
})
|
})
|
||||||
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated):
|
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests):
|
||||||
user = User.get_by_id(user_id)
|
user = User.get_by_id(user_id)
|
||||||
if user:
|
if user:
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
authed_user = auth.get_authed_user()
|
authed_user = auth.get_authed_user()
|
||||||
is_self = authed_user and authed_user.id == user.id
|
is_self = authed_user and authed_user.id == user.id
|
||||||
|
if with_requests:
|
||||||
|
requests = CCR.get_by_user(user)
|
||||||
|
requests_dump = ccrs_schema.dump(requests)
|
||||||
|
result["requests"] = requests_dump
|
||||||
if with_proposals:
|
if with_proposals:
|
||||||
proposals = Proposal.get_by_user(user)
|
proposals = Proposal.get_by_user(user)
|
||||||
proposals_dump = user_proposals_schema.dump(proposals)
|
proposals_dump = user_proposals_schema.dump(proposals)
|
||||||
|
@ -75,14 +82,22 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
||||||
comments_dump = user_comments_schema.dump(comments)
|
comments_dump = user_comments_schema.dump(comments)
|
||||||
result["comments"] = comments_dump
|
result["comments"] = comments_dump
|
||||||
if with_pending and is_self:
|
if with_pending and is_self:
|
||||||
pending = Proposal.get_by_user(user, [
|
pending_proposals = Proposal.get_by_user(user, [
|
||||||
ProposalStatus.STAKING,
|
ProposalStatus.STAKING,
|
||||||
ProposalStatus.PENDING,
|
ProposalStatus.PENDING,
|
||||||
ProposalStatus.APPROVED,
|
ProposalStatus.APPROVED,
|
||||||
ProposalStatus.REJECTED,
|
ProposalStatus.REJECTED,
|
||||||
])
|
])
|
||||||
pending_dump = user_proposals_schema.dump(pending)
|
pending_proposals_dump = user_proposals_schema.dump(pending_proposals)
|
||||||
result["pendingProposals"] = pending_dump
|
result["pendingProposals"] = pending_proposals_dump
|
||||||
|
pending_ccrs = CCR.get_by_user(user, [
|
||||||
|
ProposalStatus.STAKING,
|
||||||
|
ProposalStatus.PENDING,
|
||||||
|
ProposalStatus.APPROVED,
|
||||||
|
ProposalStatus.REJECTED,
|
||||||
|
])
|
||||||
|
pending_ccrs_dump = ccrs_schema.dump(pending_ccrs)
|
||||||
|
result["pendingRequests"] = pending_ccrs_dump
|
||||||
if with_arbitrated and is_self:
|
if with_arbitrated and is_self:
|
||||||
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
||||||
|
|
||||||
|
@ -349,9 +364,11 @@ def get_user_settings(user_id):
|
||||||
@body({
|
@body({
|
||||||
"emailSubscriptions": fields.Dict(required=False, missing=None),
|
"emailSubscriptions": fields.Dict(required=False, missing=None),
|
||||||
"refundAddress": fields.Str(required=False, missing=None,
|
"refundAddress": fields.Str(required=False, missing=None,
|
||||||
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r}))
|
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r})),
|
||||||
|
"tipJarAddress": fields.Str(required=False, missing=None),
|
||||||
|
"tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here
|
||||||
})
|
})
|
||||||
def set_user_settings(user_id, email_subscriptions, refund_address):
|
def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_address, tip_jar_view_key):
|
||||||
if email_subscriptions:
|
if email_subscriptions:
|
||||||
try:
|
try:
|
||||||
email_subscriptions = keys_to_snake_case(email_subscriptions)
|
email_subscriptions = keys_to_snake_case(email_subscriptions)
|
||||||
|
@ -364,6 +381,14 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
|
||||||
if refund_address:
|
if refund_address:
|
||||||
g.current_user.settings.refund_address = refund_address
|
g.current_user.settings.refund_address = refund_address
|
||||||
|
|
||||||
|
if tip_jar_address is not None:
|
||||||
|
if tip_jar_address is not '':
|
||||||
|
validate_blockchain_get('/validate/address', {'address': tip_jar_address})
|
||||||
|
|
||||||
|
g.current_user.settings.tip_jar_address = tip_jar_address
|
||||||
|
if tip_jar_view_key is not None:
|
||||||
|
g.current_user.settings.tip_jar_view_key = tip_jar_view_key
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return user_settings_schema.dump(g.current_user.settings)
|
return user_settings_schema.dump(g.current_user.settings)
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
from functools import wraps
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from flask import request, g, jsonify, session, current_app
|
from flask import request, g, jsonify, session, current_app
|
||||||
from flask_security.core import current_user
|
from flask_security.core import current_user
|
||||||
from flask_security.utils import logout_user
|
from flask_security.utils import logout_user
|
||||||
from grant.proposal.models import Proposal
|
|
||||||
from grant.settings import BLOCKCHAIN_API_SECRET
|
from grant.settings import BLOCKCHAIN_API_SECRET
|
||||||
from grant.user.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class AuthException(Exception):
|
class AuthException(Exception):
|
||||||
|
@ -28,7 +27,7 @@ def throw_on_banned(user):
|
||||||
raise AuthException("You are banned")
|
raise AuthException("You are banned")
|
||||||
|
|
||||||
|
|
||||||
def is_auth_fresh(minutes: int=20):
|
def is_auth_fresh(minutes: int = 20):
|
||||||
if 'last_login_time' in session:
|
if 'last_login_time' in session:
|
||||||
last = session['last_login_time']
|
last = session['last_login_time']
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
@ -41,6 +40,8 @@ def is_email_verified():
|
||||||
|
|
||||||
|
|
||||||
def auth_user(email, password):
|
def auth_user(email, password):
|
||||||
|
from grant.user.models import User
|
||||||
|
|
||||||
existing_user = User.get_by_email(email)
|
existing_user = User.get_by_email(email)
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
raise AuthException("No user exists with that email")
|
raise AuthException("No user exists with that email")
|
||||||
|
@ -85,6 +86,8 @@ def requires_auth(f):
|
||||||
def requires_same_user_auth(f):
|
def requires_same_user_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.user.models import User
|
||||||
|
|
||||||
user_id = kwargs["user_id"]
|
user_id = kwargs["user_id"]
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500
|
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500
|
||||||
|
@ -114,6 +117,8 @@ def requires_email_verified_auth(f):
|
||||||
def requires_team_member_auth(f):
|
def requires_team_member_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.proposal.models import Proposal
|
||||||
|
|
||||||
proposal_id = kwargs["proposal_id"]
|
proposal_id = kwargs["proposal_id"]
|
||||||
if not proposal_id:
|
if not proposal_id:
|
||||||
return jsonify(message="Decorator requires_team_member_auth requires path variable <proposal_id>"), 500
|
return jsonify(message="Decorator requires_team_member_auth requires path variable <proposal_id>"), 500
|
||||||
|
@ -131,9 +136,33 @@ def requires_team_member_auth(f):
|
||||||
return requires_email_verified_auth(decorated)
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
|
def requires_ccr_owner_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.ccr.models import CCR
|
||||||
|
|
||||||
|
ccr_id = kwargs["ccr_id"]
|
||||||
|
if not ccr_id:
|
||||||
|
return jsonify(message="Decorator requires_ccr_owner_auth requires path variable <ccr_id>"), 500
|
||||||
|
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if not ccr:
|
||||||
|
return jsonify(message="No CCR exists with id {}".format(ccr_id)), 404
|
||||||
|
|
||||||
|
if g.current_user.id != ccr.author.id:
|
||||||
|
return jsonify(message="You are not authorized to modify this CCR"), 403
|
||||||
|
|
||||||
|
g.current_ccr = ccr
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
def requires_arbiter_auth(f):
|
def requires_arbiter_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.proposal.models import Proposal
|
||||||
|
|
||||||
proposal_id = kwargs["proposal_id"]
|
proposal_id = kwargs["proposal_id"]
|
||||||
if not proposal_id:
|
if not proposal_id:
|
||||||
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500
|
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500
|
||||||
|
|
|
@ -11,10 +11,22 @@ class CustomEnum():
|
||||||
not attr.startswith('__')]
|
not attr.startswith('__')]
|
||||||
|
|
||||||
|
|
||||||
class ProposalStatusEnum(CustomEnum):
|
class CCRStatusEnum(CustomEnum):
|
||||||
DRAFT = 'DRAFT'
|
DRAFT = 'DRAFT'
|
||||||
PENDING = 'PENDING'
|
PENDING = 'PENDING'
|
||||||
|
APPROVED = 'APPROVED'
|
||||||
|
REJECTED = 'REJECTED'
|
||||||
|
LIVE = 'LIVE'
|
||||||
|
DELETED = 'DELETED'
|
||||||
|
|
||||||
|
|
||||||
|
CCRStatus = CCRStatusEnum()
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalStatusEnum(CustomEnum):
|
||||||
|
DRAFT = 'DRAFT'
|
||||||
STAKING = 'STAKING'
|
STAKING = 'STAKING'
|
||||||
|
PENDING = 'PENDING'
|
||||||
APPROVED = 'APPROVED'
|
APPROVED = 'APPROVED'
|
||||||
REJECTED = 'REJECTED'
|
REJECTED = 'REJECTED'
|
||||||
LIVE = 'LIVE'
|
LIVE = 'LIVE'
|
||||||
|
@ -34,7 +46,6 @@ ProposalSort = ProposalSortEnum()
|
||||||
|
|
||||||
class ProposalStageEnum(CustomEnum):
|
class ProposalStageEnum(CustomEnum):
|
||||||
PREVIEW = 'PREVIEW'
|
PREVIEW = 'PREVIEW'
|
||||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
|
||||||
WIP = 'WIP'
|
WIP = 'WIP'
|
||||||
COMPLETED = 'COMPLETED'
|
COMPLETED = 'COMPLETED'
|
||||||
FAILED = 'FAILED'
|
FAILED = 'FAILED'
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import abc
|
import abc
|
||||||
from sqlalchemy import or_, and_
|
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.comment.models import Comment, comments_schema
|
from grant.comment.models import Comment, comments_schema
|
||||||
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
|
||||||
from grant.comment.models import Comment, comments_schema
|
|
||||||
from grant.user.models import User, UserSettings, users_schema
|
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
|
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
||||||
|
from grant.user.models import User, UserSettings, users_schema
|
||||||
|
from .enums import CCRStatus, ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, \
|
||||||
|
MilestoneStage
|
||||||
|
|
||||||
|
|
||||||
def extract_filters(sw, strings):
|
def extract_filters(sw, strings):
|
||||||
|
@ -39,13 +41,13 @@ class Pagination(abc.ABC):
|
||||||
# consider moving these args into __init__ and attaching to self
|
# consider moving these args into __init__ and attaching to self
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema,
|
schema: ma.Schema,
|
||||||
query: db.Query,
|
query: db.Query,
|
||||||
page: int,
|
page: int,
|
||||||
filters: list,
|
filters: list,
|
||||||
search: str,
|
search: str,
|
||||||
sort: str,
|
sort: str,
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -58,6 +60,7 @@ class ProposalPagination(Pagination):
|
||||||
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
|
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
|
||||||
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
|
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
|
||||||
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
|
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
|
||||||
|
self.FILTERS.extend(['ACCEPTED_WITH_FUNDING', 'ACCEPTED_WITHOUT_FUNDING'])
|
||||||
self.PAGE_SIZE = 9
|
self.PAGE_SIZE = 9
|
||||||
self.SORT_MAP = {
|
self.SORT_MAP = {
|
||||||
'CREATED:DESC': Proposal.date_created.desc(),
|
'CREATED:DESC': Proposal.date_created.desc(),
|
||||||
|
@ -67,13 +70,13 @@ class ProposalPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema,
|
schema: ma.Schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='PUBLISHED:DESC',
|
sort: str = 'PUBLISHED:DESC',
|
||||||
):
|
):
|
||||||
query = query or Proposal.query
|
query = query or Proposal.query
|
||||||
sort = sort or 'PUBLISHED:DESC'
|
sort = sort or 'PUBLISHED:DESC'
|
||||||
|
@ -102,6 +105,10 @@ class ProposalPagination(Pagination):
|
||||||
if milestone_filters:
|
if milestone_filters:
|
||||||
query = query.join(Proposal.milestones) \
|
query = query.join(Proposal.milestones) \
|
||||||
.filter(Milestone.stage.in_(milestone_filters))
|
.filter(Milestone.stage.in_(milestone_filters))
|
||||||
|
if 'ACCEPTED_WITH_FUNDING' in filters:
|
||||||
|
query = query.filter(Proposal.accepted_with_funding == True)
|
||||||
|
if 'ACCEPTED_WITHOUT_FUNDING' in filters:
|
||||||
|
query = query.filter(Proposal.accepted_with_funding == False)
|
||||||
|
|
||||||
# SORT (see self.SORT_MAP)
|
# SORT (see self.SORT_MAP)
|
||||||
if sort:
|
if sort:
|
||||||
|
@ -137,13 +144,13 @@ class ContributionPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=proposal_contributions_schema,
|
schema: ma.Schema = proposal_contributions_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='PUBLISHED:DESC',
|
sort: str = 'PUBLISHED:DESC',
|
||||||
):
|
):
|
||||||
query = query or ProposalContribution.query
|
query = query or ProposalContribution.query
|
||||||
sort = sort or 'CREATED:DESC'
|
sort = sort or 'CREATED:DESC'
|
||||||
|
@ -162,9 +169,9 @@ class ContributionPagination(Pagination):
|
||||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||||
.join(Proposal) \
|
.join(Proposal) \
|
||||||
.filter(or_(
|
.filter(or_(
|
||||||
Proposal.stage == ProposalStage.FAILED,
|
Proposal.stage == ProposalStage.FAILED,
|
||||||
Proposal.stage == ProposalStage.CANCELED,
|
Proposal.stage == ProposalStage.CANCELED,
|
||||||
)) \
|
)) \
|
||||||
.join(ProposalContribution.user) \
|
.join(ProposalContribution.user) \
|
||||||
.join(UserSettings) \
|
.join(UserSettings) \
|
||||||
.filter(UserSettings.refund_address != None)
|
.filter(UserSettings.refund_address != None)
|
||||||
|
@ -174,9 +181,9 @@ class ContributionPagination(Pagination):
|
||||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||||
.join(Proposal) \
|
.join(Proposal) \
|
||||||
.filter(or_(
|
.filter(or_(
|
||||||
Proposal.stage == ProposalStage.FAILED,
|
Proposal.stage == ProposalStage.FAILED,
|
||||||
Proposal.stage == ProposalStage.CANCELED,
|
Proposal.stage == ProposalStage.CANCELED,
|
||||||
)) \
|
)) \
|
||||||
.join(ProposalContribution.user, isouter=True) \
|
.join(ProposalContribution.user, isouter=True) \
|
||||||
.join(UserSettings, isouter=True) \
|
.join(UserSettings, isouter=True) \
|
||||||
.filter(UserSettings.refund_address == None)
|
.filter(UserSettings.refund_address == None)
|
||||||
|
@ -217,13 +224,13 @@ class UserPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=users_schema,
|
schema: ma.Schema = users_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='EMAIL:DESC',
|
sort: str = 'EMAIL:DESC',
|
||||||
):
|
):
|
||||||
query = query or Proposal.query
|
query = query or Proposal.query
|
||||||
sort = sort or 'EMAIL:DESC'
|
sort = sort or 'EMAIL:DESC'
|
||||||
|
@ -273,13 +280,13 @@ class CommentPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=comments_schema,
|
schema: ma.Schema = comments_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='CREATED:DESC',
|
sort: str = 'CREATED:DESC',
|
||||||
):
|
):
|
||||||
query = query or Comment.query
|
query = query or Comment.query
|
||||||
sort = sort or 'CREATED:DESC'
|
sort = sort or 'CREATED:DESC'
|
||||||
|
@ -315,7 +322,58 @@ class CommentPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CCRPagination(Pagination):
|
||||||
|
def __init__(self):
|
||||||
|
self.FILTERS = [f'STATUS_{s}' for s in CCRStatus.list()]
|
||||||
|
self.PAGE_SIZE = 9
|
||||||
|
self.SORT_MAP = {
|
||||||
|
'CREATED:DESC': CCR.date_created.desc(),
|
||||||
|
'CREATED:ASC': CCR.date_created
|
||||||
|
}
|
||||||
|
|
||||||
|
def paginate(
|
||||||
|
self,
|
||||||
|
schema: ma.Schema,
|
||||||
|
query: db.Query = None,
|
||||||
|
page: int = 1,
|
||||||
|
filters: list = None,
|
||||||
|
search: str = None,
|
||||||
|
sort: str = 'PUBLISHED:DESC',
|
||||||
|
):
|
||||||
|
query = query or CCR.query
|
||||||
|
sort = sort or 'PUBLISHED:DESC'
|
||||||
|
|
||||||
|
# FILTER
|
||||||
|
if filters:
|
||||||
|
self.validate_filters(filters)
|
||||||
|
status_filters = extract_filters('STATUS_', filters)
|
||||||
|
|
||||||
|
if status_filters:
|
||||||
|
query = query.filter(CCR.status.in_(status_filters))
|
||||||
|
|
||||||
|
# SORT (see self.SORT_MAP)
|
||||||
|
if sort:
|
||||||
|
self.validate_sort(sort)
|
||||||
|
query = query.order_by(self.SORT_MAP[sort])
|
||||||
|
|
||||||
|
# SEARCH
|
||||||
|
if search:
|
||||||
|
query = query.filter(CCR.title.ilike(f'%{search}%'))
|
||||||
|
|
||||||
|
res = query.paginate(page, self.PAGE_SIZE, False)
|
||||||
|
return {
|
||||||
|
'page': res.page,
|
||||||
|
'total': res.total,
|
||||||
|
'page_size': self.PAGE_SIZE,
|
||||||
|
'items': schema.dump(res.items),
|
||||||
|
'filters': filters,
|
||||||
|
'search': search,
|
||||||
|
'sort': sort
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# expose pagination methods here
|
# expose pagination methods here
|
||||||
|
ccr = CCRPagination().paginate
|
||||||
proposal = ProposalPagination().paginate
|
proposal = ProposalPagination().paginate
|
||||||
contribution = ContributionPagination().paginate
|
contribution = ContributionPagination().paginate
|
||||||
comment = CommentPagination().paginate
|
comment = CommentPagination().paginate
|
||||||
|
|
|
@ -30,6 +30,9 @@ def blockchain_get(path, params=None):
|
||||||
|
|
||||||
|
|
||||||
def validate_blockchain_get(path, params=None):
|
def validate_blockchain_get(path, params=None):
|
||||||
|
if path == '/validate/address' and params and params['address'] and params['address'][0] == 't':
|
||||||
|
raise ValidationException('T addresses are not allowed')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = blockchain_get(path, params)
|
res = blockchain_get(path, params)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 0ba15ddf5053
|
||||||
|
Revises: 2013e180c438
|
||||||
|
Create Date: 2019-11-13 17:26:36.584040
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0ba15ddf5053'
|
||||||
|
down_revision = '2013e180c438'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('milestone', sa.Column('days_estimated', sa.String(length=255), nullable=True))
|
||||||
|
op.alter_column('milestone', 'date_estimated',
|
||||||
|
existing_type=postgresql.TIMESTAMP(),
|
||||||
|
nullable=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('milestone', 'date_estimated',
|
||||||
|
existing_type=postgresql.TIMESTAMP(),
|
||||||
|
nullable=False)
|
||||||
|
op.drop_column('milestone', 'days_estimated')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 1e1460456ce4
|
||||||
|
Revises: c55f96720196
|
||||||
|
Create Date: 2019-11-21 20:36:37.504400
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1e1460456ce4'
|
||||||
|
down_revision = 'c55f96720196'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('rfp', sa.Column('version', sa.String(length=255), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('rfp', 'version')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 2013e180c438
|
||||||
|
Revises: 7fea7427e9d6
|
||||||
|
Create Date: 2019-11-05 15:53:00.533347
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2013e180c438'
|
||||||
|
down_revision = '7fea7427e9d6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('proposal', 'category',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('rfp', 'category',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('rfp', 'category',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=False)
|
||||||
|
op.alter_column('proposal', 'category',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=False)
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 2721189b0c8f
|
||||||
|
Revises: 1e1460456ce4
|
||||||
|
Create Date: 2019-11-27 19:59:20.246227
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2721189b0c8f'
|
||||||
|
down_revision = '1e1460456ce4'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('ccr',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date_created', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('brief', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('content', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('target', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('reject_reason', sa.String(), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('rfp_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('ccr')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 515abdefed7a
|
||||||
|
Revises: 4505f00c4ebd
|
||||||
|
Create Date: 2019-10-17 16:41:58.519224
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '515abdefed7a'
|
||||||
|
down_revision = '4505f00c4ebd'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('proposal_subscribers',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('proposal_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
||||||
|
)
|
||||||
|
op.add_column('proposal', sa.Column('accepted_with_funding', sa.Boolean(), nullable=True))
|
||||||
|
op.add_column('proposal', sa.Column('version', sa.String(length=255), nullable=True))
|
||||||
|
op.alter_column('proposal', 'deadline_duration',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('proposal', 'deadline_duration',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False)
|
||||||
|
op.drop_column('proposal', 'version')
|
||||||
|
op.drop_column('proposal', 'accepted_with_funding')
|
||||||
|
op.drop_table('proposal_subscribers')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 7fea7427e9d6
|
||||||
|
Revises: f24d53f211ef
|
||||||
|
Create Date: 2019-10-24 12:18:39.734758
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7fea7427e9d6'
|
||||||
|
down_revision = 'f24d53f211ef'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('rfp_liker',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('rfp_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
||||||
|
)
|
||||||
|
op.create_table('proposal_liker',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('proposal_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
||||||
|
)
|
||||||
|
op.create_table('comment_liker',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('comment_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['comment_id'], ['comment.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('comment_liker')
|
||||||
|
op.drop_table('proposal_liker')
|
||||||
|
op.drop_table('rfp_liker')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 9d2f7db5b5a6
|
||||||
|
Revises: 0ba15ddf5053
|
||||||
|
Create Date: 2019-11-13 17:29:46.810554
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '9d2f7db5b5a6'
|
||||||
|
down_revision = '0ba15ddf5053'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('user_settings', sa.Column('tip_jar_address', sa.String(length=255), nullable=True))
|
||||||
|
op.add_column('user_settings', sa.Column('tip_jar_view_key', sa.String(length=255), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('user_settings', 'tip_jar_view_key')
|
||||||
|
op.drop_column('user_settings', 'tip_jar_address')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: c55f96720196
|
||||||
|
Revises: 9d2f7db5b5a6
|
||||||
|
Create Date: 2019-11-08 17:34:55.828331
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c55f96720196'
|
||||||
|
down_revision = '9d2f7db5b5a6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('proposal', sa.Column('tip_jar_address', sa.String(length=255), nullable=True))
|
||||||
|
op.add_column('proposal', sa.Column('tip_jar_view_key', sa.String(length=255), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('proposal', 'tip_jar_view_key')
|
||||||
|
op.drop_column('proposal', 'tip_jar_address')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: f24d53f211ef
|
||||||
|
Revises: 515abdefed7a
|
||||||
|
Create Date: 2019-10-23 16:32:02.161367
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f24d53f211ef'
|
||||||
|
down_revision = '515abdefed7a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('proposal_follower',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('proposal_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
|
||||||
|
)
|
||||||
|
op.drop_table('proposal_subscribers')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('proposal_subscribers',
|
||||||
|
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('proposal_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name='proposal_subscribers_proposal_id_fkey'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='proposal_subscribers_user_id_fkey')
|
||||||
|
)
|
||||||
|
op.drop_table('proposal_follower')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -242,30 +242,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
# 2 proposals created by BaseProposalCreatorConfig
|
# 2 proposals created by BaseProposalCreatorConfig
|
||||||
self.assertEqual(len(resp.json['items']), 2)
|
self.assertEqual(len(resp.json['items']), 2)
|
||||||
|
|
||||||
def test_update_proposal(self):
|
|
||||||
self.login_admin()
|
|
||||||
# set to 1 (on)
|
|
||||||
resp_on = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
|
|
||||||
data=json.dumps({"contributionMatching": 1}))
|
|
||||||
self.assert200(resp_on)
|
|
||||||
self.assertEqual(resp_on.json['contributionMatching'], 1)
|
|
||||||
resp_off = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
|
|
||||||
data=json.dumps({"contributionMatching": 0}))
|
|
||||||
self.assert200(resp_off)
|
|
||||||
self.assertEqual(resp_off.json['contributionMatching'], 0)
|
|
||||||
|
|
||||||
def test_update_proposal_no_auth(self):
|
|
||||||
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 1}))
|
|
||||||
self.assert401(resp)
|
|
||||||
|
|
||||||
def test_update_proposal_bad_matching(self):
|
|
||||||
self.login_admin()
|
|
||||||
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 2}))
|
|
||||||
self.assert400(resp)
|
|
||||||
self.assertTrue(resp.json['message'])
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_approve_proposal(self, mock_get):
|
def test_accept_proposal_with_funding(self, mock_get):
|
||||||
self.login_admin()
|
self.login_admin()
|
||||||
|
|
||||||
# proposal needs to be PENDING
|
# proposal needs to be PENDING
|
||||||
|
@ -273,11 +251,94 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
# approve
|
# approve
|
||||||
resp = self.app.put(
|
resp = self.app.put(
|
||||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
data=json.dumps({"isApprove": True})
|
data=json.dumps({"isAccepted": True, "withFunding": True})
|
||||||
|
)
|
||||||
|
print(resp.json)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["status"], ProposalStatus.LIVE)
|
||||||
|
self.assertEqual(resp.json["acceptedWithFunding"], True)
|
||||||
|
self.assertEqual(resp.json["target"], resp.json["contributionBounty"])
|
||||||
|
|
||||||
|
# milestones should have estimated dates
|
||||||
|
for milestone in resp.json["milestones"]:
|
||||||
|
self.assertIsNotNone(milestone["dateEstimated"])
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_accept_proposal_without_funding(self, mock_get):
|
||||||
|
self.login_admin()
|
||||||
|
|
||||||
|
# proposal needs to be PENDING
|
||||||
|
self.proposal.status = ProposalStatus.PENDING
|
||||||
|
|
||||||
|
# approve
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
|
data=json.dumps({"isAccepted": True, "withFunding": False})
|
||||||
|
)
|
||||||
|
print(resp.json)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["status"], ProposalStatus.LIVE)
|
||||||
|
self.assertEqual(resp.json["acceptedWithFunding"], False)
|
||||||
|
self.assertEqual(resp.json["contributionBounty"], "0")
|
||||||
|
|
||||||
|
# milestones should not have estimated dates
|
||||||
|
for milestone in resp.json["milestones"]:
|
||||||
|
self.assertIsNone(milestone["dateEstimated"])
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_change_proposal_to_accepted_with_funding(self, mock_get):
|
||||||
|
self.login_admin()
|
||||||
|
|
||||||
|
# proposal needs to be PENDING
|
||||||
|
self.proposal.status = ProposalStatus.PENDING
|
||||||
|
|
||||||
|
# accept without funding
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
|
data=json.dumps({"isAccepted": True, "withFunding": False})
|
||||||
)
|
)
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
|
self.assertEqual(resp.json["acceptedWithFunding"], False)
|
||||||
|
|
||||||
|
# change to accepted with funding
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund"
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["acceptedWithFunding"], True)
|
||||||
|
|
||||||
|
# milestones should have estimated dates
|
||||||
|
for milestone in resp.json["milestones"]:
|
||||||
|
self.assertIsNotNone(milestone["dateEstimated"])
|
||||||
|
|
||||||
|
# should fail if proposal is already accepted with funding
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund"
|
||||||
|
)
|
||||||
|
self.assert404(resp)
|
||||||
|
self.assertEqual(resp.json['message'], "Proposal already accepted with funding.")
|
||||||
|
self.proposal.accepted_with_funding = False
|
||||||
|
|
||||||
|
# should fail if proposal is not version two
|
||||||
|
self.proposal.version = ''
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund"
|
||||||
|
)
|
||||||
|
self.assert404(resp)
|
||||||
|
self.assertEqual(resp.json['message'], "Only version two proposals can be accepted with funding")
|
||||||
|
self.proposal.version = '2'
|
||||||
|
|
||||||
|
# should failed if proposal is not LIVE or APPROVED
|
||||||
|
self.proposal.status = ProposalStatus.PENDING
|
||||||
|
self.proposal.accepted_with_funding = False
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund"
|
||||||
|
)
|
||||||
|
self.assert404(resp)
|
||||||
|
self.assertEqual(resp.json["message"], 'Only live or approved proposals can be modified by this endpoint')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_reject_proposal(self, mock_get):
|
def test_reject_proposal(self, mock_get):
|
||||||
|
@ -288,8 +349,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
# reject
|
# reject
|
||||||
resp = self.app.put(
|
resp = self.app.put(
|
||||||
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."})
|
data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
|
||||||
)
|
)
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
||||||
|
@ -325,19 +386,3 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
|
|
||||||
def test_create_rfp_fails_with_bad_category(self):
|
|
||||||
self.login_admin()
|
|
||||||
|
|
||||||
resp = self.app.post(
|
|
||||||
"/api/v1/admin/rfps",
|
|
||||||
data=json.dumps({
|
|
||||||
"brief": "Some brief",
|
|
||||||
"category": "NOT_CORE_DEV",
|
|
||||||
"content": "CONTENT",
|
|
||||||
"dateCloses": 1553980004,
|
|
||||||
"status": "DRAFT",
|
|
||||||
"title": "TITLE"
|
|
||||||
})
|
|
||||||
)
|
|
||||||
self.assert400(resp)
|
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from grant.ccr.models import CCR
|
||||||
|
from ..config import BaseCCRCreatorConfig
|
||||||
|
from ..test_data import test_ccr
|
||||||
|
|
||||||
|
|
||||||
|
class TestCCRApi(BaseCCRCreatorConfig):
|
||||||
|
|
||||||
|
def test_create_new_draft(self):
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/ccrs/drafts",
|
||||||
|
)
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
|
||||||
|
ccr_db = CCR.query.filter_by(id=resp.json['ccrId'])
|
||||||
|
self.assertIsNotNone(ccr_db)
|
||||||
|
|
||||||
|
def test_no_auth_create_new_draft(self):
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/ccrs/drafts"
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
def test_update_CCR_draft(self):
|
||||||
|
new_title = "Updated!"
|
||||||
|
new_ccr = test_ccr.copy()
|
||||||
|
new_ccr["title"] = new_title
|
||||||
|
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/ccrs/{}".format(self.ccr.id),
|
||||||
|
data=json.dumps(new_ccr),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
print(resp)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["title"], new_title)
|
||||||
|
self.assertEqual(self.ccr.title, new_title)
|
|
@ -6,6 +6,7 @@ from flask_testing import TestCase
|
||||||
from mock import patch
|
from mock import patch
|
||||||
|
|
||||||
from grant.app import create_app
|
from grant.app import create_app
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.extensions import limiter
|
from grant.extensions import limiter
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.proposal.models import Proposal
|
from grant.proposal.models import Proposal
|
||||||
|
@ -13,7 +14,7 @@ from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||||
from grant.task.jobs import ProposalReminder
|
from grant.task.jobs import ProposalReminder
|
||||||
from grant.user.models import User, SocialMedia, db, Avatar
|
from grant.user.models import User, SocialMedia, db, Avatar
|
||||||
from grant.utils.enums import ProposalStatus
|
from grant.utils.enums import ProposalStatus
|
||||||
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests
|
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests, test_ccr
|
||||||
|
|
||||||
|
|
||||||
class BaseTestConfig(TestCase):
|
class BaseTestConfig(TestCase):
|
||||||
|
@ -138,14 +139,14 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
||||||
{
|
{
|
||||||
"title": "Milestone 1",
|
"title": "Milestone 1",
|
||||||
"content": "Content 1",
|
"content": "Content 1",
|
||||||
"date_estimated": (datetime.now() + timedelta(days=364)).timestamp(), # random unix time in the future
|
"days_estimated": "30",
|
||||||
"payout_percent": 50,
|
"payout_percent": 50,
|
||||||
"immediate_payout": True
|
"immediate_payout": True
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Milestone 2",
|
"title": "Milestone 2",
|
||||||
"content": "Content 2",
|
"content": "Content 2",
|
||||||
"date_estimated": (datetime.now() + timedelta(days=365)).timestamp(), # random unix time in the future
|
"days_estimated": "20",
|
||||||
"payout_percent": 50,
|
"payout_percent": 50,
|
||||||
"immediate_payout": False
|
"immediate_payout": False
|
||||||
}
|
}
|
||||||
|
@ -184,3 +185,23 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
self.proposal.set_pending_when_ready()
|
self.proposal.set_pending_when_ready()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCCRCreatorConfig(BaseUserConfig):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self._ccr = CCR.create(
|
||||||
|
status=ProposalStatus.DRAFT,
|
||||||
|
title=test_ccr["title"],
|
||||||
|
content=test_ccr["content"],
|
||||||
|
brief=test_ccr["brief"],
|
||||||
|
target=test_ccr["target"],
|
||||||
|
user_id=self.user.id
|
||||||
|
)
|
||||||
|
self._ccr_id = self._ccr.id
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# always return fresh (avoid detached instance issues)
|
||||||
|
@property
|
||||||
|
def ccr(self):
|
||||||
|
return CCR.query.filter_by(id=self._ccr_id).first()
|
||||||
|
|
|
@ -0,0 +1,151 @@
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
from mock import patch
|
||||||
|
from grant.proposal.models import Proposal, db, proposal_schema
|
||||||
|
from grant.milestone.models import Milestone
|
||||||
|
from grant.task.models import Task
|
||||||
|
from grant.task.jobs import MilestoneDeadline
|
||||||
|
from grant.utils.enums import ProposalStatus, Category, MilestoneStage
|
||||||
|
from ..config import BaseUserConfig
|
||||||
|
from ..test_data import test_team, mock_blockchain_api_requests
|
||||||
|
|
||||||
|
|
||||||
|
test_milestones = [
|
||||||
|
{
|
||||||
|
"title": "first milestone",
|
||||||
|
"content": "content",
|
||||||
|
"daysEstimated": "30",
|
||||||
|
"payoutPercent": "25",
|
||||||
|
"immediatePayout": False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "second milestone",
|
||||||
|
"content": "content",
|
||||||
|
"daysEstimated": "10",
|
||||||
|
"payoutPercent": "25",
|
||||||
|
"immediatePayout": False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "third milestone",
|
||||||
|
"content": "content",
|
||||||
|
"daysEstimated": "20",
|
||||||
|
"payoutPercent": "25",
|
||||||
|
"immediatePayout": False
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "fourth milestone",
|
||||||
|
"content": "content",
|
||||||
|
"daysEstimated": "30",
|
||||||
|
"payoutPercent": "25",
|
||||||
|
"immediatePayout": False
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
test_proposal = {
|
||||||
|
"team": test_team,
|
||||||
|
"content": "## My Proposal",
|
||||||
|
"title": "Give Me Money",
|
||||||
|
"brief": "$$$",
|
||||||
|
"milestones": test_milestones,
|
||||||
|
"category": Category.ACCESSIBILITY,
|
||||||
|
"target": "12345",
|
||||||
|
"payoutAddress": "123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMilestoneMethods(BaseUserConfig):
|
||||||
|
|
||||||
|
def init_proposal(self, proposal_data):
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/proposals/drafts"
|
||||||
|
)
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
proposal_id = resp.json["proposalId"]
|
||||||
|
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{proposal_id}",
|
||||||
|
data=json.dumps(proposal_data),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
proposal = Proposal.query.get(proposal_id)
|
||||||
|
proposal.status = ProposalStatus.PENDING
|
||||||
|
|
||||||
|
# accept with funding
|
||||||
|
proposal.approve_pending(True, True)
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print(proposal_schema.dump(proposal))
|
||||||
|
return proposal
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_set_v2_date_estimates(self, mock_get):
|
||||||
|
proposal_data = test_proposal.copy()
|
||||||
|
proposal = self.init_proposal(proposal_data)
|
||||||
|
total_days_estimated = 0
|
||||||
|
|
||||||
|
# make sure date_estimated has been populated on all milestones
|
||||||
|
for milestone in proposal.milestones:
|
||||||
|
total_days_estimated += int(milestone.days_estimated)
|
||||||
|
self.assertIsNotNone(milestone.date_estimated)
|
||||||
|
|
||||||
|
# check the proposal `date_approved` has been used for first milestone calculation
|
||||||
|
first_milestone = proposal.milestones[0]
|
||||||
|
expected_base_date = proposal.date_approved
|
||||||
|
expected_days_estimated = first_milestone.days_estimated
|
||||||
|
expected_date_estimated = expected_base_date + datetime.timedelta(days=int(expected_days_estimated))
|
||||||
|
|
||||||
|
self.assertEqual(first_milestone.date_estimated, expected_date_estimated)
|
||||||
|
|
||||||
|
# check that the `date_estimated` of the final milestone has been calculated with the cumulative
|
||||||
|
# `days_estimated` of the previous milestones
|
||||||
|
last_milestone = proposal.milestones[-1]
|
||||||
|
expected_date_estimated = expected_base_date + datetime.timedelta(days=int(total_days_estimated))
|
||||||
|
self.assertEqual(last_milestone.date_estimated, expected_date_estimated)
|
||||||
|
|
||||||
|
# check to see a task has been created
|
||||||
|
tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all()
|
||||||
|
self.assertEqual(len(tasks), 1)
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_set_v2_date_estimates_immediate_payout(self, mock_get):
|
||||||
|
proposal_data = test_proposal.copy()
|
||||||
|
proposal_data["milestones"][0]["immediate_payout"] = True
|
||||||
|
|
||||||
|
self.init_proposal(proposal_data)
|
||||||
|
tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all()
|
||||||
|
|
||||||
|
# ensure MilestoneDeadline task not created when immediate payout is set
|
||||||
|
self.assertEqual(len(tasks), 0)
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_set_v2_date_estimates_deadline_recalculation(self, mock_get):
|
||||||
|
proposal_data = test_proposal.copy()
|
||||||
|
proposal = self.init_proposal(proposal_data)
|
||||||
|
|
||||||
|
first_ms = proposal.milestones[0]
|
||||||
|
second_ms = proposal.milestones[1]
|
||||||
|
|
||||||
|
first_ms.stage = MilestoneStage.PAID
|
||||||
|
first_ms.date_paid = datetime.datetime.now()
|
||||||
|
|
||||||
|
expected_base_date = datetime.datetime.now() + datetime.timedelta(days=42)
|
||||||
|
second_ms.stage = MilestoneStage.PAID
|
||||||
|
second_ms.date_paid = expected_base_date
|
||||||
|
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
|
||||||
|
proposal = Proposal.query.get(proposal.id)
|
||||||
|
third_ms = proposal.milestones[2]
|
||||||
|
expected_date_estimated = expected_base_date + datetime.timedelta(days=int(third_ms.days_estimated))
|
||||||
|
|
||||||
|
# ensure `date_estimated` was recalculated as expected
|
||||||
|
self.assertEqual(third_ms.date_estimated, expected_date_estimated)
|
|
@ -126,7 +126,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
self.login_default_user()
|
self.login_default_user()
|
||||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json['status'], ProposalStatus.STAKING)
|
self.assertEqual(resp.json['status'], ProposalStatus.PENDING)
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_no_auth_proposal_draft_submit_for_approval(self, mock_get):
|
def test_no_auth_proposal_draft_submit_for_approval(self, mock_get):
|
||||||
|
@ -152,60 +152,6 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||||
self.assert400(resp)
|
self.assert400(resp)
|
||||||
|
|
||||||
# /stake
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
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.normalize()))
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
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_blockchain_api_requests)
|
|
||||||
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_blockchain_api_requests)
|
|
||||||
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.assert400(resp)
|
|
||||||
|
|
||||||
# /publish
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_publish_proposal_approved(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_no_auth_publish_proposal(self, mock_get):
|
|
||||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
|
||||||
self.assert401(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
|
||||||
def test_invalid_proposal_publish_proposal(self, mock_get):
|
|
||||||
self.login_default_user()
|
|
||||||
resp = self.app.put("/api/v1/proposals/12345/publish")
|
|
||||||
self.assert404(resp)
|
|
||||||
|
|
||||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
def test_invalid_status_proposal_publish_proposal(self, mock_get):
|
def test_invalid_status_proposal_publish_proposal(self, mock_get):
|
||||||
self.login_default_user()
|
self.login_default_user()
|
||||||
|
@ -223,14 +169,115 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
# /
|
# /
|
||||||
def test_get_proposals(self):
|
def test_get_proposals(self):
|
||||||
self.test_publish_proposal_approved()
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
resp = self.app.get("/api/v1/proposals/")
|
resp = self.app.get("/api/v1/proposals/")
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
|
|
||||||
def test_get_proposals_does_not_include_team_member_email_addresses(self):
|
def test_get_proposals_does_not_include_team_member_email_addresses(self):
|
||||||
self.test_publish_proposal_approved()
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
resp = self.app.get("/api/v1/proposals/")
|
resp = self.app.get("/api/v1/proposals/")
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
for each_proposal in resp.json['items']:
|
for each_proposal in resp.json['items']:
|
||||||
for team_member in each_proposal["team"]:
|
for team_member in each_proposal["team"]:
|
||||||
self.assertIsNone(team_member.get('email_address'))
|
self.assertIsNone(team_member.get('email_address'))
|
||||||
|
|
||||||
|
def test_follow_proposal(self):
|
||||||
|
# not logged in
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/follow",
|
||||||
|
data=json.dumps({"isFollow": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
# logged in
|
||||||
|
self.login_default_user()
|
||||||
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
|
|
||||||
|
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}")
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedFollows"], False)
|
||||||
|
|
||||||
|
# follow
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/follow",
|
||||||
|
data=json.dumps({"isFollow": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}")
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedFollows"], True)
|
||||||
|
|
||||||
|
self.assertEqual(self.proposal.followers[0].id, self.user.id)
|
||||||
|
self.assertEqual(self.user.followed_proposals[0].id, self.proposal.id)
|
||||||
|
|
||||||
|
# un-follow
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/follow",
|
||||||
|
data=json.dumps({"isFollow": False}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}")
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedFollows"], False)
|
||||||
|
|
||||||
|
self.assertEqual(len(self.proposal.followers), 0)
|
||||||
|
self.assertEqual(len(self.user.followed_proposals), 0)
|
||||||
|
|
||||||
|
def test_like_proposal(self):
|
||||||
|
# not logged in
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
# logged in
|
||||||
|
self.login_default_user()
|
||||||
|
|
||||||
|
# proposal not yet live
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert404(resp)
|
||||||
|
self.assertEquals(resp.json["message"], "Cannot like a proposal that's not live")
|
||||||
|
|
||||||
|
# proposal is live
|
||||||
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertTrue(self.user in self.proposal.likes)
|
||||||
|
|
||||||
|
resp = self.app.get(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}"
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedLiked"], True)
|
||||||
|
self.assertEqual(resp.json["likesCount"], 1)
|
||||||
|
|
||||||
|
# test unliking a proposal
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/like",
|
||||||
|
data=json.dumps({"isLiked": False}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertTrue(self.user not in self.proposal.likes)
|
||||||
|
|
||||||
|
resp = self.app.get(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}"
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedLiked"], False)
|
||||||
|
self.assertEqual(resp.json["likesCount"], 0)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from grant.proposal.models import Proposal, db
|
from grant.proposal.models import Proposal, Comment, db
|
||||||
from grant.utils.enums import ProposalStatus
|
from grant.utils.enums import ProposalStatus
|
||||||
from ..config import BaseUserConfig
|
from ..config import BaseUserConfig
|
||||||
from ..test_data import test_comment, test_reply, test_comment_large
|
from ..test_data import test_comment, test_reply, test_comment_large
|
||||||
|
@ -148,3 +148,59 @@ class TestProposalCommentAPI(BaseUserConfig):
|
||||||
|
|
||||||
self.assertStatus(comment_res, 403)
|
self.assertStatus(comment_res, 403)
|
||||||
self.assertIn('silenced', comment_res.json['message'])
|
self.assertIn('silenced', comment_res.json['message'])
|
||||||
|
|
||||||
|
def test_like_comment(self):
|
||||||
|
proposal = Proposal(status=ProposalStatus.LIVE)
|
||||||
|
db.session.add(proposal)
|
||||||
|
|
||||||
|
comment = Comment(
|
||||||
|
proposal_id=proposal.id,
|
||||||
|
user_id=self.other_user.id,
|
||||||
|
parent_comment_id=None,
|
||||||
|
content=test_comment["comment"]
|
||||||
|
)
|
||||||
|
comment_id = comment.id
|
||||||
|
db.session.add(comment)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# comment not found
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/comment/123456789/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
# not logged in
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/comment/{comment_id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
# logged in
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/comment/{comment_id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
self.assertEqual(resp.json["authedLiked"], True)
|
||||||
|
self.assertEqual(resp.json["likesCount"], 1)
|
||||||
|
comment = Comment.query.get(comment_id)
|
||||||
|
self.assertTrue(self.user in comment.likes)
|
||||||
|
|
||||||
|
# test unliking a proposal
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/comment/{comment.id}/like",
|
||||||
|
data=json.dumps({"isLiked": False}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
self.assertEqual(resp.json["authedLiked"], False)
|
||||||
|
self.assertEqual(resp.json["likesCount"], 0)
|
||||||
|
comment = Comment.query.get(comment_id)
|
||||||
|
self.assertTrue(self.user not in comment.likes)
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import json
|
||||||
|
from grant.proposal.models import Proposal, ProposalStatus, db
|
||||||
|
from ..config import BaseProposalCreatorConfig
|
||||||
|
from mock import patch
|
||||||
|
from ..test_data import mock_blockchain_api_requests
|
||||||
|
|
||||||
|
address_json = {
|
||||||
|
"address": "valid_address"
|
||||||
|
}
|
||||||
|
|
||||||
|
view_key_json = {
|
||||||
|
"viewKey": "valid_view_key"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestProposalInviteAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_set_proposal_tip_address(self, mock_get):
|
||||||
|
self.login_default_user()
|
||||||
|
res = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/tips",
|
||||||
|
data=json.dumps(address_json),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertStatus(res, 200)
|
||||||
|
proposal = Proposal.query.get(self.proposal.id)
|
||||||
|
self.assertEqual(proposal.tip_jar_address, address_json["address"])
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_set_proposal_tip_view_key(self, mock_get):
|
||||||
|
self.login_default_user()
|
||||||
|
res = self.app.put(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}/tips",
|
||||||
|
data=json.dumps(view_key_json),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertStatus(res, 200)
|
||||||
|
proposal = Proposal.query.get(self.proposal.id)
|
||||||
|
self.assertEqual(proposal.tip_jar_view_key, view_key_json["viewKey"])
|
||||||
|
|
||||||
|
# test to make sure a user on the proposal team can see the view key
|
||||||
|
res = self.app.get(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}",
|
||||||
|
data=json.dumps(view_key_json),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assert200(res)
|
||||||
|
self.assertEqual(res.json["tipJarViewKey"], view_key_json["viewKey"])
|
||||||
|
|
||||||
|
# test to make sure a user not on the proposal team can't see the view key
|
||||||
|
self.proposal.status = ProposalStatus.LIVE
|
||||||
|
db.session.add(self.proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
self.login_other_user()
|
||||||
|
res = self.app.get(
|
||||||
|
f"/api/v1/proposals/{self.proposal.id}",
|
||||||
|
data=json.dumps(view_key_json),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assert200(res)
|
||||||
|
self.assertIsNone(res.json["tipJarViewKey"])
|
|
@ -0,0 +1,84 @@
|
||||||
|
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
from ..config import BaseProposalCreatorConfig
|
||||||
|
from grant.rfp.models import RFP, RFPStatus, db, Category
|
||||||
|
|
||||||
|
|
||||||
|
class TestRfpApi(BaseProposalCreatorConfig):
|
||||||
|
def test_rfp_like(self):
|
||||||
|
rfp = RFP(
|
||||||
|
title="title",
|
||||||
|
brief="brief",
|
||||||
|
content="content",
|
||||||
|
date_closes=datetime.datetime(2030, 1, 1),
|
||||||
|
bounty="10",
|
||||||
|
status=RFPStatus.DRAFT,
|
||||||
|
)
|
||||||
|
rfp_id = rfp.id
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# not logged in
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/rfps/{rfp_id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert401(resp)
|
||||||
|
|
||||||
|
# logged in, but rfp does not exist
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/rfps/123456789/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert404(resp)
|
||||||
|
|
||||||
|
# RFP is not live
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/rfps/{rfp_id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert404(resp)
|
||||||
|
self.assertEqual(resp.json["message"], "RFP is not live")
|
||||||
|
|
||||||
|
# set RFP live, test like
|
||||||
|
rfp = RFP.query.get(rfp_id)
|
||||||
|
rfp.status = RFPStatus.LIVE
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/rfps/{rfp_id}/like",
|
||||||
|
data=json.dumps({"isLiked": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
rfp = RFP.query.get(rfp_id)
|
||||||
|
self.assertTrue(self.user in rfp.likes)
|
||||||
|
resp = self.app.get(
|
||||||
|
f"/api/v1/rfps/{rfp_id}"
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedLiked"], True)
|
||||||
|
self.assertEqual(resp.json["likesCount"], 1)
|
||||||
|
|
||||||
|
# test unliking
|
||||||
|
resp = self.app.put(
|
||||||
|
f"/api/v1/rfps/{rfp_id}/like",
|
||||||
|
data=json.dumps({"isLiked": False}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
rfp = RFP.query.get(rfp_id)
|
||||||
|
self.assertTrue(self.user not in rfp.likes)
|
||||||
|
resp = self.app.get(
|
||||||
|
f"/api/v1/rfps/{rfp_id}"
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["authedLiked"], False)
|
||||||
|
self.assertEqual(resp.json["likesCount"], 0)
|
||||||
|
|
|
@ -1,11 +1,71 @@
|
||||||
from datetime import datetime
|
import json
|
||||||
|
|
||||||
from grant.task.models import Task
|
from grant.utils import totp_2fa
|
||||||
|
from grant.task.jobs import MilestoneDeadline
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from grant.task.models import Task, db
|
||||||
|
from grant.task.jobs import PruneDraft
|
||||||
|
from grant.milestone.models import Milestone
|
||||||
|
from grant.proposal.models import Proposal, ProposalUpdate
|
||||||
|
from grant.utils.enums import ProposalStatus, ProposalStage, Category
|
||||||
|
|
||||||
from ..config import BaseProposalCreatorConfig
|
from ..config import BaseProposalCreatorConfig
|
||||||
|
from ..test_data import mock_blockchain_api_requests
|
||||||
|
|
||||||
|
from mock import patch, Mock
|
||||||
|
|
||||||
|
test_update = {
|
||||||
|
"title": "Update Title",
|
||||||
|
"content": "Update content."
|
||||||
|
}
|
||||||
|
|
||||||
|
milestones_data = [
|
||||||
|
{
|
||||||
|
"title": "All the money straightaway",
|
||||||
|
"content": "cool stuff with it",
|
||||||
|
"days_estimated": 30,
|
||||||
|
"payout_percent": "100",
|
||||||
|
"immediate_payout": False
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
class TestTaskAPI(BaseProposalCreatorConfig):
|
class TestTaskAPI(BaseProposalCreatorConfig):
|
||||||
|
def p(self, path, data):
|
||||||
|
return self.app.post(path, data=json.dumps(data), content_type="application/json")
|
||||||
|
|
||||||
|
def login_admin(self):
|
||||||
|
# set admin
|
||||||
|
self.user.set_admin(True)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# login
|
||||||
|
r = self.p("/api/v1/admin/login", {
|
||||||
|
"username": self.user.email_address,
|
||||||
|
"password": self.user_password
|
||||||
|
})
|
||||||
|
self.assert200(r)
|
||||||
|
|
||||||
|
# 2fa on the natch
|
||||||
|
r = self.app.get("/api/v1/admin/2fa")
|
||||||
|
self.assert200(r)
|
||||||
|
|
||||||
|
# ... init
|
||||||
|
r = self.app.get("/api/v1/admin/2fa/init")
|
||||||
|
self.assert200(r)
|
||||||
|
|
||||||
|
codes = r.json['backupCodes']
|
||||||
|
secret = r.json['totpSecret']
|
||||||
|
uri = r.json['totpUri']
|
||||||
|
|
||||||
|
# ... enable/verify
|
||||||
|
r = self.p("/api/v1/admin/2fa/enable", {
|
||||||
|
"backupCodes": codes,
|
||||||
|
"totpSecret": secret,
|
||||||
|
"verifyCode": totp_2fa.current_totp(secret)
|
||||||
|
})
|
||||||
|
self.assert200(r)
|
||||||
|
return r
|
||||||
|
|
||||||
def test_proposal_reminder_task_is_created(self):
|
def test_proposal_reminder_task_is_created(self):
|
||||||
tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all()
|
tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all()
|
||||||
|
@ -22,3 +82,262 @@ class TestTaskAPI(BaseProposalCreatorConfig):
|
||||||
tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all()
|
tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all()
|
||||||
self.assertEqual(tasks, [])
|
self.assertEqual(tasks, [])
|
||||||
|
|
||||||
|
@patch('grant.task.views.datetime')
|
||||||
|
def test_proposal_pruning(self, mock_datetime):
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/proposals/drafts",
|
||||||
|
)
|
||||||
|
proposal_id = resp.json['proposalId']
|
||||||
|
|
||||||
|
# make sure proposal was created
|
||||||
|
proposal = Proposal.query.get(proposal_id)
|
||||||
|
self.assertIsNotNone(proposal)
|
||||||
|
|
||||||
|
# make sure the task was created
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
tasks = Task.query.all()
|
||||||
|
self.assertEqual(len(tasks), 1)
|
||||||
|
task = tasks[0]
|
||||||
|
self.assertEqual(resp.json['proposalId'], task.blob['proposal_id'])
|
||||||
|
self.assertFalse(task.completed)
|
||||||
|
|
||||||
|
# mock time so task will run when called
|
||||||
|
after_time = datetime.now() + timedelta(seconds=PruneDraft.PRUNE_TIME + 100)
|
||||||
|
mock_datetime.now = Mock(return_value=after_time)
|
||||||
|
|
||||||
|
# run task
|
||||||
|
resp = self.app.get("/api/v1/task")
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
# make sure task ran successfully
|
||||||
|
tasks = Task.query.all()
|
||||||
|
self.assertEqual(len(tasks), 1)
|
||||||
|
task = tasks[0]
|
||||||
|
self.assertTrue(task.completed)
|
||||||
|
proposal = Proposal.query.get(proposal_id)
|
||||||
|
self.assertIsNone(proposal)
|
||||||
|
|
||||||
|
def test_proposal_pruning_noops(self):
|
||||||
|
# ensure all proposal noop states work as expected
|
||||||
|
|
||||||
|
def status(p):
|
||||||
|
p.status = ProposalStatus.LIVE
|
||||||
|
|
||||||
|
def title(p):
|
||||||
|
p.title = 'title'
|
||||||
|
|
||||||
|
def brief(p):
|
||||||
|
p.brief = 'brief'
|
||||||
|
|
||||||
|
def content(p):
|
||||||
|
p.content = 'content'
|
||||||
|
|
||||||
|
def category(p):
|
||||||
|
p.category = Category.DEV_TOOL
|
||||||
|
|
||||||
|
def target(p):
|
||||||
|
p.target = '100'
|
||||||
|
|
||||||
|
def payout_address(p):
|
||||||
|
p.payout_address = 'address'
|
||||||
|
|
||||||
|
def milestones(p):
|
||||||
|
Milestone.make(milestones_data, p)
|
||||||
|
|
||||||
|
modifiers = [
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
brief,
|
||||||
|
content,
|
||||||
|
category,
|
||||||
|
target,
|
||||||
|
payout_address,
|
||||||
|
milestones
|
||||||
|
]
|
||||||
|
|
||||||
|
for modifier in modifiers:
|
||||||
|
proposal = Proposal.create(status=ProposalStatus.DRAFT)
|
||||||
|
proposal_id = proposal.id
|
||||||
|
modifier(proposal)
|
||||||
|
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
blob = {
|
||||||
|
"proposal_id": proposal_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
job_type=PruneDraft.JOB_TYPE,
|
||||||
|
blob=blob,
|
||||||
|
execute_after=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
PruneDraft.process_task(task)
|
||||||
|
|
||||||
|
proposal = Proposal.query.get(proposal_id)
|
||||||
|
self.assertIsNotNone(proposal)
|
||||||
|
|
||||||
|
@patch('grant.task.jobs.send_email')
|
||||||
|
@patch('grant.task.views.datetime')
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_milestone_deadline(self, mock_get, mock_datetime, mock_send_email):
|
||||||
|
tasks = Task.query.filter_by(completed=False).all()
|
||||||
|
self.assertEqual(len(tasks), 0)
|
||||||
|
|
||||||
|
self.proposal.arbiter.user = self.user
|
||||||
|
db.session.add(self.proposal)
|
||||||
|
|
||||||
|
# unset immediate_payout so task will be added
|
||||||
|
for milestone in self.proposal.milestones:
|
||||||
|
if milestone.immediate_payout:
|
||||||
|
milestone.immediate_payout = False
|
||||||
|
db.session.add(milestone)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
self.login_admin()
|
||||||
|
|
||||||
|
# proposal needs to be PENDING
|
||||||
|
self.proposal.status = ProposalStatus.PENDING
|
||||||
|
|
||||||
|
# approve proposal with funding
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||||
|
data=json.dumps({"isAccepted": True, "withFunding": True})
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
tasks = Task.query.filter_by(completed=False).all()
|
||||||
|
self.assertEqual(len(tasks), 1)
|
||||||
|
|
||||||
|
# fast forward the clock so task will run
|
||||||
|
after_time = datetime.now() + timedelta(days=365)
|
||||||
|
mock_datetime.now = Mock(return_value=after_time)
|
||||||
|
|
||||||
|
# run task
|
||||||
|
resp = self.app.get("/api/v1/task")
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
# make sure task ran
|
||||||
|
tasks = Task.query.filter_by(completed=False).all()
|
||||||
|
self.assertEqual(len(tasks), 0)
|
||||||
|
mock_send_email.assert_called()
|
||||||
|
|
||||||
|
@patch('grant.task.jobs.send_email')
|
||||||
|
def test_milestone_deadline_update_posted(self, mock_send_email):
|
||||||
|
tasks = Task.query.all()
|
||||||
|
self.assertEqual(len(tasks), 0)
|
||||||
|
|
||||||
|
# set date_estimated on milestone to be in the past
|
||||||
|
milestone = self.proposal.milestones[0]
|
||||||
|
milestone.date_estimated = datetime.now() - timedelta(hours=1)
|
||||||
|
db.session.add(milestone)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# make task
|
||||||
|
ms_deadline = MilestoneDeadline(self.proposal, milestone)
|
||||||
|
ms_deadline.make_task()
|
||||||
|
|
||||||
|
# check make task
|
||||||
|
tasks = Task.query.all()
|
||||||
|
self.assertEqual(len(tasks), 1)
|
||||||
|
|
||||||
|
# login and post proposal update
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/proposals/{}/updates".format(self.proposal.id),
|
||||||
|
data=json.dumps(test_update),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
|
||||||
|
# run task
|
||||||
|
resp = self.app.get("/api/v1/task")
|
||||||
|
self.assert200(resp)
|
||||||
|
|
||||||
|
# make sure task ran and did NOT send out an email
|
||||||
|
tasks = Task.query.filter_by(completed=False).all()
|
||||||
|
self.assertEqual(len(tasks), 0)
|
||||||
|
mock_send_email.assert_not_called()
|
||||||
|
|
||||||
|
@patch('grant.task.jobs.send_email')
|
||||||
|
def test_milestone_deadline_noops(self, mock_send_email):
|
||||||
|
# make sure all milestone deadline noop states work as expected
|
||||||
|
|
||||||
|
def proposal_delete(p, m):
|
||||||
|
db.session.delete(p)
|
||||||
|
|
||||||
|
def proposal_status(p, m):
|
||||||
|
p.status = ProposalStatus.DELETED
|
||||||
|
db.session.add(p)
|
||||||
|
|
||||||
|
def proposal_stage(p, m):
|
||||||
|
p.stage = ProposalStage.CANCELED
|
||||||
|
db.session.add(p)
|
||||||
|
|
||||||
|
def milestone_delete(p, m):
|
||||||
|
db.session.delete(m)
|
||||||
|
|
||||||
|
def milestone_date_requested(p, m):
|
||||||
|
m.date_requested = datetime.now()
|
||||||
|
db.session.add(m)
|
||||||
|
|
||||||
|
def update_posted(p, m):
|
||||||
|
# login and post proposal update
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.post(
|
||||||
|
"/api/v1/proposals/{}/updates".format(proposal.id),
|
||||||
|
data=json.dumps(test_update),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertStatus(resp, 201)
|
||||||
|
|
||||||
|
modifiers = [
|
||||||
|
proposal_delete,
|
||||||
|
proposal_status,
|
||||||
|
proposal_stage,
|
||||||
|
milestone_delete,
|
||||||
|
milestone_date_requested,
|
||||||
|
update_posted
|
||||||
|
]
|
||||||
|
|
||||||
|
for modifier in modifiers:
|
||||||
|
# make proposal and milestone
|
||||||
|
proposal = Proposal.create(status=ProposalStatus.LIVE)
|
||||||
|
proposal.arbiter.user = self.other_user
|
||||||
|
proposal.team.append(self.user)
|
||||||
|
proposal_id = proposal.id
|
||||||
|
Milestone.make(milestones_data, proposal)
|
||||||
|
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# grab update count for blob
|
||||||
|
update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all())
|
||||||
|
|
||||||
|
# run modifications to trigger noop
|
||||||
|
proposal = Proposal.query.get(proposal_id)
|
||||||
|
milestone = proposal.milestones[0]
|
||||||
|
milestone_id = milestone.id
|
||||||
|
modifier(proposal, milestone)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# make task
|
||||||
|
blob = {
|
||||||
|
"proposal_id": proposal_id,
|
||||||
|
"milestone_id": milestone_id,
|
||||||
|
"update_count": update_count
|
||||||
|
}
|
||||||
|
task = Task(
|
||||||
|
job_type=MilestoneDeadline.JOB_TYPE,
|
||||||
|
blob=blob,
|
||||||
|
execute_after=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# run task
|
||||||
|
MilestoneDeadline.process_task(task)
|
||||||
|
|
||||||
|
# check to make sure noop occurred
|
||||||
|
mock_send_email.assert_not_called()
|
||||||
|
|
|
@ -31,7 +31,7 @@ milestones = [
|
||||||
{
|
{
|
||||||
"title": "All the money straightaway",
|
"title": "All the money straightaway",
|
||||||
"content": "cool stuff with it",
|
"content": "cool stuff with it",
|
||||||
"dateEstimated": 1549505307,
|
"daysEstimated": "30",
|
||||||
"payoutPercent": "100",
|
"payoutPercent": "100",
|
||||||
"immediatePayout": False
|
"immediatePayout": False
|
||||||
}
|
}
|
||||||
|
@ -44,11 +44,19 @@ test_proposal = {
|
||||||
"brief": "$$$",
|
"brief": "$$$",
|
||||||
"milestones": milestones,
|
"milestones": milestones,
|
||||||
"category": Category.ACCESSIBILITY,
|
"category": Category.ACCESSIBILITY,
|
||||||
"target": "123.456",
|
"target": "12345",
|
||||||
"payoutAddress": "123",
|
"payoutAddress": "123",
|
||||||
"deadlineDuration": 100
|
"deadlineDuration": 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test_ccr = {
|
||||||
|
"user_id": test_user,
|
||||||
|
"content": "## My Proposal",
|
||||||
|
"title": "Give Me Money",
|
||||||
|
"brief": "$$$",
|
||||||
|
"target": "123.456",
|
||||||
|
}
|
||||||
|
|
||||||
test_comment = {
|
test_comment = {
|
||||||
"comment": "Test comment"
|
"comment": "Test comment"
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ from grant.user.models import User, user_schema, db
|
||||||
from mock import patch
|
from mock import patch
|
||||||
|
|
||||||
from ..config import BaseUserConfig
|
from ..config import BaseUserConfig
|
||||||
from ..test_data import test_user
|
from ..test_data import test_user, mock_blockchain_api_requests
|
||||||
|
|
||||||
|
|
||||||
class TestUserAPI(BaseUserConfig):
|
class TestUserAPI(BaseUserConfig):
|
||||||
|
@ -385,3 +385,34 @@ class TestUserAPI(BaseUserConfig):
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
self.assert400(resp)
|
self.assert400(resp)
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_put_user_settings_tip_jar_address(self, mock_get):
|
||||||
|
address = "address"
|
||||||
|
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/users/{}/settings".format(self.user.id),
|
||||||
|
data=json.dumps({'tipJarAddress': address}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["tipJarAddress"], address)
|
||||||
|
user = User.query.get(self.user.id)
|
||||||
|
self.assertEqual(user.settings.tip_jar_address, address)
|
||||||
|
|
||||||
|
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||||
|
def test_put_user_settings_tip_jar_view_key(self, mock_get):
|
||||||
|
view_key = "view_key"
|
||||||
|
|
||||||
|
self.login_default_user()
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/users/{}/settings".format(self.user.id),
|
||||||
|
data=json.dumps({'tipJarViewKey': view_key}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
self.assertEqual(resp.json["tipJarViewKey"], view_key)
|
||||||
|
user = User.query.get(self.user.id)
|
||||||
|
self.assertEqual(user.settings.tip_jar_view_key, view_key)
|
||||||
|
|
||||||
|
|
|
@ -26,4 +26,4 @@ DISABLE_SSL=true
|
||||||
# TESTNET=true
|
# TESTNET=true
|
||||||
|
|
||||||
# Maximum amount for a proposal target, keep in sync with backend .env
|
# Maximum amount for a proposal target, keep in sync with backend .env
|
||||||
PROPOSAL_TARGET_MAX=10000
|
PROPOSAL_TARGET_MAX=999999
|
||||||
|
|
|
@ -20,15 +20,20 @@ import 'styles/style.less';
|
||||||
const opts = { fallback: <Loader size="large" /> };
|
const opts = { fallback: <Loader size="large" /> };
|
||||||
const Home = loadable(() => import('pages/index'), opts);
|
const Home = loadable(() => import('pages/index'), opts);
|
||||||
const Create = loadable(() => import('pages/create'), opts);
|
const Create = loadable(() => import('pages/create'), opts);
|
||||||
|
const CreateRequest = loadable(() => import('pages/create-request'), opts);
|
||||||
|
const RequestEdit = loadable(() => import('pages/request-edit'), opts);
|
||||||
const ProposalEdit = loadable(() => import('pages/proposal-edit'), opts);
|
const ProposalEdit = loadable(() => import('pages/proposal-edit'), opts);
|
||||||
const Proposals = loadable(() => import('pages/proposals'), opts);
|
const Proposals = loadable(() => import('pages/proposals'), opts);
|
||||||
const Proposal = loadable(() => import('pages/proposal'), opts);
|
const Proposal = loadable(() => import('pages/proposal'), opts);
|
||||||
|
const Guide = loadable(() => import('pages/guide'), opts);
|
||||||
|
const Ccr = loadable(() => import('pages/ccr'), opts);
|
||||||
const Auth = loadable(() => import('pages/auth'));
|
const Auth = loadable(() => import('pages/auth'));
|
||||||
const SignOut = loadable(() => import('pages/sign-out'), opts);
|
const SignOut = loadable(() => import('pages/sign-out'), opts);
|
||||||
const Profile = loadable(() => import('pages/profile'), opts);
|
const Profile = loadable(() => import('pages/profile'), opts);
|
||||||
const Settings = loadable(() => import('pages/settings'), opts);
|
const Settings = loadable(() => import('pages/settings'), opts);
|
||||||
const Exception = loadable(() => import('pages/exception'), opts);
|
const Exception = loadable(() => import('pages/exception'), opts);
|
||||||
const Tos = loadable(() => import('pages/tos'));
|
const Tos = loadable(() => import('pages/tos'));
|
||||||
|
const ProposalTutorial = loadable(() => import('pages/proposal-tutorial'));
|
||||||
const About = loadable(() => import('pages/about'), opts);
|
const About = loadable(() => import('pages/about'), opts);
|
||||||
const Privacy = loadable(() => import('pages/privacy'), opts);
|
const Privacy = loadable(() => import('pages/privacy'), opts);
|
||||||
const Contact = loadable(() => import('pages/contact'), opts);
|
const Contact = loadable(() => import('pages/contact'), opts);
|
||||||
|
@ -63,6 +68,43 @@ const routeConfigs: RouteConfig[] = [
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Create request
|
||||||
|
route: {
|
||||||
|
path: '/create-request',
|
||||||
|
component: CreateRequest,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Create a Request',
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Request edit page
|
||||||
|
route: {
|
||||||
|
path: '/ccrs/:id/edit',
|
||||||
|
component: RequestEdit,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Edit Request',
|
||||||
|
isFullScreen: true,
|
||||||
|
hideFooter: true,
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Request view page
|
||||||
|
route: {
|
||||||
|
path: '/ccrs/:id',
|
||||||
|
component: Ccr,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'View Request',
|
||||||
|
isFullScreen: true,
|
||||||
|
hideFooter: true,
|
||||||
|
},
|
||||||
|
onlyLoggedIn: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Create proposal
|
// Create proposal
|
||||||
route: {
|
route: {
|
||||||
|
@ -165,6 +207,30 @@ const routeConfigs: RouteConfig[] = [
|
||||||
},
|
},
|
||||||
onlyLoggedIn: false,
|
onlyLoggedIn: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Terms of Service page
|
||||||
|
route: {
|
||||||
|
path: '/guide',
|
||||||
|
component: Guide,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Guide',
|
||||||
|
},
|
||||||
|
onlyLoggedIn: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Terms of Service page
|
||||||
|
route: {
|
||||||
|
path: '/proposal-tutorial',
|
||||||
|
component: ProposalTutorial,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Proposal Tutorial',
|
||||||
|
},
|
||||||
|
onlyLoggedIn: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// About page
|
// About page
|
||||||
route: {
|
route: {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
ProposalPageParams,
|
ProposalPageParams,
|
||||||
PageParams,
|
PageParams,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
|
CCR,
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import {
|
import {
|
||||||
formatUserForPost,
|
formatUserForPost,
|
||||||
|
@ -23,6 +24,7 @@ import {
|
||||||
formatProposalPageParamsForGet,
|
formatProposalPageParamsForGet,
|
||||||
formatProposalPageFromGet,
|
formatProposalPageFromGet,
|
||||||
} from 'utils/api';
|
} from 'utils/api';
|
||||||
|
import { CCRDraft } from 'types/ccr';
|
||||||
|
|
||||||
export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> {
|
export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> {
|
||||||
let serverParams;
|
let serverParams;
|
||||||
|
@ -42,6 +44,24 @@ export function getProposal(proposalId: number | string): Promise<{ data: Propos
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function followProposal(proposalId: number, isFollow: boolean) {
|
||||||
|
return axios.put(`/api/v1/proposals/${proposalId}/follow`, { isFollow });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function likeProposal(proposalId: number, isLiked: boolean) {
|
||||||
|
return axios.put(`/api/v1/proposals/${proposalId}/like`, { isLiked });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function likeRfp(rfpId: number, isLiked: boolean) {
|
||||||
|
return axios.put(`/api/v1/rfps/${rfpId}/like`, { isLiked });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function likeComment(commentId: number, isLiked: boolean) {
|
||||||
|
return axios
|
||||||
|
.put(`/api/v1/comment/${commentId}/like`, { isLiked })
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProposalComments(proposalId: number | string, params: PageParams) {
|
export function getProposalComments(proposalId: number | string, params: PageParams) {
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
|
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
|
||||||
}
|
}
|
||||||
|
@ -70,6 +90,7 @@ export function getUser(address: string): Promise<{ data: User }> {
|
||||||
return axios
|
return axios
|
||||||
.get(`/api/v1/users/${address}`, {
|
.get(`/api/v1/users/${address}`, {
|
||||||
params: {
|
params: {
|
||||||
|
withRequests: true,
|
||||||
withProposals: true,
|
withProposals: true,
|
||||||
withComments: true,
|
withComments: true,
|
||||||
withFunded: true,
|
withFunded: true,
|
||||||
|
@ -137,6 +158,8 @@ export function getUserSettings(
|
||||||
interface SettingsArgs {
|
interface SettingsArgs {
|
||||||
emailSubscriptions?: EmailSubscriptions;
|
emailSubscriptions?: EmailSubscriptions;
|
||||||
refundAddress?: string;
|
refundAddress?: string;
|
||||||
|
tipJarAddress?: string;
|
||||||
|
tipJarViewKey?: string;
|
||||||
}
|
}
|
||||||
export function updateUserSettings(
|
export function updateUserSettings(
|
||||||
userId: string | number,
|
userId: string | number,
|
||||||
|
@ -181,14 +204,18 @@ export function verifySocial(service: SOCIAL_SERVICE, code: string): Promise<any
|
||||||
return axios.post(`/api/v1/users/social/${service}/verify`, { code });
|
return axios.post(`/api/v1/users/social/${service}/verify`, { code });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCrowdFundFactoryJSON(): Promise<any> {
|
interface ProposalTipJarArgs {
|
||||||
const res = await axios.get(process.env.CROWD_FUND_FACTORY_URL as string);
|
address?: string;
|
||||||
return res.data;
|
viewKey?: string;
|
||||||
}
|
}
|
||||||
|
export function updateProposalTipJarSettings(
|
||||||
export async function fetchCrowdFundJSON(): Promise<any> {
|
proposalId: string | number,
|
||||||
const res = await axios.get(process.env.CROWD_FUND_URL as string);
|
args?: ProposalTipJarArgs,
|
||||||
return res.data;
|
): Promise<{ data: Proposal }> {
|
||||||
|
return axios.put(`/api/v1/proposals/${proposalId}/tips`, args).then(res => {
|
||||||
|
res.data = formatProposalFromGet(res.data);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postProposalUpdate(
|
export function postProposalUpdate(
|
||||||
|
@ -344,12 +371,6 @@ export function getProposalContribution(
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
|
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProposalStakingContribution(
|
|
||||||
proposalId: number,
|
|
||||||
): Promise<{ data: ContributionWithAddressesAndUser }> {
|
|
||||||
return axios.get(`/api/v1/proposals/${proposalId}/stake`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRFPs(): Promise<{ data: RFP[] }> {
|
export function getRFPs(): Promise<{ data: RFP[] }> {
|
||||||
return axios.get('/api/v1/rfps/').then(res => {
|
return axios.get('/api/v1/rfps/').then(res => {
|
||||||
res.data = res.data.map(formatRFPFromGet);
|
res.data = res.data.map(formatRFPFromGet);
|
||||||
|
@ -367,3 +388,49 @@ export function getRFP(rfpId: number | string): Promise<{ data: RFP }> {
|
||||||
export function resendEmailVerification(): Promise<{ data: void }> {
|
export function resendEmailVerification(): Promise<{ data: void }> {
|
||||||
return axios.put(`/api/v1/users/me/resend-verification`);
|
return axios.put(`/api/v1/users/me/resend-verification`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHomeLatest(): Promise<{
|
||||||
|
data: {
|
||||||
|
latestProposals: Proposal[];
|
||||||
|
latestRfps: RFP[];
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
return axios.get('/api/v1/home/latest').then(res => {
|
||||||
|
res.data = {
|
||||||
|
latestProposals: res.data.latestProposals.map(formatProposalFromGet),
|
||||||
|
latestRfps: res.data.latestRfps.map(formatRFPFromGet),
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CCRs
|
||||||
|
export function getCCRDrafts(): Promise<{ data: CCRDraft[] }> {
|
||||||
|
return axios.get('/api/v1/ccrs/drafts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postCCRDraft(): Promise<{ data: CCRDraft }> {
|
||||||
|
return axios.post('/api/v1/ccrs/drafts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCCR(ccrId: number): Promise<any> {
|
||||||
|
return axios.delete(`/api/v1/ccrs/${ccrId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putCCR(ccr: CCRDraft): Promise<{ data: CCRDraft }> {
|
||||||
|
// Exclude some keys
|
||||||
|
const { ccrId, author, dateCreated, status, ...rest } = ccr;
|
||||||
|
return axios.put(`/api/v1/ccrs/${ccrId}`, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCCR(ccrId: number | string): Promise<{ data: CCR }> {
|
||||||
|
return axios.get(`/api/v1/ccrs/${ccrId}`).then(res => {
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putCCRSubmitForApproval(ccr: CCRDraft): Promise<{ data: CCR }> {
|
||||||
|
return axios.put(`/api/v1/ccrs/${ccr.ccrId}/submit_for_approval`).then(res => {
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Redirect, RouteProps } from 'react-router';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { authActions } from 'modules/auth';
|
||||||
|
import { NativeButtonProps } from 'antd/lib/button/button';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { compose } from 'recompose';
|
||||||
|
|
||||||
|
type OwnProps = NativeButtonProps;
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
user: AppState['auth']['user'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
setAuthForwardLocation: typeof authActions['setAuthForwardLocation'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & RouteProps & StateProps & DispatchProps;
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
sendToAuth: false,
|
||||||
|
};
|
||||||
|
type State = typeof STATE;
|
||||||
|
|
||||||
|
class AuthButton extends React.Component<Props, State> {
|
||||||
|
state: State = { ...STATE };
|
||||||
|
public render() {
|
||||||
|
const { location, children, loading } = this.props;
|
||||||
|
if (this.state.sendToAuth) {
|
||||||
|
return <Redirect to={{ ...location, pathname: '/profile' }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button loading={loading} onClick={this.handleClick}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!this.props.onClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.props.user) {
|
||||||
|
this.props.onClick(e);
|
||||||
|
} else {
|
||||||
|
const { location, setAuthForwardLocation } = this.props;
|
||||||
|
setAuthForwardLocation(location);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ sendToAuth: true });
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
(state: AppState) => ({
|
||||||
|
user: state.auth.user,
|
||||||
|
}),
|
||||||
|
{ setAuthForwardLocation: authActions.setAuthForwardLocation },
|
||||||
|
);
|
||||||
|
|
||||||
|
export default compose<Props, OwnProps>(
|
||||||
|
withRouter,
|
||||||
|
withConnect,
|
||||||
|
)(AuthButton);
|
|
@ -41,7 +41,7 @@ class SignUp extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item className="SignUp-form-item" label="Title">
|
<Form.Item className="SignUp-form-item" label="About you">
|
||||||
{getFieldDecorator('title', {
|
{getFieldDecorator('title', {
|
||||||
rules: [{ required: true, message: 'Please add your title' }],
|
rules: [{ required: true, message: 'Please add your title' }],
|
||||||
})(
|
})(
|
||||||
|
@ -127,7 +127,8 @@ class SignUp extends React.Component<Props> {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const { createUser } = this.props;
|
const { createUser } = this.props;
|
||||||
this.props.form.validateFieldsAndScroll((err: any, values: any) => {
|
this.props.form.validateFieldsAndScroll((err: any, values: any) => {
|
||||||
if (!err) {
|
const hasAgreed = this.props.form.getFieldValue('hasAgreed');
|
||||||
|
if (!err && hasAgreed) {
|
||||||
delete values.passwordConfirm;
|
delete values.passwordConfirm;
|
||||||
createUser(values);
|
createUser(values);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,177 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Divider, List, message, Popconfirm, Spin } from 'antd';
|
||||||
|
import Placeholder from 'components/Placeholder';
|
||||||
|
import { getIsVerified } from 'modules/auth/selectors';
|
||||||
|
import Loader from 'components/Loader';
|
||||||
|
import { CCRDraft, CCRSTATUS } from 'types';
|
||||||
|
import {
|
||||||
|
createCCRDraft,
|
||||||
|
deleteCCRDraft,
|
||||||
|
fetchAndCreateCCRDrafts,
|
||||||
|
} from 'modules/ccr/actions';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import './style.less';
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
drafts: AppState['ccr']['drafts'];
|
||||||
|
isFetchingDrafts: AppState['ccr']['isFetchingDrafts'];
|
||||||
|
fetchDraftsError: AppState['ccr']['fetchDraftsError'];
|
||||||
|
isCreatingDraft: AppState['ccr']['isCreatingDraft'];
|
||||||
|
createDraftError: AppState['ccr']['createDraftError'];
|
||||||
|
isDeletingDraft: AppState['ccr']['isDeletingDraft'];
|
||||||
|
deleteDraftError: AppState['ccr']['deleteDraftError'];
|
||||||
|
isVerified: ReturnType<typeof getIsVerified>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
createCCRDraft: typeof createCCRDraft;
|
||||||
|
deleteCCRDraft: typeof deleteCCRDraft;
|
||||||
|
fetchAndCreateCCRDrafts: typeof fetchAndCreateCCRDrafts;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
createIfNone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = StateProps & DispatchProps & OwnProps;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
deletingId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRDraftList extends React.Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
deletingId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchAndCreateCCRDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
const { isDeletingDraft, deleteDraftError, createDraftError } = this.props;
|
||||||
|
if (prevProps.isDeletingDraft && !isDeletingDraft) {
|
||||||
|
this.setState({ deletingId: null });
|
||||||
|
}
|
||||||
|
if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) {
|
||||||
|
message.error(deleteDraftError, 3);
|
||||||
|
}
|
||||||
|
if (createDraftError && prevProps.createDraftError !== createDraftError) {
|
||||||
|
message.error(createDraftError, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { drafts, isCreatingDraft, isFetchingDrafts, isVerified } = this.props;
|
||||||
|
const { deletingId } = this.state;
|
||||||
|
|
||||||
|
if (!isVerified) {
|
||||||
|
return (
|
||||||
|
<div className="CreateRequestDraftList">
|
||||||
|
<Placeholder
|
||||||
|
title="Your email is not verified"
|
||||||
|
subtitle="Please confirm your email before creating a request."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drafts || isCreatingDraft) {
|
||||||
|
return <Loader size="large" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let draftsEl;
|
||||||
|
if (drafts.length) {
|
||||||
|
draftsEl = (
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={drafts}
|
||||||
|
loading={isFetchingDrafts}
|
||||||
|
renderItem={(d: CCRDraft) => {
|
||||||
|
const actions = [
|
||||||
|
<Link key="edit" to={`/ccrs/${d.ccrId}/edit`}>
|
||||||
|
Edit
|
||||||
|
</Link>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="Are you sure?"
|
||||||
|
onConfirm={() => this.deleteDraft(d.ccrId)}
|
||||||
|
>
|
||||||
|
<a>Delete</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Spin tip="deleting..." spinning={deletingId === d.ccrId}>
|
||||||
|
<List.Item actions={actions}>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{d.title || <em>Untitled Request</em>}
|
||||||
|
{d.status === CCRSTATUS.REJECTED && <em> (changes requested)</em>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={d.brief || <em>No description</em>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
draftsEl = (
|
||||||
|
<Placeholder
|
||||||
|
title="You have no drafts"
|
||||||
|
subtitle="Why not make one now? Click below to start."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CreateRequestDraftList">
|
||||||
|
<h2 className="CreateRequestDraftList-title">Your Request Drafts</h2>
|
||||||
|
{draftsEl}
|
||||||
|
<Divider>or</Divider>
|
||||||
|
<Button
|
||||||
|
className="CreateRequestDraftList-create"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={() => this.createDraft()}
|
||||||
|
loading={isCreatingDraft}
|
||||||
|
>
|
||||||
|
Create a new Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDraft = () => {
|
||||||
|
this.props.createCCRDraft();
|
||||||
|
};
|
||||||
|
|
||||||
|
private deleteDraft = (ccrId: number) => {
|
||||||
|
this.props.deleteCCRDraft(ccrId);
|
||||||
|
this.setState({ deletingId: ccrId });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
state => ({
|
||||||
|
drafts: state.ccr.drafts,
|
||||||
|
isFetchingDrafts: state.ccr.isFetchingDrafts,
|
||||||
|
fetchDraftsError: state.ccr.fetchDraftsError,
|
||||||
|
isCreatingDraft: state.ccr.isCreatingDraft,
|
||||||
|
createDraftError: state.ccr.createDraftError,
|
||||||
|
isDeletingDraft: state.ccr.isDeletingDraft,
|
||||||
|
deleteDraftError: state.ccr.deleteDraftError,
|
||||||
|
isVerified: getIsVerified(state),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
createCCRDraft,
|
||||||
|
deleteCCRDraft,
|
||||||
|
fetchAndCreateCCRDrafts,
|
||||||
|
},
|
||||||
|
)(CCRDraftList);
|
|
@ -0,0 +1,26 @@
|
||||||
|
.CreateRequestDraftList {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-create {
|
||||||
|
display: block;
|
||||||
|
max-width: 280px;
|
||||||
|
height: 3.2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-divider {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-alert {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Form, Input } from 'antd';
|
||||||
|
import { CCRDraft } from 'types';
|
||||||
|
import { getCCRErrors } from 'modules/ccr/utils';
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
ccrId: number;
|
||||||
|
initialState?: Partial<State>;
|
||||||
|
|
||||||
|
updateForm(form: Partial<CCRDraft>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps;
|
||||||
|
|
||||||
|
interface State extends Partial<CCRDraft> {
|
||||||
|
title: string;
|
||||||
|
brief: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CCRFlowBasics extends React.Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
title: '',
|
||||||
|
brief: '',
|
||||||
|
target: '',
|
||||||
|
...(props.initialState || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { title, brief, target } = this.state;
|
||||||
|
const errors = getCCRErrors(this.state, true);
|
||||||
|
|
||||||
|
// Don't show target error at zero since it defaults to that
|
||||||
|
// Error just shows up at the end to prevent submission
|
||||||
|
if (target === '0') {
|
||||||
|
errors.target = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form layout="vertical" style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||||
|
<Form.Item
|
||||||
|
label="Title"
|
||||||
|
validateStatus={errors.title ? 'error' : undefined}
|
||||||
|
help={errors.title}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
name="title"
|
||||||
|
placeholder="Short and sweet"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Brief"
|
||||||
|
validateStatus={errors.brief ? 'error' : undefined}
|
||||||
|
help={errors.brief}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
name="brief"
|
||||||
|
placeholder="An elevator-pitch version of your request, max 140 chars"
|
||||||
|
value={brief}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Target amount"
|
||||||
|
validateStatus={errors.target ? 'error' : undefined}
|
||||||
|
help={
|
||||||
|
errors.target ||
|
||||||
|
'Accepted proposals will be paid out in ZEC based in USD market price at payout time. Zcash Foundation administrators may opt to adjust this value before approval.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
name="target"
|
||||||
|
placeholder="500"
|
||||||
|
type="number"
|
||||||
|
value={target}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
addonBefore="$"
|
||||||
|
maxLength={16}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInputChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
) => {
|
||||||
|
const { value, name } = event.currentTarget;
|
||||||
|
this.setState({ [name]: value } as any, () => {
|
||||||
|
this.props.updateForm(this.state);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CCRFlowBasics;
|
|
@ -0,0 +1,86 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
@small-query: ~'(max-width: 640px)';
|
||||||
|
|
||||||
|
.CCRExplainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&-header {
|
||||||
|
margin: 3rem auto 5rem;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&-subtitle {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-create {
|
||||||
|
display: block;
|
||||||
|
width: 280px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
height: 4.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-actions {
|
||||||
|
margin: 6rem auto;
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-items {
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 2rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
margin-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 8rem;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { withNamespaces, WithNamespaces } from 'react-i18next';
|
||||||
|
import SubmitIcon from 'static/images/guide-submit.svg';
|
||||||
|
import ReviewIcon from 'static/images/guide-review.svg';
|
||||||
|
import './CCRExplainer.less';
|
||||||
|
import * as ls from 'local-storage';
|
||||||
|
import { Button, Checkbox, Icon } from 'antd';
|
||||||
|
|
||||||
|
interface CreateProps {
|
||||||
|
startSteps: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = WithNamespaces & CreateProps;
|
||||||
|
|
||||||
|
const CCRExplainer: React.SFC<Props> = ({ startSteps }) => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
'Anyone can create a request for improvements to the Zcash ecosystem. Approved requests are posted publicly to garner interest for proposals.',
|
||||||
|
icon: <SubmitIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text:
|
||||||
|
"The request is reviewed by the Zcash Foundation. \nYou'll be notified should the Zcash Foundation choose to make your request public.",
|
||||||
|
icon: <ReviewIcon />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CCRExplainer">
|
||||||
|
<div className="CCRExplainer-header">
|
||||||
|
<h2 className="CCRExplainer-header-title">Creating a Request</h2>
|
||||||
|
<div className="CCRExplainer-header-subtitle">
|
||||||
|
We can't wait to get your request! Before starting, here's what you should
|
||||||
|
know...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="CCRExplainer-items">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div className="CCRExplainer-items-item" key={idx}>
|
||||||
|
<div className="CCRExplainer-items-item-icon">{item.icon}</div>
|
||||||
|
<div className="CCRExplainer-items-item-text">{item.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="CCRExplainer-actions">
|
||||||
|
<Checkbox onChange={ev => ls.set<boolean>('noExplainCCR', ev.target.checked)}>
|
||||||
|
Don't show this again
|
||||||
|
</Checkbox>
|
||||||
|
<Button
|
||||||
|
className="CCRExplainer-create"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
onClick={() => startSteps()}
|
||||||
|
>
|
||||||
|
Let's do this <Icon type="right-circle-o" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNamespaces()(CCRExplainer);
|
|
@ -0,0 +1,41 @@
|
||||||
|
@import '~styles/variables.less';
|
||||||
|
|
||||||
|
.CCRFinal {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
&.is-error .anticon {
|
||||||
|
color: @error-color;
|
||||||
|
}
|
||||||
|
&.is-success .anticon {
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue