Merge pull request #488 from ZcashFoundation/develop

ZF Grants 2.0
This commit is contained in:
Daniel Ternyak 2019-12-10 23:55:44 -06:00 committed by GitHub
commit 452637cc28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
222 changed files with 10042 additions and 1755 deletions

View File

@ -13,6 +13,8 @@ import UserDetail from 'components/UserDetail';
import Emails from 'components/Emails';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
import CCRs from 'components/CCRs';
import CCRDetail from 'components/CCRDetail';
import RFPs from 'components/RFPs';
import RFPForm from 'components/RFPForm';
import RFPDetail from 'components/RFPDetail';
@ -47,6 +49,8 @@ class Routes extends React.Component<Props> {
<Route path="/users" component={Users} />
<Route path="/proposals/:id" component={ProposalDetail} />
<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/:id/edit" component={RFPForm} />
<Route path="/rfps/:id" component={RFPDetail} />

View File

@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
}, 1000);
render() {
const { arbiter } = this.props;
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching;
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
const disp = {
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
type="primary"
onClick={this.handleShowSearch}
{...this.props.buttonProps}
disabled={buttonDisabled}
>
{disp[arbiter.status]}
</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',
title: 'Proposal rejected',
description: 'Sent when an admin rejects your submitted proposal',
title: 'Proposal changes requested',
description: 'Sent when an admin requests changes for your submitted proposal',
},
{
id: 'proposal_contribution',
@ -130,6 +130,11 @@ export default [
title: 'Milestone 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',
title: 'Admin Approval',
@ -145,4 +150,15 @@ export default [
title: 'Admin Payout',
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[];

View File

@ -14,6 +14,7 @@ class Home extends React.Component {
const {
userCount,
proposalCount,
ccrPendingCount,
proposalPendingCount,
proposalNoArbiterCount,
proposalMilestonePayoutsCount,
@ -21,6 +22,13 @@ class Home extends React.Component {
} = store.stats;
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 && (
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
@ -32,7 +40,7 @@ class Home extends React.Component {
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</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
</Link>{' '}
to view them.

View File

@ -26,10 +26,6 @@
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
&-popover {
@ -46,4 +42,9 @@
white-space: inherit;
}
}
&-review {
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
}

View File

@ -11,7 +11,6 @@ import {
Collapse,
Popconfirm,
Input,
Switch,
Tag,
message,
} from 'antd';
@ -26,11 +25,11 @@ import {
} from 'src/types';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl';
import { toZat, fromZat } from 'src/util/units';
import FeedbackModal from '../FeedbackModal';
import { formatUsd } from 'util/formatters';
import './index.less';
type Props = RouteComponentProps<any>;
@ -38,6 +37,7 @@ type Props = RouteComponentProps<any>;
const STATE = {
paidTxId: '',
showCancelAndRefundPopover: false,
showChangeToAcceptedWithFundingPopover: false,
};
type State = typeof STATE;
@ -65,17 +65,32 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 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 disabled = this.getCancelAndRefundDisabled();
return (
<Popconfirm
title={
<p>
Are you sure you want to cancel proposal and begin
<br />
the refund process? This cannot be undone.
</p>
isVersionTwo ? (
<p>
Are you sure you want to cancel proposal?
<br />
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"
cancelText="cancel"
@ -95,7 +110,40 @@ class ProposalDetailNaked extends React.Component<Props, State> {
disabled={disabled}
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>
</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 = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
<Alert
@ -194,39 +179,76 @@ class ProposalDetailNaked extends React.Component<Props, State> {
const renderReview = () =>
p.status === PROPOSAL_STATUS.PENDING && (
<Alert
showIcon
type="warning"
message="Review Pending"
description={
<div>
<p>Please review this proposal and render your judgment.</p>
<Button
loading={store.proposalDetailApproving}
icon="check"
type="primary"
onClick={this.handleApprove}
>
Approve
</Button>
<Button
loading={store.proposalDetailApproving}
icon="close"
type="danger"
onClick={() => {
FeedbackModal.open({
title: 'Reject this proposal?',
label: 'Please provide a reason:',
okText: 'Reject',
onOk: this.handleReject,
});
}}
>
Reject
</Button>
</div>
}
/>
<>
<Row gutter={16}>
<Col span={isVersionTwo ? 16 : 24}>
<Alert
showIcon
type="warning"
message="Review Pending"
description={
<div>
<p>Please review this proposal and render your judgment.</p>
<Button
className="ProposalDetail-review"
loading={store.proposalDetailApproving}
icon="check"
type="primary"
onClick={() => this.handleApprove(true)}
>
Approve With Funding
</Button>
<Button
className="ProposalDetail-review"
loading={store.proposalDetailApproving}
icon="check"
type="default"
onClick={() => this.handleApprove(false)}
>
Approve Without Funding
</Button>
<Button
className="ProposalDetail-review"
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 = () =>
@ -234,12 +256,12 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Alert
showIcon
type="error"
message="Rejected"
message="Changes requested"
description={
<div>
<p>
This proposal has been rejected. The team will be able to re-submit it for
approval should they desire to do so.
This proposal 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 />
@ -250,7 +272,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
);
const renderNominateArbiter = () =>
needsArbiter && (
needsArbiter &&
shouldShowArbiter && (
<Alert
showIcon
type="warning"
@ -297,11 +320,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return;
}
const ms = p.currentMilestone;
const amount = fromZat(
toZat(p.target)
.mul(new BN(ms.payoutPercent))
.divn(100),
);
let paymentMsg;
if (p.isVersionTwo) {
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 (
<Alert
className="ProposalDetail-alert"
@ -318,7 +353,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</p>
<p>
{' '}
Please make a payment of <b>{amount.toString()} ZEC</b> to:
Please make a payment of <b>{paymentMsg}</b> to:
</p>{' '}
<pre>{p.payoutAddress}</pre>
<Input.Search
@ -381,7 +416,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderMilestoneAccepted()}
{renderFailed()}
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
</Collapse.Panel>
@ -391,24 +425,35 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Collapse.Panel>
<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={
<>
{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>
)
}
<p>{milestone.content}</p>
</Card>
))}
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
@ -419,12 +464,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{/* RIGHT SIDE */}
<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 */}
<Card size="small" className="ProposalDetail-controls">
{renderCancelControl()}
{renderArbiterControl()}
{renderBountyControl()}
{renderMatchingControl()}
{shouldShowChangeToAcceptedWithFunding &&
renderChangeToAcceptedWithFundingControl()}
</Card>
{/* DETAILS */}
@ -447,13 +503,19 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
{renderDeetItem('status', p.status)}
{renderDeetItem('stage', p.stage)}
{renderDeetItem('category', p.category)}
{renderDeetItem('target', p.target)}
{renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)}
{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('bounty', p.contributionBounty)}
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
{renderDeetItem(
'acceptedWithFunding',
JSON.stringify(p.acceptedWithFunding),
)}
{renderDeetItem(
'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 = () => {
return Number(this.props.match.params.id);
};
@ -526,44 +602,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
this.setState({ showCancelAndRefundPopover: false });
};
private handleApprove = () => {
store.approveProposal(true);
private handleApprove = (withFunding: boolean) => {
store.approveProposal(true, withFunding);
};
private handleReject = async (reason: string) => {
await store.approveProposal(false, reason);
message.info('Proposal rejected');
};
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');
},
});
}
await store.approveProposal(false, false, reason);
message.info('Proposal changes requested');
};
private handlePaidMilestone = async () => {

View File

@ -2,13 +2,14 @@ import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
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 Back from 'components/Back';
import Markdown from 'components/Markdown';
import { formatDateSeconds } from 'util/time';
import store from 'src/store';
import { PROPOSAL_STATUS } from 'src/types';
import { formatUsd } from 'src/util/formatters';
import './index.less';
type Props = RouteComponentProps<{ id?: string }>;
@ -37,9 +38,11 @@ class RFPDetail extends React.Component<Props> {
</div>
);
const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING);
const acceptedProposals = rfp.proposals.filter(p =>
p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED
const pendingProposals = rfp.proposals.filter(
p => p.status === PROPOSAL_STATUS.PENDING,
);
const acceptedProposals = rfp.proposals.filter(
p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED,
);
return (
@ -66,6 +69,20 @@ class RFPDetail extends React.Component<Props> {
{/* RIGHT SIDE */}
<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 */}
<Card className="RFPDetail-actions" size="small">
<Link to={`/rfps/${rfp.id}/edit`}>
@ -90,10 +107,15 @@ class RFPDetail extends React.Component<Props> {
{renderDeetItem('id', rfp.id)}
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
{renderDeetItem('status', rfp.status)}
{renderDeetItem('category', rfp.category)}
{renderDeetItem('matching', String(rfp.matching))}
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
{renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
{renderDeetItem(
'bounty',
rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`,
)}
{renderDeetItem(
'dateCloses',
rfp.dateCloses && formatDateSeconds(rfp.dateCloses),
)}
</Card>
{/* PROPOSALS */}

View File

@ -3,23 +3,10 @@ import moment from 'moment';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import {
Form,
Input,
Select,
Icon,
Button,
message,
Spin,
Checkbox,
Row,
Col,
DatePicker,
} from 'antd';
import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
import { CATEGORY_UI } from 'util/ui';
import { RFP_STATUS, RFPArgs } from 'src/types';
import { typedKeys } from 'util/ts';
import { RFP_STATUSES, getStatusById } from 'util/statuses';
import Markdown from 'components/Markdown';
@ -54,13 +41,14 @@ class RFPForm extends React.Component<Props, State> {
title: '',
brief: '',
content: '',
category: '',
status: '',
matching: false,
bounty: undefined,
dateCloses: undefined,
};
const rfpId = this.getRFPId();
let isVersionTwo = true;
if (rfpId) {
if (!store.rfpsFetched) {
return <Spin />;
@ -72,12 +60,12 @@ class RFPForm extends React.Component<Props, State> {
title: rfp.title,
brief: rfp.brief,
content: rfp.content,
category: rfp.category,
status: rfp.status,
matching: rfp.matching,
bounty: rfp.bounty,
dateCloses: rfp.dateCloses || undefined,
};
isVersionTwo = rfp.isVersionTwo;
} else {
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);
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
const bountyMatchRule = isVersionTwo
? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' }
: {};
return (
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
<Back to="/rfps" text="RFPs" />
@ -131,28 +123,6 @@ class RFPForm extends React.Component<Props, State> {
</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">
{getFieldDecorator('brief', {
initialValue: defaults.brief,
@ -199,26 +169,20 @@ class RFPForm extends React.Component<Props, State> {
<Form.Item className="RFPForm-bounty" label="Bounty">
{getFieldDecorator('bounty', {
initialValue: defaults.bounty,
rules: [
{ required: true, message: 'Bounty is required' },
bountyMatchRule,
],
})(
<Input
autoComplete="off"
name="bounty"
placeholder="100"
addonAfter="ZEC"
placeholder="1000"
addonBefore={isVersionTwo ? '$' : undefined}
addonAfter={isVersionTwo ? undefined : 'ZEC'}
size="large"
/>,
)}
{getFieldDecorator('matching', {
initialValue: defaults.matching,
})(
<Checkbox
className="RFPForm-bounty-matching"
name="matching"
defaultChecked={defaults.matching}
>
Match community contributions for approved proposals
</Checkbox>,
)}
</Form.Item>
</Col>
<Col sm={12} xs={24}>

View File

@ -51,6 +51,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">Proposals</span>
</Link>
</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">
<Link to="/rfps">
<Icon type="notification" />

View File

@ -4,6 +4,7 @@ import axios, { AxiosError } from 'axios';
import {
User,
Proposal,
CCR,
Contribution,
ContributionArgs,
RFP,
@ -129,9 +130,15 @@ async function deleteProposal(id: number) {
return data;
}
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
isApprove,
async function approveProposal(
id: number,
isAccepted: boolean,
withFunding: boolean,
rejectReason?: string,
) {
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
isAccepted,
withFunding,
rejectReason,
});
return data;
@ -142,6 +149,11 @@ async function cancelProposal(id: number) {
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>) {
const { data } = await api.get('/admin/comments', { params });
return data;
@ -165,6 +177,28 @@ async function getEmailExample(type: string) {
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() {
const { data } = await api.get(`/admin/rfps`);
return data;
@ -218,6 +252,7 @@ const app = store({
stats: {
userCount: 0,
proposalCount: 0,
ccrPendingCount: 0,
proposalPendingCount: 0,
proposalNoArbiterCount: 0,
proposalMilestonePayoutsCount: 0,
@ -282,6 +317,25 @@ const app = store({
proposalDetailCanceling: false,
proposalDetailUpdating: 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: {
page: createDefaultPageData<Comment>('CREATED:DESC'),
@ -482,6 +536,53 @@ const app = store({
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
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) {
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
@ -546,7 +651,12 @@ const app = store({
app.proposalDetailApproving = true;
try {
const { proposalId } = app.proposalDetail;
const res = await approveProposal(proposalId, isApprove, rejectReason);
const res = await approveProposal(
proposalId,
isAccepted,
withFunding,
rejectReason,
);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
@ -565,6 +675,19 @@ const app = store({
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) {
app.proposalDetailMarkingMilestonePaid = true;
try {

View File

@ -17,7 +17,8 @@ export interface Milestone {
index: number;
content: string;
dateCreated: number;
dateEstimated: number;
dateEstimated?: number;
daysEstimated?: string;
dateRequested: number;
dateAccepted: number;
dateRejected: number;
@ -41,18 +42,18 @@ export interface RFP {
title: string;
brief: string;
content: string;
category: string;
status: string;
proposals: Proposal[];
matching: boolean;
bounty: string | null;
dateCloses: number | null;
isVersionTwo: boolean;
ccr?: CCR;
}
export interface RFPArgs {
title: string;
brief: string;
content: string;
category: string;
matching: boolean;
dateCloses: number | null | undefined;
bounty: string | null | undefined;
@ -102,7 +103,6 @@ export interface Proposal {
title: string;
content: string;
stage: PROPOSAL_STAGE;
category: string;
milestones: Milestone[];
currentMilestone?: Milestone;
team: User[];
@ -116,6 +116,8 @@ export interface Proposal {
rfpOptIn: null | boolean;
rfp?: RFP;
arbiter: ProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean;
}
export interface Comment {
id: number;
@ -199,6 +201,30 @@ export enum PROPOSAL_CATEGORY {
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 {
page: number;
filters: string[];

View File

@ -5,6 +5,7 @@ import {
PROPOSAL_ARBITER_STATUSES,
MILESTONE_STAGES,
PROPOSAL_STAGES,
CCR_STATUSES,
} from './statuses';
export interface Filter {
@ -59,7 +60,21 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
color: s.tagColor,
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 = {
list: PROPOSAL_FILTERS,
@ -80,6 +95,20 @@ export const rfpFilters: 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
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
@ -87,17 +116,20 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
display: `Status: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Status',
})).concat([{
id: 'REFUNDABLE',
display: 'Refundable',
color: '#afd500',
group: 'Refundable',
}, {
id: 'DONATION',
display: 'Donations',
color: '#afd500',
group: 'Donations',
}]);
})).concat([
{
id: 'REFUNDABLE',
display: 'Refundable',
color: '#afd500',
group: 'Refundable',
},
{
id: 'DONATION',
display: 'Donations',
color: '#afd500',
group: 'Donations',
},
]);
export const contributionFilters: 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 {
PROPOSAL_STATUS,
CCR_STATUS,
RFP_STATUS,
CONTRIBUTION_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>> = [
{
id: PROPOSAL_STATUS.APPROVED,
@ -77,14 +118,14 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
id: PROPOSAL_STATUS.PENDING,
tagDisplay: 'Awaiting Approval',
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,
tagDisplay: 'Approval Rejected',
tagDisplay: 'Changes Requested',
tagColor: '#eb4118',
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,

View File

@ -39,4 +39,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
PROPOSAL_STAKING_AMOUNT=0.025
# 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
To run only select test, Flask allows you to match against the test filename with ``-t` like so:
flask test -t proposal
## Migrations
Whenever a database migration needs to be made. Run the following commands

View File

@ -149,6 +149,10 @@ example_email_args = {
'proposal': proposal,
'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': {
'proposal': proposal,
'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_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.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.email.send import generate_email, send_email
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.user.models import User, UserSettings, admin_users_schema, admin_user_schema
from grant.utils import pagination
from grant.utils.enums import Category
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
@ -34,6 +34,7 @@ from grant.utils.enums import (
ProposalArbiterStatus,
MilestoneStage,
RFPStatus,
CCRStatus
)
from grant.utils.misc import make_url, make_explore_url
from .example_emails import example_email_args
@ -137,6 +138,9 @@ def logout():
def stats():
user_count = db.session.query(func.count(User.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)) \
.filter(Proposal.status == ProposalStatus.PENDING) \
.scalar()
@ -145,6 +149,7 @@ def stats():
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
.filter(Proposal.stage != ProposalStage.CANCELED) \
.filter(Proposal.accepted_with_funding == True) \
.scalar()
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
.join(Proposal.milestones) \
@ -159,15 +164,16 @@ def stats():
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
.join(ProposalContribution.user) \
.join(UserSettings) \
.filter(UserSettings.refund_address != None) \
.scalar()
return {
"userCount": user_count,
"ccrPendingCount": ccr_pending_count,
"proposalCount": proposal_count,
"proposalPendingCount": proposal_pending_count,
"proposalNoArbiterCount": proposal_no_arbiter_count,
@ -313,9 +319,9 @@ def set_arbiter(proposal_id, user_id):
db.session.commit()
return {
'proposal': proposal_schema.dump(proposal),
'user': admin_user_schema.dump(user)
}, 200
'proposal': proposal_schema.dump(proposal),
'user': admin_user_schema.dump(user)
}, 200
# PROPOSALS
@ -352,45 +358,48 @@ def delete_proposal(id):
return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>', methods=['PUT'])
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
@body({
"contributionMatching": fields.Int(required=False, missing=None),
"contributionBounty": fields.Str(required=False, missing=None)
})
@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),
"isAccepted": fields.Bool(required=True),
"withFunding": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@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()
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()
return proposal_schema.dump(proposal)
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'])
@admin.admin_auth_required
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
for ms in proposal.milestones:
if ms.id == int(mid):
is_final_milestone = False
ms.mark_paid(tx_id)
db.session.add(ms)
db.session.flush()
# 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)
if num_paid == len(proposal.milestones):
is_final_milestone = True
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
db.session.add(proposal)
db.session.flush()
@ -437,6 +448,18 @@ def paid_milestone_payout_request(id, mid, tx_id):
'tx_explorer_url': make_explore_url(tx_id),
'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 {"message": "No milestone matching id"}, 404
@ -455,6 +478,64 @@ def get_email_example(type):
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
@ -470,7 +551,6 @@ def get_rfps():
"title": fields.Str(required=True),
"brief": 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),
"matching": fields.Bool(required=False, missing=False),
"dateCloses": fields.Int(required=False, missing=None)
@ -502,13 +582,12 @@ def get_rfp(rfp_id):
"brief": fields.Str(required=True),
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
"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),
"matching": fields.Bool(required=False, default=False, missing=False),
"dateCloses": fields.Int(required=False, missing=None),
})
@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()
if not rfp:
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.brief = brief
rfp.content = content
rfp.category = category
rfp.matching = matching
rfp.bounty = bounty
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.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
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.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200
@ -711,7 +789,6 @@ def edit_comment(comment_id, hidden, reported):
@blueprint.route("/financials", methods=["GET"])
@admin.admin_auth_required
def financials():
nfmt = '999999.99999999' # smallest unit of ZEC
def sql_pc(where: str):
@ -743,7 +820,8 @@ def financials():
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
'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'"))),
'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
'refunding': str(ex(sql_pc_p(
'''

View File

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""The app module, containing the app factory function."""
import sentry_sdk
import logging
import traceback
import sentry_sdk
from animal_case import animalify
from flask import Flask, Response, jsonify, request, current_app, g
from flask_cors import CORS
@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore
from flask_sslify import SSLify
from sentry_sdk.integrations.flask import FlaskIntegration
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.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
@ -129,6 +144,7 @@ def register_extensions(app):
def register_blueprints(app):
"""Register Flask blueprints."""
app.register_blueprint(ccr.views.blueprint)
app.register_blueprint(comment.views.blueprint)
app.register_blueprint(proposal.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(task.views.blueprint)
app.register_blueprint(rfp.views.blueprint)
app.register_blueprint(home.views.blueprint)
if E2E_TESTING and DEBUG:
print('Warning: e2e end-points are open, this should only be the case for development or testing')
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(proposal.commands.create_proposal)
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.mangle_users)
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.utils.ma_fields import UnixDate
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~~'
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):
__tablename__ = "comment"
@ -25,6 +34,15 @@ class Comment(db.Model):
author = db.relationship("User", back_populates="comments")
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):
self.id = gen_random_id(Comment)
self.proposal_id = proposal_id
@ -49,6 +67,28 @@ class Comment(db.Model):
self.hidden = hidden
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?
def all_hidden(replies):
@ -74,6 +114,8 @@ class CommentSchema(ma.Schema):
"replies",
"reported",
"hidden",
"authed_liked",
"likes_count"
)
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.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 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 = {
'home_url': make_url('/'),
@ -68,18 +69,34 @@ def change_password_info(email_args):
def proposal_approved(email_args):
return {
'subject': 'Your proposal has been approved!',
'title': 'Your proposal has been approved',
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
'subject': 'Your proposal has been reviewed',
'title': 'Your proposal has been reviewed',
'preview': '{} is now live on ZF Grants.'.format(email_args['proposal'].title),
'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):
return {
'subject': 'Your proposal has been rejected',
'title': 'Your proposal has been rejected',
'preview': '{} has been rejected'.format(email_args['proposal'].title),
'subject': 'Your proposal has changes requested',
'title': 'Your proposal has changes requested',
'preview': '{} has changes requested'.format(email_args['proposal'].title),
'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):
p = email_args['proposal']
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):
return {
'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 = {
'signup': signup_info,
'team_invite': team_invite_info,
@ -314,6 +372,8 @@ get_info_lookup = {
'change_email': change_email_info,
'change_email_old': change_email_old_info,
'change_password': change_password_info,
'ccr_rejected': ccr_rejected,
'ccr_approved': ccr_approved,
'proposal_approved': proposal_approved,
'proposal_rejected': proposal_rejected,
'proposal_contribution': proposal_contribution,
@ -330,12 +390,16 @@ get_info_lookup = {
'comment_reply': comment_reply,
'proposal_arbiter': proposal_arbiter,
'milestone_request': milestone_request,
'milestone_deadline': milestone_deadline,
'milestone_reject': milestone_reject,
'milestone_accept': milestone_accept,
'milestone_paid': milestone_paid,
'admin_approval': admin_approval,
'admin_approval_ccr': admin_approval_ccr,
'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,
'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):

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.ma_fields import UnixDate
from grant.utils.misc import gen_random_id
from grant.task.jobs import MilestoneDeadline
class MilestoneException(Exception):
@ -22,7 +23,8 @@ class Milestone(db.Model):
content = db.Column(db.Text, nullable=False)
payout_percent = db.Column(db.String(255), nullable=False)
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)
@ -46,7 +48,7 @@ class Milestone(db.Model):
index: int,
title: str,
content: str,
date_estimated: datetime,
days_estimated: str,
payout_percent: str,
immediate_payout: bool,
stage: str = MilestoneStage.IDLE,
@ -56,13 +58,14 @@ class Milestone(db.Model):
self.title = title[:255]
self.content = content[:255]
self.stage = stage
self.date_estimated = date_estimated
self.days_estimated = days_estimated[:255]
self.payout_percent = payout_percent[:255]
self.immediate_payout = immediate_payout
self.proposal_id = proposal_id
self.date_created = datetime.datetime.now()
self.index = index
@staticmethod
def make(milestones_data, proposal):
if milestones_data:
@ -72,7 +75,7 @@ class Milestone(db.Model):
m = Milestone(
title=milestone_data["title"][: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],
immediate_payout=milestone_data["immediate_payout"],
proposal_id=proposal.id,
@ -80,6 +83,55 @@ class Milestone(db.Model):
)
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):
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
@ -140,6 +192,7 @@ class MilestoneSchema(ma.Schema):
"date_rejected",
"date_accepted",
"date_paid",
"days_estimated"
)
date_created = UnixDate(attribute='date_created')

View File

@ -7,7 +7,7 @@ from flask.cli import with_appcontext
from .models import Proposal, db
from grant.milestone.models import Milestone
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
@ -35,9 +35,9 @@ def create_proposals(count):
user = User.query.filter_by().first()
for i in range(count):
if i < 5:
stage = ProposalStageEnum.FUNDING_REQUIRED
stage = ProposalStage.WIP
else:
stage = ProposalStageEnum.COMPLETED
stage = ProposalStage.COMPLETED
p = Proposal.create(
stage=stage,
status=ProposalStatus.LIVE,
@ -51,6 +51,10 @@ def create_proposals(count):
)
p.date_published = datetime.datetime.now()
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.flush()
num_ms = randint(1, 9)
@ -58,7 +62,7 @@ def create_proposals(count):
m = Milestone(
title=f'Fake MS {j}',
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)),
immediate_payout=j == 0,
proposal_id=p.id,
@ -74,5 +78,119 @@ def create_proposals(count):
)
db.session.add(c)
Milestone.set_v2_date_estimates(p)
db.session.add(p)
db.session.commit()
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
from typing import Optional
from decimal import Decimal, ROUND_DOWN
from functools import reduce
from flask import current_app
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.orm import column_property
from flask import current_app
from grant.comment.models import Comment
from grant.email.send import send_email
from grant.extensions import ma, db
@ -32,6 +32,20 @@ proposal_team = db.Table(
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):
__tablename__ = "proposal_team_invite"
@ -145,6 +159,8 @@ class ProposalContribution(db.Model):
raise ValidationException('Proposal ID is required')
# User ID (must belong to an existing user)
if user_id:
from grant.user.models import User
user = User.query.filter(User.id == user_id).first()
if not user:
raise ValidationException('No user matching that ID')
@ -212,32 +228,72 @@ class ProposalArbiter(db.Model):
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):
__tablename__ = "proposal"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
version = db.Column(db.String(255), nullable=True)
# Content info
status = db.Column(db.String(255), nullable=False)
title = db.Column(db.String(255), nullable=False)
brief = db.Column(db.String(255), nullable=False)
stage = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False, default=default_proposal_content())
category = db.Column(db.String(255), nullable=True)
date_approved = db.Column(db.DateTime)
date_published = db.Column(db.DateTime)
reject_reason = db.Column(db.String())
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
# Payment info
target = 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_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
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
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")
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")
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__(
self,
status: str = ProposalStatus.DRAFT,
title: str = '',
brief: str = '',
content: str = '',
content: str = default_proposal_content(),
stage: str = ProposalStage.PREVIEW,
target: str = '0',
payout_address: str = '',
@ -272,18 +344,16 @@ class Proposal(db.Model):
self.payout_address = payout_address
self.deadline_duration = deadline_duration
self.stage = stage
self.version = '2'
@staticmethod
def simple_validate(proposal):
# Validate fields to be database save-able.
# Stricter validation is done in validate_publishable.
stage = proposal.get('stage')
category = proposal.get('category')
if stage and not ProposalStage.includes(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):
payout_total = 0.0
@ -316,7 +386,7 @@ class Proposal(db.Model):
self.validate_publishable_milestones()
# 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:
if not hasattr(self, field):
raise ValidationException("Proposal must have a {}".format(field))
@ -329,13 +399,15 @@ class Proposal(db.Model):
if len(self.content) > 250000:
raise ValidationException("Content cannot be longer than 250,000 characters")
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
if Decimal(self.target) < 0.0001:
raise ValidationException("Target cannot be less than 0.0001")
raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX))
if Decimal(self.target) < 0:
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:
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:
res = blockchain_get('/validate/address', {'address': self.payout_address})
except:
@ -344,16 +416,37 @@ class Proposal(db.Model):
if not res['valid']:
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
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_dates(self):
present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def validate_milestone_days(self):
for milestone in self.milestones:
if present > milestone.date_estimated:
raise ValidationException("Milestone date estimate must be in the future ")
if milestone.immediate_payout:
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
def create(**kwargs):
@ -396,6 +489,7 @@ class Proposal(db.Model):
content: str = '',
target: str = '0',
payout_address: str = '',
tip_jar_address: Optional[str] = None,
deadline_duration: int = 5184000 # 60 days
):
self.title = title[:255]
@ -404,18 +498,12 @@ class Proposal(db.Model):
self.content = content[:300000]
self.target = target[:255] if target != '' else '0'
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
Proposal.simple_validate(vars(self))
def update_rfp_opt_in(self, opt_in: bool):
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(
self,
@ -469,19 +557,15 @@ class Proposal(db.Model):
'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):
self.validate_publishable()
self.validate_milestone_dates()
self.validate_milestone_days()
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
# specific validation
if self.status not in allowed_statuses:
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
# set to PENDING if staked, else STAKING
if self.is_staked:
self.status = ProposalStatus.PENDING
else:
self.status = ProposalStatus.STAKING
self.set_pending()
def set_pending_when_ready(self):
if self.status == ProposalStatus.STAKING and self.is_staked:
@ -489,31 +573,44 @@ class Proposal(db.Model):
# state: status STAKING -> PENDING
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.status = ProposalStatus.PENDING
db.session.add(self)
db.session.flush()
# state: status PENDING -> (APPROVED || REJECTED)
def approve_pending(self, is_approve, reject_reason=None):
# state: status PENDING -> (LIVE || REJECTED)
def approve_pending(self, is_approve, with_funding, reject_reason=None):
self.validate_publishable()
# specific validation
if not self.status == ProposalStatus.PENDING:
raise ValidationException(f"Proposal must be pending to approve or reject")
if is_approve:
self.status = ProposalStatus.APPROVED
self.status = ProposalStatus.LIVE
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:
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', {
'user': t,
'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'),
'admin_note': 'Congratulations! Your proposal has been approved.'
'admin_note': admin_note
})
else:
if not reject_reason:
@ -528,6 +625,10 @@ class Proposal(db.Model):
'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
def publish(self):
self.validate_publishable()
@ -536,28 +637,7 @@ class Proposal(db.Model):
raise ValidationException(f"Proposal status must be approved")
self.date_published = datetime.datetime.now()
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
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):
# do not allow changes on funded/WIP proposals
@ -567,20 +647,9 @@ class Proposal(db.Model):
self.contribution_bounty = str(Decimal(bounty))
db.session.add(self)
db.session.flush()
self.set_funded_when_ready()
def set_contribution_matching(self, matching: float):
# do not allow on funded/WIP proposals
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 fully_fund_contibution_bounty(self):
self.set_contribution_bounty(self.target)
def cancel(self):
if self.status != ProposalStatus.LIVE:
@ -603,6 +672,33 @@ class Proposal(db.Model):
'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
def contributed(self):
contributions = ProposalContribution.query \
@ -635,12 +731,7 @@ class Proposal(db.Model):
@hybrid_property
def is_staked(self):
# Don't use self.contributed since that ignores stake contributions
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
return True
@hybrid_property
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}
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 Meta:
@ -694,7 +827,6 @@ class ProposalSchema(ma.Schema):
"updates",
"milestones",
"current_milestone",
"category",
"team",
"payout_address",
"deadline_duration",
@ -703,13 +835,23 @@ class ProposalSchema(ma.Schema):
"invites",
"rfp",
"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_approved = ma.Method("get_date_approved")
date_published = ma.Method("get_date_published")
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)
team = ma.Nested("UserSchema", many=True)
@ -731,6 +873,11 @@ class ProposalSchema(ma.Schema):
def get_date_published(self, obj):
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()
proposals_schema = ProposalSchema(many=True)
@ -748,6 +895,10 @@ user_fields = [
"date_published",
"reject_reason",
"team",
"accepted_with_funding",
"is_version_two",
"authed_follows",
"authed_liked"
]
user_proposal_schema = ProposalSchema(only=user_fields)
user_proposals_schema = ProposalSchema(many=True, only=user_fields)

View File

@ -1,4 +1,5 @@
from decimal import Decimal
from datetime import datetime
from flask import Blueprint, g, request, current_app
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.rfp.models import RFP
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.utils import pagination
from grant.utils.auth import (
@ -24,8 +25,9 @@ from grant.utils.auth import (
get_authed_user,
internal_webhook
)
from grant.utils.requests import validate_blockchain_get
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.misc import is_email, make_url, from_zat, make_explore_url
from .models import (
@ -108,6 +110,9 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
if not proposal:
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
parent = None
if parent_comment_id:
@ -187,10 +192,16 @@ def make_proposal_draft(rfp_id):
rfp = RFP.query.filter_by(id=rfp_id).first()
if not rfp:
return {"message": "The request this proposal was made for doesnt exist"}, 400
proposal.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)
db.session.add(rfp)
task = PruneDraft(proposal)
task.make_task()
db.session.add(proposal)
db.session.commit()
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
"title": 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),
"target": 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),
"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
@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"])
@requires_team_member_auth
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
@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"])
@requires_team_member_auth
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}'),
})
# 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)
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 '',
})
# on funding target reached.
contribution.proposal.set_funded_when_ready()
db.session.commit()
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 {"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 grant.extensions import ma, db
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.misc import dt_to_unix, gen_random_id
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):
__tablename__ = "rfp"
@ -16,13 +25,16 @@ class RFP(db.Model):
title = db.Column(db.String(255), nullable=False)
brief = db.Column(db.String(255), 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)
matching = db.Column(db.Boolean, default=False, nullable=False)
_bounty = db.Column("bounty", db.String(255), nullable=True)
date_closes = db.Column(db.DateTime, nullable=True)
date_opened = db.Column(db.DateTime, nullable=True)
date_closed = db.Column(db.DateTime, nullable=True)
version = db.Column(db.String(255), nullable=True)
ccr = db.relationship("CCR", uselist=False, back_populates="rfp")
# Relationships
proposals = db.relationship(
@ -38,6 +50,15 @@ class RFP(db.Model):
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
def bounty(self):
return self._bounty
@ -49,29 +70,50 @@ class RFP(db.Model):
else:
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__(
self,
title: str,
brief: str,
content: str,
category: str,
bounty: str,
date_closes: datetime,
matching: bool = False,
status: str = RFPStatus.DRAFT,
):
assert RFPStatus.includes(status)
assert Category.includes(category)
self.id = gen_random_id(RFP)
self.date_created = datetime.now()
self.title = title[:255]
self.brief = brief[:255]
self.content = content
self.category = category
self.bounty = bounty
self.date_closes = date_closes
self.matching = matching
self.status = status
self.version = '2'
class RFPSchema(ma.Schema):
@ -83,7 +125,6 @@ class RFPSchema(ma.Schema):
"title",
"brief",
"content",
"category",
"status",
"matching",
"bounty",
@ -92,13 +133,19 @@ class RFPSchema(ma.Schema):
"date_opened",
"date_closed",
"accepted_proposals",
"authed_liked",
"likes_count",
"is_version_two",
"ccr"
)
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
status = ma.Method("get_status")
date_closes = ma.Method("get_date_closes")
date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed")
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
is_version_two = ma.Method("get_is_version_two")
def get_status(self, obj):
# 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):
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()
rfps_schema = RFPSchema(many=True)
@ -129,7 +179,6 @@ class AdminRFPSchema(ma.Schema):
"title",
"brief",
"content",
"category",
"status",
"matching",
"bounty",
@ -138,14 +187,18 @@ class AdminRFPSchema(ma.Schema):
"date_opened",
"date_closed",
"proposals",
"is_version_two",
"ccr"
)
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
status = ma.Method("get_status")
date_created = ma.Method("get_date_created")
date_closes = ma.Method("get_date_closes")
date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed")
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
is_version_two = ma.Method("get_is_version_two")
def get_status(self, obj):
# 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):
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_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 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")
@ -25,3 +28,20 @@ def get_rfp(rfp_id):
if not rfp or rfp.status == RFPStatus.DRAFT:
return {"message": "No RFP with that ID"}, 404
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_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>")
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
UI = {
'NAME': 'ZF Grants',
'PRIMARY': '#CF8A00',

View File

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from grant.extensions import db
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 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 = {
1: ProposalReminder.process_task,
2: ProposalDeadline.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;">
Congratulations on your approval! We look forward to seeing the support your
proposal receives. To get your campaign started, click below and follow the
instructions to publish your proposal.
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
</p>
{% if args.admin_note %}
@ -13,22 +11,3 @@
</p>
{% 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
proposal receives. To start the fundraising (and the clock) go to the URL
below and publish your proposal.
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
{% if args.admin_note %}
A note from the admin team was attached to your approval:

View File

@ -1,7 +1,6 @@
<p style="margin: 0 0 20px;">
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
shortly.
has been canceled.
</p>
<p style="margin: 0;">

View File

@ -1,6 +1,5 @@
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
shortly.
has been canceled.
If you have any further questions, please contact support for more information:
{{ args.support_url }}

View File

@ -1,5 +1,5 @@
<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.
</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.
{% if args.admin_note %}

View File

@ -1,7 +1,9 @@
import click
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()
@ -23,7 +25,6 @@ from .models import User, db
# 'account address, or email address of an ' \
# 'existing user.')
@click.command()
@click.argument('identity')
@with_appcontext
@ -36,6 +37,7 @@ def set_admin(identity):
if user:
user.set_admin(True)
user.email_verification.has_verified = True
db.session.add(user)
db.session.commit()
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,
'account address, or email address of an
'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 sqlalchemy.ext.hybrid import hybrid_property
from grant.comment.models import Comment
from grant.ccr.models import CCR
from grant.email.models import EmailVerification, EmailRecovery
from grant.email.send import send_email
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)
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
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")
@ -123,6 +126,7 @@ class User(db.Model, UserMixin):
# relations
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
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")
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
lazy=True, cascade="all, delete-orphan")
@ -133,6 +137,18 @@ class User(db.Model, UserMixin):
roles = db.relationship('Role', secondary='roles_users',
backref=db.backref('users', lazy='dynamic'))
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__(
self,
@ -343,13 +359,15 @@ class UserSchema(ma.Schema):
"avatar",
"display_name",
"userid",
"email_verified"
"email_verified",
"tip_jar_address"
)
social_medias = ma.Nested("SocialMediaSchema", many=True)
avatar = ma.Nested("AvatarSchema")
userid = ma.Method("get_userid")
email_verified = ma.Method("get_email_verified")
tip_jar_address = ma.Method("get_tip_jar_address")
def get_userid(self, obj):
return obj.id
@ -357,6 +375,9 @@ class UserSchema(ma.Schema):
def get_email_verified(self, obj):
return obj.email_verification.has_verified
def get_tip_jar_address(self, obj):
return obj.settings.tip_jar_address
user_schema = UserSchema()
users_schema = UserSchema(many=True)
@ -399,6 +420,8 @@ class UserSettingsSchema(ma.Schema):
fields = (
"email_subscriptions",
"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
from grant.comment.models import Comment, user_comments_schema
from grant.email.models import EmailRecovery
from grant.ccr.models import CCR, ccrs_schema
from grant.extensions import limiter
from grant.parser import query, body
from grant.proposal.models import (
Proposal,
ProposalTeamInvite,
invites_with_proposal_schema,
ProposalContribution,
user_proposal_contributions_schema,
user_proposals_schema,
user_proposal_arbiters_schema
)
from grant.proposal.models import ProposalContribution
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.utils.exceptions import ValidationException
from grant.utils.requests import validate_blockchain_get
@ -50,14 +51,20 @@ def get_me():
"withComments": fields.Bool(required=False, missing=None),
"withFunded": 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)
if user:
result = user_schema.dump(user)
authed_user = auth.get_authed_user()
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:
proposals = Proposal.get_by_user(user)
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)
result["comments"] = comments_dump
if with_pending and is_self:
pending = Proposal.get_by_user(user, [
pending_proposals = Proposal.get_by_user(user, [
ProposalStatus.STAKING,
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
])
pending_dump = user_proposals_schema.dump(pending)
result["pendingProposals"] = pending_dump
pending_proposals_dump = user_proposals_schema.dump(pending_proposals)
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:
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
@ -349,9 +364,11 @@ def get_user_settings(user_id):
@body({
"emailSubscriptions": fields.Dict(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:
try:
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:
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()
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 functools import wraps
import sentry_sdk
from flask import request, g, jsonify, session, current_app
from flask_security.core import current_user
from flask_security.utils import logout_user
from grant.proposal.models import Proposal
from grant.settings import BLOCKCHAIN_API_SECRET
from grant.user.models import User
class AuthException(Exception):
@ -28,7 +27,7 @@ def throw_on_banned(user):
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:
last = session['last_login_time']
now = datetime.now()
@ -41,6 +40,8 @@ def is_email_verified():
def auth_user(email, password):
from grant.user.models import User
existing_user = User.get_by_email(email)
if not existing_user:
raise AuthException("No user exists with that email")
@ -85,6 +86,8 @@ def requires_auth(f):
def requires_same_user_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
from grant.user.models import User
user_id = kwargs["user_id"]
if not user_id:
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):
@wraps(f)
def decorated(*args, **kwargs):
from grant.proposal.models import Proposal
proposal_id = kwargs["proposal_id"]
if not proposal_id:
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)
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):
@wraps(f)
def decorated(*args, **kwargs):
from grant.proposal.models import Proposal
proposal_id = kwargs["proposal_id"]
if not proposal_id:
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500

View File

@ -11,10 +11,22 @@ class CustomEnum():
not attr.startswith('__')]
class ProposalStatusEnum(CustomEnum):
class CCRStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
PENDING = 'PENDING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
DELETED = 'DELETED'
CCRStatus = CCRStatusEnum()
class ProposalStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
STAKING = 'STAKING'
PENDING = 'PENDING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
@ -34,7 +46,6 @@ ProposalSort = ProposalSortEnum()
class ProposalStageEnum(CustomEnum):
PREVIEW = 'PREVIEW'
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
WIP = 'WIP'
COMPLETED = 'COMPLETED'
FAILED = 'FAILED'

View File

@ -1,12 +1,14 @@
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.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 .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):
@ -39,13 +41,13 @@ class Pagination(abc.ABC):
# consider moving these args into __init__ and attaching to self
@abc.abstractmethod
def paginate(
self,
schema: ma.Schema,
query: db.Query,
page: int,
filters: list,
search: str,
sort: str,
self,
schema: ma.Schema,
query: db.Query,
page: int,
filters: list,
search: str,
sort: str,
):
pass
@ -58,6 +60,7 @@ class ProposalPagination(Pagination):
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'MILESTONE_{c}' for c in MilestoneStage.list()])
self.FILTERS.extend(['ACCEPTED_WITH_FUNDING', 'ACCEPTED_WITHOUT_FUNDING'])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': Proposal.date_created.desc(),
@ -67,13 +70,13 @@ class ProposalPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='PUBLISHED:DESC',
self,
schema: ma.Schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'PUBLISHED:DESC',
):
query = query or Proposal.query
sort = sort or 'PUBLISHED:DESC'
@ -102,6 +105,10 @@ class ProposalPagination(Pagination):
if milestone_filters:
query = query.join(Proposal.milestones) \
.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)
if sort:
@ -137,13 +144,13 @@ class ContributionPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema=proposal_contributions_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='PUBLISHED:DESC',
self,
schema: ma.Schema = proposal_contributions_schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'PUBLISHED:DESC',
):
query = query or ProposalContribution.query
sort = sort or 'CREATED:DESC'
@ -162,9 +169,9 @@ class ContributionPagination(Pagination):
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
.join(ProposalContribution.user) \
.join(UserSettings) \
.filter(UserSettings.refund_address != None)
@ -174,9 +181,9 @@ class ContributionPagination(Pagination):
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
.join(ProposalContribution.user, isouter=True) \
.join(UserSettings, isouter=True) \
.filter(UserSettings.refund_address == None)
@ -217,13 +224,13 @@ class UserPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema=users_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='EMAIL:DESC',
self,
schema: ma.Schema = users_schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'EMAIL:DESC',
):
query = query or Proposal.query
sort = sort or 'EMAIL:DESC'
@ -273,13 +280,13 @@ class CommentPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema=comments_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='CREATED:DESC',
self,
schema: ma.Schema = comments_schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'CREATED:DESC',
):
query = query or Comment.query
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
ccr = CCRPagination().paginate
proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate
comment = CommentPagination().paginate

View File

@ -30,6 +30,9 @@ def 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:
res = blockchain_get(path, params)
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
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)
def test_approve_proposal(self, mock_get):
def test_accept_proposal_with_funding(self, mock_get):
self.login_admin()
# proposal needs to be PENDING
@ -273,11 +251,94 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# approve
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data=json.dumps({"isApprove": True})
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
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.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)
def test_reject_proposal(self, mock_get):
@ -288,8 +349,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# reject
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."})
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
)
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
@ -325,19 +386,3 @@ class TestAdminAPI(BaseProposalCreatorConfig):
})
)
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 grant.app import create_app
from grant.ccr.models import CCR
from grant.extensions import limiter
from grant.milestone.models import Milestone
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.user.models import User, SocialMedia, db, Avatar
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):
@ -138,14 +139,14 @@ class BaseProposalCreatorConfig(BaseUserConfig):
{
"title": "Milestone 1",
"content": "Content 1",
"date_estimated": (datetime.now() + timedelta(days=364)).timestamp(), # random unix time in the future
"days_estimated": "30",
"payout_percent": 50,
"immediate_payout": True
},
{
"title": "Milestone 2",
"content": "Content 2",
"date_estimated": (datetime.now() + timedelta(days=365)).timestamp(), # random unix time in the future
"days_estimated": "20",
"payout_percent": 50,
"immediate_payout": False
}
@ -184,3 +185,23 @@ class BaseProposalCreatorConfig(BaseUserConfig):
db.session.add(contribution)
db.session.flush()
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()
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
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)
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))
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)
def test_invalid_status_proposal_publish_proposal(self, mock_get):
self.login_default_user()
@ -223,14 +169,115 @@ class TestProposalAPI(BaseProposalCreatorConfig):
# /
def test_get_proposals(self):
self.test_publish_proposal_approved()
self.proposal.status = ProposalStatus.LIVE
resp = self.app.get("/api/v1/proposals/")
self.assert200(resp)
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/")
self.assert200(resp)
for each_proposal in resp.json['items']:
for team_member in each_proposal["team"]:
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
from grant.proposal.models import Proposal, db
from grant.proposal.models import Proposal, Comment, db
from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig
from ..test_data import test_comment, test_reply, test_comment_large
@ -148,3 +148,59 @@ class TestProposalCommentAPI(BaseUserConfig):
self.assertStatus(comment_res, 403)
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 ..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):
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):
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()
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",
"content": "cool stuff with it",
"dateEstimated": 1549505307,
"daysEstimated": "30",
"payoutPercent": "100",
"immediatePayout": False
}
@ -44,11 +44,19 @@ test_proposal = {
"brief": "$$$",
"milestones": milestones,
"category": Category.ACCESSIBILITY,
"target": "123.456",
"target": "12345",
"payoutAddress": "123",
"deadlineDuration": 100
}
test_ccr = {
"user_id": test_user,
"content": "## My Proposal",
"title": "Give Me Money",
"brief": "$$$",
"target": "123.456",
}
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 ..config import BaseUserConfig
from ..test_data import test_user
from ..test_data import test_user, mock_blockchain_api_requests
class TestUserAPI(BaseUserConfig):
@ -385,3 +385,34 @@ class TestUserAPI(BaseUserConfig):
content_type='application/json'
)
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
# Maximum amount for a proposal target, keep in sync with backend .env
PROPOSAL_TARGET_MAX=10000
PROPOSAL_TARGET_MAX=999999

View File

@ -20,15 +20,20 @@ import 'styles/style.less';
const opts = { fallback: <Loader size="large" /> };
const Home = loadable(() => import('pages/index'), 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 Proposals = loadable(() => import('pages/proposals'), 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 SignOut = loadable(() => import('pages/sign-out'), opts);
const Profile = loadable(() => import('pages/profile'), opts);
const Settings = loadable(() => import('pages/settings'), opts);
const Exception = loadable(() => import('pages/exception'), opts);
const Tos = loadable(() => import('pages/tos'));
const ProposalTutorial = loadable(() => import('pages/proposal-tutorial'));
const About = loadable(() => import('pages/about'), opts);
const Privacy = loadable(() => import('pages/privacy'), opts);
const Contact = loadable(() => import('pages/contact'), opts);
@ -63,6 +68,43 @@ const routeConfigs: RouteConfig[] = [
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
route: {
@ -165,6 +207,30 @@ const routeConfigs: RouteConfig[] = [
},
onlyLoggedIn: false,
},
{
// Terms of Service page
route: {
path: '/guide',
component: Guide,
exact: true,
},
template: {
title: 'Guide',
},
onlyLoggedIn: false,
},
{
// Terms of Service page
route: {
path: '/proposal-tutorial',
component: ProposalTutorial,
exact: true,
},
template: {
title: 'Proposal Tutorial',
},
onlyLoggedIn: false,
},
{
// About page
route: {

View File

@ -14,6 +14,7 @@ import {
ProposalPageParams,
PageParams,
UserSettings,
CCR,
} from 'types';
import {
formatUserForPost,
@ -23,6 +24,7 @@ import {
formatProposalPageParamsForGet,
formatProposalPageFromGet,
} from 'utils/api';
import { CCRDraft } from 'types/ccr';
export function getProposals(page?: ProposalPageParams): Promise<{ data: ProposalPage }> {
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) {
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
}
@ -70,6 +90,7 @@ export function getUser(address: string): Promise<{ data: User }> {
return axios
.get(`/api/v1/users/${address}`, {
params: {
withRequests: true,
withProposals: true,
withComments: true,
withFunded: true,
@ -137,6 +158,8 @@ export function getUserSettings(
interface SettingsArgs {
emailSubscriptions?: EmailSubscriptions;
refundAddress?: string;
tipJarAddress?: string;
tipJarViewKey?: string;
}
export function updateUserSettings(
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 });
}
export async function fetchCrowdFundFactoryJSON(): Promise<any> {
const res = await axios.get(process.env.CROWD_FUND_FACTORY_URL as string);
return res.data;
interface ProposalTipJarArgs {
address?: string;
viewKey?: string;
}
export async function fetchCrowdFundJSON(): Promise<any> {
const res = await axios.get(process.env.CROWD_FUND_URL as string);
return res.data;
export function updateProposalTipJarSettings(
proposalId: string | number,
args?: ProposalTipJarArgs,
): Promise<{ data: Proposal }> {
return axios.put(`/api/v1/proposals/${proposalId}/tips`, args).then(res => {
res.data = formatProposalFromGet(res.data);
return res;
});
}
export function postProposalUpdate(
@ -344,12 +371,6 @@ export function getProposalContribution(
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[] }> {
return axios.get('/api/v1/rfps/').then(res => {
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 }> {
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 className="SignUp-form-item" label="Title">
<Form.Item className="SignUp-form-item" label="About you">
{getFieldDecorator('title', {
rules: [{ required: true, message: 'Please add your title' }],
})(
@ -127,7 +127,8 @@ class SignUp extends React.Component<Props> {
ev.preventDefault();
const { createUser } = this.props;
this.props.form.validateFieldsAndScroll((err: any, values: any) => {
if (!err) {
const hasAgreed = this.props.form.getFieldValue('hasAgreed');
if (!err && hasAgreed) {
delete values.passwordConfirm;
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