Merge pull request #484 from grant-project/develop

ZF Grants 2.0
This commit is contained in:
Daniel Ternyak 2019-12-10 15:50:55 -06:00 committed by GitHub
commit 044deea218
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
220 changed files with 9908 additions and 1755 deletions

View File

@ -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} />

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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} &nbsp;
</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;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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[];

View File

@ -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.

View File

@ -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;
}
} }

View File

@ -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 () => {

View File

@ -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 */}

View File

@ -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}>

View File

@ -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" />

View File

@ -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 {

View File

@ -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[];

View File

@ -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,

View File

@ -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;
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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",
},
} }

View File

@ -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(
''' '''

View File

@ -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)

View File

@ -0,0 +1,2 @@
from . import models
from . import views

230
backend/grant/ccr/models.py Normal file
View File

@ -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)

113
backend/grant/ccr/views.py Normal file
View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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
} }

View File

@ -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):

View File

@ -0,0 +1 @@
from . import views

View File

@ -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),
}

View File

@ -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')

View File

@ -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")

View File

@ -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 teams background and experience. Demonstrate that you have the skills and expertise necessary for the project that youre 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 [ZFs 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)

View File

@ -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 doesnt exist"}, 400 return {"message": "The request this proposal was made for doesnt 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

View File

@ -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)

View File

@ -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

View File

@ -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',

View File

@ -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
} }

View File

@ -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>

View File

@ -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 }}

View File

@ -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 %}

View File

@ -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 }}

View File

@ -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>

View File

@ -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.

View File

@ -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>

View File

@ -0,0 +1,3 @@
Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted!
Check it out: {{ args.proposal_url }}

View File

@ -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>

View File

@ -0,0 +1,3 @@
Your followed proposal {{ args.proposal.title }} has an update!
Check it out: {{ args.proposal_url }}

View File

@ -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>

View File

@ -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 }}

View File

@ -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>

View File

@ -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:

View File

@ -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;">

View File

@ -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 }}

View File

@ -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>

View File

@ -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 %}

View File

@ -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()

View File

@ -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"
) )

View File

@ -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)

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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:

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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)

View File

View File

@ -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)

View File

@ -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()

View File

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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"])

View File

View File

@ -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)

View File

@ -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()

View File

@ -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"
} }

View File

@ -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)

View File

@ -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

View File

@ -20,9 +20,13 @@ 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);
@ -63,6 +67,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 +206,18 @@ const routeConfigs: RouteConfig[] = [
}, },
onlyLoggedIn: false, onlyLoggedIn: false,
}, },
{
// Terms of Service page
route: {
path: '/guide',
component: Guide,
exact: true,
},
template: {
title: 'Guide',
},
onlyLoggedIn: false,
},
{ {
// About page // About page
route: { route: {

View File

@ -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;
});
}

View File

@ -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);

View File

@ -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);
} }

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}
}
}
}

View File

@ -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);

View File

@ -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