ZF Grants 2.1 (#496)

* fix ccr pagination defaults

* add ccr admin tests

* add ccr user tests

* checkpoint

* fix tslint

* request changes discussion flow mvp

* admin - add discussion status

* backend - add live drafts

* admin - add live drafts

* frontend - add live drafts

* frontend - add edit discussion proposal

* fix tsc

* include DISCUSSION status in propsal listview

* do not make live draft on admin request changes

* hide live drafts from user proposal draft list

* fix backend tests

* add admin tests

* add user tests

* fix: liking, viewing discussion proposals, admin menu

* admin - update hints for live drafts

* fe - add better messaging when updating a proposal

* be - fix like test

* remove TODO comments

* add new email types

* fix storybook

* add revision tab story

* backend - implement proposal revisions

* frontend - implement proposal revisions

* update revision tab story

* fix lint

* remove set detection

* email proposal followers on revision

* restrict banner to team members only

* misc bug fixes

* update, add backend tests

* add milestone title change to revision history story

* fix milestones display in preview

* allow archived proposals to be queried

* implement archived proposal page

* fix tsc

* implement archived proposal get route

* move styling into less

* remove proposal archive parent id

* handle archived proposal status

* cleanup

* remove contributions, switch to USD, implement quarters

* use Qs to preserve formatting

* handle edit only kyc

* prevent ARCHIVED proposals from being sent to admin

* display latest revision first

* admin - proposal & ccr reject permanently

* backend - proposal & ccr reject permanently

* frontend - proposal & ccr reject permanently

* fix tsc

* use $ in milestone payout email

* introduce custom filters to proposal listview

* hide archive link on first revision

* upgrade packages

* add bech32 implementation

* add z address validation with tests

* fix tslint

* use local address validation

* fix tests, remove blockchain mock gets

* add additional bad addresses

* update briefs to include page break message

* remove contributions routes, menu entry

* disable countribution count admin stats

* remove matching and pretty print in finance

* fix tslint

* separate out rejected permanently proposals

* make removing proposals generic

* allow linked tabs to be ignored

* remove rejected permanently, bugfix

* update preview link to point to rejected tab

* implement rejected permanently tab, add tab message

* refactor variable

* fix tslint

* fix tslint

* send ccr reject permanently email on rejection

* fix preview message

* wire up proposal arbiter and rejected emails

* disable tip jar in proposal and profile

* sync ccr/proposal drafts on create form init

* check invites on submit modal open

* update team invite language

* update team text when edit

* fix ccr rejected permanently tag

* text changes, email preview fix

* display changes requested tag when in discussion with changes requested

* enable social share on open for discussion proposals, update language

* place sort below filter

* derive filter from query string

* use better filter names in query params

* fix tslint

* create snapshot of original proposal on first revision

* clear invites between edits, account for additional changes not tracked in revisions

* update tests

* fix test

* remove print

* SameSite Fixes (#150)

* QA Fixes 2 (#151)

* set filters as query strings on change

* remove rejected permanently tags

* add dollar sign in financials legend

* fix tsc

* Copy Touchups (#152)

* Email Fixes (#155)

* fix ZEC in milestone payout emails

* fix links in rejected permanently CCR/proposal emails

* Poll for Team and Invite Changes in Create Flow (#153)

* poll for team and invite changes in create flow

* fix tslint

Co-authored-by: Daniel Ternyak <dternyak@gmail.com>

* pretty print payouts by quarter (#156)

Co-authored-by: Daniel Ternyak <dternyak@gmail.com>

* Remove Blockchain Module (#154)

* remove blockchain route from backend, remove calls to node

* revert blockchain_get removal

* Add Tags to Proposal Cards (#157)

* add tag to proposals and dynamically set v1 card height

* listen on window resize

* make card height props optional

* set tag in bottom right, remove dynamic card resize, add dynamic tag resize

* cleanup

* cleanup

Co-authored-by: Daniel Ternyak <dternyak@gmail.com>

* Improve Frontend Address Validation (#158)

Co-authored-by: Daniel Ternyak <dternyak@gmail.com>

* Remove blockchain module (#162)

* remove blockchain route from backend, remove calls to node

* revert blockchain_get removal

* Remove Blockchain App (#160)

* remove blockchain app

* remove blockchain app from travis

Co-authored-by: Danny Skubak <skubakdj@gmail.com>

* Proposal Edit Fixes (#161)

* fe - display error if edit creation fails

* be - restrict live draft publish

Co-authored-by: Daniel Ternyak <dternyak@gmail.com>

* Restrict Arbiter Assignment (#159)

Co-authored-by: Daniel Ternyak <dternyak@gmail.com>

* Email Copy updates

* Remove Admin Financials Card

* Hookup 'proposal_approved_without_funding' to admin email example

* bump various package versions

* Update yarn.lock files

* Attach 'proposal_approved_without_funding' to backend example email

* bump package versions

Co-authored-by: Danny Skubak <skubakdj@gmail.com>
This commit is contained in:
Daniel Ternyak 2020-04-07 21:56:32 -05:00 committed by GitHub
parent 7301d2a4e0
commit 5a15022987
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
176 changed files with 7028 additions and 7564 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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 youve asked for too much money for the project youve 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",
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

8
backend/grant/patches.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
Your proposal {{ args.proposal.title }} is ready for payout requests.
Visit your proposal to see more:
{{ args.proposal_url }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -84,4 +84,4 @@ Flask-Limiter==1.0.1
validate_email==1.3
# validate URLS
validators==0.12.4
validators==0.12.6

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ test_proposal = {
"milestones": milestones,
"category": Category.ACCESSIBILITY,
"target": "12345",
"payoutAddress": "123",
"payoutAddress": "zs15el0hzs4w60ggfy6kq4p3zttjrl00mfq7yxfwsjqpz9d7hptdtkltzlcqar994jg2ju3j9k85zk",
"deadlineDuration": 100
}

View File

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

View File

View File

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

View File

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

69
blockchain/.gitignore vendored
View File

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

View File

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

View File

@ -1 +0,0 @@
web: yarn start

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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