commit
f50b516ade
|
@ -18,12 +18,3 @@ matrix:
|
|||
install: pip install -r requirements/dev.txt
|
||||
script:
|
||||
- flask test
|
||||
# Blockchain
|
||||
- language: node_js
|
||||
node_js: 8.13.0
|
||||
before_install:
|
||||
- cd blockchain
|
||||
install: yarn
|
||||
script:
|
||||
- yarn run test
|
||||
- yarn run build
|
||||
|
|
|
@ -103,11 +103,14 @@
|
|||
"tslint-react": "^3.6.0",
|
||||
"typescript": "3.0.3",
|
||||
"url-loader": "^1.1.1",
|
||||
"webpack": "^4.19.0",
|
||||
"webpack": "^4.42.0",
|
||||
"webpack-cli": "^3.1.0",
|
||||
"webpack-dev-server": "3.2.1",
|
||||
"webpack-hot-middleware": "^2.24.0",
|
||||
"xss": "^1.0.3"
|
||||
"xss": "^1.0.3",
|
||||
"acorn": "^6.4.1",
|
||||
"minimist": "^1.2.3",
|
||||
"kind-of": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bn.js": "4.11.1",
|
||||
|
|
|
@ -18,9 +18,6 @@ import CCRDetail from 'components/CCRDetail';
|
|||
import RFPs from 'components/RFPs';
|
||||
import RFPForm from 'components/RFPForm';
|
||||
import RFPDetail from 'components/RFPDetail';
|
||||
import Contributions from 'components/Contributions';
|
||||
import ContributionForm from 'components/ContributionForm';
|
||||
import ContributionDetail from 'components/ContributionDetail';
|
||||
import Financials from 'components/Financials';
|
||||
import Moderation from 'components/Moderation';
|
||||
import Settings from 'components/Settings';
|
||||
|
@ -55,10 +52,6 @@ class Routes extends React.Component<Props> {
|
|||
<Route path="/rfps/:id/edit" component={RFPForm} />
|
||||
<Route path="/rfps/:id" component={RFPDetail} />
|
||||
<Route path="/rfps" component={RFPs} />
|
||||
<Route path="/contributions/new" component={ContributionForm} />
|
||||
<Route path="/contributions/:id/edit" component={ContributionForm} />
|
||||
<Route path="/contributions/:id" component={ContributionDetail} />
|
||||
<Route path="/contributions" component={Contributions} />
|
||||
<Route path="/financials" component={Financials} />
|
||||
<Route path="/emails/:type?" component={Emails} />
|
||||
<Route path="/moderation" component={Moderation} />
|
||||
|
|
|
@ -34,7 +34,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
const { showSearch, searching } = this.state;
|
||||
const { results, search, error } = store.arbitersSearch;
|
||||
const showEmpty = !results.length && !searching;
|
||||
const buttonDisabled = isVersionTwo && acceptedWithFunding === false
|
||||
const buttonDisabled = isVersionTwo && !acceptedWithFunding;
|
||||
|
||||
const disp = {
|
||||
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
||||
|
|
|
@ -73,8 +73,8 @@ class CCRDetailNaked extends React.Component<Props, State> {
|
|||
<Button
|
||||
className="CCRDetail-review"
|
||||
loading={store.ccrDetailApproving}
|
||||
icon="close"
|
||||
type="danger"
|
||||
icon="warning"
|
||||
type="default"
|
||||
onClick={() => {
|
||||
FeedbackModal.open({
|
||||
title: 'Request changes for this Request?',
|
||||
|
@ -86,6 +86,22 @@ class CCRDetailNaked extends React.Component<Props, State> {
|
|||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
className="CCRDetail-review"
|
||||
loading={store.ccrDetailRejectingPermanently}
|
||||
icon="close"
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
FeedbackModal.open({
|
||||
title: 'Reject this CCR permanently?',
|
||||
label: 'Please provide a reason:',
|
||||
okText: 'Reject Permanently',
|
||||
onOk: this.handleRejectPermanently,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reject Permanently
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
@ -185,7 +201,6 @@ class CCRDetailNaked extends React.Component<Props, State> {
|
|||
<Link to={`/users/${c.author.userid}`}>{c.author.displayName}</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
@ -215,6 +230,11 @@ class CCRDetailNaked extends React.Component<Props, State> {
|
|||
await store.approveCCR(false, reason);
|
||||
message.info('CCR changes requested');
|
||||
};
|
||||
|
||||
private handleRejectPermanently = async (rejectReason: string) => {
|
||||
await store.rejectPermanentlyCcr(rejectReason);
|
||||
message.info('CCR rejected permanently');
|
||||
};
|
||||
}
|
||||
|
||||
const CCRDetail = withRouter(view(CCRDetailNaked));
|
||||
|
|
|
@ -41,11 +41,52 @@ export default [
|
|||
title: 'Proposal approved',
|
||||
description: 'Sent when an admin approves your submitted proposal',
|
||||
},
|
||||
{
|
||||
id: 'proposal_approved_without_funding',
|
||||
title: 'Proposal approved without funding',
|
||||
description: 'Sent when an admin approves your submitted proposal',
|
||||
},
|
||||
{
|
||||
id: 'proposal_approved_discussion',
|
||||
title: 'Proposal approved for public discussion',
|
||||
description: 'Sent when an admin approves a proposal for public discussion',
|
||||
},
|
||||
{
|
||||
id: 'proposal_rejected',
|
||||
title: 'Proposal changes requested',
|
||||
description: 'Sent when an admin requests changes for your submitted proposal',
|
||||
},
|
||||
{
|
||||
id: 'proposal_rejected_permanently',
|
||||
title: 'Proposal rejected permanently',
|
||||
description: 'Sent when an admin rejects a proposal permanently',
|
||||
},
|
||||
{
|
||||
id: 'proposal_arbiter_assigned',
|
||||
title: 'Proposal arbiter assigned',
|
||||
description: 'Sent when a nominated arbiter accepts',
|
||||
},
|
||||
{
|
||||
id: 'ccr_approved',
|
||||
title: 'Request has been approved',
|
||||
description: 'Sent when an admin approves a submitted CCR',
|
||||
},
|
||||
{
|
||||
id: 'ccr_rejected',
|
||||
title: 'Request has changes requested',
|
||||
description: 'Sent when an admin requests changes for a CCR',
|
||||
},
|
||||
{
|
||||
id: 'ccr_rejected_permanently',
|
||||
title: 'Request rejected permanently',
|
||||
description: 'Sent when an admin rejects a CCR permanently',
|
||||
},
|
||||
{
|
||||
id: 'proposal_rejected_discussion',
|
||||
title: 'Proposal changes requested',
|
||||
description:
|
||||
'Sent when an admin requests changes for a proposal open for public discussion',
|
||||
},
|
||||
{
|
||||
id: 'proposal_contribution',
|
||||
title: 'Proposal received contribution',
|
||||
|
@ -140,6 +181,11 @@ export default [
|
|||
title: 'Admin Approval',
|
||||
description: 'Sent when proposal is ready for review',
|
||||
},
|
||||
{
|
||||
id: 'admin_changes_resolved',
|
||||
title: 'Admin Requested Changes Resolved',
|
||||
description: 'Sent when proposal team has marked requested changes as resolved',
|
||||
},
|
||||
{
|
||||
id: 'admin_arbiter',
|
||||
title: 'Admin Arbiter',
|
||||
|
@ -161,4 +207,9 @@ export default [
|
|||
title: 'Followed Proposal Update',
|
||||
description: 'Sent to followers of a proposal when it has a new update',
|
||||
},
|
||||
{
|
||||
id: 'followed_proposal_revised',
|
||||
title: 'Followed Proposal Revised',
|
||||
description: 'Sent to followers of a proposal when a revision has been made',
|
||||
},
|
||||
] as Email[];
|
||||
|
|
|
@ -1,95 +1,53 @@
|
|||
import React from 'react';
|
||||
import { Spin, Card, Row, Col } from 'antd';
|
||||
import { Spin, Card, Row, Col, Dropdown, Button, Icon, Menu } from 'antd';
|
||||
import { Charts } from 'ant-design-pro';
|
||||
import { view } from 'react-easy-state';
|
||||
import store from '../../store';
|
||||
import Info from 'components/Info';
|
||||
import { formatUsd } from '../../util/formatters';
|
||||
import './index.less';
|
||||
|
||||
class Financials extends React.Component {
|
||||
componentDidMount() {
|
||||
store.fetchFinancials();
|
||||
interface State {
|
||||
selectedYear: string;
|
||||
}
|
||||
|
||||
class Financials extends React.Component<{}, State> {
|
||||
state: State = {
|
||||
selectedYear: '',
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
await store.fetchFinancials();
|
||||
|
||||
const years = Object.keys(store.financials.payoutsByQuarter);
|
||||
const selectedYear = years[years.length - 1];
|
||||
|
||||
this.setState({
|
||||
selectedYear,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { contributions, grants, payouts } = store.financials;
|
||||
if (!store.financialsFetched) {
|
||||
const { selectedYear } = this.state;
|
||||
const { grants, payouts, payoutsByQuarter } = store.financials;
|
||||
if (!store.financialsFetched || !selectedYear) {
|
||||
return <Spin tip="Loading financials..." />;
|
||||
}
|
||||
|
||||
const years = Object.keys(store.financials.payoutsByQuarter);
|
||||
const quarterData = payoutsByQuarter[this.state.selectedYear];
|
||||
|
||||
const payoutsByQuarterMenu = (
|
||||
<Menu onClick={e => this.setState({ selectedYear: e.key })}>
|
||||
{years.map(year => (
|
||||
<Menu.Item key={year}>{year}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="Financials">
|
||||
<Row gutter={16}>
|
||||
<Col lg={8} md={12} sm={24}>
|
||||
<Card size="small" title="Contributions">
|
||||
<Charts.Pie
|
||||
hasLegend
|
||||
title="Contributions"
|
||||
subTitle="Total"
|
||||
total={() => (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: 'ⓩ ' + contributions.total,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
data={[
|
||||
{ x: 'funded', y: parseFloat(contributions.funded) },
|
||||
{ x: 'funding', y: parseFloat(contributions.funding) },
|
||||
{ x: 'refunding', y: parseFloat(contributions.refunding) },
|
||||
{ x: 'refunded', y: parseFloat(contributions.refunded) },
|
||||
{ x: 'donation', y: parseFloat(contributions.donations) },
|
||||
{ x: 'staking', y: parseFloat(contributions.staking) },
|
||||
]}
|
||||
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
|
||||
height={180}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col lg={8} md={12} sm={24}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Info
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Matching and bounty obligations for active and completed
|
||||
proposals.
|
||||
</p>
|
||||
<b>matching</b> - total matching amount pleged
|
||||
<br />
|
||||
<b>bounties</b> - total bounty amount pledged
|
||||
<br />
|
||||
</>
|
||||
}
|
||||
>
|
||||
Grants
|
||||
</Info>
|
||||
}
|
||||
>
|
||||
<Charts.Pie
|
||||
hasLegend
|
||||
title="Grants"
|
||||
subTitle="Total"
|
||||
total={() => (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: 'ⓩ ' + grants.total,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
data={[
|
||||
{ x: 'bounties', y: parseFloat(grants.bounty) },
|
||||
{ x: 'matching', y: parseFloat(grants.matching) },
|
||||
]}
|
||||
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
|
||||
height={180}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col lg={8} md={12} sm={24}>
|
||||
<Card
|
||||
size="small"
|
||||
|
@ -120,7 +78,7 @@ class Financials extends React.Component {
|
|||
total={() => (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: 'ⓩ ' + payouts.total,
|
||||
__html: '$ ' + formatUsd(grants.total, false),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -129,7 +87,70 @@ class Financials extends React.Component {
|
|||
{ x: 'future', y: parseFloat(payouts.future) },
|
||||
{ x: 'paid', y: parseFloat(payouts.paid) },
|
||||
]}
|
||||
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
|
||||
valueFormat={val => (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: `${formatUsd(val, true, 2)}` }}
|
||||
/>
|
||||
)}
|
||||
height={180}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col lg={8} md={12} sm={24}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Milestone payouts broken down by quarter. Use the dropdown to
|
||||
select a different year.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Payouts by Quarter
|
||||
</Info>
|
||||
<Dropdown overlay={payoutsByQuarterMenu} trigger={['click']}>
|
||||
<Button>
|
||||
{this.state.selectedYear} <Icon type="down" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Charts.Pie
|
||||
hasLegend
|
||||
title="Contributions"
|
||||
subTitle="Total"
|
||||
total={() => (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: '$ ' + formatUsd(quarterData.yearTotal, false, 2),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
data={[
|
||||
{ x: 'Q1', y: parseFloat(quarterData.q1) },
|
||||
{ x: 'Q2', y: parseFloat(quarterData.q2) },
|
||||
{ x: 'Q3', y: parseFloat(quarterData.q3) },
|
||||
{ x: 'Q4', y: parseFloat(quarterData.q3) },
|
||||
]}
|
||||
valueFormat={val => (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: `${formatUsd(val, true, 2)}` }}
|
||||
/>
|
||||
)}
|
||||
height={180}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
@ -177,8 +177,91 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
/>
|
||||
);
|
||||
|
||||
const renderReview = () =>
|
||||
const renderKycColumn = () =>
|
||||
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>
|
||||
);
|
||||
|
||||
const renderReviewDiscussion = () =>
|
||||
p.status === PROPOSAL_STATUS.PENDING && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={isVersionTwo ? 16 : 24}>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="Review Discussion"
|
||||
description={
|
||||
<div>
|
||||
<p>Please review this proposal and render your judgment.</p>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={store.proposalDetailApprovingDiscussion}
|
||||
icon="check"
|
||||
type="primary"
|
||||
onClick={() => this.handleApproveDiscussion()}
|
||||
>
|
||||
Open for Public Review
|
||||
</Button>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={store.proposalDetailApprovingDiscussion}
|
||||
icon="warning"
|
||||
type="default"
|
||||
onClick={() => {
|
||||
FeedbackModal.open({
|
||||
title: 'Request changes to this proposal?',
|
||||
label: 'Please provide a reason:',
|
||||
okText: 'Request changes',
|
||||
onOk: this.handleRejectDiscussion,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={store.proposalDetailRejectingPermanently}
|
||||
icon="close"
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
FeedbackModal.open({
|
||||
title: 'Reject this proposal permanently?',
|
||||
label: 'Please provide a reason:',
|
||||
okText: 'Reject Permanently',
|
||||
onOk: this.handleRejectPermanently,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reject Permanently
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
{renderKycColumn()}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderReviewProposal = () =>
|
||||
p.status === PROPOSAL_STATUS.DISCUSSION &&
|
||||
!p.changesRequestedDiscussion && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={isVersionTwo ? 16 : 24}>
|
||||
|
@ -191,25 +274,25 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<p>Please review this proposal and render your judgment.</p>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={store.proposalDetailApproving}
|
||||
loading={store.proposalDetailAcceptingProposal}
|
||||
icon="check"
|
||||
type="primary"
|
||||
onClick={() => this.handleApprove(true)}
|
||||
onClick={() => this.handleAcceptProposal(true, true)}
|
||||
>
|
||||
Approve With Funding
|
||||
</Button>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={store.proposalDetailApproving}
|
||||
loading={store.proposalDetailAcceptingProposal}
|
||||
icon="check"
|
||||
type="default"
|
||||
onClick={() => this.handleApprove(false)}
|
||||
onClick={() => this.handleAcceptProposal(true, false)}
|
||||
>
|
||||
Approve Without Funding
|
||||
</Button>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={store.proposalDetailApproving}
|
||||
loading={store.proposalDetailMarkingChangesAsResolved}
|
||||
icon="close"
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
|
@ -217,36 +300,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
title: 'Request changes to this proposal?',
|
||||
label: 'Please provide a reason:',
|
||||
okText: 'Request changes',
|
||||
onOk: this.handleReject,
|
||||
onOk: this.handleRejectProposal,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Request changes
|
||||
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>
|
||||
)}
|
||||
{renderKycColumn()}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
@ -271,6 +335,39 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
/>
|
||||
);
|
||||
|
||||
const renderChangesRequestedDiscussion = () =>
|
||||
p.status === PROPOSAL_STATUS.DISCUSSION &&
|
||||
p.changesRequestedDiscussion && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="Changes requested"
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
This proposal has changes requested. The team will be able to update their
|
||||
proposal and mark the changes as resolved should they desire to do so.
|
||||
</p>
|
||||
<b>Reason:</b>
|
||||
<br />
|
||||
<i>{p.changesRequestedDiscussionReason}</i>
|
||||
<br />
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
className="ProposalDetail-review"
|
||||
loading={false}
|
||||
icon="check"
|
||||
type="danger"
|
||||
onClick={this.handleMarkChangesAsResolved}
|
||||
>
|
||||
Mark Request as Resolved
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderNominateArbiter = () =>
|
||||
needsArbiter &&
|
||||
shouldShowArbiter && (
|
||||
|
@ -409,8 +506,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{/* MAIN */}
|
||||
<Col span={18}>
|
||||
{renderApproved()}
|
||||
{renderReview()}
|
||||
{renderReviewDiscussion()}
|
||||
{renderReviewProposal()}
|
||||
{renderRejected()}
|
||||
{renderChangesRequestedDiscussion()}
|
||||
{renderNominateArbiter()}
|
||||
{renderNominatedArbiter()}
|
||||
{renderMilestoneAccepted()}
|
||||
|
@ -602,15 +701,42 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
this.setState({ showCancelAndRefundPopover: false });
|
||||
};
|
||||
|
||||
private handleApprove = (withFunding: boolean) => {
|
||||
store.approveProposal(true, withFunding);
|
||||
private handleApproveDiscussion = async () => {
|
||||
await store.approveDiscussion(true);
|
||||
message.info('Proposal now open for discussion');
|
||||
};
|
||||
|
||||
private handleReject = async (reason: string) => {
|
||||
await store.approveProposal(false, false, reason);
|
||||
private handleRejectDiscussion = async (rejectReason: string) => {
|
||||
await store.approveDiscussion(false, rejectReason);
|
||||
message.info('Proposal changes requested');
|
||||
};
|
||||
|
||||
private handleRejectPermanently = async (rejectReason: string) => {
|
||||
await store.rejectPermanentlyProposal(rejectReason);
|
||||
message.info('Proposal rejected permanently');
|
||||
};
|
||||
|
||||
private handleAcceptProposal = async (
|
||||
isAccepted: boolean,
|
||||
withFunding: boolean,
|
||||
changesRequestedReason?: string,
|
||||
) => {
|
||||
await store.acceptProposal(isAccepted, withFunding, changesRequestedReason);
|
||||
message.info(`Proposal accepted ${withFunding ? 'with' : 'without'} funding`);
|
||||
};
|
||||
|
||||
private handleRejectProposal = async (changesRequestedReason: string) => {
|
||||
await store.acceptProposal(false, false, changesRequestedReason);
|
||||
message.info(`Proposal changes requested`);
|
||||
};
|
||||
|
||||
private handleMarkChangesAsResolved = async () => {
|
||||
const success = await store.markProposalChangesAsResolved();
|
||||
if (success) {
|
||||
message.info(`Requested changes marked as resolved`);
|
||||
}
|
||||
};
|
||||
|
||||
private handlePaidMilestone = async () => {
|
||||
const pid = store.proposalDetail!.proposalId;
|
||||
const mid = store.proposalDetail!.currentMilestone!.id;
|
||||
|
|
|
@ -63,12 +63,6 @@ class Template extends React.Component<Props> {
|
|||
<span className="nav-text">RFPs</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="contributions">
|
||||
<Link to="/contributions">
|
||||
<Icon type="dollar" />
|
||||
<span className="nav-text">Contributions</span>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="financials">
|
||||
<Link to="/financials">
|
||||
<Icon type="audit" />
|
||||
|
|
|
@ -130,20 +130,44 @@ async function deleteProposal(id: number) {
|
|||
return data;
|
||||
}
|
||||
|
||||
async function approveProposal(
|
||||
async function approveDiscussion(
|
||||
id: number,
|
||||
isOpenForDiscussion: boolean,
|
||||
rejectReason?: string,
|
||||
) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/discussion`, {
|
||||
isOpenForDiscussion,
|
||||
rejectReason,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function acceptProposal(
|
||||
id: number,
|
||||
isAccepted: boolean,
|
||||
withFunding: boolean,
|
||||
rejectReason?: string,
|
||||
changesRequestedReason?: string,
|
||||
) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
|
||||
isAccepted,
|
||||
withFunding,
|
||||
changesRequestedReason,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function rejectPermanentlyProposal(id: number, rejectReason: string) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/reject_permanently`, {
|
||||
rejectReason,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function markProposalChangesAsResolved(id: number) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/resolve`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cancelProposal(id: number) {
|
||||
const { data } = await api.put(`/admin/proposals/${id}/cancel`);
|
||||
return data;
|
||||
|
@ -190,6 +214,13 @@ async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string
|
|||
return data;
|
||||
}
|
||||
|
||||
async function rejectPermanentlyCcr(id: number, rejectReason: string) {
|
||||
const { data } = await api.put(`/admin/ccrs/${id}/reject_permanently`, {
|
||||
rejectReason,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchCCRs(params: Partial<PageQuery>) {
|
||||
const { data } = await api.get(`/admin/ccrs`, { params });
|
||||
return data;
|
||||
|
@ -238,6 +269,14 @@ async function editContribution(id: number, args: ContributionArgs) {
|
|||
return data;
|
||||
}
|
||||
|
||||
interface QuarterData {
|
||||
q1: string;
|
||||
q2: string;
|
||||
q3: string;
|
||||
q4: string;
|
||||
yearTotal: string;
|
||||
}
|
||||
|
||||
// STORE
|
||||
const app = store({
|
||||
/*** DATA ***/
|
||||
|
@ -267,22 +306,13 @@ const app = store({
|
|||
matching: '0',
|
||||
bounty: '0',
|
||||
},
|
||||
contributions: {
|
||||
total: '0',
|
||||
gross: '0',
|
||||
staking: '0',
|
||||
funding: '0',
|
||||
funded: '0',
|
||||
refunding: '0',
|
||||
refunded: '0',
|
||||
donations: '0',
|
||||
},
|
||||
payouts: {
|
||||
total: '0',
|
||||
due: '0',
|
||||
paid: '0',
|
||||
future: '0',
|
||||
},
|
||||
payoutsByQuarter: {} as { [type: string]: QuarterData },
|
||||
},
|
||||
|
||||
users: {
|
||||
|
@ -312,12 +342,15 @@ const app = store({
|
|||
|
||||
proposalDetail: null as null | Proposal,
|
||||
proposalDetailFetching: false,
|
||||
proposalDetailApproving: false,
|
||||
proposalDetailApprovingDiscussion: false,
|
||||
proposalDetailMarkingChangesAsResolved: false,
|
||||
proposalDetailAcceptingProposal: false,
|
||||
proposalDetailMarkingMilestonePaid: false,
|
||||
proposalDetailCanceling: false,
|
||||
proposalDetailUpdating: false,
|
||||
proposalDetailUpdated: false,
|
||||
proposalDetailChangingToAcceptedWithFunding: false,
|
||||
proposalDetailRejectingPermanently: false,
|
||||
|
||||
ccrs: {
|
||||
page: createDefaultPageData<CCR>('CREATED:DESC'),
|
||||
|
@ -335,6 +368,7 @@ const app = store({
|
|||
ccrDetailUpdating: false,
|
||||
ccrDetailUpdated: false,
|
||||
ccrDetailChangingToAcceptedWithFunding: false,
|
||||
ccrDetailRejectingPermanently: false,
|
||||
ccrCreatedRFPId: null,
|
||||
|
||||
comments: {
|
||||
|
@ -583,6 +617,24 @@ const app = store({
|
|||
app.ccrDetailApproving = false;
|
||||
},
|
||||
|
||||
async rejectPermanentlyCcr(rejectReason: string) {
|
||||
if (!app.ccrDetail) {
|
||||
const m = 'store.rejectPermanentlyCcr(): Expected ccrDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
console.error(m);
|
||||
return;
|
||||
}
|
||||
app.ccrDetailRejectingPermanently = true;
|
||||
try {
|
||||
const { ccrId } = app.ccrDetail;
|
||||
await rejectPermanentlyCcr(ccrId, rejectReason);
|
||||
await app.fetchCCRDetail(ccrId);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.ccrDetailRejectingPermanently = false;
|
||||
},
|
||||
|
||||
// Proposals
|
||||
|
||||
async fetchProposals() {
|
||||
|
@ -636,32 +688,89 @@ const app = store({
|
|||
handleApiError(e);
|
||||
}
|
||||
},
|
||||
|
||||
async approveProposal(
|
||||
async acceptProposal(
|
||||
isAccepted: boolean,
|
||||
withFunding: boolean,
|
||||
rejectReason?: string,
|
||||
changesRequestedReason?: string,
|
||||
) {
|
||||
if (!app.proposalDetail) {
|
||||
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
|
||||
const m = 'store.acceptProposal(): Expected proposalDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
console.error(m);
|
||||
return;
|
||||
}
|
||||
app.proposalDetailApproving = true;
|
||||
app.proposalDetailAcceptingProposal = true;
|
||||
try {
|
||||
const { proposalId } = app.proposalDetail;
|
||||
const res = await approveProposal(
|
||||
const res = await acceptProposal(
|
||||
proposalId,
|
||||
isAccepted,
|
||||
withFunding,
|
||||
rejectReason,
|
||||
changesRequestedReason,
|
||||
);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.proposalDetailApproving = false;
|
||||
app.proposalDetailAcceptingProposal = false;
|
||||
},
|
||||
|
||||
async approveDiscussion(isOpenForDiscussion: boolean, rejectReason?: string) {
|
||||
if (!app.proposalDetail) {
|
||||
const m = 'store.approveDiscussion(): Expected proposalDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
console.error(m);
|
||||
return;
|
||||
}
|
||||
app.proposalDetailApprovingDiscussion = true;
|
||||
try {
|
||||
const { proposalId } = app.proposalDetail;
|
||||
const res = await approveDiscussion(proposalId, isOpenForDiscussion, rejectReason);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.proposalDetailApprovingDiscussion = false;
|
||||
},
|
||||
|
||||
async rejectPermanentlyProposal(rejectReason: string) {
|
||||
if (!app.proposalDetail) {
|
||||
const m =
|
||||
'store.rejectPermanentlyProposal(): Expected proposalDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
console.error(m);
|
||||
return;
|
||||
}
|
||||
app.proposalDetailRejectingPermanently = true;
|
||||
try {
|
||||
const { proposalId } = app.proposalDetail;
|
||||
const res = await rejectPermanentlyProposal(proposalId, rejectReason);
|
||||
app.updateProposalInStore(res);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.proposalDetailRejectingPermanently = false;
|
||||
},
|
||||
|
||||
async markProposalChangesAsResolved() {
|
||||
if (!app.proposalDetail) {
|
||||
const m = 'store.approveDiscussion(): Expected proposalDetail to be populated!';
|
||||
app.generalError.push(m);
|
||||
return;
|
||||
}
|
||||
let success = false;
|
||||
app.proposalDetailMarkingChangesAsResolved = true;
|
||||
try {
|
||||
const { proposalId } = app.proposalDetail;
|
||||
const res = await markProposalChangesAsResolved(proposalId);
|
||||
app.updateProposalInStore(res);
|
||||
success = true;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
success = false;
|
||||
}
|
||||
app.proposalDetailMarkingChangesAsResolved = false;
|
||||
return success;
|
||||
},
|
||||
|
||||
async cancelProposal(id: number) {
|
||||
|
|
|
@ -73,9 +73,12 @@ export interface ProposalArbiter {
|
|||
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
|
||||
export enum PROPOSAL_STATUS {
|
||||
DRAFT = 'DRAFT',
|
||||
LIVE_DRAFT = 'LIVE_DRAFT',
|
||||
PENDING = 'PENDING',
|
||||
DISCUSSION = 'DISCUSSION',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY',
|
||||
LIVE = 'LIVE',
|
||||
DELETED = 'DELETED',
|
||||
STAKING = 'STAKING',
|
||||
|
@ -118,6 +121,8 @@ export interface Proposal {
|
|||
arbiter: ProposalArbiter;
|
||||
acceptedWithFunding: boolean | null;
|
||||
isVersionTwo: boolean;
|
||||
changesRequestedDiscussion: boolean | null;
|
||||
changesRequestedDiscussionReason: string | null;
|
||||
}
|
||||
export interface Comment {
|
||||
id: number;
|
||||
|
@ -206,6 +211,7 @@ export enum CCR_STATUS {
|
|||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY',
|
||||
LIVE = 'LIVE',
|
||||
DELETED = 'DELETED',
|
||||
}
|
||||
|
|
|
@ -87,6 +87,13 @@ export const CCR_STATUSES: Array<StatusSoT<CCR_STATUS>> = [
|
|||
hint:
|
||||
'Admin has requested changes for this Request. User may adjust it and resubmit for approval.',
|
||||
},
|
||||
{
|
||||
id: CCR_STATUS.REJECTED_PERMANENTLY,
|
||||
tagDisplay: 'Rejected Permanently',
|
||||
tagColor: '#eb4118',
|
||||
hint:
|
||||
'Admin has rejected this CCR permanently. It cannot be resubmitted for approval.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||
|
@ -96,6 +103,18 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
|||
tagColor: '#afd500',
|
||||
hint: 'Proposal has been approved and is awaiting being published by user.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.DISCUSSION,
|
||||
tagDisplay: 'Open for Public Review',
|
||||
tagColor: '#afd500',
|
||||
hint: 'Proposal has been opened for public discussion.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.LIVE_DRAFT,
|
||||
tagDisplay: 'Live Draft',
|
||||
tagColor: '#8d8d8d',
|
||||
hint: 'Proposal is an edit that will to be published to another proposal.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.DELETED,
|
||||
tagDisplay: 'Deleted',
|
||||
|
@ -127,6 +146,13 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
|||
hint:
|
||||
'Admin has requested changes for this proposal. User may adjust it and resubmit for approval.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.REJECTED_PERMANENTLY,
|
||||
tagDisplay: 'Rejected Permanently',
|
||||
tagColor: '#eb4118',
|
||||
hint:
|
||||
'Admin has rejected this proposal permanently. It cannot be resubmitted for approval.',
|
||||
},
|
||||
{
|
||||
id: PROPOSAL_STATUS.STAKING,
|
||||
tagDisplay: 'Staking',
|
||||
|
|
1413
admin/yarn.lock
1413
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -7,6 +7,7 @@ DATABASE_URL="sqlite:////tmp/dev.db"
|
|||
REDISTOGO_URL="redis://localhost:6379"
|
||||
SECRET_KEY="not-so-secret"
|
||||
SENDGRID_API_KEY="optional, but emails won't send without it"
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
|
||||
# set this so third-party cookie blocking doesn't kill backend sessions (production)
|
||||
# SESSION_COOKIE_DOMAIN="zfnd.org"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Create an application instance."""
|
||||
from grant.patches import patch_werkzeug_set_samesite
|
||||
patch_werkzeug_set_samesite()
|
||||
from grant.app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
|
|
@ -35,8 +35,17 @@ class FakeUpdate(object):
|
|||
proposal_id = 123
|
||||
|
||||
|
||||
class FakeCCR(object):
|
||||
id = 123
|
||||
title = 'Example CCR'
|
||||
brief = 'This is an example CCR'
|
||||
content = 'Example example example example'
|
||||
target = "100"
|
||||
|
||||
|
||||
user = FakeUser()
|
||||
proposal = FakeProposal()
|
||||
ccr = FakeCCR()
|
||||
milestone = FakeMilestone()
|
||||
contribution = FakeContribution()
|
||||
update = FakeUpdate()
|
||||
|
@ -67,16 +76,55 @@ example_email_args = {
|
|||
'recover_url': 'http://somerecoverurl.com',
|
||||
'contact_url': 'http://somecontacturl.com',
|
||||
},
|
||||
'proposal_approved_without_funding': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com',
|
||||
'admin_note': "We've opened up your proposal for community donations.",
|
||||
},
|
||||
'proposal_approved': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com',
|
||||
'admin_note': 'This proposal was the hottest stuff our team has seen yet. We look forward to throwing the fat stacks at you.',
|
||||
},
|
||||
'proposal_approved_discussion': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com',
|
||||
},
|
||||
'proposal_rejected': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com',
|
||||
'admin_note': 'We think that you’ve asked for too much money for the project you’ve proposed, and for such an inexperienced team. Feel free to change your target amount, or elaborate on why you need so much money, and try applying again.',
|
||||
},
|
||||
'proposal_rejected_discussion': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com',
|
||||
},
|
||||
'proposal_rejected_permanently': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com',
|
||||
'profile_rejected_url': 'http://someproposal.com/profile?tab=rejected',
|
||||
'admin_note': 'We don\'t really think this is needed right now by the ecosystem. Feel free to elaborate and submit again',
|
||||
},
|
||||
'proposal_arbiter_assigned': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'http://someproposal.com'
|
||||
},
|
||||
'ccr_approved': {
|
||||
'ccr': ccr,
|
||||
'ccr_url': 'http://someproposal.com',
|
||||
'admin_note': 'This proposal was the hottest stuff our team has seen yet. Great work.',
|
||||
},
|
||||
'ccr_rejected': {
|
||||
'ccr': ccr,
|
||||
'ccr_url': 'http://someproposal.com',
|
||||
'admin_note': 'We don\'t really think this is needed right now by the ecosystem. Feel free to elaborate and submit again',
|
||||
},
|
||||
'ccr_rejected_permanently': {
|
||||
'ccr': ccr,
|
||||
'ccr_url': 'http://someproposal.com',
|
||||
'profile_rejected_url': 'http://someproposal.com/profile?tab=rejected',
|
||||
'admin_note': 'We don\'t really think this will ever be needed by the ecosystem.',
|
||||
},
|
||||
'proposal_contribution': {
|
||||
'proposal': proposal,
|
||||
'contribution': contribution,
|
||||
|
@ -174,6 +222,10 @@ example_email_args = {
|
|||
'proposal': proposal,
|
||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||
},
|
||||
'admin_changes_resolved': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||
},
|
||||
'admin_arbiter': {
|
||||
'proposal': proposal,
|
||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||
|
@ -191,4 +243,8 @@ example_email_args = {
|
|||
"proposal": proposal,
|
||||
"proposal_url": "http://someproposal.com",
|
||||
},
|
||||
'followed_proposal_revised': {
|
||||
"proposal": proposal,
|
||||
"proposal_url": "http://someproposal.com",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -158,19 +158,19 @@ def stats():
|
|||
.filter(Milestone.stage == MilestoneStage.ACCEPTED) \
|
||||
.scalar()
|
||||
# Count contributions on proposals that didn't get funded for users who have specified a refund address
|
||||
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
|
||||
.filter(ProposalContribution.refund_tx_id == None) \
|
||||
.filter(ProposalContribution.staking == False) \
|
||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
.join(Proposal) \
|
||||
.filter(or_(
|
||||
Proposal.stage == ProposalStage.FAILED,
|
||||
Proposal.stage == ProposalStage.CANCELED,
|
||||
)) \
|
||||
.join(ProposalContribution.user) \
|
||||
.join(UserSettings) \
|
||||
.filter(UserSettings.refund_address != None) \
|
||||
.scalar()
|
||||
# contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
|
||||
# .filter(ProposalContribution.refund_tx_id == None) \
|
||||
# .filter(ProposalContribution.staking == False) \
|
||||
# .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||
# .join(Proposal) \
|
||||
# .filter(or_(
|
||||
# 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,
|
||||
|
@ -178,7 +178,7 @@ def stats():
|
|||
"proposalPendingCount": proposal_pending_count,
|
||||
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
||||
"proposalMilestonePayoutsCount": proposal_milestone_payouts_count,
|
||||
"contributionRefundableCount": contribution_refundable_count,
|
||||
"contributionRefundableCount": 0,
|
||||
}
|
||||
|
||||
|
||||
|
@ -302,6 +302,9 @@ def set_arbiter(proposal_id, user_id):
|
|||
if proposal.is_failed:
|
||||
return {"message": "Cannot set arbiter on failed proposal"}, 400
|
||||
|
||||
if proposal.version == '2' and not proposal.accepted_with_funding:
|
||||
return {"message": "Cannot set arbiter, proposal has not been accepted with funding"}, 400
|
||||
|
||||
user = User.query.filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"message": "User not found"}, 404
|
||||
|
@ -334,7 +337,7 @@ def get_proposals(page, filters, search, sort):
|
|||
filters_workaround = request.args.getlist('filters[]')
|
||||
page = pagination.proposal(
|
||||
schema=proposals_schema,
|
||||
query=Proposal.query,
|
||||
query=Proposal.query.filter(Proposal.status.notin_([ProposalStatus.ARCHIVED])),
|
||||
page=page,
|
||||
filters=filters_workaround,
|
||||
search=search,
|
||||
|
@ -358,25 +361,96 @@ def delete_proposal(id):
|
|||
return {"message": "Not implemented."}, 400
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
|
||||
@blueprint.route('/proposals/<proposal_id>/discussion', methods=['PUT'])
|
||||
@body({
|
||||
"isAccepted": fields.Bool(required=True),
|
||||
"withFunding": fields.Bool(required=True),
|
||||
"isOpenForDiscussion": fields.Bool(required=True),
|
||||
"rejectReason": fields.Str(required=False, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
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_accepted, with_funding, reject_reason)
|
||||
def open_proposal_for_discussion(proposal_id, is_open_for_discussion, reject_reason=None):
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
if not proposal:
|
||||
return {"message": "No Proposal found."}, 404
|
||||
|
||||
if is_accepted and with_funding:
|
||||
proposal.approve_discussion(is_open_for_discussion, reject_reason)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
|
||||
@body({
|
||||
"isAccepted": fields.Bool(required=True),
|
||||
"withFunding": fields.Bool(required=False, missing=None),
|
||||
"changesRequestedReason": fields.Str(required=False, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def accept_proposal(id, is_accepted, with_funding, changes_requested_reason):
|
||||
proposal = Proposal.query.get(id)
|
||||
if not proposal:
|
||||
return {"message": "No proposal found."}, 404
|
||||
|
||||
if is_accepted:
|
||||
proposal.accept_proposal(with_funding)
|
||||
|
||||
if with_funding:
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
else:
|
||||
proposal.request_changes_discussion(changes_requested_reason)
|
||||
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
return {"message": "No proposal found."}, 404
|
||||
|
||||
@blueprint.route('/proposals/<proposal_id>/reject_permanently', methods=['PUT'])
|
||||
@body({
|
||||
"rejectReason": fields.Str(required=True, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def reject_permanently_proposal(proposal_id, reject_reason):
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
|
||||
if not proposal:
|
||||
return {"message": "No proposal found."}, 404
|
||||
|
||||
reject_permanently_statuses = [
|
||||
ProposalStatus.REJECTED,
|
||||
ProposalStatus.PENDING
|
||||
]
|
||||
|
||||
if proposal.status not in reject_permanently_statuses:
|
||||
return {"message": "Proposal status is not REJECTED or PENDING."}, 401
|
||||
|
||||
proposal.status = ProposalStatus.REJECTED_PERMANENTLY
|
||||
proposal.reject_reason = reject_reason
|
||||
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
for user in proposal.team:
|
||||
send_email(user.email_address, 'proposal_rejected_permanently', {
|
||||
'user': user,
|
||||
'proposal': proposal,
|
||||
'proposal_url': make_url(f'/proposals/{proposal.id}'),
|
||||
'admin_note': reject_reason,
|
||||
'profile_rejected_url': make_url('/profile?tab=rejected'),
|
||||
})
|
||||
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<proposal_id>/resolve', methods=['PUT'])
|
||||
@admin.admin_auth_required
|
||||
def resolve_changes_discussion(proposal_id):
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
if not proposal:
|
||||
return {"message": "No proposal found"}, 404
|
||||
|
||||
proposal.resolve_changes_discussion()
|
||||
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route('/proposals/<id>/accept/fund', methods=['PUT'])
|
||||
|
@ -536,6 +610,41 @@ def approve_ccr(ccr_id, is_accepted, reject_reason=None):
|
|||
return {"message": "No CCR found."}, 404
|
||||
|
||||
|
||||
@blueprint.route('/ccrs/<ccr_id>/reject_permanently', methods=['PUT'])
|
||||
@body({
|
||||
"rejectReason": fields.Str(required=True, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def reject_permanently_ccr(ccr_id, reject_reason):
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
|
||||
if not ccr:
|
||||
return {"message": "No CCR found."}, 404
|
||||
|
||||
reject_permanently_statuses = [
|
||||
CCRStatus.REJECTED,
|
||||
CCRStatus.PENDING
|
||||
]
|
||||
|
||||
if ccr.status not in reject_permanently_statuses:
|
||||
return {"message": "CCR status is not REJECTED or PENDING."}, 401
|
||||
|
||||
ccr.status = CCRStatus.REJECTED_PERMANENTLY
|
||||
ccr.reject_reason = reject_reason
|
||||
|
||||
db.session.add(ccr)
|
||||
db.session.commit()
|
||||
|
||||
send_email(ccr.author.email_address, 'ccr_rejected_permanently', {
|
||||
'user': ccr.author,
|
||||
'ccr': ccr,
|
||||
'admin_note': reject_reason,
|
||||
'profile_rejected_url': make_url('/profile?tab=rejected')
|
||||
})
|
||||
|
||||
return ccr_schema.dump(ccr)
|
||||
|
||||
|
||||
# Requests for Proposal
|
||||
|
||||
|
||||
|
@ -809,50 +918,60 @@ def financials():
|
|||
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
|
||||
FROM milestone as ms
|
||||
INNER JOIN proposal as p ON ms.proposal_id = p.id
|
||||
WHERE {where}
|
||||
WHERE p.version = '2' AND {where}
|
||||
'''
|
||||
|
||||
def ex(sql: str):
|
||||
res = db.engine.execute(text(sql))
|
||||
return [row[0] if row[0] else Decimal(0) for row in res][0].normalize()
|
||||
|
||||
contributions = {
|
||||
'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')"))),
|
||||
# should have a refund_address
|
||||
'refunding': str(ex(sql_pc_p(
|
||||
'''
|
||||
pc.status = 'CONFIRMED' AND
|
||||
pc.staking = FALSE AND
|
||||
pc.refund_tx_id IS NULL AND
|
||||
p.stage IN ('CANCELED', 'FAILED') AND
|
||||
us.refund_address IS NOT NULL
|
||||
'''
|
||||
))),
|
||||
# here we don't care about current refund_address of user, just that there has been a refund_tx_id
|
||||
'refunded': str(ex(sql_pc_p(
|
||||
'''
|
||||
pc.status = 'CONFIRMED' AND
|
||||
pc.staking = FALSE AND
|
||||
pc.refund_tx_id IS NOT NULL AND
|
||||
p.stage IN ('CANCELED', 'FAILED')
|
||||
'''
|
||||
))),
|
||||
# if there is no user, or the user hasn't any refund_address
|
||||
'donations': str(ex(sql_pc_p(
|
||||
'''
|
||||
pc.status = 'CONFIRMED' AND
|
||||
pc.staking = FALSE AND
|
||||
pc.refund_tx_id IS NULL AND
|
||||
(pc.user_id IS NULL OR us.refund_address IS NULL) AND
|
||||
p.stage IN ('CANCELED', 'FAILED')
|
||||
'''
|
||||
))),
|
||||
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
|
||||
}
|
||||
def gen_quarter_date_range(year, quarter):
|
||||
if quarter == 1:
|
||||
return f"{year}-1-1", f"{year}-3-31"
|
||||
if quarter == 2:
|
||||
return f"{year}-4-1", f"{year}-6-30"
|
||||
if quarter == 3:
|
||||
return f"{year}-7-1", f"{year}-9-30"
|
||||
if quarter == 4:
|
||||
return f"{year}-10-1", f"{year}-12-31"
|
||||
|
||||
# contributions = {
|
||||
# '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')"))),
|
||||
# # should have a refund_address
|
||||
# 'refunding': str(ex(sql_pc_p(
|
||||
# '''
|
||||
# pc.status = 'CONFIRMED' AND
|
||||
# pc.staking = FALSE AND
|
||||
# pc.refund_tx_id IS NULL AND
|
||||
# p.stage IN ('CANCELED', 'FAILED') AND
|
||||
# us.refund_address IS NOT NULL
|
||||
# '''
|
||||
# ))),
|
||||
# # here we don't care about current refund_address of user, just that there has been a refund_tx_id
|
||||
# 'refunded': str(ex(sql_pc_p(
|
||||
# '''
|
||||
# pc.status = 'CONFIRMED' AND
|
||||
# pc.staking = FALSE AND
|
||||
# pc.refund_tx_id IS NOT NULL AND
|
||||
# p.stage IN ('CANCELED', 'FAILED')
|
||||
# '''
|
||||
# ))),
|
||||
# # if there is no user, or the user hasn't any refund_address
|
||||
# 'donations': str(ex(sql_pc_p(
|
||||
# '''
|
||||
# pc.status = 'CONFIRMED' AND
|
||||
# pc.staking = FALSE AND
|
||||
# pc.refund_tx_id IS NULL AND
|
||||
# (pc.user_id IS NULL OR us.refund_address IS NULL) AND
|
||||
# p.stage IN ('CANCELED', 'FAILED')
|
||||
# '''
|
||||
# ))),
|
||||
# 'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
|
||||
# }
|
||||
|
||||
po_due = ex(sql_ms("ms.stage = 'ACCEPTED'")) # payments accepted but not yet marked as paid
|
||||
po_paid = ex(sql_ms("ms.stage = 'PAID'")) # will catch paid ms from all proposals regardless of status/stage
|
||||
|
@ -860,6 +979,24 @@ def financials():
|
|||
po_future = ex(sql_ms("ms.stage IN ('IDLE', 'REJECTED', 'REQUESTED') AND p.stage IN ('WIP', 'COMPLETED')"))
|
||||
po_total = po_due + po_paid + po_future
|
||||
|
||||
now = datetime.now()
|
||||
start_year = 2019
|
||||
end_year = now.year
|
||||
|
||||
payouts_by_quarter = {}
|
||||
|
||||
for year in range(start_year, end_year + 1):
|
||||
payouts_by_quarter[f"{year}"] = {}
|
||||
year_total = 0
|
||||
|
||||
for quarter in range(1, 5):
|
||||
begin, end = gen_quarter_date_range(year, quarter)
|
||||
payouts = ex(sql_ms(f"ms.stage = 'PAID' AND (ms.date_paid BETWEEN '{begin}' AND '{end}')"))
|
||||
payouts_by_quarter[f"{year}"][f"q{quarter}"] = str(payouts)
|
||||
year_total += payouts
|
||||
|
||||
payouts_by_quarter[f"{year}"]["year_total"] = str(year_total)
|
||||
|
||||
payouts = {
|
||||
'total': str(po_total),
|
||||
'due': str(po_due),
|
||||
|
@ -876,7 +1013,7 @@ def financials():
|
|||
def add_str_dec(a: str, b: str):
|
||||
return str((Decimal(a) + Decimal(b)).quantize(Decimal('0.001'), rounding=ROUND_HALF_DOWN))
|
||||
|
||||
proposals = Proposal.query.all()
|
||||
proposals = Proposal.query.filter_by(version='2')
|
||||
|
||||
for p in proposals:
|
||||
# CANCELED proposals excluded, though they could have had milestones paid out with grant funds
|
||||
|
@ -899,7 +1036,6 @@ def financials():
|
|||
|
||||
return {
|
||||
'grants': grants,
|
||||
'contributions': contributions,
|
||||
'payouts': payouts,
|
||||
'net': str(Decimal(contributions['gross']) - Decimal(payouts['paid']))
|
||||
'payouts_by_quarter': payouts_by_quarter
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ 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,
|
||||
|
@ -20,7 +21,6 @@ from grant import (
|
|||
milestone,
|
||||
admin,
|
||||
email,
|
||||
blockchain,
|
||||
task,
|
||||
rfp,
|
||||
e2e,
|
||||
|
@ -44,6 +44,8 @@ class JSONResponse(Response):
|
|||
|
||||
|
||||
def create_app(config_objects=["grant.settings"]):
|
||||
from grant.patches import patch_werkzeug_set_samesite
|
||||
patch_werkzeug_set_samesite()
|
||||
app = Flask(__name__.split(".")[0])
|
||||
app.response_class = JSONResponse
|
||||
|
||||
|
@ -151,7 +153,6 @@ def register_blueprints(app):
|
|||
app.register_blueprint(milestone.views.blueprint)
|
||||
app.register_blueprint(admin.views.blueprint)
|
||||
app.register_blueprint(email.views.blueprint)
|
||||
app.register_blueprint(blockchain.views.blueprint)
|
||||
app.register_blueprint(task.views.blueprint)
|
||||
app.register_blueprint(rfp.views.blueprint)
|
||||
app.register_blueprint(home.views.blueprint)
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
from . import views
|
|
@ -1,14 +0,0 @@
|
|||
from flask import Blueprint, current_app
|
||||
|
||||
from grant.blockchain.bootstrap import send_bootstrap_data
|
||||
from grant.utils.auth import internal_webhook
|
||||
|
||||
blueprint = Blueprint("blockchain", __name__, url_prefix="/api/v1/blockchain")
|
||||
|
||||
|
||||
@blueprint.route("/bootstrap", methods=["GET"])
|
||||
@internal_webhook
|
||||
def get_bootstrap_info():
|
||||
current_app.logger.info('Bootstrap data requested from blockchain watcher microservice...')
|
||||
send_bootstrap_data()
|
||||
return {"message": "ok"}, 200
|
|
@ -67,6 +67,7 @@ def delete_ccr(ccr_id):
|
|||
CCRStatus.PENDING,
|
||||
CCRStatus.APPROVED,
|
||||
CCRStatus.REJECTED,
|
||||
CCRStatus.REJECTED_PERMANENTLY
|
||||
]
|
||||
status = g.current_ccr.status
|
||||
if status not in deleteable_statuses:
|
||||
|
|
|
@ -14,6 +14,7 @@ from .subscription_settings import EmailSubscription, is_subscribed
|
|||
default_template_args = {
|
||||
'home_url': make_url('/'),
|
||||
'account_url': make_url('/profile'),
|
||||
'profile_rejected_url': make_url('/profile?tab=rejected'),
|
||||
'email_settings_url': make_url('/profile/settings?tab=emails'),
|
||||
'unsubscribe_url': make_url('/profile/settings?tab=emails'),
|
||||
}
|
||||
|
@ -69,38 +70,94 @@ def change_password_info(email_args):
|
|||
|
||||
def proposal_approved(email_args):
|
||||
return {
|
||||
'subject': 'Your proposal has been reviewed',
|
||||
'title': 'Your proposal has been reviewed',
|
||||
'preview': '{} is now live on ZF Grants.'.format(email_args['proposal'].title),
|
||||
'subject': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
|
||||
'preview': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||
}
|
||||
|
||||
|
||||
def proposal_approved_without_funding(email_args):
|
||||
return {
|
||||
'subject': "Your proposal '{}' has been listed on ZF Grants for community donations".format(
|
||||
email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' has been listed on ZF Grants for community donations".format(
|
||||
email_args['proposal'].title),
|
||||
'preview': "Your proposal '{}' has been listed on ZF Grants for community donations".format(
|
||||
email_args['proposal'].title),
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||
}
|
||||
|
||||
|
||||
def proposal_approved_discussion(email_args):
|
||||
return {
|
||||
'subject': "Your proposal '{}' has been approved for public discussion".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' has been approved for public discussion".format(email_args['proposal'].title),
|
||||
'preview': '{} is now open for public discussion 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',
|
||||
'subject': "Your request '{}' has been approved!".format(email_args['ccr'].title),
|
||||
'title': "Your request '{}' has been approved!".format(email_args['ccr'].title),
|
||||
'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',
|
||||
'subject': "Your request '{}' has changes requested".format(email_args['ccr'].title),
|
||||
'title': "Your request '{}' has changes requested".format(email_args['ccr'].title),
|
||||
'preview': '{} has changes requested'.format(email_args['ccr'].title),
|
||||
}
|
||||
|
||||
|
||||
def ccr_rejected_permanently(email_args):
|
||||
return {
|
||||
'subject': "Your request '{}' has been rejected".format(email_args['ccr'].title),
|
||||
'title': "Your request '{}' has been rejected".format(email_args['ccr'].title),
|
||||
'preview': f'{email_args["ccr"].title} won\'t be accepted',
|
||||
}
|
||||
|
||||
|
||||
def proposal_rejected(email_args):
|
||||
return {
|
||||
'subject': 'Your proposal has changes requested',
|
||||
'title': 'Your proposal has changes requested',
|
||||
'subject': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
|
||||
'preview': '{} has changes requested'.format(email_args['proposal'].title),
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||
}
|
||||
|
||||
|
||||
def proposal_rejected_discussion(email_args):
|
||||
return {
|
||||
'subject': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
|
||||
'preview': '{} has changes requested'.format(email_args['proposal'].title),
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||
}
|
||||
|
||||
|
||||
def proposal_rejected_permanently(email_args):
|
||||
return {
|
||||
'subject': "Your proposal '{}' has been rejected".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' has been rejected".format(email_args['proposal'].title),
|
||||
'preview': '{} has been rejected'.format(email_args['proposal'].title),
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||
}
|
||||
|
||||
|
||||
def proposal_arbiter_assigned(email_args):
|
||||
return {
|
||||
'subject': "Your proposal '{}' is ready for payout requests".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' is ready for payout requests".format(email_args['proposal'].title),
|
||||
'preview': '{} is ready for payout '.format(email_args['proposal'].title),
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
||||
}
|
||||
|
||||
|
||||
def proposal_contribution(email_args):
|
||||
if email_args['contribution'].private:
|
||||
email_args['contributor'] = None
|
||||
|
@ -118,7 +175,8 @@ def proposal_contribution(email_args):
|
|||
|
||||
def proposal_comment(email_args):
|
||||
return {
|
||||
'subject': 'New comment from {}'.format(email_args['author'].display_name),
|
||||
'subject': "New comment from {} to your proposal '{}'".format(email_args['author'].display_name,
|
||||
email_args['proposal'].title),
|
||||
'title': 'You got a comment',
|
||||
'preview': '{} has added a comment to your proposal {}'.format(
|
||||
email_args['author'].display_name,
|
||||
|
@ -130,8 +188,8 @@ def proposal_comment(email_args):
|
|||
|
||||
def proposal_failed(email_args):
|
||||
return {
|
||||
'subject': 'Your proposal failed to get funding',
|
||||
'title': 'Proposal failed',
|
||||
'subject': "Your proposal '{}' failed to get funding".format(email_args['proposal'].title),
|
||||
'title': "Your proposal '{}' failed to get funding".format(email_args['proposal'].title),
|
||||
'preview': 'Your proposal entitled {} failed to get enough funding by the deadline'.format(
|
||||
email_args['proposal'].title,
|
||||
),
|
||||
|
@ -143,7 +201,7 @@ def proposal_canceled(email_args):
|
|||
return {
|
||||
'subject': 'Your proposal has been canceled',
|
||||
'title': 'Proposal canceled',
|
||||
'preview': 'Your proposal entitled {} has been canceled, and your contributors will be refunded'.format(
|
||||
'preview': 'Your proposal entitled {} has been canceled'.format(
|
||||
email_args['proposal'].title,
|
||||
),
|
||||
}
|
||||
|
@ -291,7 +349,7 @@ def milestone_accept(email_args):
|
|||
return {
|
||||
'subject': f'Payout approved for {p.title} - {ms.title}!',
|
||||
'title': f'Milestone payout approved',
|
||||
'preview': f'The payout of {a} ZEC for milestone {ms.title} has been approved.',
|
||||
'preview': f'The payout of ${a} in ZEC for milestone {ms.title} has been approved.',
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
|
||||
}
|
||||
|
||||
|
@ -303,7 +361,7 @@ def milestone_paid(email_args):
|
|||
return {
|
||||
'subject': f'{p.title} - {ms.title} has been paid!',
|
||||
'title': f'Milestone paid',
|
||||
'preview': f'The milestone {ms.title} payout of {a} ZEC has been paid!',
|
||||
'preview': f'The milestone {ms.title} payout of ${a} in ZEC has been paid!',
|
||||
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
|
||||
}
|
||||
|
||||
|
@ -326,6 +384,15 @@ def admin_approval_ccr(email_args):
|
|||
}
|
||||
|
||||
|
||||
def admin_changes_resolved(email_args):
|
||||
return {
|
||||
'subject': f'Changes marked as resolved for {email_args["proposal"].title}',
|
||||
'title': f'Changes Resolved',
|
||||
'preview': f'Team members of proposal {email_args["proposal"].title} have marked requested changes as resolved.',
|
||||
'subscription': EmailSubscription.ADMIN_APPROVAL,
|
||||
}
|
||||
|
||||
|
||||
def admin_arbiter(email_args):
|
||||
return {
|
||||
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
||||
|
@ -365,6 +432,16 @@ def followed_proposal_update(email_args):
|
|||
}
|
||||
|
||||
|
||||
def followed_proposal_revised(email_args):
|
||||
p = email_args["proposal"]
|
||||
return {
|
||||
"subject": f"Proposal '{p.title}' has been revised.",
|
||||
"title": f"Proposal Revised",
|
||||
"preview": f"Followed proposal {p.title} has been revised",
|
||||
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
|
||||
}
|
||||
|
||||
|
||||
get_info_lookup = {
|
||||
'signup': signup_info,
|
||||
'team_invite': team_invite_info,
|
||||
|
@ -372,10 +449,16 @@ 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,
|
||||
'ccr_rejected': ccr_rejected,
|
||||
'ccr_rejected_permanently': ccr_rejected_permanently,
|
||||
'proposal_approved_without_funding': proposal_approved_without_funding,
|
||||
'proposal_approved': proposal_approved,
|
||||
'proposal_approved_discussion': proposal_approved_discussion,
|
||||
'proposal_rejected': proposal_rejected,
|
||||
'proposal_rejected_discussion': proposal_rejected_discussion,
|
||||
'proposal_rejected_permanently': proposal_rejected_permanently,
|
||||
'proposal_arbiter_assigned': proposal_arbiter_assigned,
|
||||
'proposal_contribution': proposal_contribution,
|
||||
'proposal_comment': proposal_comment,
|
||||
'proposal_failed': proposal_failed,
|
||||
|
@ -396,10 +479,12 @@ get_info_lookup = {
|
|||
'milestone_paid': milestone_paid,
|
||||
'admin_approval': admin_approval,
|
||||
'admin_approval_ccr': admin_approval_ccr,
|
||||
'admin_changes_resolved': admin_changes_resolved,
|
||||
'admin_arbiter': admin_arbiter,
|
||||
'admin_payout': admin_payout,
|
||||
'followed_proposal_milestone': followed_proposal_milestone,
|
||||
'followed_proposal_update': followed_proposal_update
|
||||
'followed_proposal_update': followed_proposal_update,
|
||||
'followed_proposal_revised': followed_proposal_revised
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -83,6 +83,26 @@ class Milestone(db.Model):
|
|||
)
|
||||
db.session.add(m)
|
||||
|
||||
# clone milestones from one proposal to another
|
||||
@staticmethod
|
||||
def clone(source_proposal, destination_proposal):
|
||||
# delete any milestones on destination proposal
|
||||
[db.session.delete(ms) for ms in destination_proposal.milestones]
|
||||
|
||||
# copy milestones from source proposal to destination proposal
|
||||
for i, ms in enumerate(source_proposal.milestones):
|
||||
new_ms = Milestone(
|
||||
proposal_id=destination_proposal.id,
|
||||
title=ms.title,
|
||||
content=ms.content,
|
||||
days_estimated=ms.days_estimated,
|
||||
payout_percent=ms.payout_percent,
|
||||
immediate_payout=ms.immediate_payout,
|
||||
index=i
|
||||
)
|
||||
db.session.add(new_ms)
|
||||
|
||||
|
||||
# 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`.
|
||||
#
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
from werkzeug import http, wrappers
|
||||
|
||||
from grant.werkzeug_http_fork import dump_cookie
|
||||
|
||||
|
||||
def patch_werkzeug_set_samesite():
|
||||
http.dump_cookie = dump_cookie
|
||||
wrappers.base_response.dump_cookie = dump_cookie
|
|
@ -1,14 +1,16 @@
|
|||
import datetime
|
||||
import json
|
||||
from typing import Optional
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from functools import reduce
|
||||
|
||||
from marshmallow import post_dump
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, ForeignKey
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import column_property
|
||||
|
||||
from grant.comment.models import Comment
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.email.send import send_email
|
||||
from grant.extensions import ma, db
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
|
||||
|
@ -19,12 +21,14 @@ from grant.utils.enums import (
|
|||
Category,
|
||||
ContributionStatus,
|
||||
ProposalArbiterStatus,
|
||||
MilestoneStage
|
||||
MilestoneStage,
|
||||
ProposalChange
|
||||
)
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.misc import dt_to_unix, make_url, make_admin_url, gen_random_id
|
||||
from grant.utils.requests import blockchain_get
|
||||
from grant.utils.stubs import anonymous_user
|
||||
from grant.utils.validate import is_z_address_valid
|
||||
|
||||
proposal_team = db.Table(
|
||||
'proposal_team', db.Model.metadata,
|
||||
|
@ -228,6 +232,111 @@ class ProposalArbiter(db.Model):
|
|||
raise ValidationException('User is not arbiter')
|
||||
|
||||
|
||||
class ProposalRevision(db.Model):
|
||||
__tablename__ = "proposal_revision"
|
||||
|
||||
id = db.Column(db.Integer(), primary_key=True)
|
||||
date_created = db.Column(db.DateTime)
|
||||
|
||||
# user who submitted the changes
|
||||
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
author = db.relationship("User", uselist=False, lazy=True)
|
||||
|
||||
# the proposal these changes are associated with
|
||||
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||
proposal = db.relationship("Proposal", foreign_keys=[proposal_id], back_populates="revisions")
|
||||
|
||||
# the archived proposal id associated with these changes
|
||||
proposal_archive_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||
|
||||
# the detected changes as a JSON string
|
||||
changes = db.Column(db.Text, nullable=False)
|
||||
|
||||
# the placement of this revision in the total revisions
|
||||
revision_index = db.Column(db.Integer)
|
||||
|
||||
def __init__(self, author, proposal_id: int, proposal_archive_id: int, changes: str, revision_index: int):
|
||||
self.id = gen_random_id(ProposalRevision)
|
||||
self.date_created = datetime.datetime.now()
|
||||
self.author = author
|
||||
self.proposal_id = proposal_id
|
||||
self.proposal_archive_id = proposal_archive_id
|
||||
self.changes = changes
|
||||
self.revision_index = revision_index
|
||||
|
||||
@staticmethod
|
||||
def calculate_milestone_changes(old_milestones, new_milestones):
|
||||
changes = []
|
||||
old_length = len(old_milestones)
|
||||
new_length = len(new_milestones)
|
||||
|
||||
# determine the longer milestone collection so we can enumerate it
|
||||
long_ms = None
|
||||
short_ms = None
|
||||
if old_length >= new_length:
|
||||
long_ms = old_milestones
|
||||
short_ms = new_milestones
|
||||
else:
|
||||
long_ms = new_milestones
|
||||
short_ms = old_milestones
|
||||
|
||||
# detect whether we're adding or removing milestones
|
||||
is_adding = False
|
||||
is_removing = False
|
||||
if old_length > new_length:
|
||||
is_removing = True
|
||||
if new_length > old_length:
|
||||
is_adding = True
|
||||
|
||||
for i, ms in enumerate(long_ms):
|
||||
compare_ms = short_ms[i] if len(short_ms) - 1 >= i else None
|
||||
|
||||
# when compare milestone doesn't exist, the current milestone is either being added or removed
|
||||
if not compare_ms:
|
||||
if is_adding:
|
||||
changes.append({"type": ProposalChange.MILESTONE_ADD, "milestone_index": i})
|
||||
if is_removing:
|
||||
changes.append({"type": ProposalChange.MILESTONE_REMOVE, "milestone_index": i})
|
||||
continue
|
||||
|
||||
if ms.days_estimated != compare_ms.days_estimated:
|
||||
changes.append({"type": ProposalChange.MILESTONE_EDIT_DAYS, "milestone_index": i})
|
||||
|
||||
if ms.immediate_payout != compare_ms.immediate_payout:
|
||||
changes.append({"type": ProposalChange.MILESTONE_EDIT_IMMEDIATE_PAYOUT, "milestone_index": i})
|
||||
|
||||
if ms.payout_percent != compare_ms.payout_percent:
|
||||
changes.append({"type": ProposalChange.MILESTONE_EDIT_PERCENT, "milestone_index": i})
|
||||
|
||||
if ms.content != compare_ms.content:
|
||||
changes.append({"type": ProposalChange.MILESTONE_EDIT_CONTENT, "milestone_index": i})
|
||||
|
||||
if ms.title != compare_ms.title:
|
||||
changes.append({"type": ProposalChange.MILESTONE_EDIT_TITLE, "milestone_index": i})
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def calculate_proposal_changes(old_proposal, new_proposal):
|
||||
proposal_changes = []
|
||||
|
||||
if old_proposal.brief != new_proposal.brief:
|
||||
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_BRIEF})
|
||||
|
||||
if old_proposal.content != new_proposal.content:
|
||||
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_CONTENT})
|
||||
|
||||
if old_proposal.target != new_proposal.target:
|
||||
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TARGET})
|
||||
|
||||
if old_proposal.title != new_proposal.title:
|
||||
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TITLE})
|
||||
|
||||
milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones)
|
||||
|
||||
return proposal_changes + milestone_changes
|
||||
|
||||
|
||||
def default_proposal_content():
|
||||
return """# Applicant background
|
||||
|
||||
|
@ -283,6 +392,8 @@ class Proposal(db.Model):
|
|||
date_published = db.Column(db.DateTime)
|
||||
reject_reason = db.Column(db.String())
|
||||
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
|
||||
changes_requested_discussion = db.Column(db.Boolean(), nullable=True)
|
||||
changes_requested_discussion_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Payment info
|
||||
target = db.Column(db.String(255), nullable=False)
|
||||
|
@ -320,6 +431,10 @@ class Proposal(db.Model):
|
|||
.where(proposal_liker.c.proposal_id == id)
|
||||
.correlate_except(proposal_liker)
|
||||
)
|
||||
live_draft_parent_id = db.Column(db.Integer, ForeignKey('proposal.id'))
|
||||
live_draft = db.relationship("Proposal", uselist=False, backref=db.backref('live_draft_parent', remote_side=[id], uselist=False))
|
||||
|
||||
revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -407,24 +522,13 @@ class Proposal(db.Model):
|
|||
if self.deadline_duration > 7776000:
|
||||
raise ValidationException("Deadline duration cannot be more than 90 days")
|
||||
|
||||
# Check with node that the payout address is kosher
|
||||
try:
|
||||
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
||||
except:
|
||||
raise ValidationException(
|
||||
"Could not validate your payout address due to an internal server error, please try again later")
|
||||
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")
|
||||
# Validate payout address
|
||||
if not is_z_address_valid(self.payout_address):
|
||||
raise ValidationException("Payout address is not a valid z address")
|
||||
|
||||
# Validate tip jar address
|
||||
if self.tip_jar_address and not is_z_address_valid(self.tip_jar_address):
|
||||
raise ValidationException("Tip address is not a valid z address")
|
||||
|
||||
# Then run through regular validation
|
||||
Proposal.simple_validate(vars(self))
|
||||
|
@ -465,7 +569,7 @@ class Proposal(db.Model):
|
|||
return proposal
|
||||
|
||||
@staticmethod
|
||||
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
|
||||
def get_by_user(user, statuses=[ProposalStatus.LIVE, ProposalStatus.DISCUSSION]):
|
||||
status_filter = or_(Proposal.status == v for v in statuses)
|
||||
return Proposal.query \
|
||||
.join(proposal_team) \
|
||||
|
@ -578,39 +682,19 @@ class Proposal(db.Model):
|
|||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
# state: status PENDING -> (LIVE || REJECTED)
|
||||
def approve_pending(self, is_approve, with_funding, reject_reason=None):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
# approve a proposal moving from PENDING to DISCUSSION status
|
||||
# state: status PENDING -> (DISCUSSION || REJECTED)
|
||||
def approve_discussion(self, is_open_for_discussion, reject_reason=None):
|
||||
if not self.status == ProposalStatus.PENDING:
|
||||
raise ValidationException(f"Proposal must be pending to approve or reject")
|
||||
raise ValidationException("Proposal must be pending to open for public discussion")
|
||||
|
||||
if is_approve:
|
||||
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()
|
||||
if is_open_for_discussion:
|
||||
self.status = ProposalStatus.DISCUSSION
|
||||
for t in self.team:
|
||||
admin_note = ''
|
||||
if with_funding:
|
||||
admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.'
|
||||
else:
|
||||
admin_note = '''
|
||||
We've chosen to list your proposal on ZF Grants, but we won't be funding your proposal at this time.
|
||||
Your proposal can still receive funding from the community in the form of tips if you have set a tip address for your proposal.
|
||||
If you have not yet done so, you can do this from the actions dropdown at your proposal.
|
||||
'''
|
||||
send_email(t.email_address, 'proposal_approved', {
|
||||
send_email(t.email_address, 'proposal_approved_discussion', {
|
||||
'user': t,
|
||||
'proposal': self,
|
||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||
'admin_note': admin_note
|
||||
'proposal_url': make_url(f'/proposals/{self.id}')
|
||||
})
|
||||
else:
|
||||
if not reject_reason:
|
||||
|
@ -625,6 +709,73 @@ class Proposal(db.Model):
|
|||
'admin_note': reject_reason
|
||||
})
|
||||
|
||||
# request changes for a proposal with a DISCUSSION status
|
||||
def request_changes_discussion(self, reason):
|
||||
if self.status != ProposalStatus.DISCUSSION:
|
||||
raise ValidationException("Proposal does not have a DISCUSSION status")
|
||||
if not reason:
|
||||
raise ValidationException("Please provide a reason for requesting changes")
|
||||
|
||||
self.changes_requested_discussion = True
|
||||
self.changes_requested_discussion_reason = reason
|
||||
for t in self.team:
|
||||
send_email(t.email_address, 'proposal_rejected_discussion', {
|
||||
'user': t,
|
||||
'proposal': self,
|
||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||
'admin_note': reason
|
||||
})
|
||||
|
||||
# mark a request changes as resolve for a proposal with a DISCUSSION status
|
||||
def resolve_changes_discussion(self):
|
||||
if self.status != ProposalStatus.DISCUSSION:
|
||||
raise ValidationException("Proposal does not have a DISCUSSION status")
|
||||
|
||||
if not self.changes_requested_discussion:
|
||||
raise ValidationException("Proposal does not have changes requested")
|
||||
|
||||
self.changes_requested_discussion = False
|
||||
self.changes_requested_discussion_reason = None
|
||||
|
||||
# state: status DISCUSSION -> (LIVE)
|
||||
def accept_proposal(self, with_funding):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
if not self.status == ProposalStatus.DISCUSSION:
|
||||
raise ValidationException(f"Proposal must have a DISCUSSION status to approve or reject")
|
||||
|
||||
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:
|
||||
if with_funding:
|
||||
admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.'
|
||||
send_email(t.email_address, 'proposal_approved', {
|
||||
'user': t,
|
||||
'proposal': self,
|
||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||
'admin_note': admin_note
|
||||
})
|
||||
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_without_funding', {
|
||||
'user': t,
|
||||
'proposal': self,
|
||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
||||
'admin_note': admin_note
|
||||
})
|
||||
|
||||
def update_proposal_with_funding(self):
|
||||
self.accepted_with_funding = True
|
||||
self.fully_fund_contibution_bounty()
|
||||
|
@ -803,6 +954,105 @@ class Proposal(db.Model):
|
|||
else:
|
||||
return self.tip_jar_view_key
|
||||
|
||||
# make a LIVE_DRAFT proposal by copying the relevant fields from an existing proposal
|
||||
@staticmethod
|
||||
def make_live_draft(proposal):
|
||||
live_draft_proposal = Proposal.create(
|
||||
title=proposal.title,
|
||||
brief=proposal.brief,
|
||||
content=proposal.content,
|
||||
target=proposal.target,
|
||||
payout_address=proposal.payout_address,
|
||||
status=ProposalStatus.LIVE_DRAFT
|
||||
)
|
||||
live_draft_proposal.tip_jar_address = proposal.tip_jar_address
|
||||
live_draft_proposal.changes_requested_discussion_reason = proposal.changes_requested_discussion_reason
|
||||
live_draft_proposal.rfp_opt_in = proposal.rfp_opt_in
|
||||
live_draft_proposal.team = proposal.team
|
||||
|
||||
db.session.add(live_draft_proposal)
|
||||
|
||||
Milestone.clone(proposal, live_draft_proposal)
|
||||
|
||||
return live_draft_proposal
|
||||
|
||||
# port changes made in LIVE_DRAFT proposal to self and delete the draft
|
||||
def consume_live_draft(self, author):
|
||||
if self.status != ProposalStatus.DISCUSSION:
|
||||
raise ValidationException("Proposal is not open for public review")
|
||||
|
||||
live_draft = self.live_draft
|
||||
revision_changes = ProposalRevision.calculate_proposal_changes(self, live_draft)
|
||||
|
||||
if len(revision_changes) == 0:
|
||||
if live_draft.rfp_opt_in == self.rfp_opt_in \
|
||||
and live_draft.payout_address == self.payout_address \
|
||||
and live_draft.tip_jar_address == self.tip_jar_address \
|
||||
and live_draft.team == self.team:
|
||||
|
||||
raise ValidationException("Live draft does not appear to have any changes")
|
||||
else:
|
||||
# cover special cases where properties not tracked in revisions have changed:
|
||||
self.rfp_opt_in = live_draft.rfp_opt_in
|
||||
self.payout_address = live_draft.payout_address
|
||||
self.tip_jar_address = live_draft.tip_jar_address
|
||||
self.team = live_draft.team
|
||||
self.live_draft = None
|
||||
db.session.add(self)
|
||||
db.session.delete(live_draft)
|
||||
return False
|
||||
|
||||
# if this is the first revision, create a base revision that's a snapshot of the original proposal
|
||||
if len(self.revisions) == 0:
|
||||
base_draft = self.make_live_draft(self)
|
||||
base_draft.status = ProposalStatus.ARCHIVED
|
||||
base_draft.invites = []
|
||||
|
||||
db.session.add(base_draft)
|
||||
|
||||
base_revision = ProposalRevision(
|
||||
author=author,
|
||||
proposal_id=self.id,
|
||||
proposal_archive_id=base_draft.id,
|
||||
changes=json.dumps([]),
|
||||
revision_index=0
|
||||
)
|
||||
self.revisions.append(base_revision)
|
||||
|
||||
revision_index = len(self.revisions)
|
||||
|
||||
revision = ProposalRevision(
|
||||
author=author,
|
||||
proposal_id=self.id,
|
||||
proposal_archive_id=live_draft.id,
|
||||
changes=json.dumps(revision_changes),
|
||||
revision_index=revision_index
|
||||
)
|
||||
|
||||
self.title = live_draft.title
|
||||
self.brief = live_draft.brief
|
||||
self.content = live_draft.content
|
||||
self.target = live_draft.target
|
||||
self.payout_address = live_draft.payout_address
|
||||
self.tip_jar_address = live_draft.tip_jar_address
|
||||
self.rfp_opt_in = live_draft.rfp_opt_in
|
||||
self.team = live_draft.team
|
||||
self.invites = []
|
||||
self.live_draft = None
|
||||
|
||||
self.revisions.append(revision)
|
||||
|
||||
db.session.add(self)
|
||||
|
||||
# copy milestones
|
||||
Milestone.clone(live_draft, self)
|
||||
|
||||
# archive live draft
|
||||
live_draft.status = ProposalStatus.ARCHIVED
|
||||
live_draft.invites = []
|
||||
db.session.add(live_draft)
|
||||
return True
|
||||
|
||||
|
||||
class ProposalSchema(ma.Schema):
|
||||
class Meta:
|
||||
|
@ -843,7 +1093,10 @@ class ProposalSchema(ma.Schema):
|
|||
"authed_liked",
|
||||
"likes_count",
|
||||
"tip_jar_address",
|
||||
"tip_jar_view_key"
|
||||
"tip_jar_view_key",
|
||||
"changes_requested_discussion",
|
||||
"changes_requested_discussion_reason",
|
||||
"live_draft_id"
|
||||
)
|
||||
|
||||
date_created = ma.Method("get_date_created")
|
||||
|
@ -852,6 +1105,7 @@ class ProposalSchema(ma.Schema):
|
|||
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")
|
||||
live_draft_id = ma.Method("get_live_draft_id")
|
||||
|
||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||
team = ma.Nested("UserSchema", many=True)
|
||||
|
@ -879,6 +1133,10 @@ class ProposalSchema(ma.Schema):
|
|||
def get_tip_jar_view_key(self, obj):
|
||||
return obj.get_tip_jar_view_key
|
||||
|
||||
def get_live_draft_id(self, obj):
|
||||
return obj.live_draft.id if obj.live_draft else None
|
||||
|
||||
|
||||
proposal_schema = ProposalSchema()
|
||||
proposals_schema = ProposalSchema(many=True)
|
||||
user_fields = [
|
||||
|
@ -894,6 +1152,7 @@ user_fields = [
|
|||
"date_approved",
|
||||
"date_published",
|
||||
"reject_reason",
|
||||
"changes_requested_discussion_reason",
|
||||
"team",
|
||||
"accepted_with_funding",
|
||||
"is_version_two",
|
||||
|
@ -934,6 +1193,40 @@ proposal_update_schema = ProposalUpdateSchema()
|
|||
proposals_update_schema = ProposalUpdateSchema(many=True)
|
||||
|
||||
|
||||
class ProposalRevisionSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = ProposalRevision
|
||||
# Fields to expose
|
||||
fields = (
|
||||
"revision_id",
|
||||
"date_created",
|
||||
"author",
|
||||
"proposal_id",
|
||||
"proposal_archive_id",
|
||||
"changes",
|
||||
"revision_index"
|
||||
)
|
||||
|
||||
revision_id = ma.Method("get_revision_id")
|
||||
date_created = ma.Method("get_date_created")
|
||||
changes = ma.Method("get_changes")
|
||||
|
||||
author = ma.Nested("UserSchema")
|
||||
|
||||
def get_revision_id(self, obj):
|
||||
return obj.id
|
||||
|
||||
def get_date_created(self, obj):
|
||||
return dt_to_unix(obj.date_created)
|
||||
|
||||
def get_changes(self, obj):
|
||||
return json.loads(obj.changes)
|
||||
|
||||
|
||||
proposal_revision_schema = ProposalRevisionSchema()
|
||||
proposals_revisions_schema = ProposalRevisionSchema(many=True)
|
||||
|
||||
|
||||
class ProposalTeamInviteSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = ProposalTeamInvite
|
||||
|
|
|
@ -25,7 +25,7 @@ from grant.utils.auth import (
|
|||
get_authed_user,
|
||||
internal_webhook
|
||||
)
|
||||
from grant.utils.requests import validate_blockchain_get
|
||||
from grant.utils.validate import is_z_address_valid
|
||||
from grant.utils.enums import Category
|
||||
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, RFPStatus
|
||||
from grant.utils.exceptions import ValidationException
|
||||
|
@ -36,11 +36,13 @@ from .models import (
|
|||
proposal_schema,
|
||||
ProposalUpdate,
|
||||
proposal_update_schema,
|
||||
proposals_revisions_schema,
|
||||
ProposalContribution,
|
||||
proposal_contribution_schema,
|
||||
proposal_team,
|
||||
ProposalTeamInvite,
|
||||
proposal_team_invite_schema,
|
||||
proposal_team_invites_schema,
|
||||
proposal_proposal_contributions_schema,
|
||||
db,
|
||||
)
|
||||
|
@ -52,7 +54,9 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
|
|||
def get_proposal(proposal_id):
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
if proposal:
|
||||
if proposal.status != ProposalStatus.LIVE:
|
||||
if proposal.status == ProposalStatus.ARCHIVED:
|
||||
return {"message": "Proposal has been archived"}, 401
|
||||
if proposal.status not in [ProposalStatus.LIVE, ProposalStatus.DISCUSSION]:
|
||||
if proposal.status == ProposalStatus.DELETED:
|
||||
return {"message": "Proposal was deleted"}, 404
|
||||
authed_user = get_authed_user()
|
||||
|
@ -64,6 +68,19 @@ def get_proposal(proposal_id):
|
|||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/archive", methods=["GET"])
|
||||
def get_archived_proposal(proposal_id):
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
|
||||
if not proposal:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
if proposal.status != ProposalStatus.ARCHIVED:
|
||||
return {"message": "Proposal is not archived"}, 401
|
||||
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
|
||||
@query(paginated_fields)
|
||||
def get_proposal_comments(proposal_id, page, filters, search, sort):
|
||||
|
@ -110,8 +127,8 @@ 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
|
||||
if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.DISCUSSION:
|
||||
return {"message": "Proposal must be live or open for public review to comment"}, 400
|
||||
|
||||
# Make sure the parent comment exists
|
||||
parent = None
|
||||
|
@ -164,7 +181,10 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
|||
@query(paginated_fields)
|
||||
def get_proposals(page, filters, search, sort):
|
||||
filters_workaround = request.args.getlist('filters[]')
|
||||
query = Proposal.query.filter_by(status=ProposalStatus.LIVE) \
|
||||
query = Proposal.query.filter(or_(
|
||||
Proposal.status == ProposalStatus.LIVE,
|
||||
Proposal.status == ProposalStatus.DISCUSSION
|
||||
)) \
|
||||
.filter(Proposal.stage != ProposalStage.CANCELED) \
|
||||
.filter(Proposal.stage != ProposalStage.FAILED)
|
||||
page = pagination.proposal(
|
||||
|
@ -207,6 +227,22 @@ def make_proposal_draft(rfp_id):
|
|||
return proposal_schema.dump(proposal), 201
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/draft", methods=["POST"])
|
||||
@requires_team_member_auth
|
||||
def make_proposal_live_draft(proposal_id):
|
||||
proposal = g.current_proposal
|
||||
|
||||
if proposal.status != ProposalStatus.DISCUSSION:
|
||||
return {"message": "Proposal does not have a DISCUSSION status"}, 404
|
||||
|
||||
if not proposal.live_draft:
|
||||
proposal.live_draft = Proposal.make_live_draft(proposal)
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
return proposal_schema.dump(proposal.live_draft), 201
|
||||
|
||||
|
||||
@blueprint.route("/drafts", methods=["GET"])
|
||||
@requires_auth
|
||||
def get_proposal_drafts():
|
||||
|
@ -215,6 +251,7 @@ def get_proposal_drafts():
|
|||
.filter(or_(
|
||||
Proposal.status == ProposalStatus.DRAFT,
|
||||
Proposal.status == ProposalStatus.REJECTED,
|
||||
Proposal.status == ProposalStatus.LIVE_DRAFT
|
||||
))
|
||||
.join(proposal_team)
|
||||
.filter(proposal_team.c.user_id == g.current_user.id)
|
||||
|
@ -241,6 +278,7 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
|||
# Update the base proposal fields
|
||||
try:
|
||||
if g.current_proposal.status not in [ProposalStatus.DRAFT,
|
||||
ProposalStatus.LIVE_DRAFT,
|
||||
ProposalStatus.REJECTED]:
|
||||
raise ValidationException(
|
||||
f"Proposal with status: {g.current_proposal.status} are not authorized for updates"
|
||||
|
@ -261,6 +299,21 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
|||
return proposal_schema.dump(g.current_proposal), 200
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/resolve", methods=["PUT"])
|
||||
@requires_team_member_auth
|
||||
def resolve_changes_discussion(proposal_id):
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
if not proposal:
|
||||
return {"message": "No proposal found"}, 404
|
||||
|
||||
proposal.resolve_changes_discussion()
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
proposal.send_admin_email('admin_changes_resolved')
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/tips", methods=["PUT"])
|
||||
@requires_team_member_auth
|
||||
@body({
|
||||
|
@ -268,11 +321,11 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
|||
"viewKey": fields.Str(required=False, missing=None)
|
||||
})
|
||||
def update_proposal_tip_jar(proposal_id, address, view_key):
|
||||
if address is not None and address is not '' and not is_z_address_valid(address):
|
||||
return {"message": "Tip address is not a valid z address"}, 400
|
||||
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
|
||||
|
||||
|
@ -299,6 +352,7 @@ def delete_proposal(proposal_id):
|
|||
deleteable_statuses = [
|
||||
ProposalStatus.DRAFT,
|
||||
ProposalStatus.PENDING,
|
||||
ProposalStatus.REJECTED_PERMANENTLY,
|
||||
ProposalStatus.APPROVED,
|
||||
ProposalStatus.REJECTED,
|
||||
ProposalStatus.STAKING,
|
||||
|
@ -339,6 +393,42 @@ def publish_proposal(proposal_id):
|
|||
return proposal_schema.dump(g.current_proposal), 200
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/publish/live", methods=["PUT"])
|
||||
@requires_team_member_auth
|
||||
def publish_live_draft(proposal_id):
|
||||
if g.current_proposal.status != ProposalStatus.LIVE_DRAFT:
|
||||
return {"message": "Proposal is not a live draft"}, 403
|
||||
|
||||
if not g.current_proposal.live_draft_parent_id:
|
||||
return {"message": "No parent proposal found"}, 404
|
||||
|
||||
parent_proposal = Proposal.query.get(g.current_proposal.live_draft_parent_id)
|
||||
|
||||
if not parent_proposal:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
# TODO: double check this isn't needed:
|
||||
#
|
||||
# if g.current_user not in proposal.team:
|
||||
# return {"message": "You are not a team member of this proposal"}
|
||||
|
||||
try:
|
||||
parent_proposal.live_draft.validate_publishable()
|
||||
except ValidationException as e:
|
||||
return {"message": "{}".format(str(e))}, 400
|
||||
|
||||
had_revisions = parent_proposal.consume_live_draft(g.current_user)
|
||||
db.session.commit()
|
||||
|
||||
# Send email to all followers if revisions were detected
|
||||
if had_revisions:
|
||||
parent_proposal.send_follower_email(
|
||||
"followed_proposal_revised", url_suffix="?tab=revisions"
|
||||
)
|
||||
|
||||
return proposal_schema.dump(parent_proposal), 200
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
|
||||
def get_proposal_updates(proposal_id):
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
|
@ -362,6 +452,26 @@ def get_proposal_update(proposal_id, update_id):
|
|||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/revisions", methods=["GET"])
|
||||
def get_proposal_revisions(proposal_id):
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
|
||||
if not proposal:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
if proposal.status in [ProposalStatus.DRAFT, ProposalStatus.REJECTED]:
|
||||
return {"message": "Proposal is not live"}, 400
|
||||
|
||||
def sort_by_revision_index(r):
|
||||
return r.revision_index
|
||||
|
||||
revisions = proposal.revisions
|
||||
revisions.sort(key=sort_by_revision_index)
|
||||
|
||||
dumped_revisions = proposals_revisions_schema.dump(revisions)
|
||||
return dumped_revisions
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
|
||||
@limiter.limit("5/day;1/minute")
|
||||
@requires_team_member_auth
|
||||
|
@ -395,6 +505,16 @@ def post_proposal_update(proposal_id, title, content):
|
|||
return dumped_update, 201
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/invites", methods=["GET"])
|
||||
@requires_team_member_auth
|
||||
def get_proposal_team_invites(proposal_id):
|
||||
proposal_dump = proposal_schema.dump(g.current_proposal)
|
||||
return {
|
||||
"team": proposal_dump["team"],
|
||||
"invites": proposal_dump["invites"]
|
||||
}
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
||||
@limiter.limit("30/day;10/minute")
|
||||
@requires_team_member_auth
|
||||
|
@ -710,8 +830,8 @@ def like_proposal(proposal_id, is_liked):
|
|||
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
|
||||
if proposal.status not in [ProposalStatus.LIVE, ProposalStatus.DISCUSSION]:
|
||||
return {"message": "Cannot like a proposal that's not live or in discussion"}, 404
|
||||
|
||||
proposal.like(user, is_liked)
|
||||
db.session.commit()
|
||||
|
|
|
@ -31,6 +31,8 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|||
# so backend session cookies are first-party
|
||||
SESSION_COOKIE_DOMAIN = env.str('SESSION_COOKIE_DOMAIN', default=None)
|
||||
CORS_DOMAINS = env.str('CORS_DOMAINS', default='*')
|
||||
SESSION_COOKIE_SAMESITE = env.str('SESSION_COOKIE_SAMESITE', default='None')
|
||||
SESSION_COOKIE_SECURE = True if SESSION_COOKIE_SAMESITE == 'None' else False
|
||||
|
||||
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
||||
SENDGRID_DEFAULT_FROM = "noreply@grants.zfnd.org"
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Team members of proposal
|
||||
<a href="{{ args.proposal_url }}" target="_blank">
|
||||
{{ args.proposal.title }}</a
|
||||
>
|
||||
have marked requested changes as resolved. 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.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;"
|
||||
>
|
||||
Review Proposal
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
|
@ -0,0 +1,5 @@
|
|||
Team members of proposal {{ args.proposal.title }} have marked requested changes as resolved.
|
||||
|
||||
As an admin you can help out by reviewing it.
|
||||
|
||||
Visit the proposal and review: {{ args.proposal_url }}
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the admin team was attached to your approval:
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
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:
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the admin team was attached to your rejection:
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
|
|
|
@ -2,7 +2,7 @@ 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:
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<p style="margin: 0;">
|
||||
Your request has been rejected. Your request won't be publicly visible on ZF Grants.
|
||||
<a href="{{ args.profile_rejected_url }}" >Visit your profile</a> to delete this request.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
</p>
|
||||
{% endif %}
|
|
@ -0,0 +1,9 @@
|
|||
Your request has been rejected. Your request won't be publicly visible on ZF Grants. Visit your profile to delete this request:
|
||||
|
||||
{{ args.profile_rejected_url }}
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
|
@ -0,0 +1,29 @@
|
|||
<p style="margin: 0;">
|
||||
Your followed proposal {{ args.proposal.title }} has been revised!
|
||||
</p>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
bgcolor="{{ UI.PRIMARY }}"
|
||||
style="border-radius: 3px;"
|
||||
>
|
||||
<a
|
||||
href="{{ args.proposal_url }}"
|
||||
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||
UI.PRIMARY
|
||||
}}; display: inline-block;"
|
||||
target="_blank"
|
||||
>
|
||||
Check it out
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
|
@ -0,0 +1,3 @@
|
|||
Your followed proposal {{ args.proposal.title }} has been revised!
|
||||
|
||||
Check it out: {{ args.proposal_url }}
|
|
@ -3,7 +3,7 @@
|
|||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
payout of <b>{{ args.amount }} ZEC</b> has been approved.
|
||||
payout of <b>${{ args.amount }}</b> in ZEC has been approved.
|
||||
</p>
|
||||
|
||||
<p style="margin: 0;">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
The proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
|
||||
payout of {{args.amount}} ZEC has been approved!
|
||||
payout of ${{args.amount}} in ZEC has been approved!
|
||||
|
||||
You will receive payment shortly!
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
|
||||
Hooray! <b>${{ args.amount }}</b> in ZEC has been paid out for
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.milestone.title }}</a
|
||||
>! You can view the transaction below:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.milestone.title }}"!
|
||||
Hooray! ${{args.amount}} in ZEC has been paid out for "{{ args.proposal.title }} - {{args.milestone.title }}"!
|
||||
You can view the transaction below:
|
||||
|
||||
{{ args.tx_explorer_url }}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<p style="margin: 0;">
|
||||
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||
Congratulations, your proposal has been funded by the Zcash Foundation! Once an arbiter is selected by the Foundation, you'll be able to request payouts according to your grant's milestone schedule.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the admin team was attached to your approval:
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||
Congratulations, your proposal has been funded by the Zcash Foundation! Once an arbiter is selected by the Foundation, you'll be able to request payouts according to your grant's milestone schedule.
|
||||
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the admin team was attached to your approval:
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<p style="margin: 0;">
|
||||
Your proposal has been approved for public discussion and community feedback on ZF Grants. The Zcash Foundation reviews open grant applications on an ongoing basis, and may request additional revisions based on open feedback before making a final funding determination.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
</p>
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
Your proposal has been approved for public discussion and community feedback on ZF Grants. The Zcash Foundation reviews open grant applications on an ongoing basis, and may request additional revisions based on open feedback before making a final funding determination.
|
||||
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
||||
{{ args.proposal_url }}
|
|
@ -0,0 +1,13 @@
|
|||
<p style="margin: 0;">
|
||||
Your proposal has been reviewed by the Zcash Foundation and has been listed on ZF Grants for community donations. Although the Zcash Foundation won't be providing funding to your proposal directly, the community will have an opportunity to provide funding to your 'tip address'.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
</p>
|
||||
{% endif %}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
||||
{{ args.proposal_url }}
|
|
@ -0,0 +1,6 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Your proposal {{ args.proposal.title }} is ready for payout requests.
|
||||
<a href="{{ args.proposal_url }}" target="_blank">
|
||||
Visit your proposal</a
|
||||
> to see more.
|
||||
</p>
|
|
@ -0,0 +1,5 @@
|
|||
Your proposal {{ args.proposal.title }} is ready for payout requests.
|
||||
|
||||
Visit your proposal to see more:
|
||||
|
||||
{{ args.proposal_url }}
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the admin team was attached to your rejection:
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
|
|
|
@ -2,7 +2,7 @@ Your proposal 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:
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<p style="margin: 0;">
|
||||
Your proposal is still open for public discussion, but the ZF team has requested changes.
|
||||
Please make the necessary edits and mark the changes as resolved.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
|
||||
Please note that repeated submissions without significant changes or with
|
||||
content that doesn't match the platform guidelines may result in a removal
|
||||
of your submission privileges.
|
||||
</p>
|
|
@ -0,0 +1,12 @@
|
|||
Your proposal is still open for public discussion, but the ZF team has requested changes.
|
||||
Please make the necessary edits and mark the changes as resolved.
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
||||
|
||||
Please note that repeated submissions without significant changes or with
|
||||
content that doesn't match the platform guidelines may result in a removal
|
||||
of your submission privileges.
|
|
@ -0,0 +1,13 @@
|
|||
<p style="margin: 0;">
|
||||
Your proposal has been rejected.
|
||||
<a href="{{ args.profile_rejected_url }}" >Visit your profile</a> to delete this proposal.
|
||||
</p>
|
||||
|
||||
{% if args.admin_note %}
|
||||
<p style="margin: 20px 0 0;">
|
||||
A note from the Zcash Foundation:
|
||||
</p>
|
||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||
“{{ args.admin_note }}”
|
||||
</p>
|
||||
{% endif %}
|
|
@ -0,0 +1,9 @@
|
|||
Your proposal has been rejected. Visit your profile to delete this proposal:
|
||||
|
||||
{{ args.profile_rejected_url }}
|
||||
|
||||
{% if args.admin_note %}
|
||||
A note from the Zcash Foundation:
|
||||
|
||||
> {{ args.admin_note }}
|
||||
{% endif %}
|
|
@ -4,6 +4,8 @@ from flask import Blueprint, g, current_app
|
|||
from marshmallow import fields
|
||||
from validate_email import validate_email
|
||||
from webargs import validate
|
||||
from grant.email.send import send_email
|
||||
from grant.utils.misc import make_url
|
||||
|
||||
import grant.utils.auth as auth
|
||||
from grant.comment.models import Comment, user_comments_schema
|
||||
|
@ -20,9 +22,8 @@ from grant.proposal.models import (
|
|||
user_proposal_arbiters_schema
|
||||
)
|
||||
from grant.proposal.models import ProposalContribution
|
||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
||||
from grant.utils.enums import ProposalStatus, ContributionStatus, CCRStatus
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.requests import validate_blockchain_get
|
||||
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
|
||||
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
||||
from .models import (
|
||||
|
@ -34,6 +35,7 @@ from .models import (
|
|||
user_settings_schema,
|
||||
db
|
||||
)
|
||||
from grant.utils.validate import is_z_address_valid
|
||||
|
||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||
|
||||
|
@ -52,10 +54,11 @@ def get_me():
|
|||
"withFunded": fields.Bool(required=False, missing=None),
|
||||
"withPending": fields.Bool(required=False, missing=None),
|
||||
"withArbitrated": fields.Bool(required=False, missing=None),
|
||||
"withRequests": fields.Bool(required=False, missing=None)
|
||||
"withRequests": fields.Bool(required=False, missing=None),
|
||||
"withRejectedPermanently": fields.Bool(required=False, missing=None)
|
||||
|
||||
})
|
||||
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests):
|
||||
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests, with_rejected_permanently):
|
||||
user = User.get_by_id(user_id)
|
||||
if user:
|
||||
result = user_schema.dump(user)
|
||||
|
@ -91,15 +94,24 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
|||
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,
|
||||
CCRStatus.PENDING,
|
||||
CCRStatus.APPROVED,
|
||||
CCRStatus.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)
|
||||
if with_rejected_permanently and is_self:
|
||||
rejected_proposals = Proposal.get_by_user(user, [
|
||||
ProposalStatus.REJECTED_PERMANENTLY
|
||||
])
|
||||
result["rejectedPermanentlyProposals"] = user_proposals_schema.dump(rejected_proposals)
|
||||
|
||||
rejected_ccrs = CCR.get_by_user(user, [
|
||||
CCRStatus.REJECTED_PERMANENTLY,
|
||||
])
|
||||
result["rejectedPermanentlyRequests"] = ccrs_schema.dump(rejected_ccrs)
|
||||
|
||||
return result
|
||||
else:
|
||||
|
@ -363,8 +375,7 @@ def get_user_settings(user_id):
|
|||
@auth.requires_same_user_auth
|
||||
@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})),
|
||||
"refundAddress": fields.Str(required=False, missing=None),
|
||||
"tipJarAddress": fields.Str(required=False, missing=None),
|
||||
"tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here
|
||||
})
|
||||
|
@ -376,16 +387,18 @@ def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_addr
|
|||
except ValidationException as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
if refund_address is not None and refund_address != '' and not is_z_address_valid(refund_address):
|
||||
return {"message": "Refund address is not a valid z address"}, 400
|
||||
if refund_address == '' and g.current_user.settings.refund_address:
|
||||
return {"message": "Refund address cannot be unset, only changed"}, 400
|
||||
if refund_address:
|
||||
g.current_user.settings.refund_address = refund_address
|
||||
|
||||
if tip_jar_address is not None and tip_jar_address is not '' and not is_z_address_valid(tip_jar_address):
|
||||
return {"message": "Tip address is not a valid z address"}, 400
|
||||
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
|
||||
|
||||
|
@ -406,6 +419,14 @@ def set_user_arbiter(user_id, proposal_id, is_accept):
|
|||
|
||||
if is_accept:
|
||||
proposal.arbiter.accept_nomination(g.current_user.id)
|
||||
|
||||
for user in proposal.team:
|
||||
send_email(user.email_address, 'proposal_arbiter_assigned', {
|
||||
'user': user,
|
||||
'proposal': proposal,
|
||||
'proposal_url': make_url(f'/proposals/{proposal.id}')
|
||||
})
|
||||
|
||||
return {"message": "Accepted nomination"}, 200
|
||||
else:
|
||||
proposal.arbiter.reject_nomination(g.current_user.id)
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
# Copyright (c) 2017 Pieter Wuille
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
"""Reference implementation for Bech32 and segwit addresses."""
|
||||
|
||||
|
||||
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def bech32_polymod(values):
|
||||
"""Internal function that computes the Bech32 checksum."""
|
||||
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ value
|
||||
for i in range(5):
|
||||
chk ^= generator[i] if ((top >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def bech32_hrp_expand(hrp):
|
||||
"""Expand the HRP into values for checksum computation."""
|
||||
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
||||
|
||||
|
||||
def bech32_verify_checksum(hrp, data):
|
||||
"""Verify a checksum given HRP and converted data characters."""
|
||||
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
|
||||
|
||||
|
||||
def bech32_create_checksum(hrp, data):
|
||||
"""Compute the checksum values given HRP and data."""
|
||||
values = bech32_hrp_expand(hrp) + data
|
||||
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def bech32_encode(hrp, data):
|
||||
"""Compute a Bech32 string given HRP and data values."""
|
||||
combined = data + bech32_create_checksum(hrp, data)
|
||||
return hrp + '1' + ''.join([CHARSET[d] for d in combined])
|
||||
|
||||
|
||||
def bech32_decode(bech):
|
||||
"""Validate a Bech32 string, and determine HRP and data."""
|
||||
if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
|
||||
(bech.lower() != bech and bech.upper() != bech)):
|
||||
return (None, None)
|
||||
bech = bech.lower()
|
||||
pos = bech.rfind('1')
|
||||
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
|
||||
return (None, None)
|
||||
if not all(x in CHARSET for x in bech[pos+1:]):
|
||||
return (None, None)
|
||||
hrp = bech[:pos]
|
||||
data = [CHARSET.find(x) for x in bech[pos+1:]]
|
||||
if not bech32_verify_checksum(hrp, data):
|
||||
return (None, None)
|
||||
return (hrp, data[:-6])
|
||||
|
||||
|
||||
def convertbits(data, frombits, tobits, pad=True):
|
||||
"""General power-of-2 base conversion."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret = []
|
||||
maxv = (1 << tobits) - 1
|
||||
max_acc = (1 << (frombits + tobits - 1)) - 1
|
||||
for value in data:
|
||||
if value < 0 or (value >> frombits):
|
||||
return None
|
||||
acc = ((acc << frombits) | value) & max_acc
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad:
|
||||
if bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
||||
return None
|
||||
return ret
|
||||
|
||||
|
||||
def decode(hrp, addr):
|
||||
"""Decode a segwit address."""
|
||||
hrpgot, data = bech32_decode(addr)
|
||||
if hrpgot != hrp:
|
||||
return (None, None)
|
||||
decoded = convertbits(data[1:], 5, 8, False)
|
||||
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
|
||||
return (None, None)
|
||||
if data[0] > 16:
|
||||
return (None, None)
|
||||
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
|
||||
return (None, None)
|
||||
return (data[0], decoded)
|
||||
|
||||
|
||||
def encode(hrp, witver, witprog):
|
||||
"""Encode a segwit address."""
|
||||
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5))
|
||||
if decode(hrp, ret) == (None, None):
|
||||
return None
|
||||
return ret
|
|
@ -16,6 +16,7 @@ class CCRStatusEnum(CustomEnum):
|
|||
PENDING = 'PENDING'
|
||||
APPROVED = 'APPROVED'
|
||||
REJECTED = 'REJECTED'
|
||||
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY'
|
||||
LIVE = 'LIVE'
|
||||
DELETED = 'DELETED'
|
||||
|
||||
|
@ -25,10 +26,14 @@ CCRStatus = CCRStatusEnum()
|
|||
|
||||
class ProposalStatusEnum(CustomEnum):
|
||||
DRAFT = 'DRAFT'
|
||||
LIVE_DRAFT = 'LIVE_DRAFT'
|
||||
ARCHIVED = 'ARCHIVED'
|
||||
STAKING = 'STAKING'
|
||||
DISCUSSION = 'DISCUSSION'
|
||||
PENDING = 'PENDING'
|
||||
APPROVED = 'APPROVED'
|
||||
REJECTED = 'REJECTED'
|
||||
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY'
|
||||
LIVE = 'LIVE'
|
||||
DELETED = 'DELETED'
|
||||
|
||||
|
@ -102,3 +107,20 @@ class ProposalArbiterStatusEnum(CustomEnum):
|
|||
|
||||
|
||||
ProposalArbiterStatus = ProposalArbiterStatusEnum()
|
||||
|
||||
|
||||
class ProposalChangeEnum(CustomEnum):
|
||||
PROPOSAL_EDIT_BRIEF = 'PROPOSAL_EDIT_BRIEF'
|
||||
PROPOSAL_EDIT_CONTENT = 'PROPOSAL_EDIT_CONTENT'
|
||||
PROPOSAL_EDIT_TARGET = 'PROPOSAL_EDIT_TARGET'
|
||||
PROPOSAL_EDIT_TITLE = 'PROPOSAL_EDIT_TITLE'
|
||||
MILESTONE_ADD = 'MILESTONE_ADD'
|
||||
MILESTONE_REMOVE = 'MILESTONE_REMOVE'
|
||||
MILESTONE_EDIT_DAYS = 'MILESTONE_EDIT_DAYS'
|
||||
MILESTONE_EDIT_IMMEDIATE_PAYOUT = 'MILESTONE_EDIT_IMMEDIATE_PAYOUT'
|
||||
MILESTONE_EDIT_PERCENT = 'MILESTONE_EDIT_PERCENT'
|
||||
MILESTONE_EDIT_CONTENT = 'MILESTONE_EDIT_CONTENT'
|
||||
MILESTONE_EDIT_TITLE = 'MILESTONE_EDIT_TITLE'
|
||||
|
||||
ProposalChange = ProposalChangeEnum()
|
||||
|
||||
|
|
|
@ -338,10 +338,10 @@ class CCRPagination(Pagination):
|
|||
page: int = 1,
|
||||
filters: list = None,
|
||||
search: str = None,
|
||||
sort: str = 'PUBLISHED:DESC',
|
||||
sort: str = 'CREATED:DESC',
|
||||
):
|
||||
query = query or CCR.query
|
||||
sort = sort or 'PUBLISHED:DESC'
|
||||
sort = sort or 'CREATED:DESC'
|
||||
|
||||
# FILTER
|
||||
if filters:
|
||||
|
|
|
@ -29,20 +29,6 @@ def blockchain_get(path, params=None):
|
|||
raise e
|
||||
|
||||
|
||||
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:
|
||||
raise ValidationException('Unable to validate zcash address right now, try again later')
|
||||
if not res.get('valid'):
|
||||
raise ValidationException('Invalid Zcash address')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def blockchain_post(path, data=None):
|
||||
if E2E_TESTING:
|
||||
return blockchain_rest_e2e(path, data)
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
from grant.utils.bech32 import bech32_decode
|
||||
|
||||
|
||||
def is_z_address_valid(addr: str):
|
||||
if type(addr) != str:
|
||||
return False
|
||||
|
||||
if addr[:3] != 'zs1':
|
||||
return False
|
||||
|
||||
hrp, data = bech32_decode(addr)
|
||||
|
||||
if hrp is None:
|
||||
return False
|
||||
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
return True
|
|
@ -0,0 +1,192 @@
|
|||
import warnings
|
||||
from datetime import timedelta, datetime
|
||||
from time import time, gmtime
|
||||
|
||||
from werkzeug._compat import to_bytes, string_types, text_type, PY2, integer_types
|
||||
from werkzeug._internal import _make_cookie_domain, _cookie_quote
|
||||
from werkzeug.urls import iri_to_uri
|
||||
|
||||
|
||||
def dump_cookie(
|
||||
key,
|
||||
value="",
|
||||
max_age=None,
|
||||
expires=None,
|
||||
path="/",
|
||||
domain=None,
|
||||
secure=False,
|
||||
httponly=False,
|
||||
charset="utf-8",
|
||||
sync_expires=True,
|
||||
max_size=4093,
|
||||
samesite=None,
|
||||
):
|
||||
"""Creates a new Set-Cookie header without the ``Set-Cookie`` prefix
|
||||
The parameters are the same as in the cookie Morsel object in the
|
||||
Python standard library but it accepts unicode data, too.
|
||||
|
||||
On Python 3 the return value of this function will be a unicode
|
||||
string, on Python 2 it will be a native string. In both cases the
|
||||
return value is usually restricted to ascii as the vast majority of
|
||||
values are properly escaped, but that is no guarantee. If a unicode
|
||||
string is returned it's tunneled through latin1 as required by
|
||||
PEP 3333.
|
||||
|
||||
The return value is not ASCII safe if the key contains unicode
|
||||
characters. This is technically against the specification but
|
||||
happens in the wild. It's strongly recommended to not use
|
||||
non-ASCII values for the keys.
|
||||
|
||||
:param max_age: should be a number of seconds, or `None` (default) if
|
||||
the cookie should last only as long as the client's
|
||||
browser session. Additionally `timedelta` objects
|
||||
are accepted, too.
|
||||
:param expires: should be a `datetime` object or unix timestamp.
|
||||
:param path: limits the cookie to a given path, per default it will
|
||||
span the whole domain.
|
||||
:param domain: Use this if you want to set a cross-domain cookie. For
|
||||
example, ``domain=".example.com"`` will set a cookie
|
||||
that is readable by the domain ``www.example.com``,
|
||||
``foo.example.com`` etc. Otherwise, a cookie will only
|
||||
be readable by the domain that set it.
|
||||
:param secure: The cookie will only be available via HTTPS
|
||||
:param httponly: disallow JavaScript to access the cookie. This is an
|
||||
extension to the cookie standard and probably not
|
||||
supported by all browsers.
|
||||
:param charset: the encoding for unicode values.
|
||||
:param sync_expires: automatically set expires if max_age is defined
|
||||
but expires not.
|
||||
:param max_size: Warn if the final header value exceeds this size. The
|
||||
default, 4093, should be safely `supported by most browsers
|
||||
<cookie_>`_. Set to 0 to disable this check.
|
||||
:param samesite: Limits the scope of the cookie such that it will only
|
||||
be attached to requests if those requests are "same-site".
|
||||
|
||||
.. _`cookie`: http://browsercookielimits.squawky.net/
|
||||
"""
|
||||
key = to_bytes(key, charset)
|
||||
value = to_bytes(value, charset)
|
||||
|
||||
if path is not None:
|
||||
path = iri_to_uri(path, charset)
|
||||
domain = _make_cookie_domain(domain)
|
||||
if isinstance(max_age, timedelta):
|
||||
max_age = (max_age.days * 60 * 60 * 24) + max_age.seconds
|
||||
if expires is not None:
|
||||
if not isinstance(expires, string_types):
|
||||
expires = cookie_date(expires)
|
||||
elif max_age is not None and sync_expires:
|
||||
expires = to_bytes(cookie_date(time() + max_age))
|
||||
|
||||
samesite = samesite.title() if samesite else None
|
||||
if samesite not in ("Strict", "Lax", 'None', None):
|
||||
raise ValueError("invalid SameSite value; must be 'Strict', 'Lax', 'None', or None")
|
||||
|
||||
buf = [key + b"=" + _cookie_quote(value)]
|
||||
|
||||
# XXX: In theory all of these parameters that are not marked with `None`
|
||||
# should be quoted. Because stdlib did not quote it before I did not
|
||||
# want to introduce quoting there now.
|
||||
for k, v, q in (
|
||||
(b"Domain", domain, True),
|
||||
(b"Expires", expires, False),
|
||||
(b"Max-Age", max_age, False),
|
||||
(b"Secure", secure, None),
|
||||
(b"HttpOnly", httponly, None),
|
||||
(b"Path", path, False),
|
||||
(b"SameSite", samesite, False),
|
||||
):
|
||||
if q is None:
|
||||
if v:
|
||||
buf.append(k)
|
||||
continue
|
||||
|
||||
if v is None:
|
||||
continue
|
||||
|
||||
tmp = bytearray(k)
|
||||
if not isinstance(v, (bytes, bytearray)):
|
||||
v = to_bytes(text_type(v), charset)
|
||||
if q:
|
||||
v = _cookie_quote(v)
|
||||
tmp += b"=" + v
|
||||
buf.append(bytes(tmp))
|
||||
|
||||
# The return value will be an incorrectly encoded latin1 header on
|
||||
# Python 3 for consistency with the headers object and a bytestring
|
||||
# on Python 2 because that's how the API makes more sense.
|
||||
rv = b"; ".join(buf)
|
||||
if not PY2:
|
||||
rv = rv.decode("latin1")
|
||||
|
||||
# Warn if the final value of the cookie is less than the limit. If the
|
||||
# cookie is too large, then it may be silently ignored, which can be quite
|
||||
# hard to debug.
|
||||
cookie_size = len(rv)
|
||||
|
||||
if max_size and cookie_size > max_size:
|
||||
value_size = len(value)
|
||||
warnings.warn(
|
||||
'The "{key}" cookie is too large: the value was {value_size} bytes'
|
||||
" but the header required {extra_size} extra bytes. The final size"
|
||||
" was {cookie_size} bytes but the limit is {max_size} bytes."
|
||||
" Browsers may silently ignore cookies larger than this.".format(
|
||||
key=key,
|
||||
value_size=value_size,
|
||||
extra_size=cookie_size - value_size,
|
||||
cookie_size=cookie_size,
|
||||
max_size=max_size,
|
||||
),
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
def cookie_date(expires=None):
|
||||
"""Formats the time to ensure compatibility with Netscape's cookie
|
||||
standard.
|
||||
|
||||
Accepts a floating point number expressed in seconds since the epoch in, a
|
||||
datetime object or a timetuple. All times in UTC. The :func:`parse_date`
|
||||
function can be used to parse such a date.
|
||||
|
||||
Outputs a string in the format ``Wdy, DD-Mon-YYYY HH:MM:SS GMT``.
|
||||
|
||||
:param expires: If provided that date is used, otherwise the current.
|
||||
"""
|
||||
return _dump_date(expires, "-")
|
||||
|
||||
|
||||
def _dump_date(d, delim):
|
||||
"""Used for `http_date` and `cookie_date`."""
|
||||
if d is None:
|
||||
d = gmtime()
|
||||
elif isinstance(d, datetime):
|
||||
d = d.utctimetuple()
|
||||
elif isinstance(d, (integer_types, float)):
|
||||
d = gmtime(d)
|
||||
return "%s, %02d%s%s%s%s %02d:%02d:%02d GMT" % (
|
||||
("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")[d.tm_wday],
|
||||
d.tm_mday,
|
||||
delim,
|
||||
(
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
)[d.tm_mon - 1],
|
||||
delim,
|
||||
str(d.tm_year),
|
||||
d.tm_hour,
|
||||
d.tm_min,
|
||||
d.tm_sec,
|
||||
)
|
|
@ -0,0 +1,30 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 6624244a249e
|
||||
Revises: 8f8001e98e65
|
||||
Create Date: 2020-02-10 20:25:34.448725
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6624244a249e'
|
||||
down_revision = '8f8001e98e65'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('proposal', sa.Column('live_draft_parent_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'proposal', 'proposal', ['live_draft_parent_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'proposal', type_='foreignkey')
|
||||
op.drop_column('proposal', 'live_draft_parent_id')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,30 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 8f8001e98e65
|
||||
Revises: 2721189b0c8f
|
||||
Create Date: 2020-02-07 12:42:57.248894
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8f8001e98e65'
|
||||
down_revision = '2721189b0c8f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('proposal', sa.Column('changes_requested_discussion', sa.Boolean(), nullable=True))
|
||||
op.add_column('proposal', sa.Column('changes_requested_discussion_reason', sa.String(length=255), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('proposal', 'changes_requested_discussion_reason')
|
||||
op.drop_column('proposal', 'changes_requested_discussion')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,40 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: bea5c35d0cd6
|
||||
Revises: 6624244a249e
|
||||
Create Date: 2020-02-26 13:55:40.484701
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bea5c35d0cd6'
|
||||
down_revision = '6624244a249e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('proposal_revision',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('date_created', sa.DateTime(), nullable=True),
|
||||
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||
sa.Column('proposal_archive_id', sa.Integer(), nullable=False),
|
||||
sa.Column('changes', sa.Text(), nullable=False),
|
||||
sa.Column('revision_index', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['author_id'], ['user.id'], ),
|
||||
sa.ForeignKeyConstraint(['proposal_archive_id'], ['proposal.id'], ),
|
||||
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('proposal_revision')
|
||||
# ### end Alembic commands ###
|
|
@ -84,4 +84,4 @@ Flask-Limiter==1.0.1
|
|||
validate_email==1.3
|
||||
|
||||
# validate URLS
|
||||
validators==0.12.4
|
||||
validators==0.12.6
|
|
@ -1,13 +1,14 @@
|
|||
import json
|
||||
from grant.utils.enums import ProposalStatus
|
||||
from grant.utils.enums import ProposalStatus, CCRStatus
|
||||
import grant.utils.admin as admin
|
||||
from grant.utils import totp_2fa
|
||||
from grant.user.models import admin_user_schema
|
||||
from grant.proposal.models import proposal_schema, db
|
||||
from grant.proposal.models import proposal_schema, db, Proposal
|
||||
from grant.ccr.models import CCR
|
||||
from mock import patch
|
||||
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
from ..test_data import mock_blockchain_api_requests
|
||||
from ..config import BaseProposalCreatorConfig, BaseCCRCreatorConfig
|
||||
from ..test_data import mock_blockchain_api_requests, test_ccr
|
||||
|
||||
json_checklogin = {
|
||||
"isLoggedIn": False,
|
||||
|
@ -242,13 +243,107 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
# 2 proposals created by BaseProposalCreatorConfig
|
||||
self.assertEqual(len(resp.json['items']), 2)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_accept_proposal_with_funding(self, mock_get):
|
||||
def test_open_proposal_for_discussion_accept(self):
|
||||
# an admin should be able to open a proposal for discussion
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# approve open for discussion
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/discussion",
|
||||
data=json.dumps({"isOpenForDiscussion": True})
|
||||
)
|
||||
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.DISCUSSION)
|
||||
|
||||
proposal = Proposal.query.get(self.proposal.id)
|
||||
self.assertEqual(proposal.status, ProposalStatus.DISCUSSION)
|
||||
|
||||
def test_open_proposal_for_discussion_reject(self):
|
||||
# an admin should be able to reject opening a proposal for discussion
|
||||
reject_reason = "this is a test"
|
||||
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# disapprove open for discussion
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/discussion",
|
||||
data=json.dumps({"isOpenForDiscussion": False, "rejectReason": reject_reason})
|
||||
)
|
||||
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
||||
self.assertEqual(resp.json["rejectReason"], reject_reason)
|
||||
|
||||
proposal = Proposal.query.get(self.proposal.id)
|
||||
self.assertEqual(proposal.status, ProposalStatus.REJECTED)
|
||||
self.assertEqual(proposal.reject_reason, reject_reason)
|
||||
|
||||
def test_open_proposal_for_discussion_bad_proposal_id_fail(self):
|
||||
# request should fail if a bad proposal id is provided
|
||||
bad_proposal_id = "11111111111111111111"
|
||||
self.login_admin()
|
||||
|
||||
# approve open for discussion
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{bad_proposal_id}/discussion",
|
||||
data=json.dumps({"isOpenForDiscussion": True})
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
def test_open_proposal_for_discussion_not_admin_fail(self):
|
||||
# request should fail if user is not an admin
|
||||
self.login_default_user()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# approve open for discussion
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/discussion",
|
||||
data=json.dumps({"isOpenForDiscussion": True})
|
||||
)
|
||||
self.assert401(resp)
|
||||
|
||||
def test_open_proposal_for_discussion_not_pending_fail(self):
|
||||
# request should fail if proposal is not in PENDING state
|
||||
self.login_admin()
|
||||
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# approve open for discussion
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/discussion",
|
||||
data=json.dumps({"isOpenForDiscussion": True})
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_open_proposal_for_discussion_no_reject_reason_fail(self):
|
||||
# denying opening a proposal for discussion should fail if no reason is provided
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# disapprove open for discussion
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/discussion",
|
||||
data=json.dumps({"isOpenForDiscussion": False})
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_accept_proposal_with_funding(self):
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be DISCUSSION
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
|
@ -264,12 +359,11 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
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):
|
||||
def test_accept_proposal_without_funding(self):
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
# proposal needs to be DISCUSSION
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
|
@ -286,13 +380,128 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
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):
|
||||
def test_accept_proposal_changes_requested(self):
|
||||
# an admin should be able to request changes on a proposal
|
||||
reason = "this is a test"
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
# proposal needs to be DISCUSSION
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": False, "changesRequestedReason": reason})
|
||||
)
|
||||
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.DISCUSSION)
|
||||
self.assertEqual(resp.json["changesRequestedDiscussion"], True)
|
||||
self.assertEqual(resp.json["changesRequestedDiscussionReason"], reason)
|
||||
|
||||
proposal = Proposal.query.get(self.proposal.id)
|
||||
self.assertEqual(proposal.status, ProposalStatus.DISCUSSION)
|
||||
self.assertEqual(proposal.changes_requested_discussion, True)
|
||||
self.assertEqual(proposal.changes_requested_discussion_reason, reason)
|
||||
|
||||
def test_accept_proposal_changes_requested_no_reason_provided_fail(self):
|
||||
# requesting changes to a proposal without providing a reason should fail
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be DISCUSSION
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# approve
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": False})
|
||||
)
|
||||
|
||||
self.assert400(resp)
|
||||
|
||||
def test_accept_proposal_changes_requested_not_discussion_fail(self):
|
||||
# requesting changes on a proposal not in DISCUSSION should fail
|
||||
self.login_admin()
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# disapprove
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": False, "changesRequestedReason": "test"})
|
||||
)
|
||||
|
||||
self.assert400(resp)
|
||||
|
||||
def test_accept_proposal_not_discussion_fail(self):
|
||||
# accepting a proposal not in DISCUSSION should fail
|
||||
self.login_admin()
|
||||
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": True})
|
||||
)
|
||||
|
||||
self.assert400(resp)
|
||||
|
||||
def test_resolve_changes_discussion(self):
|
||||
# an admin should be able to resolve discussion changes
|
||||
self.login_admin()
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
self.proposal.changes_requested_discussion = True
|
||||
self.proposal.changes_requested_discussion_reason = 'test'
|
||||
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/resolve"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json['changesRequestedDiscussion'], False)
|
||||
self.assertIsNone(resp.json['changesRequestedDiscussionReason'])
|
||||
|
||||
def test_resolve_changes_discussion_wrong_status_fail(self):
|
||||
# resolve should fail if proposal is not in a DISCUSSION state
|
||||
self.login_admin()
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
self.proposal.changes_requested_discussion = True
|
||||
self.proposal.changes_requested_discussion_reason = 'test'
|
||||
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/resolve"
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_resolve_changes_discussion_bad_proposal_fail(self):
|
||||
# resolve should fail if bad proposal id is provided
|
||||
self.login_admin()
|
||||
bad_id = '111111111111'
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{bad_id}/resolve"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
def test_resolve_changes_discussion_no_changes_requested_fail(self):
|
||||
# resolve should fail if changes are not requested on the proposal
|
||||
self.login_admin()
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
self.proposal.changes_requested_discussion = False
|
||||
self.proposal.changes_requested_discussion_reason = None
|
||||
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/resolve"
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_change_proposal_to_accepted_with_funding(self):
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be DISCUSSION
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# accept without funding
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
|
@ -330,7 +539,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
self.proposal.version = '2'
|
||||
|
||||
# should failed if proposal is not LIVE or APPROVED
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
self.proposal.accepted_with_funding = False
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund"
|
||||
|
@ -338,10 +547,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
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):
|
||||
def test_reject_proposal_discussion(self):
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
|
@ -349,18 +555,63 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
|
||||
# reject
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": False, "withFunding": False, "rejectReason": "Funnzies."})
|
||||
"/api/v1/admin/proposals/{}/discussion".format(self.proposal.id),
|
||||
data=json.dumps({"isOpenForDiscussion": False, "rejectReason": "Funnzies."})
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
||||
self.assertEqual(resp.json["rejectReason"], "Funnzies.")
|
||||
|
||||
def test_reject_permanently_proposal(self):
|
||||
rejected = {
|
||||
"rejectReason": "test"
|
||||
}
|
||||
self.login_admin()
|
||||
|
||||
# no reject reason should 400
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/reject_permanently",
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
# bad proposal id should 404
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/111111111/reject_permanently",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# bad status should 401
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/reject_permanently",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert401(resp)
|
||||
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# should go through
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/reject_permanently",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED_PERMANENTLY)
|
||||
self.assertEqual(resp.json["rejectReason"], rejected["rejectReason"])
|
||||
|
||||
@patch('grant.email.send.send_email')
|
||||
def test_nominate_arbiter(self, mock_send_email):
|
||||
mock_send_email.return_value.ok = True
|
||||
self.login_admin()
|
||||
|
||||
self.proposal.status = ProposalStatus.LIVE
|
||||
self.proposal.accepted_with_funding = True
|
||||
|
||||
# nominate arbiter
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/arbiters",
|
||||
|
@ -386,3 +637,183 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
})
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
def test_get_ccrs(self):
|
||||
create_ccr(self)
|
||||
|
||||
# non-admins should fail
|
||||
resp = self.app.get(
|
||||
"/api/v1/admin/ccrs"
|
||||
)
|
||||
self.assert401(resp)
|
||||
|
||||
# admins should be able to retrieve ccrs
|
||||
self.login_admin()
|
||||
resp = self.app.get(
|
||||
"/api/v1/admin/ccrs"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["total"], 1)
|
||||
|
||||
def test_delete_ccr(self):
|
||||
ccr_json = create_ccr(self)
|
||||
ccr_id = ccr_json["ccrId"]
|
||||
fake_id = '11111111111111'
|
||||
self.login_admin()
|
||||
|
||||
# bad CCR id should 404
|
||||
resp = self.app.delete(
|
||||
f"/api/v1/admin/ccrs/{fake_id}"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# good CCR id should 200
|
||||
resp = self.app.delete(
|
||||
f"/api/v1/admin/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
# ccr should be deleted
|
||||
resp = self.app.get(
|
||||
"/api/v1/admin/ccrs"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["total"], 0)
|
||||
|
||||
def test_get_ccr(self):
|
||||
ccr_json = create_ccr(self)
|
||||
ccr_id = ccr_json["ccrId"]
|
||||
fake_id = '11111111111111'
|
||||
self.login_admin()
|
||||
|
||||
# bad ccr id should 404
|
||||
resp = self.app.get(
|
||||
f"/api/v1/admin/ccrs/{fake_id}"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# good ccr id should 200
|
||||
resp = self.app.get(
|
||||
f"/api/v1/admin/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json, ccr_json)
|
||||
|
||||
def test_approve_ccr(self):
|
||||
ccr1_json = create_ccr(self)
|
||||
ccr1_id = ccr1_json["ccrId"]
|
||||
ccr2_json = create_ccr(self)
|
||||
ccr2_id = ccr2_json["ccrId"]
|
||||
fake_id = '11111111111111'
|
||||
accepted = {"isAccepted": True}
|
||||
rejected = {
|
||||
"isAccepted": False,
|
||||
"rejectReason": "test"
|
||||
}
|
||||
|
||||
submit_ccr(self, ccr1_id)
|
||||
submit_ccr(self, ccr2_id)
|
||||
self.login_admin()
|
||||
|
||||
# bad ccr id should 404
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/{fake_id}/accept",
|
||||
data=json.dumps(accepted),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# good ccr id that's accepted should be live
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/{ccr1_id}/accept",
|
||||
data=json.dumps(accepted),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertStatus(resp, 201)
|
||||
ccr = CCR.query.get(ccr1_id)
|
||||
self.assertEqual(ccr.status, CCRStatus.LIVE)
|
||||
|
||||
# good ccr id that's rejected should be rejected
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/{ccr2_id}/accept",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(resp)
|
||||
ccr = CCR.query.get(ccr2_id)
|
||||
self.assertEqual(ccr.status, CCRStatus.REJECTED)
|
||||
self.assertEqual(ccr.reject_reason, rejected["rejectReason"])
|
||||
|
||||
def test_reject_permanently_ccr(self):
|
||||
ccr_json = create_ccr(self)
|
||||
ccr_id = ccr_json["ccrId"]
|
||||
rejected = {
|
||||
"rejectReason": "test"
|
||||
}
|
||||
self.login_admin()
|
||||
|
||||
# no reject reason should 400
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/{ccr_id}/reject_permanently",
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
# bad ccr id should 404
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/111111111/reject_permanently",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# bad status should 401
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/{ccr_id}/reject_permanently",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert401(resp)
|
||||
|
||||
submit_ccr(self, ccr_id)
|
||||
|
||||
# should go through
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/ccrs/{ccr_id}/reject_permanently",
|
||||
data=json.dumps(rejected),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
self.assertEqual(resp.json["status"], CCRStatus.REJECTED_PERMANENTLY)
|
||||
self.assertEqual(resp.json["rejectReason"], rejected["rejectReason"])
|
||||
|
||||
|
||||
def create_ccr(self):
|
||||
# create CCR draft
|
||||
self.login_default_user()
|
||||
resp = self.app.post(
|
||||
"/api/v1/ccrs/drafts",
|
||||
)
|
||||
ccr_id = resp.json['ccrId']
|
||||
self.assertStatus(resp, 201)
|
||||
|
||||
# save CCR
|
||||
new_ccr = test_ccr.copy()
|
||||
resp = self.app.put(
|
||||
f"/api/v1/ccrs/{ccr_id}",
|
||||
data=json.dumps(new_ccr),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertStatus(resp, 200)
|
||||
return resp.json
|
||||
|
||||
|
||||
def submit_ccr(self, ccr_id):
|
||||
self.login_default_user()
|
||||
resp = self.app.put(
|
||||
f"/api/v1/ccrs/{ccr_id}/submit_for_approval"
|
||||
)
|
||||
self.assert200(resp)
|
||||
return resp.json
|
||||
|
||||
|
|
|
@ -1,13 +1,59 @@
|
|||
import json
|
||||
|
||||
from grant.ccr.models import CCR
|
||||
from grant.ccr.models import CCR, db
|
||||
from grant.utils.enums import CCRStatus
|
||||
from ..config import BaseCCRCreatorConfig
|
||||
from ..test_data import test_ccr
|
||||
|
||||
|
||||
class TestCCRApi(BaseCCRCreatorConfig):
|
||||
|
||||
def test_create_new_draft(self):
|
||||
def test_get_ccr(self):
|
||||
self.login_default_user()
|
||||
|
||||
# bad ccr id should 404
|
||||
resp = self.app.get(
|
||||
"/api/v1/ccrs/1111111111"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# ccr id should work if user is author
|
||||
ccr_id = self.ccr.id
|
||||
resp = self.app.get(
|
||||
f"/api/v1/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["ccrId"], ccr_id)
|
||||
|
||||
# ccr id should fail if user is not author
|
||||
self.login_other_user()
|
||||
resp = self.app.get(
|
||||
f"/api/v1/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# ccr should be available to anyone if it's live
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
ccr.status = CCRStatus.LIVE
|
||||
db.session.add(ccr)
|
||||
db.session.commit()
|
||||
resp = self.app.get(
|
||||
f"/api/v1/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["ccrId"], ccr_id)
|
||||
|
||||
# ccr should 404 if it's deleted
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
ccr.status = CCRStatus.DELETED
|
||||
db.session.add(ccr)
|
||||
db.session.commit()
|
||||
resp = self.app.get(
|
||||
f"/api/v1/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
def test_make_ccr_draft(self):
|
||||
self.login_default_user()
|
||||
resp = self.app.post(
|
||||
"/api/v1/ccrs/drafts",
|
||||
|
@ -17,13 +63,48 @@ class TestCCRApi(BaseCCRCreatorConfig):
|
|||
ccr_db = CCR.query.filter_by(id=resp.json['ccrId'])
|
||||
self.assertIsNotNone(ccr_db)
|
||||
|
||||
def test_no_auth_create_new_draft(self):
|
||||
def test_get_ccr_drafts(self):
|
||||
# should return drafts if they exist
|
||||
self.login_default_user()
|
||||
resp = self.app.get(
|
||||
"api/v1/ccrs/drafts"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(len(resp.json), 1)
|
||||
|
||||
# should return no drafts if they don't exist
|
||||
self.login_other_user()
|
||||
resp = self.app.get(
|
||||
"api/v1/ccrs/drafts"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(len(resp.json), 0)
|
||||
|
||||
def test_make_ccr_draft_no_auth(self):
|
||||
resp = self.app.post(
|
||||
"/api/v1/ccrs/drafts"
|
||||
)
|
||||
self.assert401(resp)
|
||||
|
||||
def test_update_CCR_draft(self):
|
||||
def test_delete_ccr(self):
|
||||
ccr_id = self.ccr.id
|
||||
self.login_default_user()
|
||||
|
||||
# ccr should exist
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
self.assertIsNotNone(ccr)
|
||||
|
||||
# author should be able to delete ccr
|
||||
resp = self.app.delete(
|
||||
f"/api/v1/ccrs/{ccr_id}"
|
||||
)
|
||||
self.assertStatus(resp, 202)
|
||||
|
||||
# ccr should no longer exist
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
self.assertIsNone(ccr)
|
||||
|
||||
def test_update_ccr(self):
|
||||
new_title = "Updated!"
|
||||
new_ccr = test_ccr.copy()
|
||||
new_ccr["title"] = new_title
|
||||
|
@ -34,7 +115,36 @@ class TestCCRApi(BaseCCRCreatorConfig):
|
|||
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)
|
||||
|
||||
# updates to live ccr should fail
|
||||
self.ccr.status = CCRStatus.LIVE
|
||||
db.session.add(self.ccr)
|
||||
db.session.commit()
|
||||
|
||||
resp = self.app.put(
|
||||
f"/api/v1/ccrs/{self.ccr.id}",
|
||||
data=json.dumps(new_ccr),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_submit_for_approval_ccr(self):
|
||||
ccr_id = self.ccr.id
|
||||
|
||||
# ccr should not be pending
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
self.assertTrue(ccr.status != CCRStatus.PENDING)
|
||||
|
||||
# author should be able to submit for approval
|
||||
self.login_default_user()
|
||||
resp = self.app.put(
|
||||
f"/api/v1/ccrs/{ccr_id}/submit_for_approval"
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
# ccr should be pending
|
||||
ccr = CCR.query.get(ccr_id)
|
||||
self.assertTrue(ccr.status == CCRStatus.PENDING)
|
||||
|
|
|
@ -174,8 +174,7 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
|||
proposal_reminder = ProposalReminder(self.proposal.id)
|
||||
proposal_reminder.make_task()
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def stake_proposal(self, mock_get):
|
||||
def stake_proposal(self):
|
||||
# 1. submit
|
||||
self.proposal.submit_for_approval()
|
||||
# 2. get staking contribution
|
||||
|
|
|
@ -49,7 +49,7 @@ test_proposal = {
|
|||
"milestones": test_milestones,
|
||||
"category": Category.ACCESSIBILITY,
|
||||
"target": "12345",
|
||||
"payoutAddress": "123",
|
||||
"payoutAddress": "zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk",
|
||||
}
|
||||
|
||||
|
||||
|
@ -71,10 +71,10 @@ class TestMilestoneMethods(BaseUserConfig):
|
|||
self.assert200(resp)
|
||||
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
proposal.status = ProposalStatus.PENDING
|
||||
proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# accept with funding
|
||||
proposal.approve_pending(True, True)
|
||||
proposal.accept_proposal(True)
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
|
||||
db.session.add(proposal)
|
||||
|
@ -83,8 +83,7 @@ class TestMilestoneMethods(BaseUserConfig):
|
|||
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):
|
||||
def test_set_v2_date_estimates(self):
|
||||
proposal_data = test_proposal.copy()
|
||||
proposal = self.init_proposal(proposal_data)
|
||||
total_days_estimated = 0
|
||||
|
@ -112,8 +111,7 @@ class TestMilestoneMethods(BaseUserConfig):
|
|||
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):
|
||||
def test_set_v2_date_estimates_immediate_payout(self):
|
||||
proposal_data = test_proposal.copy()
|
||||
proposal_data["milestones"][0]["immediate_payout"] = True
|
||||
|
||||
|
@ -123,8 +121,7 @@ class TestMilestoneMethods(BaseUserConfig):
|
|||
# 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):
|
||||
def test_set_v2_date_estimates_deadline_recalculation(self):
|
||||
proposal_data = test_proposal.copy()
|
||||
proposal = self.init_proposal(proposal_data)
|
||||
|
||||
|
|
|
@ -62,8 +62,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
)
|
||||
self.assert401(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_update_live_proposal_fails(self, mock_get):
|
||||
def test_update_live_proposal_fails(self):
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.APPROVED
|
||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
||||
|
@ -121,52 +120,67 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
self.assert404(resp)
|
||||
|
||||
# /submit_for_approval
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_proposal_draft_submit_for_approval(self, mock_get):
|
||||
def test_proposal_draft_submit_for_approval(self):
|
||||
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.PENDING)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_no_auth_proposal_draft_submit_for_approval(self, mock_get):
|
||||
def test_no_auth_proposal_draft_submit_for_approval(self):
|
||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||
self.assert401(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_invalid_proposal_draft_submit_for_approval(self, mock_get):
|
||||
def test_invalid_proposal_draft_submit_for_approval(self):
|
||||
self.login_default_user()
|
||||
resp = self.app.put("/api/v1/proposals/12345/submit_for_approval")
|
||||
self.assert404(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_invalid_status_proposal_draft_submit_for_approval(self, mock_get):
|
||||
def test_invalid_status_proposal_draft_submit_for_approval(self):
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.PENDING # should be ProposalStatus.DRAFT
|
||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||
self.assert400(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_invalid_address)
|
||||
def test_invalid_address_proposal_draft_submit_for_approval(self, mock_get):
|
||||
def test_invalid_address_proposal_draft_submit_for_approval(self):
|
||||
self.login_default_user()
|
||||
self.proposal.payout_address = 'invalid'
|
||||
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
|
||||
self.assert400(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_invalid_status_proposal_publish_proposal(self, mock_get):
|
||||
def test_invalid_status_proposal_publish_proposal(self):
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.PENDING # should be ProposalStatus.APPROVED
|
||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
||||
self.assert400(resp)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_not_verified_email_address_publish_proposal(self, mock_get):
|
||||
def test_not_verified_email_address_publish_proposal(self):
|
||||
self.login_default_user()
|
||||
self.mark_user_not_verified()
|
||||
self.proposal.status = "DRAFT"
|
||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
||||
self.assert403(resp)
|
||||
|
||||
def test_get_archived_proposal(self):
|
||||
self.login_default_user()
|
||||
|
||||
bad_id = '111111111111'
|
||||
resp = self.app.get(
|
||||
f"/api/v1/proposals/{bad_id}/archive"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
resp = self.app.get(
|
||||
f"/api/v1/proposals/{self.proposal.id}/archive"
|
||||
)
|
||||
self.assert401(resp)
|
||||
|
||||
self.proposal.status = ProposalStatus.ARCHIVED
|
||||
resp = self.app.get(
|
||||
f"/api/v1/proposals/{self.proposal.id}/archive"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(self.proposal.id, resp.json["proposalId"])
|
||||
|
||||
# /
|
||||
def test_get_proposals(self):
|
||||
self.proposal.status = ProposalStatus.LIVE
|
||||
|
@ -247,7 +261,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
content_type="application/json",
|
||||
)
|
||||
self.assert404(resp)
|
||||
self.assertEquals(resp.json["message"], "Cannot like a proposal that's not live")
|
||||
self.assertEquals(resp.json["message"], "Cannot like a proposal that's not live or in discussion")
|
||||
|
||||
# proposal is live
|
||||
self.proposal.status = ProposalStatus.LIVE
|
||||
|
@ -281,3 +295,281 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["authedLiked"], False)
|
||||
self.assertEqual(resp.json["likesCount"], 0)
|
||||
|
||||
def test_resolve_changes_discussion(self):
|
||||
self.login_default_user()
|
||||
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
self.proposal.changes_requested_discussion = True
|
||||
self.proposal.changes_requested_discussion_reason = 'test'
|
||||
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/resolve"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json['changesRequestedDiscussion'], False)
|
||||
self.assertIsNone(resp.json['changesRequestedDiscussionReason'])
|
||||
|
||||
proposal = Proposal.query.get(self.proposal.id)
|
||||
self.assertEqual(proposal.changes_requested_discussion, False)
|
||||
self.assertIsNone(proposal.changes_requested_discussion_reason)
|
||||
|
||||
def test_resolve_changes_discussion_wrong_status_fail(self):
|
||||
# resolve should fail if proposal is not in a DISCUSSION state
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
self.proposal.changes_requested_discussion = True
|
||||
self.proposal.changes_requested_discussion_reason = 'test'
|
||||
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/resolve"
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_resolve_changes_discussion_bad_proposal_fail(self):
|
||||
# resolve should fail if bad proposal id is provided
|
||||
self.login_default_user()
|
||||
bad_id = '111111111111'
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{bad_id}/resolve"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
def test_resolve_changes_discussion_no_changes_requested_fail(self):
|
||||
# resolve should fail if changes are not requested on the proposal
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
self.proposal.changes_requested_discussion = False
|
||||
self.proposal.changes_requested_discussion_reason = None
|
||||
|
||||
# resolve changes
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/resolve"
|
||||
)
|
||||
self.assert400(resp)
|
||||
|
||||
def test_make_proposal_live_draft(self):
|
||||
# user should be able to make live draft of a proposal
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
draft_resp = self.app.post(
|
||||
f"/api/v1/proposals/{self.proposal.id}/draft"
|
||||
)
|
||||
self.assertStatus(draft_resp, 201)
|
||||
self.assertIsNone(draft_resp.json['liveDraftId'])
|
||||
self.assertEqual(draft_resp.json['status'], ProposalStatus.LIVE_DRAFT)
|
||||
|
||||
proposal = Proposal.query.get(self.proposal.id)
|
||||
draft = Proposal.query.get(draft_resp.json['proposalId'])
|
||||
draft_id = draft.id
|
||||
|
||||
self.assertEqual(draft.live_draft_parent_id, proposal.id)
|
||||
self.assertEqual(proposal.live_draft, draft)
|
||||
|
||||
# live draft id should be included in the parent proposal json response
|
||||
proposal_resp = self.app.get(
|
||||
f"/api/v1/proposals/{self.proposal.id}"
|
||||
)
|
||||
self.assert200(proposal_resp)
|
||||
self.assertEqual(proposal_resp.json['liveDraftId'], draft_id)
|
||||
|
||||
# if endpoint is called again, the same live draft should be returned
|
||||
resp = self.app.post(
|
||||
f"/api/v1/proposals/{self.proposal.id}/draft"
|
||||
)
|
||||
self.assertStatus(resp, 201)
|
||||
self.assertEqual(resp.json['status'], ProposalStatus.LIVE_DRAFT)
|
||||
self.assertEqual(resp.json['proposalId'], draft_id)
|
||||
|
||||
# check milestones were copied
|
||||
|
||||
for i, ms in enumerate(draft_resp.json['milestones']):
|
||||
title_draft = ms['title']
|
||||
title_proposal = proposal_resp.json['milestones'][i]['title']
|
||||
|
||||
self.assertEqual(title_draft, title_proposal)
|
||||
|
||||
def test_make_proposal_live_draft_bad_status_fail(self):
|
||||
# making live draft should fail if not in a DISCUSSION status
|
||||
self.login_default_user()
|
||||
resp = self.app.post(
|
||||
f"/api/v1/proposals/{self.proposal.id}/draft"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
def test_publish_live_draft(self):
|
||||
# user should be able to publish live draft of a proposal
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# double check to make sure there are no proposal revisions
|
||||
self.assertEqual(len(self.proposal.revisions), 0)
|
||||
|
||||
# create live draft
|
||||
draft_resp = self.app.post(
|
||||
f"/api/v1/proposals/{self.proposal.id}/draft"
|
||||
)
|
||||
|
||||
# check the two proposals have been related correctly
|
||||
self.assertStatus(draft_resp, 201)
|
||||
self.assertNotEqual(draft_resp.json['proposalId'], self.proposal.id)
|
||||
draft = Proposal.query.get(draft_resp.json['proposalId'])
|
||||
draft_id = draft.id
|
||||
|
||||
# update live draft title
|
||||
new_draft_title = 'This is a test for live drafts!'
|
||||
draft.title = new_draft_title
|
||||
|
||||
# update live draft first milestone title
|
||||
new_milestone_title = 'This is a test renaming a milestone title'
|
||||
first_draft_milestone = draft.milestones[0]
|
||||
first_draft_milestone.title = new_milestone_title
|
||||
|
||||
# persist changes
|
||||
db.session.add(first_draft_milestone)
|
||||
db.session.add(draft)
|
||||
db.session.commit()
|
||||
|
||||
# publish live draft
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{draft_id}/publish/live"
|
||||
)
|
||||
self.assert200(resp)
|
||||
self.assertEqual(resp.json['proposalId'], self.proposal.id)
|
||||
|
||||
# check to see the changes have been copied to the proposal
|
||||
proposal = Proposal.query.get(self.proposal.id)
|
||||
self.assertEqual(proposal.title, new_draft_title)
|
||||
self.assertEqual(proposal.milestones[0].title, new_milestone_title)
|
||||
|
||||
# check the draft has been archived
|
||||
self.assertIsNone(proposal.live_draft)
|
||||
old_live_draft = Proposal.query.get(draft_id)
|
||||
self.assertEqual(old_live_draft.status, ProposalStatus.ARCHIVED)
|
||||
|
||||
# check the proposal revision and base snapshot was added
|
||||
self.assertEqual(len(self.proposal.revisions), 2)
|
||||
|
||||
def find_revision_with_index(revisions, index):
|
||||
return next((r for r in revisions if r.revision_index == index), None)
|
||||
|
||||
# check the base snapshot was created correctly
|
||||
base_revision = find_revision_with_index(self.proposal.revisions, 0)
|
||||
self.assertEqual(base_revision.revision_index, 0)
|
||||
self.assertEqual(base_revision.author, self.user)
|
||||
self.assertEqual(base_revision.proposal, self.proposal)
|
||||
self.assertIsNotNone(base_revision.proposal_archive_id)
|
||||
self.assertNotEqual(base_revision.proposal_archive_id, draft_id)
|
||||
self.assertEqual(len(json.loads(base_revision.changes)), 0)
|
||||
|
||||
# check the proposal revision was created correctly
|
||||
revision = find_revision_with_index(proposal.revisions, 1)
|
||||
self.assertEqual(revision.revision_index, 1)
|
||||
self.assertEqual(revision.author, self.user)
|
||||
self.assertEqual(revision.proposal, self.proposal)
|
||||
self.assertEqual(revision.proposal_archive_id, draft_id)
|
||||
self.assertEqual(len(json.loads(revision.changes)), 2)
|
||||
|
||||
def test_publish_live_draft_bad_status_fail(self):
|
||||
# publishing a live draft without a LIVE_DRAFT status should fail
|
||||
self.login_default_user()
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/publish/live"
|
||||
)
|
||||
self.assert403(resp)
|
||||
|
||||
def test_publish_live_draft_bad_parent_fail(self):
|
||||
# publishing a live draft without a valid parent should fail
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.LIVE_DRAFT
|
||||
db.session.add(self.proposal)
|
||||
db.session.commit()
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/publish/live"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
# publishing a live draft with an invalid parent should fail
|
||||
self.proposal.live_draft_parent_id = 111111111111
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/publish/live"
|
||||
)
|
||||
self.assert404(resp)
|
||||
|
||||
def get_proposal_revisions(self):
|
||||
# user should be able to publish live draft of a proposal
|
||||
self.login_default_user()
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# double check to make sure there are no proposal revisions
|
||||
self.assertEqual(len(self.proposal.revisions), 0)
|
||||
|
||||
# create live draft
|
||||
draft1_resp = self.app.post(
|
||||
f"/api/v1/proposals/{self.proposal.id}/draft"
|
||||
)
|
||||
|
||||
self.assertStatus(draft1_resp, 201)
|
||||
draft1 = Proposal.query.get(draft1_resp.json['proposalId'])
|
||||
draft1_id = draft1.id
|
||||
|
||||
# set new title and save
|
||||
draft1.title = 'draft 1 title'
|
||||
db.session.add(draft1)
|
||||
db.session.commit()
|
||||
|
||||
# publish live draft1
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{draft1_id}/publish/live"
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
# make sure proposal revision was created as expected
|
||||
self.assertEqual(len(self.proposal.revisions), 1)
|
||||
|
||||
# create second live draft
|
||||
draft2_resp = self.app.post(
|
||||
f"/api/v1/proposals/{self.proposal.id}/draft"
|
||||
)
|
||||
self.assertStatus(draft2_resp, 201)
|
||||
draft2 = Proposal.query.get(draft2_resp.json['proposalId'])
|
||||
draft2_id = draft2.id
|
||||
|
||||
# set new title and save
|
||||
draft2.title = 'draft 2 title'
|
||||
db.session.add(draft2)
|
||||
db.session.commit()
|
||||
|
||||
# publish live draft2
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{draft2_id}/publish/live"
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
# make sure proposal revision was created as expected
|
||||
self.assertEqual(len(self.proposal.revisions), 2)
|
||||
|
||||
# finally, call the revisions API and make sure it returns the two revisions as expected
|
||||
revisions_resp = self.app.get(
|
||||
f"/api/v1/proposals/{self.proposal.id}/revisions"
|
||||
)
|
||||
revisions = revisions_resp.json
|
||||
self.assertEqual(len(revisions), 2)
|
||||
|
||||
revision1 = revisions[0]
|
||||
revision2 = revisions[1]
|
||||
|
||||
# check revision 1 data
|
||||
self.assertEqual(revision1["proposalId"], self.proposal.id)
|
||||
self.assertEqual(revision1["proposalArchiveId"], draft1_id)
|
||||
self.assertGreater(len(revision1["changes"]), 0)
|
||||
self.assertEqual(revision1["revisionIndex"], 0)
|
||||
|
||||
# check revision 2 data
|
||||
self.assertEqual(revision2["proposalId"], self.proposal.id)
|
||||
self.assertEqual(revision2["proposalArchiveId"], draft2_id)
|
||||
self.assertGreater(len(revision2["changes"]), 0)
|
||||
self.assertEqual(revision2["revisionIndex"], 1)
|
||||
|
|
|
@ -0,0 +1,226 @@
|
|||
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
import json
|
||||
from grant.proposal.models import Proposal, ProposalRevision
|
||||
from grant.utils.enums import ProposalChange
|
||||
from ..test_data import test_team
|
||||
|
||||
|
||||
test_milestones_a = [
|
||||
{
|
||||
"title": "first milestone a",
|
||||
"content": "content a",
|
||||
"daysEstimated": "30",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
},
|
||||
{
|
||||
"title": "second milestone a",
|
||||
"content": "content a",
|
||||
"daysEstimated": "10",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
},
|
||||
{
|
||||
"title": "third milestone a",
|
||||
"content": "content a",
|
||||
"daysEstimated": "20",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
},
|
||||
{
|
||||
"title": "fourth milestone a",
|
||||
"content": "content a",
|
||||
"daysEstimated": "30",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
}
|
||||
]
|
||||
|
||||
test_proposal_a = {
|
||||
"team": test_team,
|
||||
"content": "## My Proposal A",
|
||||
"title": "Give Me Money A",
|
||||
"brief": "$$$ A",
|
||||
"milestones": test_milestones_a,
|
||||
"target": "200",
|
||||
"payoutAddress": "123",
|
||||
}
|
||||
|
||||
test_milestones_b = [
|
||||
{
|
||||
"title": "first milestone b",
|
||||
"content": "content b",
|
||||
"daysEstimated": "30",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": True
|
||||
},
|
||||
{
|
||||
"title": "second milestone b",
|
||||
"content": "content b",
|
||||
"daysEstimated": "40",
|
||||
"payoutPercent": "75",
|
||||
"immediatePayout": False
|
||||
}
|
||||
]
|
||||
|
||||
test_proposal_b = {
|
||||
"team": test_team,
|
||||
"content": "## My Proposal B",
|
||||
"title": "Give Me Money B",
|
||||
"brief": "$$$ B",
|
||||
"milestones": test_milestones_b,
|
||||
"target": "100",
|
||||
"payoutAddress": "123",
|
||||
}
|
||||
|
||||
test_proposal_c = {
|
||||
"team": test_team,
|
||||
"content": "## My Proposal C",
|
||||
"title": "Give Me Money C",
|
||||
"brief": "$$$ C",
|
||||
"milestones": test_milestones_b,
|
||||
"target": "100",
|
||||
"payoutAddress": "123",
|
||||
}
|
||||
|
||||
test_proposal_d = {
|
||||
"team": test_team,
|
||||
"content": "## My Proposal B",
|
||||
"title": "Give Me Money B",
|
||||
"brief": "$$$ B",
|
||||
"milestones": test_milestones_b,
|
||||
"target": "200",
|
||||
"payoutAddress": "123",
|
||||
}
|
||||
|
||||
|
||||
class TestProposalMethods(BaseProposalCreatorConfig):
|
||||
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)
|
||||
return proposal_id
|
||||
|
||||
def validate_changes(self, changes, expected_change, expected_milestone_index=None):
|
||||
if expected_milestone_index is not None:
|
||||
change = {"type": expected_change, "milestone_index": expected_milestone_index}
|
||||
else:
|
||||
change = {"type": expected_change}
|
||||
|
||||
self.assertTrue(change in changes)
|
||||
|
||||
def test_calculate_milestone_changes_no_changes(self):
|
||||
old_proposal_id = self.init_proposal(test_proposal_a)
|
||||
new_proposal_id = self.init_proposal(test_proposal_a)
|
||||
old_proposal = Proposal.query.get(old_proposal_id)
|
||||
new_proposal = Proposal.query.get(new_proposal_id)
|
||||
|
||||
changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones)
|
||||
self.assertEqual(len(changes), 0)
|
||||
|
||||
def test_calculate_milestone_changes_a_to_b(self):
|
||||
old_proposal_id = self.init_proposal(test_proposal_a)
|
||||
new_proposal_id = self.init_proposal(test_proposal_b)
|
||||
old_proposal = Proposal.query.get(old_proposal_id)
|
||||
new_proposal = Proposal.query.get(new_proposal_id)
|
||||
|
||||
changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones)
|
||||
|
||||
print(changes)
|
||||
|
||||
# going from milestones a to b, there should be 9 total changes
|
||||
self.assertEqual(len(changes), 9)
|
||||
|
||||
# the following change types should be detected
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_TITLE, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_CONTENT, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_IMMEDIATE_PAYOUT, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_TITLE, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_CONTENT, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_DAYS, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_PERCENT, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_REMOVE, 2)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_REMOVE, 3)
|
||||
|
||||
def test_calculate_milestone_changes_b_to_a(self):
|
||||
old_proposal_id = self.init_proposal(test_proposal_b)
|
||||
new_proposal_id = self.init_proposal(test_proposal_a)
|
||||
old_proposal = Proposal.query.get(old_proposal_id)
|
||||
new_proposal = Proposal.query.get(new_proposal_id)
|
||||
|
||||
changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones)
|
||||
|
||||
print(changes)
|
||||
|
||||
# going from milestones b to a, there should be 9 total changes
|
||||
self.assertEqual(len(changes), 9)
|
||||
|
||||
# the following change types should be detected
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_TITLE, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_CONTENT, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_IMMEDIATE_PAYOUT, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_TITLE, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_CONTENT, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_DAYS, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_PERCENT, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_ADD, 2)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_ADD, 3)
|
||||
|
||||
def test_calculate_proposal_changes_c_to_d(self):
|
||||
old_proposal_id = self.init_proposal(test_proposal_c)
|
||||
new_proposal_id = self.init_proposal(test_proposal_d)
|
||||
old_proposal = Proposal.query.get(old_proposal_id)
|
||||
new_proposal = Proposal.query.get(new_proposal_id)
|
||||
|
||||
changes = ProposalRevision.calculate_proposal_changes(old_proposal, new_proposal)
|
||||
|
||||
print(changes)
|
||||
|
||||
# going from proposal c to d, there should be 4 total changes
|
||||
self.assertEqual(len(changes), 4)
|
||||
|
||||
# the following change types should be detected
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_CONTENT)
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_TITLE)
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_BRIEF)
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_TARGET)
|
||||
|
||||
def test_calculate_proposal_changes_d_to_a(self):
|
||||
old_proposal_id = self.init_proposal(test_proposal_d)
|
||||
new_proposal_id = self.init_proposal(test_proposal_a)
|
||||
old_proposal = Proposal.query.get(old_proposal_id)
|
||||
new_proposal = Proposal.query.get(new_proposal_id)
|
||||
|
||||
changes = ProposalRevision.calculate_proposal_changes(old_proposal, new_proposal)
|
||||
|
||||
print(changes)
|
||||
|
||||
# going from proposal d to a, there should be 4 total changes
|
||||
self.assertEqual(len(changes), 12)
|
||||
|
||||
# the following proposal change types should be detected
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_CONTENT)
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_TITLE)
|
||||
self.validate_changes(changes, ProposalChange.PROPOSAL_EDIT_BRIEF)
|
||||
|
||||
# the following milestone change types should be detected
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_TITLE, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_CONTENT, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_IMMEDIATE_PAYOUT, 0)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_TITLE, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_CONTENT, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_DAYS, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_EDIT_PERCENT, 1)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_ADD, 2)
|
||||
self.validate_changes(changes, ProposalChange.MILESTONE_ADD, 3)
|
|
@ -5,7 +5,7 @@ from mock import patch
|
|||
from ..test_data import mock_blockchain_api_requests
|
||||
|
||||
address_json = {
|
||||
"address": "valid_address"
|
||||
"address": "zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk"
|
||||
}
|
||||
|
||||
view_key_json = {
|
||||
|
@ -15,8 +15,7 @@ view_key_json = {
|
|||
|
||||
class TestProposalInviteAPI(BaseProposalCreatorConfig):
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_set_proposal_tip_address(self, mock_get):
|
||||
def test_set_proposal_tip_address(self):
|
||||
self.login_default_user()
|
||||
res = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/tips",
|
||||
|
@ -27,8 +26,7 @@ class TestProposalInviteAPI(BaseProposalCreatorConfig):
|
|||
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):
|
||||
def test_set_proposal_tip_view_key(self):
|
||||
self.login_default_user()
|
||||
res = self.app.put(
|
||||
f"/api/v1/proposals/{self.proposal.id}/tips",
|
||||
|
|
|
@ -181,8 +181,7 @@ class TestTaskAPI(BaseProposalCreatorConfig):
|
|||
|
||||
@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):
|
||||
def test_milestone_deadline(self, mock_datetime, mock_send_email):
|
||||
tasks = Task.query.filter_by(completed=False).all()
|
||||
self.assertEqual(len(tasks), 0)
|
||||
|
||||
|
@ -199,8 +198,8 @@ class TestTaskAPI(BaseProposalCreatorConfig):
|
|||
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
# proposal needs to be DISCUSSION
|
||||
self.proposal.status = ProposalStatus.DISCUSSION
|
||||
|
||||
# approve proposal with funding
|
||||
resp = self.app.put(
|
||||
|
|
|
@ -45,7 +45,7 @@ test_proposal = {
|
|||
"milestones": milestones,
|
||||
"category": Category.ACCESSIBILITY,
|
||||
"target": "12345",
|
||||
"payoutAddress": "123",
|
||||
"payoutAddress": "zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk",
|
||||
"deadlineDuration": 100
|
||||
}
|
||||
|
||||
|
|
|
@ -386,9 +386,8 @@ class TestUserAPI(BaseUserConfig):
|
|||
)
|
||||
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"
|
||||
def test_put_user_settings_tip_jar_address(self):
|
||||
address = "zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk"
|
||||
|
||||
self.login_default_user()
|
||||
resp = self.app.put(
|
||||
|
@ -401,8 +400,7 @@ class TestUserAPI(BaseUserConfig):
|
|||
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):
|
||||
def test_put_user_settings_tip_jar_view_key(self):
|
||||
view_key = "view_key"
|
||||
|
||||
self.login_default_user()
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
|
||||
from ..config import BaseTestConfig
|
||||
from grant.utils.validate import is_z_address_valid
|
||||
|
||||
|
||||
good_addresses = [
|
||||
"zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk",
|
||||
"zs15xh8vjmlnqknztlgs7s8cu9wlj4rnd0m7hpyjwkjehalhlgwhfkvqnp8ycdpvhzfwywnc9r7yqs",
|
||||
"zs1h6u0s750jtqthyf5dcck5xgvhadfsvp6vj0meem9y8ledq5r7gc6fnrgeseeprnmkzfwk8x4erc",
|
||||
"zs1ke0qynx5hvx6rkqk2cg6eg2cdc7kn6ugs7ye0907pr6d09d6dsmzcxzhpawpcj73nk4svc6ualm",
|
||||
"zs1m576g2evlaem403jlam3s08aned3srg5cvwphm4w7jjxylxsulzegusjpjxstau0klzckhld4s4",
|
||||
"zs1su7cu6kgxp8luxs4rvvy4rd42caau7jkxmaufmj5ny6e8nuctuw4cptepjff6m8kvnsdywt733k",
|
||||
"zs1qyk7tqzgreu9qsxs6ze0ptgm7tlr9j9e2dumqv5en7whhwwznscll49nteghtegz4mvtj7nt304",
|
||||
"zs15vku5xmethefjarje8e9ee82znhtzlyesskee5fz0kge3m4sealp3xaqdx2se6gj2e2uzw5amnz",
|
||||
"zs1szg7qncv0ywjppttagxecetlr5gler7uwcqdcfrshugh27l8mux209ff243fy60svphzvz8fss0",
|
||||
"zs1rdk0xkrk2mxjge33su9fshxzrdpg5hz25zutl82l6gjyz5ph5lw9hhtd759hdg8qqttyxpqudxc"
|
||||
|
||||
]
|
||||
|
||||
bad_addresses = [
|
||||
None,
|
||||
False,
|
||||
3,
|
||||
"",
|
||||
"cs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk",
|
||||
"zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9ddhptdtkltzlcqar994jg2ju3j9k85zz",
|
||||
"zs15xh8vjmlnqknztlgs7s8cu9wlj4r0d0m7hpyjwkjehalhlgwhfkvqnp8ycdpvhzfwywnc9r7yqs",
|
||||
"zs1h6u0s750jtqthyf5dcck5xgvhadfsvp6vj0eeem9y8ledq5r7gc6fnrgeseeprnmkzfwk8x4erc",
|
||||
"zs1ke0qynx5hvx6rkqk2cg6eg2cdc7kn6ugs7yd0907pr6d09d6dsmzcxzhpawpcj73nk4svc6ualm",
|
||||
"zs1m576g2evlaem403jlam3s08aned3srg5cvwph4w7jjxylxsulzegusjpjxstau0klzckhld4s4",
|
||||
"zs1su7cu6kgxp8luxs4rvvy4rd42caau7jkxmauhfmj5ny6e8nuctuw4cptepjff6m8kvnsdywt733k",
|
||||
"zs1qyk7tqzgreu9qsxs6ze0ptgm7tlr9j9e2duv5en7whhwwznscll49nteghtegz4mvtj7nt304",
|
||||
"zs15vku5xmethefjarje8e9ee82znhtzlzwyesskee5fz0kge3m4sealp3xaqdx2se6gj2e2uzw5amnz",
|
||||
"zs1szg7qncv0ywjppttagxecetlr5gler70wcqdcfrshugh27l8mux209ff243fy60svphzvz8fss0",
|
||||
"zs1rdk0xkrk2mxjge33su9fshxzrdpg5hz25zutl82l6gjyz5ph5tw9hhtd759hdg8qqttyxpqudxc"
|
||||
]
|
||||
|
||||
|
||||
class TestValidate(BaseTestConfig):
|
||||
|
||||
def test_good_addresses_should_be_valid(self):
|
||||
for addr in good_addresses:
|
||||
is_valid = is_z_address_valid(addr)
|
||||
self.assertTrue(is_valid)
|
||||
|
||||
def test_bad_addresses_should_be_invalid(self):
|
||||
for addr in bad_addresses:
|
||||
is_valid = is_z_address_valid(addr)
|
||||
self.assertFalse(is_valid)
|
|
@ -1,48 +0,0 @@
|
|||
# Webhooks Config
|
||||
WEBHOOK_URL="http://localhost:5000/api/v1"
|
||||
|
||||
# REST Server Config
|
||||
PORT="5051"
|
||||
|
||||
# Zcash Node (Defaults are for regtest)
|
||||
ZCASH_NODE_URL="http://localhost:18232"
|
||||
ZCASH_NODE_USERNAME="zcash_user"
|
||||
ZCASH_NODE_PASSWORD="zcash_password"
|
||||
MINIMUM_BLOCK_CONFIRMATIONS="6"
|
||||
|
||||
# Shared Server Config, run `yarn genkey` to generate
|
||||
API_SECRET_HASH=""
|
||||
API_SECRET_KEY=""
|
||||
|
||||
############################ ADDRESS DERIVATION ############################
|
||||
# You should only set one OR the other. The former will generate addresses #
|
||||
# using Bip32 address derivation. The latter uses BitGo's API. If you set #
|
||||
# both, BitGo takes precedence. API key should ONLY HAVE VIEW ACCESS! #
|
||||
############################################################################
|
||||
|
||||
# BITGO_WALLET_ID=""
|
||||
# BITGO_ACCESS_TOKEN=""
|
||||
|
||||
### OR ###
|
||||
|
||||
# BIP32_XPUB=""
|
||||
|
||||
############################################################################
|
||||
|
||||
|
||||
# Addresses, run `yarn genaddress` to get sprout information
|
||||
SPROUT_ADDRESS=""
|
||||
SPROUT_VIEWKEY=""
|
||||
|
||||
# Block heights to fall back on for starting our scan
|
||||
MAINNET_START_BLOCK="464000"
|
||||
TESTNET_START_BLOCK="390000"
|
||||
|
||||
# Sentry URL
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Logging level
|
||||
LOG_LEVEL="debug"
|
||||
|
||||
# Fixie proxy URL for BitGo requests (optional)
|
||||
# FIXIE_URL=""
|
|
@ -1,69 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.testnet
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# zcash
|
||||
.zcash/regtest
|
||||
.zcash/export
|
||||
|
||||
# Output
|
||||
dist
|
|
@ -1,15 +0,0 @@
|
|||
regtest=1
|
||||
server=1
|
||||
|
||||
rpcuser=zcash_user
|
||||
rpcpassword=zcash_password
|
||||
rpcport=18232
|
||||
rpcconnect=127.0.0.1
|
||||
|
||||
txindex=1
|
||||
experimentalfeatures=1
|
||||
developersapling=1
|
||||
nuparams=5ba81b19:98
|
||||
nuparams=76b809bb:100
|
||||
|
||||
exportdir=.zcash/export
|
|
@ -1 +0,0 @@
|
|||
web: yarn start
|
|
@ -1,35 +0,0 @@
|
|||
# Blockchain Watcher
|
||||
|
||||
Creates a websocket server that reads and reports on the activity of the Zcash
|
||||
blockchain. Communicates with a node over RPC.
|
||||
|
||||
## Development
|
||||
|
||||
### First time setup (Only do once)
|
||||
|
||||
1. Run `yarn` to fetch all dependencies
|
||||
2. Copy `.env.example` to `.env`
|
||||
3. Run a zcashd regtest node with the following command
|
||||
```
|
||||
zcashd -daemon -datadir=./.zcash -wallet=offline.dat
|
||||
```
|
||||
4. Mine at least 100 blocks with `zcash-cli generate 101` to activate Overwinter and Sapling
|
||||
4. Run `yarn genkey` and copy the environment variables into `.env`
|
||||
6. Run `yarn genaddress` and copy the environment variables into `.env`
|
||||
|
||||
### After all that...
|
||||
|
||||
1. Run zcashd (without the offline wallet)
|
||||
```
|
||||
zcashd -daemon -datadir=./.zcash
|
||||
```
|
||||
2. Run the websocket server with
|
||||
```
|
||||
yarn dev
|
||||
```
|
||||
|
||||
See [the Wiki page](https://github.com/dternyak/zcash-grant-system/wiki/Running-ZCash-Regtest) for more information on running a regtest node.
|
||||
|
||||
## Deployment
|
||||
|
||||
TBD
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"name": "zcash-blockchain-watcher",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "8.13.0"
|
||||
},
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "yarn run build:live",
|
||||
"build": "tsc -p .",
|
||||
"build:live": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/dev/index.ts",
|
||||
"genkey": "yarn run ts-node src/bin/genkey.ts",
|
||||
"genaddress": "yarn run ts-node src/bin/genaddress.ts",
|
||||
"test": "NODE_ENV=test yarn ts-mocha --no-colors src/**/*.spec.ts",
|
||||
"heroku-postbuild": "yarn build",
|
||||
"start": "node ./dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "1.17.0",
|
||||
"@types/expect": "^1.20.3",
|
||||
"@types/express": "4.16.0",
|
||||
"@types/mocha": "^5.2.5",
|
||||
"@types/node": "^10.12.11",
|
||||
"isomorphic-ws": "^4.0.1",
|
||||
"mocha": "^5.2.0",
|
||||
"nodemon": "^1.18.7",
|
||||
"ts-mocha": "^2.0.0",
|
||||
"ts-node": "^7.0.1",
|
||||
"typescript": "^3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "4.6.4",
|
||||
"@types/cors": "2.8.4",
|
||||
"@types/dotenv": "^6.1.0",
|
||||
"@types/ws": "^6.0.1",
|
||||
"axios": "0.18.0",
|
||||
"bitgo": "4.48.1",
|
||||
"body-parser": "1.18.3",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "^6.1.0",
|
||||
"express": "4.16.4",
|
||||
"redux": "4.0.1",
|
||||
"stdrpc": "1.0.0",
|
||||
"winston": "3.1.0",
|
||||
"ws": "^6.1.2",
|
||||
"zcash-bitcore-lib": "0.13.20-rc3"
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import node from '../node';
|
||||
import { extractErrMessage } from '../util';
|
||||
|
||||
async function printAddressAndKey() {
|
||||
try {
|
||||
let address = await node.z_getnewaddress('sprout');
|
||||
const viewkey = await node.z_exportviewingkey(address);
|
||||
|
||||
console.log("\nCopy these to your .env\n");
|
||||
console.log(`SPROUT_ADDRESS="${address}"`);
|
||||
console.log(`SPROUT_VIEWKEY="${viewkey}"\n`);
|
||||
} catch(err) {
|
||||
console.error(extractErrMessage(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
printAddressAndKey();
|
|
@ -1,7 +0,0 @@
|
|||
import { generateApiKey } from "../util";
|
||||
|
||||
const result = generateApiKey();
|
||||
|
||||
console.log("\nCopy both to your .env, and API_SECRET_KEY to your client environment.\n");
|
||||
console.log(` API_SECRET_KEY=${result.key}`);
|
||||
console.log(` API_SECRET_HASH=${result.hash}\n`);
|
|
@ -1,57 +0,0 @@
|
|||
import { BitGo, Wallet } from 'bitgo';
|
||||
import bitcore from "zcash-bitcore-lib";
|
||||
import env from './env';
|
||||
import log from './log';
|
||||
import { getNetwork } from './node';
|
||||
|
||||
let bitgoWallet: Wallet;
|
||||
|
||||
export async function initBitGo() {
|
||||
if (!env.BITGO_ACCESS_TOKEN || !env.BITGO_WALLET_ID) {
|
||||
log.info('BITGO environment variables not set, nooping initBitGo');
|
||||
return;
|
||||
}
|
||||
|
||||
// Assert that we're on mainnet
|
||||
const network = getNetwork();
|
||||
if (network !== bitcore.Networks.mainnet) {
|
||||
throw new Error(`BitGo cannot be used on anything but mainnet, connected node is ${network}`);
|
||||
}
|
||||
|
||||
const proxy = env.FIXIE_URL || undefined;
|
||||
const bitgo = new BitGo({
|
||||
env: 'prod', // Non-prod ZEC is not supported
|
||||
accessToken: env.BITGO_ACCESS_TOKEN,
|
||||
proxy,
|
||||
});
|
||||
bitgoWallet = await bitgo.coin('zec').wallets().get({ id: env.BITGO_WALLET_ID });
|
||||
log.info(`Initialized BitGo wallet "${bitgoWallet.label()}"`);
|
||||
if (proxy) {
|
||||
log.info(`Proxying BitGo requests through ${proxy}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContributionAddress(id: number) {
|
||||
if (!bitgoWallet) {
|
||||
throw new Error('Must run initBitGo before getContributionAddress');
|
||||
}
|
||||
|
||||
// Attempt to fetch first
|
||||
const label = `Contribution #${id}`;
|
||||
const res = await bitgoWallet.addresses({ labelContains: label });
|
||||
if (res.addresses.length) {
|
||||
if (res.addresses.length > 1) {
|
||||
log.warn(`Contribution ${id} has ${res.addresses.length} associated with it. Using the first one (${res.addresses[0].address})`);
|
||||
}
|
||||
return res.addresses[0].address;
|
||||
}
|
||||
|
||||
// Create a new one otherwise
|
||||
const createRes = await bitgoWallet.createAddress({ label });
|
||||
log.info(`Generate new address for contribution ${id}`);
|
||||
return createRes.address;
|
||||
}
|
||||
|
||||
function generateLabel(id: number) {
|
||||
return `Contribution #${id}`;
|
||||
}
|
|
@ -1,272 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ts-node-websocket-microservice DEV</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
|
||||
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
|
||||
<script
|
||||
src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"
|
||||
integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #FAFAFA;
|
||||
}
|
||||
#container {
|
||||
display: flex;
|
||||
}
|
||||
#container .panel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
margin: 2rem 1rem;
|
||||
padding: 2rem;
|
||||
height: calc(100vh - 4rem);
|
||||
background: #FFF;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem;
|
||||
background: rgb(255, 255, 255);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.header .title {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
line-height: 3rem;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.panel .content {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 6rem 0;
|
||||
}
|
||||
.panel .content pre {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.message {
|
||||
margin: 1rem 1rem;
|
||||
}
|
||||
.message.sent {
|
||||
color: #66C;
|
||||
}
|
||||
.message.received {
|
||||
color: #6C6;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1rem;
|
||||
background: rgb(255, 255, 255);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.controls > * {
|
||||
width: auto;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.controls > input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<!-- WEBSOCKET -->
|
||||
<div id="websocket" class="panel">
|
||||
<div id="status" class="header">
|
||||
<span id="disconnected" class="when-disconnected label label-default"
|
||||
>disconnected</span
|
||||
>
|
||||
<span
|
||||
id="connected"
|
||||
style="display:none"
|
||||
class="when-connected label label-success"
|
||||
>connected</span
|
||||
>
|
||||
<h2 class="title">Websocket</h2>
|
||||
<button
|
||||
id="doOpen"
|
||||
type="button"
|
||||
class="when-disconnected btn btn-success btn-sm"
|
||||
>
|
||||
open
|
||||
</button>
|
||||
<button
|
||||
id="doClose"
|
||||
style="display: none"
|
||||
type="button"
|
||||
class="when-connected btn btn-danger btn-sm"
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
<div id="ws-responses" class="content"></div>
|
||||
<form id="ws-controls" class="controls">
|
||||
<select id="ws-type" class="form-control">
|
||||
<option value="contribution:disclosure">
|
||||
contribution:disclosure
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
id="ws-payload"
|
||||
class="form-control"
|
||||
type="text"
|
||||
placeholder="Payload JSON, must be valid JSON"
|
||||
disabled
|
||||
/>
|
||||
<button class="btn btn-primary">
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
// let socket = null;
|
||||
|
||||
// const addMessage = (type, payload) => {
|
||||
// $("#ws-responses").prepend(`
|
||||
// <div class="message ${type}">
|
||||
// <div class="message-info">
|
||||
// ${type} at ${new Date().toLocaleTimeString()}
|
||||
// </div>
|
||||
// <pre>${JSON.stringify(payload, null, 2)}</pre>
|
||||
// </div>
|
||||
// `);
|
||||
// };
|
||||
|
||||
// const createWebSocket = () => {
|
||||
// socket = new WebSocket("ws://localhost:$$WS_PORT", "$$API_SECRET_KEY");
|
||||
// $("#ws-payload").prop('disabled', false);
|
||||
// socket.addEventListener("open", function(event) {
|
||||
// $(".when-connected").show();
|
||||
// $(".when-disconnected").hide();
|
||||
// });
|
||||
// socket.addEventListener("close", function(event) {
|
||||
// $(".when-connected").hide();
|
||||
// $(".when-disconnected").show();
|
||||
// });
|
||||
// socket.addEventListener("message", function(event) {
|
||||
// const json = JSON.parse(event.data);
|
||||
// console.log("Message from server ", event.data);
|
||||
// addMessage('received', json);
|
||||
// });
|
||||
// };
|
||||
|
||||
// $("#ws-controls").on('submit', (ev) => {
|
||||
// ev.preventDefault();
|
||||
// const type = $("#ws-type").val();
|
||||
// const payload = $("#ws-payload").val();
|
||||
// try {
|
||||
// const message = {
|
||||
// type: type,
|
||||
// payload: JSON.parse(payload),
|
||||
// };
|
||||
// socket.send(JSON.stringify(message));
|
||||
// console.log("Sending message to server:", message);
|
||||
// addMessage('sent', message);
|
||||
// } catch(err) {
|
||||
// alert('Error: ' + err.message);
|
||||
// }
|
||||
// });
|
||||
|
||||
// $("#ws-close").click(() => {
|
||||
// socket.close();
|
||||
// });
|
||||
|
||||
// $("#ws-open").click(() => {
|
||||
// socket && socket.close();
|
||||
// createWebSocket();
|
||||
// });
|
||||
// createWebSocket();
|
||||
</script>
|
||||
|
||||
|
||||
<!-- REST API -->
|
||||
<div id="rest-api" class="panel">
|
||||
<div class="header">
|
||||
<h2 class="title">REST API</h2>
|
||||
</div>
|
||||
<div id="rest-response" class="content">
|
||||
<pre>Send a request to view the response</pre>
|
||||
</div>
|
||||
<form id="rest-controls" class="controls">
|
||||
<select id="rest-endpoint" class="form-control">
|
||||
<option value="GET /contribution/addresses">
|
||||
GET /contribution/addresses
|
||||
</option>
|
||||
<option value="POST /contribution/disclosure">
|
||||
POST /contribution/disclosure
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
id="rest-payload"
|
||||
class="form-control"
|
||||
type="text"
|
||||
placeholder="Payload JSON, must be valid JSON"
|
||||
/>
|
||||
<button class="btn btn-primary">
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
const renderResponse = (res) => {
|
||||
$("#rest-response").html(`<pre>${JSON.stringify(res, null, 2)}</pre>`);
|
||||
};
|
||||
|
||||
const sendRequest = (path, method, data) => {
|
||||
const base = 'http://localhost:$$REST_PORT';
|
||||
let query;
|
||||
let body;
|
||||
$.ajax(`${base}${path}`, {
|
||||
method,
|
||||
data,
|
||||
headers: { 'Authorization': '$$API_SECRET_KEY' },
|
||||
})
|
||||
.then(res => renderResponse(res))
|
||||
.catch(err => renderResponse(err));
|
||||
};
|
||||
|
||||
$("#rest-api").on('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
const endpoint = $("#rest-endpoint").val();
|
||||
const payloadJson = $("#rest-payload").val();
|
||||
try {
|
||||
const [method, path] = endpoint.split(' ');
|
||||
const payload = JSON.parse(payloadJson);
|
||||
sendRequest(path, method, payload);
|
||||
} catch(err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,28 +0,0 @@
|
|||
import http from "http";
|
||||
import fs from "fs";
|
||||
import env from "../env";
|
||||
import log from "../log";
|
||||
|
||||
import "../index";
|
||||
|
||||
const hostname = "127.0.0.1";
|
||||
const port = 3050;
|
||||
|
||||
http.createServer(function(request, response) {
|
||||
fs.readFile("./src/dev/index.html", function(err, html) {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
response.writeHead(200, { "Content-Type": "text/html" });
|
||||
response.write(
|
||||
html
|
||||
.toString()
|
||||
.replace(/\$\$API_SECRET_KEY/g, env.API_SECRET_KEY || "")
|
||||
.replace(/\$\$REST_PORT/g, env.PORT || "")
|
||||
);
|
||||
response.end();
|
||||
});
|
||||
})
|
||||
.listen(port, hostname, () => {
|
||||
log.info(`Devtool running at http://${hostname}:${port}/`);
|
||||
});
|
|
@ -1,72 +0,0 @@
|
|||
import dotenv from "dotenv";
|
||||
dotenv.load();
|
||||
|
||||
// Maps to .env.example variables, plus any node ones we use
|
||||
// fill in sensible defaults, falsy values will throw if not set
|
||||
const DEFAULTS = {
|
||||
NODE_ENV: "development",
|
||||
LOG_LEVEL: "info",
|
||||
|
||||
API_SECRET_HASH: "",
|
||||
API_SECRET_KEY: "",
|
||||
|
||||
WEBHOOK_URL: "",
|
||||
PORT: "5051",
|
||||
|
||||
ZCASH_NODE_URL: "",
|
||||
ZCASH_NODE_USERNAME: "",
|
||||
ZCASH_NODE_PASSWORD: "",
|
||||
MINIMUM_BLOCK_CONFIRMATIONS: "6",
|
||||
|
||||
BITGO_WALLET_ID: "",
|
||||
BITGO_ACCESS_TOKEN: "",
|
||||
|
||||
BIP32_XPUB: "",
|
||||
|
||||
SPROUT_ADDRESS: "",
|
||||
SPROUT_VIEWKEY: "",
|
||||
|
||||
MAINNET_START_BLOCK: "464000",
|
||||
TESTNET_START_BLOCK: "390000",
|
||||
|
||||
SENTRY_DSN: "",
|
||||
FIXIE_URL: "",
|
||||
};
|
||||
|
||||
const OPTIONAL: { [key: string]: undefined | boolean } = {
|
||||
BITGO_WALLET_ID: true,
|
||||
BITGO_ACCESS_TOKEN: true,
|
||||
BIP32_XPUB: true,
|
||||
FIXIE_URL: true,
|
||||
// NOTE: Remove these from optional when sapling is ready
|
||||
SPROUT_ADDRESS: true,
|
||||
SPROUT_VIEWKEY: true,
|
||||
}
|
||||
|
||||
type CustomEnvironment = typeof DEFAULTS;
|
||||
|
||||
// ignore when testing
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
// Set environment variables, throw on missing required ones
|
||||
Object.entries(DEFAULTS).forEach(([k, v]) => {
|
||||
if (!process.env[k]) {
|
||||
const defVal = (DEFAULTS as any)[k];
|
||||
if (defVal) {
|
||||
console.info(`Using default environment variable ${k}="${defVal}"`);
|
||||
process.env[k] = defVal;
|
||||
} else if (!OPTIONAL[k]) {
|
||||
throw new Error(`Missing required environment variable ${k}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure we have either xpub or bitgo, and warn if we have both
|
||||
if (!process.env.BIP32_XPUB && (!process.env.BITGO_WALLET_ID || !process.env.BITGO_ACCESS_TOKEN)) {
|
||||
throw new Error('Either BIP32_XPUB or BITGO_* environment variables required, missing both');
|
||||
}
|
||||
if (process.env.BIP32_XPUB && process.env.BITGO_WALLET_ID) {
|
||||
console.info('BIP32_XPUB and BITGO environment variables set, BIP32_XPUB will be ignored');
|
||||
}
|
||||
}
|
||||
|
||||
export default (process.env as any) as CustomEnvironment;
|
|
@ -1,38 +0,0 @@
|
|||
import * as Sentry from "@sentry/node";
|
||||
import * as Webhooks from "./webhooks";
|
||||
import * as RestServer from "./server";
|
||||
import { initNode } from "./node";
|
||||
import { initBitGo } from "./bitgo";
|
||||
import { extractErrMessage } from "./util";
|
||||
import env from "./env";
|
||||
import log from "./log";
|
||||
|
||||
async function start() {
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
environment: env.NODE_ENV,
|
||||
});
|
||||
}
|
||||
|
||||
log.info("============== Starting services ==============");
|
||||
await initNode();
|
||||
await initBitGo();
|
||||
await RestServer.start();
|
||||
Webhooks.start();
|
||||
log.info("===============================================");
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
log.info('Shutting down services...');
|
||||
Webhooks.exit();
|
||||
RestServer.exit();
|
||||
log.info('Exiting!');
|
||||
process.exit();
|
||||
});
|
||||
|
||||
start().catch(err => {
|
||||
Sentry.captureException(err);
|
||||
log.error(`Unexpected error while starting blockchain watcher: ${extractErrMessage(err)}`);
|
||||
process.exit(1);
|
||||
});
|
|
@ -1,16 +0,0 @@
|
|||
import winston from 'winston';
|
||||
import env from './env';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'MM/DD/YY HH:mm:ss' }),
|
||||
winston.format.printf(i => `[${i.timestamp}] [${i.level}]: ${i.message}`),
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
],
|
||||
});
|
||||
|
||||
export default logger;
|
|
@ -1,254 +0,0 @@
|
|||
import stdrpc from "stdrpc";
|
||||
import bitcore from "zcash-bitcore-lib";
|
||||
import { captureException } from "@sentry/node";
|
||||
import env from "./env";
|
||||
import log from "./log";
|
||||
import { extractErrMessage } from "./util";
|
||||
|
||||
export interface BlockChainInfo {
|
||||
chain: string;
|
||||
blocks: number;
|
||||
headers: number;
|
||||
bestblockhash: string;
|
||||
difficulty: number;
|
||||
// Much much more, but not necessary
|
||||
}
|
||||
|
||||
export interface ScriptPubKey {
|
||||
asm: string;
|
||||
hex: string;
|
||||
type: string;
|
||||
reqSigs?: number;
|
||||
addresses?: string[];
|
||||
}
|
||||
|
||||
export interface VIn {
|
||||
sequence: number;
|
||||
coinbase?: string;
|
||||
}
|
||||
|
||||
export interface VOut {
|
||||
value: number;
|
||||
valueZat: number;
|
||||
n: number;
|
||||
scriptPubKey: ScriptPubKey;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
txid: string;
|
||||
hex: string;
|
||||
version: number;
|
||||
locktime: number;
|
||||
expiryheight: number;
|
||||
blockhash: string;
|
||||
blocktime: number;
|
||||
confirmations: number;
|
||||
time: number;
|
||||
vin: VIn[];
|
||||
vout: VOut[];
|
||||
// unclear what vjoinsplit is
|
||||
vjoinsplit: any[];
|
||||
}
|
||||
|
||||
export interface RawTransaction {
|
||||
txid: string;
|
||||
hex: string;
|
||||
overwintered: boolean;
|
||||
version: number;
|
||||
versiongroupid: number;
|
||||
locktime: number;
|
||||
expiryheight: string;
|
||||
vin: VIn[];
|
||||
vout: VOut[];
|
||||
valueBalance: string;
|
||||
blockhash: string;
|
||||
blocktime: number;
|
||||
confirmations: number;
|
||||
time: number;
|
||||
// unclear what these are
|
||||
vjoinsplit: any[];
|
||||
vShieldedSpend: any[];
|
||||
vShieldedOutput: any[];
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
size: number;
|
||||
height: number;
|
||||
version: number;
|
||||
merkleroot: string;
|
||||
finalsaplingroot: string;
|
||||
time: number;
|
||||
nonce: string;
|
||||
solution: string;
|
||||
bits: string;
|
||||
difficulty: number;
|
||||
chainwork: string;
|
||||
anchor: string;
|
||||
// valuePools ?
|
||||
previousblockhash?: string;
|
||||
nextblockhash?: string;
|
||||
}
|
||||
|
||||
export interface BlockWithTransactionIds extends Block {
|
||||
tx: string[];
|
||||
}
|
||||
|
||||
export interface BlockWithTransactions extends Block {
|
||||
tx: Transaction[];
|
||||
}
|
||||
|
||||
export interface Receipt {
|
||||
txid: string;
|
||||
amount: number;
|
||||
memo: string;
|
||||
change: boolean;
|
||||
}
|
||||
|
||||
export interface DisclosedPayment {
|
||||
txid: string;
|
||||
jsIndex: number;
|
||||
outputIndex: number;
|
||||
version: number;
|
||||
onetimePrivKey: string;
|
||||
joinSplitPubKey: string;
|
||||
signatureVerified: boolean;
|
||||
paymentAddress: string;
|
||||
memo: string;
|
||||
value: number;
|
||||
commitmentMatch: boolean;
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Actually comes back with a bunch of args, but this is all we need
|
||||
export interface ValidationResponse {
|
||||
isvalid: boolean;
|
||||
}
|
||||
|
||||
// https://github.com/zcash/zcash/blob/master/doc/payment-api.md
|
||||
interface ZCashNode {
|
||||
getblockchaininfo: () => Promise<BlockChainInfo>;
|
||||
getblockcount: () => Promise<number>;
|
||||
getblock: {
|
||||
(numberOrHash: string | number, verbosity?: 1): Promise<
|
||||
BlockWithTransactionIds
|
||||
>;
|
||||
(numberOrHash: string | number, verbosity: 2): Promise<
|
||||
BlockWithTransactions
|
||||
>;
|
||||
(numberOrHash: string | number, verbosity: 0): Promise<string>;
|
||||
};
|
||||
gettransaction: (txid: string) => Promise<Transaction>;
|
||||
getrawtransaction: {
|
||||
(numberOrHash: string | number, verbosity: 1): Promise<RawTransaction>;
|
||||
(numberOrHash: string | number, verbosity?: 0): Promise<string>;
|
||||
};
|
||||
validateaddress: (address: string) => Promise<ValidationResponse>;
|
||||
z_getbalance: (address: string, minConf?: number) => Promise<number>;
|
||||
z_getnewaddress: (type?: "sprout" | "sapling") => Promise<string>;
|
||||
z_listaddresses: () => Promise<string[]>;
|
||||
z_listreceivedbyaddress: (
|
||||
address: string,
|
||||
minConf?: number
|
||||
) => Promise<Receipt[]>;
|
||||
z_importviewingkey: (
|
||||
key: string,
|
||||
rescan?: "yes" | "no" | "whenkeyisnew",
|
||||
startHeight?: number
|
||||
) => Promise<void>;
|
||||
z_exportviewingkey: (zaddr: string) => Promise<string>;
|
||||
z_validatepaymentdisclosure: (
|
||||
disclosure: string
|
||||
) => Promise<DisclosedPayment>;
|
||||
z_validateaddress: (address: string) => Promise<ValidationResponse>;
|
||||
}
|
||||
|
||||
export const rpcOptions = {
|
||||
url: env.ZCASH_NODE_URL,
|
||||
username: env.ZCASH_NODE_USERNAME,
|
||||
password: env.ZCASH_NODE_PASSWORD
|
||||
};
|
||||
|
||||
const node: ZCashNode = stdrpc(rpcOptions);
|
||||
|
||||
export default node;
|
||||
|
||||
let network: any;
|
||||
export async function initNode() {
|
||||
// Check if node is available & setup network
|
||||
try {
|
||||
const info = await node.getblockchaininfo();
|
||||
log.info(`Connected to ${info.chain} node at block height ${info.blocks}`);
|
||||
|
||||
if (info.chain === "regtest") {
|
||||
bitcore.Networks.enableRegtest();
|
||||
}
|
||||
if (info.chain.includes("test")) {
|
||||
network = bitcore.Networks.testnet;
|
||||
} else {
|
||||
network = bitcore.Networks.mainnet;
|
||||
}
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
log.error(extractErrMessage(err));
|
||||
log.error(`Failed to connect to zcash node with the following credentials: ${JSON.stringify(rpcOptions, null, 2)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if sprout address is readable
|
||||
// NOTE: Replace with sapling when ready
|
||||
// try {
|
||||
// if (!env.SPROUT_ADDRESS) {
|
||||
// console.error("Missing SPROUT_ADDRESS environment variable, exiting");
|
||||
// process.exit(1);
|
||||
// }
|
||||
// await node.z_getbalance(env.SPROUT_ADDRESS as string);
|
||||
// } catch (err) {
|
||||
// if (!env.SPROUT_VIEWKEY) {
|
||||
// log.error(
|
||||
// "Unable to view SPROUT_ADDRESS and missing SPROUT_VIEWKEY environment variable, exiting"
|
||||
// );
|
||||
// process.exit(1);
|
||||
// }
|
||||
// await node.z_importviewingkey(env.SPROUT_VIEWKEY as string);
|
||||
// await node.z_getbalance(env.SPROUT_ADDRESS as string);
|
||||
// }
|
||||
}
|
||||
|
||||
export function getNetwork() {
|
||||
if (!network) {
|
||||
throw new Error("Called getNetwork before initNode");
|
||||
}
|
||||
return network;
|
||||
}
|
||||
|
||||
// Relies on initNode being called first
|
||||
export async function getBootstrapBlockHeight(txid: string | undefined) {
|
||||
if (txid) {
|
||||
try {
|
||||
const tx = await node.getrawtransaction(txid, 1);
|
||||
const block = await node.getblock(tx.blockhash);
|
||||
const height =
|
||||
block.height - parseInt(env.MINIMUM_BLOCK_CONFIRMATIONS, 10);
|
||||
return height.toString();
|
||||
} catch (err) {
|
||||
log.warn(`Attempted to get block height for tx ${txid} but failed with the following error: ${extractErrMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't find the latest tx block, fall back to when the grant
|
||||
// system first launched, and scan from there. Regtest or unknown networks
|
||||
// start from the bottom.
|
||||
const net = getNetwork();
|
||||
let height = "0";
|
||||
if (net === bitcore.Networks.mainnet) {
|
||||
height = env.MAINNET_START_BLOCK;
|
||||
} else if (net === bitcore.Networks.testnet && !net.regtestEnabled) {
|
||||
height = env.TESTNET_START_BLOCK;
|
||||
}
|
||||
|
||||
log.info(`Falling back to hard-coded starter block height ${height}`);
|
||||
return height;
|
||||
}
|
|
@ -1,162 +0,0 @@
|
|||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import { Server } from 'http';
|
||||
import cors from 'cors';
|
||||
import { captureException } from "@sentry/node";
|
||||
import authMiddleware from './middleware/auth';
|
||||
import errorHandlerMiddleware from './middleware/errorHandler';
|
||||
import {
|
||||
store,
|
||||
setStartingBlockHeight,
|
||||
generateAddresses,
|
||||
getAddressesByContributionId,
|
||||
addPaymentDisclosure,
|
||||
} from '../store';
|
||||
import env from '../env';
|
||||
import node, { getBootstrapBlockHeight } from '../node';
|
||||
import { makeContributionMemo, extractErrMessage } from '../util';
|
||||
import log from '../log';
|
||||
|
||||
// Configure server
|
||||
const app = express();
|
||||
const limit = '50mb';
|
||||
app.set('port', env.PORT);
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json({ limit }));
|
||||
app.use(bodyParser.urlencoded({ extended: true, limit }));
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Routes
|
||||
app.post('/bootstrap', async (req, res) => {
|
||||
const { pendingContributions, latestTxId } = req.body;
|
||||
|
||||
let info;
|
||||
let startHeight;
|
||||
try {
|
||||
info = await node.getblockchaininfo();
|
||||
startHeight = await getBootstrapBlockHeight(latestTxId);
|
||||
} catch(err) {
|
||||
log.error(`Unknown node error during bootstrap: ${extractErrMessage(err)}`);
|
||||
return res.status(500).json({ error: 'Unknown zcash node error' });
|
||||
}
|
||||
|
||||
console.info('Bootstrapping watcher!');
|
||||
console.info(' * Start height:', startHeight);
|
||||
console.info(' * Current height:', info.blocks);
|
||||
console.info(' * Number of pending contributions:', pendingContributions.length);
|
||||
console.info('Generating addresses to watch for each contribution...');
|
||||
|
||||
// Running generate address on each will add each contribution to redux state
|
||||
try {
|
||||
const dispatchers = pendingContributions.map(async (c: any) => {
|
||||
const action = await generateAddresses(c.id);
|
||||
store.dispatch(action);
|
||||
});
|
||||
await Promise.all(dispatchers);
|
||||
console.info(`Done! Generated ${pendingContributions.length} addresses.`);
|
||||
store.dispatch(setStartingBlockHeight(startHeight));
|
||||
} catch(err) {
|
||||
log.error(`Unknown error during bootstrap address generation: ${extractErrMessage(err)}`);
|
||||
return res.status(500).json({ error: 'Failed to generate addresses for contributions' });
|
||||
}
|
||||
|
||||
// Send back some basic info about where the chain is at
|
||||
res.json({
|
||||
data: {
|
||||
startHeight,
|
||||
currentHeight: info.blocks,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/contribution/addresses', async (req, res) => {
|
||||
const { contributionId } = req.query;
|
||||
let addresses = getAddressesByContributionId(store.getState(), contributionId)
|
||||
if (!addresses) {
|
||||
try {
|
||||
const action = await generateAddresses(contributionId);
|
||||
addresses = action.payload.addresses;
|
||||
store.dispatch(action);
|
||||
} catch(err) {
|
||||
log.error(`Unknown error during address generation for contribution ${contributionId}: ${extractErrMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses) {
|
||||
res.json({
|
||||
data: {
|
||||
...addresses,
|
||||
memo: makeContributionMemo(contributionId),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to generate addresses' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.post('/contribution/disclosure', async (req, res) => {
|
||||
const { disclosure, contributionId } = req.body;
|
||||
if (!disclosure) {
|
||||
return res.status(400).json({ error: 'Argument `disclosure` is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const receipt = await node.z_validatepaymentdisclosure(disclosure);
|
||||
if (receipt.valid) {
|
||||
// Add disclosure to redux. Even if validated, we won't confirm the
|
||||
// payment until it's been settled after some number of blocks. This
|
||||
// also keeps all of the confirmation code in one place.
|
||||
store.dispatch(addPaymentDisclosure(contributionId, disclosure));
|
||||
return res.status(200).json({ data: receipt });
|
||||
} else {
|
||||
log.warn('Invalid payment disclosure provided:', receipt);
|
||||
return res.status(400).json({ error: 'Payment disclosure is invalid' });
|
||||
}
|
||||
} catch(err) {
|
||||
captureException(err);
|
||||
// -8 seems to be the "invalid disclosure hex" catch-all code
|
||||
if (err.response && err.response.data && err.response.data.error.code === -8) {
|
||||
return res.status(400).json({ error: err.response.data.error.message });
|
||||
}
|
||||
else {
|
||||
log.error(`Unknown node error during disclosure: ${extractErrMessage(err)}`);
|
||||
return res.status(500).json({ error: 'Unknown zcash node error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/validate/address', async (req, res) => {
|
||||
const { address } = req.query;
|
||||
const [tRes, zRes] = await Promise.all([
|
||||
node.validateaddress(address as string),
|
||||
node.z_validateaddress(address as string),
|
||||
]);
|
||||
return res.json({
|
||||
data: {
|
||||
valid: tRes.isvalid || zRes.isvalid,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler after all routes to catch thrown exceptions
|
||||
app.use(errorHandlerMiddleware);
|
||||
|
||||
// Exports
|
||||
let server: Server;
|
||||
|
||||
export function start() {
|
||||
return new Promise(resolve => {
|
||||
server = app.listen(env.PORT, () => {
|
||||
log.info(`REST server started on port ${env.PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function exit() {
|
||||
if (server) {
|
||||
server.close();
|
||||
log.info('REST server has been closed');
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../util';
|
||||
|
||||
export default function(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.headers['authorization']) {
|
||||
res.status(403).json({ error: 'Authorization header is required' });
|
||||
return;
|
||||
}
|
||||
if (!authenticate(req.headers['authorization'])) {
|
||||
res.status(403).json({ error: 'Authorization header is invalid' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { captureException } from "@sentry/node";
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import log from "../../log";
|
||||
import { extractErrMessage } from "../../util";
|
||||
|
||||
export default function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
|
||||
// Non-error responses, or something else handled & responded
|
||||
if (!err || res.headersSent) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
captureException(err);
|
||||
log.error(`Uncaught ${err.name} exception at ${req.method} ${req.path}: ${extractErrMessage(err)}`);
|
||||
log.debug(`Query: ${JSON.stringify(req.query, null, 2)}`);
|
||||
log.debug(`Body: ${JSON.stringify(req.body, null, 2)}`);
|
||||
log.debug(`Full stacktrace:\n${err.stack}`);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
import type, { AddressCollection } from './types';
|
||||
import { deriveTransparentAddress } from '../util';
|
||||
import { getNetwork } from '../node';
|
||||
import { getContributionAddress } from '../bitgo';
|
||||
import env from '../env';
|
||||
|
||||
export function setStartingBlockHeight(height: string | number) {
|
||||
return {
|
||||
type: type.SET_STARTING_BLOCK_HEIGHT as type.SET_STARTING_BLOCK_HEIGHT,
|
||||
payload: parseInt(height.toString(), 10),
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAddresses(contributionId: number) {
|
||||
let transparent;
|
||||
if (env.BITGO_WALLET_ID) {
|
||||
transparent = await getContributionAddress(contributionId);
|
||||
} else {
|
||||
transparent = deriveTransparentAddress(contributionId, getNetwork());
|
||||
}
|
||||
|
||||
const addresses: AddressCollection = {
|
||||
transparent,
|
||||
sprout: env.SPROUT_ADDRESS,
|
||||
};
|
||||
return {
|
||||
type: type.GENERATE_ADDRESSES as type.GENERATE_ADDRESSES,
|
||||
payload: {
|
||||
addresses,
|
||||
contributionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function addPaymentDisclosure(contributionId: number, disclosure: string) {
|
||||
return {
|
||||
type: type.ADD_PAYMENT_DISCLOSURE as type.ADD_PAYMENT_DISCLOSURE,
|
||||
payload: {
|
||||
contributionId,
|
||||
disclosure,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function confirmPaymentDisclosure(contributionId: number, disclosure: string) {
|
||||
return {
|
||||
type: type.CONFIRM_PAYMENT_DISCLOSURE as type.CONFIRM_PAYMENT_DISCLOSURE,
|
||||
payload: {
|
||||
contributionId,
|
||||
disclosure,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
export type ActionTypes =
|
||||
| ReturnType<typeof setStartingBlockHeight>
|
||||
| UnwrapPromise<ReturnType<typeof generateAddresses>>
|
||||
| ReturnType<typeof addPaymentDisclosure>
|
||||
| ReturnType<typeof confirmPaymentDisclosure>;
|
|
@ -1,8 +0,0 @@
|
|||
import { createStore, Store } from 'redux';
|
||||
import { reducer, StoreState } from './reducer';
|
||||
|
||||
export * from './reducer';
|
||||
export * from './selectors';
|
||||
export * from './actions';
|
||||
|
||||
export const store = createStore(reducer);
|
|
@ -1,53 +0,0 @@
|
|||
import type, { AddressCollection } from './types';
|
||||
import { ActionTypes } from './actions';
|
||||
import { dedupeArray, removeItem } from '../util';
|
||||
|
||||
export interface StoreState {
|
||||
startingBlockHeight: number | null;
|
||||
watchAddresses: { [contributionId: number]: AddressCollection };
|
||||
watchDisclosures: { [contributionId: number]: string };
|
||||
}
|
||||
|
||||
const INITIAL_STATE: StoreState = {
|
||||
startingBlockHeight: null,
|
||||
watchAddresses: {},
|
||||
watchDisclosures: {},
|
||||
};
|
||||
|
||||
export function reducer(state: StoreState = INITIAL_STATE, action: ActionTypes): StoreState {
|
||||
switch(action.type) {
|
||||
case type.SET_STARTING_BLOCK_HEIGHT:
|
||||
return {
|
||||
...state,
|
||||
startingBlockHeight: action.payload,
|
||||
};
|
||||
|
||||
case type.GENERATE_ADDRESSES:
|
||||
return {
|
||||
...state,
|
||||
watchAddresses: {
|
||||
...state.watchAddresses,
|
||||
[action.payload.contributionId]: action.payload.addresses,
|
||||
},
|
||||
};
|
||||
|
||||
case type.ADD_PAYMENT_DISCLOSURE:
|
||||
return {
|
||||
...state,
|
||||
watchDisclosures: {
|
||||
...state.watchDisclosures,
|
||||
[action.payload.contributionId]: action.payload.disclosure,
|
||||
},
|
||||
};
|
||||
|
||||
case type.CONFIRM_PAYMENT_DISCLOSURE: {
|
||||
const watchDisclosures = { ...state.watchDisclosures };
|
||||
delete watchDisclosures[action.payload.contributionId];
|
||||
return {
|
||||
...state,
|
||||
watchDisclosures,
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { StoreState as S } from './reducer';
|
||||
import { AddressCollection } from './types'
|
||||
|
||||
export const getWatchAddresses = (s: S) => s.watchAddresses;
|
||||
export const getAddressesByContributionId = (s: S, cid: number): AddressCollection | undefined =>
|
||||
s.watchAddresses[cid];
|
||||
|
||||
export const getWatchDisclosures = (s: S) => s.watchDisclosures;
|
||||
export const getWatchDisclosureByContributionId = (s: S, cid: number): string | undefined =>
|
||||
s.watchDisclosures[cid];
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue