Compare commits
89 Commits
Author | SHA1 | Date |
---|---|---|
Jack Gavigan | 38c268f540 | |
Daniel Ternyak | 2f87d96171 | |
Daniel Ternyak | 206ed2e63a | |
Daniel Ternyak | 819c15ba9c | |
Daniel Ternyak | c0e05a86e6 | |
Daniel Ternyak | b92a89d8ea | |
Daniel Ternyak | 424ca4d283 | |
Daniel Ternyak | e12b4e1162 | |
Daniel Ternyak | 7f065b4163 | |
Daniel Ternyak | 1a38eea631 | |
Daniel Ternyak | e8e7004f17 | |
Daniel Ternyak | 475abec08b | |
Daniel Ternyak | ed7a3343c9 | |
Daniel Ternyak | 97b0cbc4b3 | |
Daniel Ternyak | a36861d063 | |
Daniel Ternyak | d6c7119dd0 | |
Daniel Ternyak | a6dd059442 | |
Daniel Ternyak | cfd38e91b7 | |
Daniel Ternyak | ccdd6e4550 | |
Daniel Ternyak | f89e089a00 | |
Daniel Ternyak | a61cdf5b7e | |
Daniel Ternyak | e2a57e1ced | |
Daniel Ternyak | b192f00709 | |
Daniel Ternyak | 665c12bffa | |
Daniel Ternyak | 15fbdc17b8 | |
Daniel Ternyak | e612e4f403 | |
Daniel Ternyak | 08c6d6aaae | |
Daniel Ternyak | ad2743933f | |
Daniel Ternyak | 42be278348 | |
dependabot[bot] | 0c55a776c8 | |
Daniel Ternyak | f50b516ade | |
Daniel Ternyak | 5a15022987 | |
Daniel Ternyak | 452637cc28 | |
Daniel Ternyak | 7301d2a4e0 | |
Sonya Mann | b52d26b9cf | |
Sonya Mann | 560f76aaf2 | |
Daniel Ternyak | 044deea218 | |
Daniel Ternyak | 797c042629 | |
Danny Skubak | 33411f105d | |
Danny Skubak | 59ebf8e971 | |
Danny Skubak | 96d0b9e30e | |
Daniel Ternyak | 7e7650eeae | |
Danny Skubak | b02e14a42f | |
Daniel Ternyak | dc09690ea3 | |
Danny Skubak | 64d832d585 | |
Daniel Ternyak | 3311be8e98 | |
Danny Skubak | 95102842a7 | |
Daniel Ternyak | 98dce6c5ea | |
Danny Skubak | aa15b13782 | |
Danny Skubak | 597472b5c6 | |
Danny Skubak | 4a0e23e9c7 | |
Danny Skubak | 6f4e1b779b | |
Danny Skubak | 94dc22b879 | |
Danny Skubak | 7936b418f4 | |
Danny Skubak | 213595cfba | |
Danny Skubak | 8f187ad775 | |
Danny Skubak | 4702f1a752 | |
Danny Skubak | 13d762b011 | |
Danny Skubak | db49fbc7e1 | |
Danny Skubak | 216b37f6a3 | |
Danny Skubak | ceb9f8cbdf | |
Danny Skubak | b824f462f0 | |
Daniel Ternyak | d64cfb6de7 | |
Daniel Ternyak | 506a00a2fa | |
Danny Skubak | ec3350e45f | |
Danny Skubak | 08fe3efca5 | |
Danny Skubak | d98b255378 | |
Daniel Ternyak | 4b7d85872a | |
Danny Skubak | 8cfec5de5d | |
Danny Skubak | ed6d98ceec | |
Daniel Ternyak | 8ced452411 | |
Danny Skubak | 67fbbae9bf | |
Danny Skubak | 8255f0174c | |
Danny Skubak | c66be86c54 | |
Danny Skubak | 494303883a | |
Daniel Ternyak | dd9bcb8865 | |
Danny Skubak | 5f049d899b | |
Danny Skubak | 39f9cea42e | |
Danny Skubak | 9f485fabc4 | |
Danny Skubak | 25e43a34ff | |
Danny Skubak | 85c21d4cbf | |
Danny Skubak | 5799ffab19 | |
Danny Skubak | 58eb8f2455 | |
Danny Skubak | fb6b9b5af7 | |
Daniel Ternyak | 701a2f95a9 | |
Danny Skubak | 746398c59b | |
Danny Skubak | 54b0d58ffa | |
Daniel Ternyak | c1a014a4b5 | |
Danny Skubak | 7ef5dea343 |
|
@ -0,0 +1,32 @@
|
||||||
|
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||||
|
|
||||||
|
name: Node.js CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [12.x]
|
||||||
|
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
- run: cd frontend && yarn && && yarn run lint && yarn run tsc
|
|
@ -0,0 +1,32 @@
|
||||||
|
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
||||||
|
|
||||||
|
name: Python application
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python 3.7
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.7
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
cd backend && pip install -r requirements/dev.txt
|
||||||
|
- name: Test with flask test
|
||||||
|
run: |
|
||||||
|
cd backend && cp .env.example .env && flask test
|
|
@ -18,12 +18,3 @@ matrix:
|
||||||
install: pip install -r requirements/dev.txt
|
install: pip install -r requirements/dev.txt
|
||||||
script:
|
script:
|
||||||
- flask test
|
- flask test
|
||||||
# Blockchain
|
|
||||||
- language: node_js
|
|
||||||
node_js: 8.13.0
|
|
||||||
before_install:
|
|
||||||
- cd blockchain
|
|
||||||
install: yarn
|
|
||||||
script:
|
|
||||||
- yarn run test
|
|
||||||
- yarn run build
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# Zcash Grant System
|
# Zcash Grant System
|
||||||
|
|
||||||
This is a collection of the various services and components that make up the Zcash Grant System.
|
This is a collection of the various services and components that make up the old Zcash Grant System, which has been deprecated.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
|
|
|
@ -103,11 +103,14 @@
|
||||||
"tslint-react": "^3.6.0",
|
"tslint-react": "^3.6.0",
|
||||||
"typescript": "3.0.3",
|
"typescript": "3.0.3",
|
||||||
"url-loader": "^1.1.1",
|
"url-loader": "^1.1.1",
|
||||||
"webpack": "^4.19.0",
|
"webpack": "^4.42.0",
|
||||||
"webpack-cli": "^3.1.0",
|
"webpack-cli": "^3.1.0",
|
||||||
"webpack-dev-server": "3.2.1",
|
"webpack-dev-server": "3.2.1",
|
||||||
"webpack-hot-middleware": "^2.24.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/bn.js": "4.11.1",
|
"@types/bn.js": "4.11.1",
|
||||||
|
|
|
@ -13,12 +13,11 @@ import UserDetail from 'components/UserDetail';
|
||||||
import Emails from 'components/Emails';
|
import Emails from 'components/Emails';
|
||||||
import Proposals from 'components/Proposals';
|
import Proposals from 'components/Proposals';
|
||||||
import ProposalDetail from 'components/ProposalDetail';
|
import ProposalDetail from 'components/ProposalDetail';
|
||||||
|
import CCRs from 'components/CCRs';
|
||||||
|
import CCRDetail from 'components/CCRDetail';
|
||||||
import RFPs from 'components/RFPs';
|
import RFPs from 'components/RFPs';
|
||||||
import RFPForm from 'components/RFPForm';
|
import RFPForm from 'components/RFPForm';
|
||||||
import RFPDetail from 'components/RFPDetail';
|
import RFPDetail from 'components/RFPDetail';
|
||||||
import Contributions from 'components/Contributions';
|
|
||||||
import ContributionForm from 'components/ContributionForm';
|
|
||||||
import ContributionDetail from 'components/ContributionDetail';
|
|
||||||
import Financials from 'components/Financials';
|
import Financials from 'components/Financials';
|
||||||
import Moderation from 'components/Moderation';
|
import Moderation from 'components/Moderation';
|
||||||
import Settings from 'components/Settings';
|
import Settings from 'components/Settings';
|
||||||
|
@ -47,14 +46,12 @@ class Routes extends React.Component<Props> {
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
<Route path="/proposals/:id" component={ProposalDetail} />
|
<Route path="/proposals/:id" component={ProposalDetail} />
|
||||||
<Route path="/proposals" component={Proposals} />
|
<Route path="/proposals" component={Proposals} />
|
||||||
|
<Route path="/ccrs/:id" component={CCRDetail} />
|
||||||
|
<Route path="/ccrs" component={CCRs} />
|
||||||
<Route path="/rfps/new" component={RFPForm} />
|
<Route path="/rfps/new" component={RFPForm} />
|
||||||
<Route path="/rfps/:id/edit" component={RFPForm} />
|
<Route path="/rfps/:id/edit" component={RFPForm} />
|
||||||
<Route path="/rfps/:id" component={RFPDetail} />
|
<Route path="/rfps/:id" component={RFPDetail} />
|
||||||
<Route path="/rfps" component={RFPs} />
|
<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="/financials" component={Financials} />
|
||||||
<Route path="/emails/:type?" component={Emails} />
|
<Route path="/emails/:type?" component={Emails} />
|
||||||
<Route path="/moderation" component={Moderation} />
|
<Route path="/moderation" component={Moderation} />
|
||||||
|
|
|
@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { arbiter } = this.props;
|
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
|
||||||
const { showSearch, searching } = this.state;
|
const { showSearch, searching } = this.state;
|
||||||
const { results, search, error } = store.arbitersSearch;
|
const { results, search, error } = store.arbitersSearch;
|
||||||
const showEmpty = !results.length && !searching;
|
const showEmpty = !results.length && !searching;
|
||||||
|
const buttonDisabled = isVersionTwo && !acceptedWithFunding;
|
||||||
|
|
||||||
const disp = {
|
const disp = {
|
||||||
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
||||||
|
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={this.handleShowSearch}
|
onClick={this.handleShowSearch}
|
||||||
{...this.props.buttonProps}
|
{...this.props.buttonProps}
|
||||||
|
disabled={buttonDisabled}
|
||||||
>
|
>
|
||||||
{disp[arbiter.status]}
|
{disp[arbiter.status]}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
.CCRDetail {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
&-control + &-control {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-deet {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.8;
|
||||||
|
bottom: -0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ant-card,
|
||||||
|
.ant-alert,
|
||||||
|
.ant-collapse {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-popover {
|
||||||
|
&-overlay {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-alert {
|
||||||
|
& pre {
|
||||||
|
margin: 1rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-review {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,241 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Alert, Button, Card, Col, Collapse, message, Row } from 'antd';
|
||||||
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import { CCR_STATUS } from 'src/types';
|
||||||
|
import Back from 'components/Back';
|
||||||
|
import Markdown from 'components/Markdown';
|
||||||
|
import FeedbackModal from '../FeedbackModal';
|
||||||
|
import './index.less';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
const STATE = {
|
||||||
|
paidTxId: '',
|
||||||
|
showCancelAndRefundPopover: false,
|
||||||
|
showChangeToAcceptedWithFundingPopover: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = typeof STATE;
|
||||||
|
|
||||||
|
class CCRDetailNaked extends React.Component<Props, State> {
|
||||||
|
state = STATE;
|
||||||
|
rejectInput: null | TextArea = null;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const id = this.getIdFromQuery();
|
||||||
|
const { ccrDetail: c, ccrDetailFetching } = store;
|
||||||
|
|
||||||
|
if (!c || (c && c.ccrId !== id) || ccrDetailFetching) {
|
||||||
|
return 'loading ccr...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderApproved = () =>
|
||||||
|
c.status === CCR_STATUS.APPROVED && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="success"
|
||||||
|
message={`Approved on ${formatDateSeconds(c.dateApproved)}`}
|
||||||
|
description={`
|
||||||
|
This ccr has been approved.
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderReview = () =>
|
||||||
|
c.status === CCR_STATUS.PENDING && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="Review Pending"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Please review this Community Created Request and render your judgment.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="CCRDetail-review"
|
||||||
|
loading={store.ccrDetailApproving}
|
||||||
|
icon="check"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => this.handleApprove()}
|
||||||
|
>
|
||||||
|
Generate RFP from CCR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="CCRDetail-review"
|
||||||
|
loading={store.ccrDetailApproving}
|
||||||
|
icon="warning"
|
||||||
|
type="default"
|
||||||
|
onClick={() => {
|
||||||
|
FeedbackModal.open({
|
||||||
|
title: 'Request changes for this Request?',
|
||||||
|
label: 'Please provide a reason:',
|
||||||
|
okText: 'Request changes',
|
||||||
|
onOk: this.handleReject,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRejected = () =>
|
||||||
|
c.status === CCR_STATUS.REJECTED && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="error"
|
||||||
|
message="Changes requested"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This CCR has changes requested. The team will be able to re-submit it for
|
||||||
|
approval should they desire to do so.
|
||||||
|
</p>
|
||||||
|
<b>Reason:</b>
|
||||||
|
<br />
|
||||||
|
<i>{c.rejectReason}</i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
|
<div className="CCRDetail-deet">
|
||||||
|
<span>{name}</span>
|
||||||
|
{val}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="CCRDetail">
|
||||||
|
<Back to="/ccrs" text="CCRs" />
|
||||||
|
<h1>{c.title}</h1>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* MAIN */}
|
||||||
|
<Col span={18}>
|
||||||
|
{renderApproved()}
|
||||||
|
{renderReview()}
|
||||||
|
{renderRejected()}
|
||||||
|
|
||||||
|
<Collapse defaultActiveKey={['brief', 'content', 'target']}>
|
||||||
|
<Collapse.Panel key="brief" header="brief">
|
||||||
|
{c.brief}
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="content" header="content">
|
||||||
|
<Markdown source={c.content} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="target" header="target">
|
||||||
|
<Markdown source={c.target} />
|
||||||
|
</Collapse.Panel>
|
||||||
|
|
||||||
|
<Collapse.Panel key="json" header="json">
|
||||||
|
<pre>{JSON.stringify(c, null, 4)}</pre>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* RIGHT SIDE */}
|
||||||
|
<Col span={6}>
|
||||||
|
{c.rfp && (
|
||||||
|
<Alert
|
||||||
|
message="Linked to RFP"
|
||||||
|
description={
|
||||||
|
<React.Fragment>
|
||||||
|
This CCR has been accepted and is instantiated as an RFP{' '}
|
||||||
|
<Link to={`/rfps/${c.rfp.id}`}>here</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DETAILS */}
|
||||||
|
<Card title="Details" size="small">
|
||||||
|
{renderDeetItem('id', c.ccrId)}
|
||||||
|
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
|
||||||
|
{renderDeetItem(
|
||||||
|
'published',
|
||||||
|
c.datePublished ? formatDateSeconds(c.datePublished) : 'n/a',
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderDeetItem(
|
||||||
|
'status',
|
||||||
|
c.status === CCR_STATUS.LIVE ? 'Accepted/Generated RFP' : c.status,
|
||||||
|
)}
|
||||||
|
{renderDeetItem('target', c.target)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Author" size="small">
|
||||||
|
<div key={c.author.userid}>
|
||||||
|
<Link to={`/users/${c.author.userid}`}>{c.author.displayName}</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIdFromQuery = () => {
|
||||||
|
return Number(this.props.match.params.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadDetail = () => {
|
||||||
|
store.fetchCCRDetail(this.getIdFromQuery());
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleApprove = async () => {
|
||||||
|
await store.approveCCR(true);
|
||||||
|
if (store.ccrCreatedRFPId) {
|
||||||
|
message.success('Successfully created RFP from CCR!', 1);
|
||||||
|
setTimeout(
|
||||||
|
() => this.props.history.replace(`/rfps/${store.ccrCreatedRFPId}/edit`),
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleReject = async (reason: string) => {
|
||||||
|
await store.approveCCR(false, reason);
|
||||||
|
message.info('CCR changes requested');
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleRejectPermanently = async (rejectReason: string) => {
|
||||||
|
await store.rejectPermanentlyCcr(rejectReason);
|
||||||
|
message.info('CCR rejected permanently');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CCRDetail = withRouter(view(CCRDetailNaked));
|
||||||
|
export default CCRDetail;
|
|
@ -0,0 +1,16 @@
|
||||||
|
.CCRItem {
|
||||||
|
& h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
& .ant-tag {
|
||||||
|
vertical-align: text-top;
|
||||||
|
margin: 0.2rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
color: rgba(#000, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Tag, Tooltip, List } from 'antd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CCR } from 'src/types';
|
||||||
|
import { CCR_STATUSES, getStatusById } from 'util/statuses';
|
||||||
|
import { formatDateSeconds } from 'util/time';
|
||||||
|
import './CCRItem.less';
|
||||||
|
|
||||||
|
class CCRItemNaked extends React.Component<CCR> {
|
||||||
|
render() {
|
||||||
|
const props = this.props;
|
||||||
|
const status = getStatusById(CCR_STATUSES, props.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item key={props.ccrId} className="CCRItem">
|
||||||
|
<Link to={`/ccrs/${props.ccrId}`}>
|
||||||
|
<h2>
|
||||||
|
{props.title || '(no title)'}
|
||||||
|
<Tooltip title={status.hint}>
|
||||||
|
<Tag color={status.tagColor}>
|
||||||
|
{status.tagDisplay === 'Live'
|
||||||
|
? 'Accepted/Generated RFP'
|
||||||
|
: status.tagDisplay}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</h2>
|
||||||
|
<p>Created: {formatDateSeconds(props.dateCreated)}</p>
|
||||||
|
<p>{props.brief}</p>
|
||||||
|
</Link>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CCRItem = view(CCRItemNaked);
|
||||||
|
export default CCRItem;
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import store from 'src/store';
|
||||||
|
import CCRItem from './CCRItem';
|
||||||
|
import Pageable from 'components/Pageable';
|
||||||
|
import { CCR } from 'src/types';
|
||||||
|
import { ccrFilters } from 'util/filters';
|
||||||
|
|
||||||
|
class CCRs extends React.Component<{}> {
|
||||||
|
render() {
|
||||||
|
const { page } = store.ccrs;
|
||||||
|
// NOTE: sync with /backend ... pagination.py CCRPagination.SORT_MAP
|
||||||
|
const sorts = ['CREATED:DESC', 'CREATED:ASC'];
|
||||||
|
return (
|
||||||
|
<Pageable
|
||||||
|
page={page}
|
||||||
|
filters={ccrFilters}
|
||||||
|
sorts={sorts}
|
||||||
|
searchPlaceholder="Search CCR titles"
|
||||||
|
renderItem={(c: CCR) => <CCRItem key={c.ccrId} {...c} />}
|
||||||
|
handleSearch={store.fetchCCRs}
|
||||||
|
handleChangeQuery={store.setCCRPageQuery}
|
||||||
|
handleResetQuery={store.resetCCRPageQuery}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(CCRs);
|
|
@ -41,10 +41,51 @@ export default [
|
||||||
title: 'Proposal approved',
|
title: 'Proposal approved',
|
||||||
description: 'Sent when an admin approves your submitted proposal',
|
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',
|
id: 'proposal_rejected',
|
||||||
title: 'Proposal rejected',
|
title: 'Proposal changes requested',
|
||||||
description: 'Sent when an admin rejects your submitted proposal',
|
description: 'Sent when an admin requests changes for your submitted proposal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proposal_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',
|
id: 'proposal_contribution',
|
||||||
|
@ -130,11 +171,21 @@ export default [
|
||||||
title: 'Milestone paid',
|
title: 'Milestone paid',
|
||||||
description: 'Sent when milestone is paid',
|
description: 'Sent when milestone is paid',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'milestone_deadline',
|
||||||
|
title: 'Milestone deadline',
|
||||||
|
description: 'Sent when the estimated deadline for milestone has been reached',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'admin_approval',
|
id: 'admin_approval',
|
||||||
title: 'Admin Approval',
|
title: 'Admin Approval',
|
||||||
description: 'Sent when proposal is ready for review',
|
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',
|
id: 'admin_arbiter',
|
||||||
title: 'Admin Arbiter',
|
title: 'Admin Arbiter',
|
||||||
|
@ -145,4 +196,20 @@ export default [
|
||||||
title: 'Admin Payout',
|
title: 'Admin Payout',
|
||||||
description: 'Sent when milestone payout has been approved',
|
description: 'Sent when milestone payout has been approved',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'followed_proposal_milestone',
|
||||||
|
title: 'Followed Proposal Milestone',
|
||||||
|
description:
|
||||||
|
'Sent to followers of a proposal when one of its milestones has been approved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'followed_proposal_update',
|
||||||
|
title: 'Followed Proposal Update',
|
||||||
|
description: 'Sent to followers of a proposal when it has a new update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'followed_proposal_revised',
|
||||||
|
title: 'Followed Proposal Revised',
|
||||||
|
description: 'Sent to followers of a proposal when a revision has been made',
|
||||||
|
},
|
||||||
] as Email[];
|
] as Email[];
|
||||||
|
|
|
@ -1,95 +1,53 @@
|
||||||
import React from 'react';
|
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 { Charts } from 'ant-design-pro';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import store from '../../store';
|
import store from '../../store';
|
||||||
import Info from 'components/Info';
|
import Info from 'components/Info';
|
||||||
|
import { formatUsd } from '../../util/formatters';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
class Financials extends React.Component {
|
interface State {
|
||||||
componentDidMount() {
|
selectedYear: string;
|
||||||
store.fetchFinancials();
|
}
|
||||||
|
|
||||||
|
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() {
|
render() {
|
||||||
const { contributions, grants, payouts } = store.financials;
|
const { selectedYear } = this.state;
|
||||||
if (!store.financialsFetched) {
|
const { grants, payouts, payoutsByQuarter } = store.financials;
|
||||||
|
if (!store.financialsFetched || !selectedYear) {
|
||||||
return <Spin tip="Loading financials..." />;
|
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 (
|
return (
|
||||||
<div className="Financials">
|
<div className="Financials">
|
||||||
<Row gutter={16}>
|
<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}>
|
<Col lg={8} md={12} sm={24}>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
|
@ -120,7 +78,7 @@ class Financials extends React.Component {
|
||||||
total={() => (
|
total={() => (
|
||||||
<span
|
<span
|
||||||
dangerouslySetInnerHTML={{
|
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: 'future', y: parseFloat(payouts.future) },
|
||||||
{ x: 'paid', y: parseFloat(payouts.paid) },
|
{ 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}
|
height={180}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -14,6 +14,7 @@ class Home extends React.Component {
|
||||||
const {
|
const {
|
||||||
userCount,
|
userCount,
|
||||||
proposalCount,
|
proposalCount,
|
||||||
|
ccrPendingCount,
|
||||||
proposalPendingCount,
|
proposalPendingCount,
|
||||||
proposalNoArbiterCount,
|
proposalNoArbiterCount,
|
||||||
proposalMilestonePayoutsCount,
|
proposalMilestonePayoutsCount,
|
||||||
|
@ -21,6 +22,13 @@ class Home extends React.Component {
|
||||||
} = store.stats;
|
} = store.stats;
|
||||||
|
|
||||||
const actionItems = [
|
const actionItems = [
|
||||||
|
!!ccrPendingCount && (
|
||||||
|
<div>
|
||||||
|
<Icon type="exclamation-circle" /> There are <b>{ccrPendingCount}</b> community
|
||||||
|
created requests <b>waiting for review</b>.{' '}
|
||||||
|
<Link to="/ccrs?filters[]=STATUS_PENDING">Click here</Link> to view them.
|
||||||
|
</div>
|
||||||
|
),
|
||||||
!!proposalPendingCount && (
|
!!proposalPendingCount && (
|
||||||
<div>
|
<div>
|
||||||
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
|
||||||
|
@ -32,7 +40,7 @@ class Home extends React.Component {
|
||||||
<div>
|
<div>
|
||||||
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
|
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
|
||||||
live proposals <b>without an arbiter</b>.{' '}
|
live proposals <b>without an arbiter</b>.{' '}
|
||||||
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING&filters[]=STAGE_NOT_CANCELED">
|
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING&filters[]=STAGE_NOT_CANCELED&filters[]=ACCEPTED_WITH_FUNDING">
|
||||||
Click here
|
Click here
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
to view them.
|
to view them.
|
||||||
|
|
|
@ -26,10 +26,6 @@
|
||||||
.ant-alert,
|
.ant-alert,
|
||||||
.ant-collapse {
|
.ant-collapse {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
||||||
button + button {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-popover {
|
&-popover {
|
||||||
|
@ -46,4 +42,9 @@
|
||||||
white-space: inherit;
|
white-space: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-review {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,35 +2,19 @@ import React from 'react';
|
||||||
import BN from 'bn.js';
|
import BN from 'bn.js';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import {
|
import { Alert, Button, Card, Col, Collapse, Input, message, Popconfirm, Row, Switch, Tag } from 'antd';
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Card,
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Collapse,
|
|
||||||
Popconfirm,
|
|
||||||
Input,
|
|
||||||
Switch,
|
|
||||||
Tag,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import TextArea from 'antd/lib/input/TextArea';
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { formatDateSeconds, formatDurationSeconds } from 'util/time';
|
import { formatDateSeconds, formatDurationSeconds } from 'util/time';
|
||||||
import {
|
import { MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS, PROPOSAL_STAGE, PROPOSAL_STATUS } from 'src/types';
|
||||||
PROPOSAL_STATUS,
|
|
||||||
PROPOSAL_ARBITER_STATUS,
|
|
||||||
MILESTONE_STAGE,
|
|
||||||
PROPOSAL_STAGE,
|
|
||||||
} from 'src/types';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Info from 'components/Info';
|
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import ArbiterControl from 'components/ArbiterControl';
|
import ArbiterControl from 'components/ArbiterControl';
|
||||||
import { toZat, fromZat } from 'src/util/units';
|
import { fromZat, toZat } from 'src/util/units';
|
||||||
import FeedbackModal from '../FeedbackModal';
|
import FeedbackModal from '../FeedbackModal';
|
||||||
|
import { formatUsd } from 'util/formatters';
|
||||||
|
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
type Props = RouteComponentProps<any>;
|
type Props = RouteComponentProps<any>;
|
||||||
|
@ -38,6 +22,7 @@ type Props = RouteComponentProps<any>;
|
||||||
const STATE = {
|
const STATE = {
|
||||||
paidTxId: '',
|
paidTxId: '',
|
||||||
showCancelAndRefundPopover: false,
|
showCancelAndRefundPopover: false,
|
||||||
|
showChangeToAcceptedWithFundingPopover: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = typeof STATE;
|
type State = typeof STATE;
|
||||||
|
@ -45,9 +30,11 @@ type State = typeof STATE;
|
||||||
class ProposalDetailNaked extends React.Component<Props, State> {
|
class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
state = STATE;
|
state = STATE;
|
||||||
rejectInput: null | TextArea = null;
|
rejectInput: null | TextArea = null;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.loadDetail();
|
this.loadDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const id = this.getIdFromQuery();
|
const id = this.getIdFromQuery();
|
||||||
const { proposalDetail: p, proposalDetailFetching } = store;
|
const { proposalDetail: p, proposalDetailFetching } = store;
|
||||||
|
@ -56,6 +43,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
return 'loading proposal...';
|
return 'loading proposal...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(p.fundedByZomg);
|
||||||
|
|
||||||
const needsArbiter =
|
const needsArbiter =
|
||||||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||||
p.status === PROPOSAL_STATUS.LIVE &&
|
p.status === PROPOSAL_STATUS.LIVE &&
|
||||||
|
@ -65,21 +54,36 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
const { isVersionTwo } = p;
|
||||||
|
const shouldShowArbiter =
|
||||||
|
!isVersionTwo || (isVersionTwo && p.acceptedWithFunding === true);
|
||||||
|
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund';
|
||||||
|
const shouldShowChangeToAcceptedWithFunding =
|
||||||
|
isVersionTwo && p.acceptedWithFunding === false;
|
||||||
|
|
||||||
const renderCancelControl = () => {
|
const renderCancelControl = () => {
|
||||||
const disabled = this.getCancelAndRefundDisabled();
|
const disabled = this.getCancelAndRefundDisabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={
|
title={
|
||||||
<p>
|
isVersionTwo ? (
|
||||||
Are you sure you want to cancel proposal and begin
|
<p>
|
||||||
<br />
|
Are you sure you want to cancel proposal?
|
||||||
the refund process? This cannot be undone.
|
<br />
|
||||||
</p>
|
This cannot be undone.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Are you sure you want to cancel proposal and begin
|
||||||
|
<br />
|
||||||
|
the refund process? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
placement="left"
|
placement='left'
|
||||||
cancelText="cancel"
|
cancelText='cancel'
|
||||||
okText="confirm"
|
okText='confirm'
|
||||||
visible={this.state.showCancelAndRefundPopover}
|
visible={this.state.showCancelAndRefundPopover}
|
||||||
okButtonProps={{
|
okButtonProps={{
|
||||||
loading: store.proposalDetailCanceling,
|
loading: store.proposalDetailCanceling,
|
||||||
|
@ -88,14 +92,47 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
onConfirm={this.handleConfirmCancel}
|
onConfirm={this.handleConfirmCancel}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="close-circle"
|
icon='close-circle'
|
||||||
className="ProposalDetail-controls-control"
|
className='ProposalDetail-controls-control'
|
||||||
loading={store.proposalDetailCanceling}
|
loading={store.proposalDetailCanceling}
|
||||||
onClick={this.handleCancelAndRefundClick}
|
onClick={this.handleCancelAndRefundClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
Cancel & refund
|
{cancelButtonText}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderChangeToAcceptedWithFundingControl = () => {
|
||||||
|
return (
|
||||||
|
<Popconfirm
|
||||||
|
title={
|
||||||
|
<p>
|
||||||
|
Are you sure you want to accept the proposal
|
||||||
|
<br />
|
||||||
|
with funding? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
placement='left'
|
||||||
|
cancelText='cancel'
|
||||||
|
okText='confirm'
|
||||||
|
visible={this.state.showChangeToAcceptedWithFundingPopover}
|
||||||
|
okButtonProps={{
|
||||||
|
loading: store.proposalDetailCanceling,
|
||||||
|
}}
|
||||||
|
onCancel={this.handleChangeToAcceptWithFundingCancel}
|
||||||
|
onConfirm={this.handleChangeToAcceptWithFundingConfirm}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon='close-circle'
|
||||||
|
className='ProposalDetail-controls-control'
|
||||||
|
loading={store.proposalDetailChangingToAcceptedWithFunding}
|
||||||
|
onClick={this.handleChangeToAcceptedWithFunding}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Accept With Funding
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
);
|
);
|
||||||
|
@ -116,74 +153,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderMatchingControl = () => (
|
|
||||||
<div className="ProposalDetail-controls-control">
|
|
||||||
<Popconfirm
|
|
||||||
overlayClassName="ProposalDetail-popover-overlay"
|
|
||||||
onConfirm={this.handleToggleMatching}
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
|
|
||||||
</div>
|
|
||||||
{p.status === PROPOSAL_STATUS.LIVE && (
|
|
||||||
<div>
|
|
||||||
This is a LIVE proposal, this will alter the funding state of the
|
|
||||||
proposal!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
okText="ok"
|
|
||||||
cancelText="cancel"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={p.contributionMatching === 1}
|
|
||||||
loading={store.proposalDetailUpdating}
|
|
||||||
disabled={
|
|
||||||
p.isFailed ||
|
|
||||||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
|
||||||
}
|
|
||||||
/>{' '}
|
|
||||||
</Popconfirm>
|
|
||||||
<span>
|
|
||||||
matching{' '}
|
|
||||||
<Info
|
|
||||||
placement="right"
|
|
||||||
content={
|
|
||||||
<span>
|
|
||||||
<b>Contribution matching</b>
|
|
||||||
<br /> Funded amount will be multiplied by 2.
|
|
||||||
<br /> <i>Disabled after proposal is fully-funded.</i>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBountyControl = () => (
|
|
||||||
<div className="ProposalDetail-controls-control">
|
|
||||||
<Button
|
|
||||||
icon="dollar"
|
|
||||||
className="ProposalDetail-controls-control"
|
|
||||||
loading={store.proposalDetailUpdating}
|
|
||||||
onClick={this.handleSetBounty}
|
|
||||||
disabled={
|
|
||||||
p.isFailed || [PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
|
||||||
}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Set bounty
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderApproved = () =>
|
const renderApproved = () =>
|
||||||
p.status === PROPOSAL_STATUS.APPROVED && (
|
p.status === PROPOSAL_STATUS.APPROVED && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="success"
|
type='success'
|
||||||
message={`Approved on ${formatDateSeconds(p.dateApproved)}`}
|
message={`Approved on ${formatDateSeconds(p.dateApproved)}`}
|
||||||
description={`
|
description={`
|
||||||
This proposal has been approved and will become live when a team-member
|
This proposal has been approved and will become live when a team-member
|
||||||
|
@ -192,54 +166,159 @@ 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 by user' : '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 && (
|
p.status === PROPOSAL_STATUS.PENDING && (
|
||||||
<Alert
|
<>
|
||||||
showIcon
|
<Row gutter={16}>
|
||||||
type="warning"
|
<Col span={isVersionTwo ? 16 : 24}>
|
||||||
message="Review Pending"
|
<Alert
|
||||||
description={
|
showIcon
|
||||||
<div>
|
type='warning'
|
||||||
<p>Please review this proposal and render your judgment.</p>
|
message='Review Discussion'
|
||||||
<Button
|
description={
|
||||||
loading={store.proposalDetailApproving}
|
<div>
|
||||||
icon="check"
|
<p>Please review this proposal and render your judgment.</p>
|
||||||
type="primary"
|
<Button
|
||||||
onClick={this.handleApprove}
|
className='ProposalDetail-review'
|
||||||
>
|
loading={store.proposalDetailApprovingDiscussion}
|
||||||
Approve
|
icon='check'
|
||||||
</Button>
|
type='primary'
|
||||||
<Button
|
onClick={() => this.handleApproveDiscussion()}
|
||||||
loading={store.proposalDetailApproving}
|
>
|
||||||
icon="close"
|
Open for Public Review
|
||||||
type="danger"
|
</Button>
|
||||||
onClick={() => {
|
<Button
|
||||||
FeedbackModal.open({
|
className='ProposalDetail-review'
|
||||||
title: 'Reject this proposal?',
|
loading={store.proposalDetailApprovingDiscussion}
|
||||||
label: 'Please provide a reason:',
|
icon='warning'
|
||||||
okText: 'Reject',
|
type='default'
|
||||||
onOk: this.handleReject,
|
onClick={() => {
|
||||||
});
|
FeedbackModal.open({
|
||||||
}}
|
title: 'Request changes to this proposal?',
|
||||||
>
|
label: 'Please provide a reason:',
|
||||||
Reject
|
okText: 'Request changes',
|
||||||
</Button>
|
onOk: this.handleRejectDiscussion,
|
||||||
</div>
|
});
|
||||||
}
|
}}
|
||||||
/>
|
>
|
||||||
|
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}>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type='warning'
|
||||||
|
message='Review Pending'
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>Please review this proposal and render your judgment.</p>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className='ProposalDetail-review'
|
||||||
|
loading={store.proposalDetailAcceptingProposal}
|
||||||
|
icon='check'
|
||||||
|
type='primary'
|
||||||
|
onClick={() => this.handleAcceptProposal(true, true)}
|
||||||
|
>
|
||||||
|
Approve With Funding
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='ProposalDetail-review'
|
||||||
|
loading={store.proposalDetailAcceptingProposal}
|
||||||
|
icon='check'
|
||||||
|
type='default'
|
||||||
|
onClick={() => this.handleAcceptProposal(true, false)}
|
||||||
|
>
|
||||||
|
Approve Without Funding
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className='ProposalDetail-review'
|
||||||
|
loading={store.proposalDetailMarkingChangesAsResolved}
|
||||||
|
icon='close'
|
||||||
|
type='danger'
|
||||||
|
onClick={() => {
|
||||||
|
FeedbackModal.open({
|
||||||
|
title: 'Request changes to this proposal?',
|
||||||
|
label: 'Please provide a reason:',
|
||||||
|
okText: 'Request changes',
|
||||||
|
onOk: this.handleRejectProposal,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
{renderKycColumn()}
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRejected = () =>
|
const renderRejected = () =>
|
||||||
p.status === PROPOSAL_STATUS.REJECTED && (
|
p.status === PROPOSAL_STATUS.REJECTED && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="error"
|
type='error'
|
||||||
message="Rejected"
|
message='Changes requested'
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
This proposal has been rejected. The team will be able to re-submit it for
|
This proposal has changes requested. The team will be able to re-submit it
|
||||||
approval should they desire to do so.
|
for approval should they desire to do so.
|
||||||
</p>
|
</p>
|
||||||
<b>Reason:</b>
|
<b>Reason:</b>
|
||||||
<br />
|
<br />
|
||||||
|
@ -249,28 +328,89 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderNominateArbiter = () =>
|
const renderChangesRequestedDiscussion = () =>
|
||||||
needsArbiter && (
|
p.status === PROPOSAL_STATUS.DISCUSSION &&
|
||||||
|
p.changesRequestedDiscussion && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type='error'
|
||||||
message="No arbiter on live proposal"
|
message='Changes requested'
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>An arbiter is required to review milestone payout requests.</p>
|
<p>
|
||||||
<ArbiterControl {...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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderNominateArbiter = () =>
|
||||||
|
needsArbiter &&
|
||||||
|
shouldShowArbiter && (
|
||||||
|
<>
|
||||||
|
{!p.kycApproved ? (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type='error'
|
||||||
|
message='KYC approval required'
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Please wait until an Admin has marked KYC approved before proceeding
|
||||||
|
with payouts.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className='ProposalDetail-review'
|
||||||
|
loading={store.proposalDetailApprovingKyc}
|
||||||
|
icon='check'
|
||||||
|
type='primary'
|
||||||
|
onClick={() => this.handleApproveKYC()}
|
||||||
|
>
|
||||||
|
KYC Approved
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type='warning'
|
||||||
|
message='No arbiter on live proposal'
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>An arbiter is required to review milestone payout requests.</p>
|
||||||
|
<ArbiterControl {...p} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const renderNominatedArbiter = () =>
|
const renderNominatedArbiter = () =>
|
||||||
PROPOSAL_ARBITER_STATUS.NOMINATED === p.arbiter.status &&
|
PROPOSAL_ARBITER_STATUS.NOMINATED === p.arbiter.status &&
|
||||||
p.status === PROPOSAL_STATUS.LIVE && (
|
p.status === PROPOSAL_STATUS.LIVE && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="info"
|
type='info'
|
||||||
message="Arbiter has been nominated"
|
message='Arbiter has been nominated'
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
|
@ -297,16 +437,28 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ms = p.currentMilestone;
|
const ms = p.currentMilestone;
|
||||||
const amount = fromZat(
|
|
||||||
toZat(p.target)
|
let paymentMsg;
|
||||||
.mul(new BN(ms.payoutPercent))
|
if (p.isVersionTwo) {
|
||||||
.divn(100),
|
const target = parseFloat(p.target.toString());
|
||||||
);
|
const payoutPercent = parseFloat(ms.payoutPercent);
|
||||||
|
const amountNum = (target * payoutPercent) / 100;
|
||||||
|
const amount = formatUsd(amountNum, true, 2);
|
||||||
|
paymentMsg = `${amount} in ZEC`;
|
||||||
|
} else {
|
||||||
|
const amount = fromZat(
|
||||||
|
toZat(p.target)
|
||||||
|
.mul(new BN(ms.payoutPercent))
|
||||||
|
.divn(100),
|
||||||
|
);
|
||||||
|
paymentMsg = `${amount} ZEC`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
className="ProposalDetail-alert"
|
className='ProposalDetail-alert'
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type='warning'
|
||||||
message={null}
|
message={null}
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
|
@ -318,13 +470,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{' '}
|
{' '}
|
||||||
Please make a payment of <b>{amount.toString()} ZEC</b> to:
|
Please make a payment of <b>{paymentMsg}</b> to:
|
||||||
</p>{' '}
|
</p>{' '}
|
||||||
<pre>{p.payoutAddress}</pre>
|
<pre>{p.payoutAddress}</pre>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="please enter payment txid"
|
placeholder='please enter payment txid'
|
||||||
value={this.state.paidTxId}
|
value={this.state.paidTxId}
|
||||||
enterButton="Mark Paid"
|
enterButton='Mark Paid'
|
||||||
onChange={e => this.setState({ paidTxId: e.target.value })}
|
onChange={e => this.setState({ paidTxId: e.target.value })}
|
||||||
onSearch={this.handlePaidMilestone}
|
onSearch={this.handlePaidMilestone}
|
||||||
/>
|
/>
|
||||||
|
@ -338,7 +490,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
p.isFailed && (
|
p.isFailed && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="error"
|
type='error'
|
||||||
message={
|
message={
|
||||||
p.stage === PROPOSAL_STAGE.FAILED ? 'Proposal failed' : 'Proposal canceled'
|
p.stage === PROPOSAL_STAGE.FAILED ? 'Proposal failed' : 'Proposal canceled'
|
||||||
}
|
}
|
||||||
|
@ -360,58 +512,71 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDeetItem = (name: string, val: any) => (
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
<div className="ProposalDetail-deet">
|
<div className='ProposalDetail-deet'>
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
{val}
|
{val}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
<div className="ProposalDetail">
|
<div className='ProposalDetail'>
|
||||||
<Back to="/proposals" text="Proposals" />
|
<Back to='/proposals' text='Proposals' />
|
||||||
<h1>{p.title}</h1>
|
<h1>{p.title}</h1>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{/* MAIN */}
|
{/* MAIN */}
|
||||||
<Col span={18}>
|
<Col span={18}>
|
||||||
{renderApproved()}
|
{renderApproved()}
|
||||||
{renderReview()}
|
{renderReviewDiscussion()}
|
||||||
|
{renderReviewProposal()}
|
||||||
{renderRejected()}
|
{renderRejected()}
|
||||||
|
{renderChangesRequestedDiscussion()}
|
||||||
{renderNominateArbiter()}
|
{renderNominateArbiter()}
|
||||||
{renderNominatedArbiter()}
|
{renderNominatedArbiter()}
|
||||||
{renderMilestoneAccepted()}
|
{renderMilestoneAccepted()}
|
||||||
{renderFailed()}
|
{renderFailed()}
|
||||||
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
|
||||||
|
<Collapse.Panel key='brief' header='brief'>
|
||||||
<Collapse.Panel key="brief" header="brief">
|
|
||||||
{p.brief}
|
{p.brief}
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="content" header="content">
|
<Collapse.Panel key='content' header='content'>
|
||||||
<Markdown source={p.content} />
|
<Markdown source={p.content} />
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="milestones" header="milestones">
|
<Collapse.Panel key='milestones' header='milestones'>
|
||||||
{
|
{p.milestones.map((milestone, i) => (
|
||||||
p.milestones.map((milestone, i) =>
|
<Card
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{milestone.title + ' '}
|
||||||
|
{milestone.immediatePayout && (
|
||||||
|
<Tag color='magenta'>Immediate Payout</Tag>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
extra={`${milestone.payoutPercent}% Payout`}
|
||||||
|
key={i}
|
||||||
|
>
|
||||||
|
{p.isVersionTwo && (
|
||||||
|
<p>
|
||||||
|
<b>Estimated Days to Complete:</b>{' '}
|
||||||
|
{milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<b>Estimated Date:</b>{' '}
|
||||||
|
{milestone.dateEstimated
|
||||||
|
? formatDateSeconds(milestone.dateEstimated)
|
||||||
|
: 'N/A'}{' '}
|
||||||
|
</p>
|
||||||
|
|
||||||
<Card title={
|
<p>{milestone.content}</p>
|
||||||
<>
|
</Card>
|
||||||
{milestone.title + ' '}
|
))}
|
||||||
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
extra={`${milestone.payoutPercent}% Payout`}
|
|
||||||
key={i}
|
|
||||||
>
|
|
||||||
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p>
|
|
||||||
<p>{milestone.content}</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel key="json" header="json">
|
<Collapse.Panel key='json' header='json'>
|
||||||
<pre>{JSON.stringify(p, null, 4)}</pre>
|
<pre>{JSON.stringify(p, null, 4)}</pre>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
|
@ -419,16 +584,39 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
|
|
||||||
{/* RIGHT SIDE */}
|
{/* RIGHT SIDE */}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
|
{p.isVersionTwo &&
|
||||||
|
!p.acceptedWithFunding &&
|
||||||
|
p.stage === PROPOSAL_STAGE.WIP && (
|
||||||
|
<Alert
|
||||||
|
message='Accepted without funding'
|
||||||
|
description="This proposal has been posted publicly, but isn't being funded by the Zcash Foundation."
|
||||||
|
type='info'
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<Card size="small" className="ProposalDetail-controls">
|
<Card size='small' className='ProposalDetail-controls'>
|
||||||
{renderCancelControl()}
|
{renderCancelControl()}
|
||||||
{renderArbiterControl()}
|
{renderArbiterControl()}
|
||||||
{renderBountyControl()}
|
|
||||||
{renderMatchingControl()}
|
{
|
||||||
|
p.acceptedWithFunding &&
|
||||||
|
<div style={{ marginTop: '10px' }}>
|
||||||
|
<Switch checkedChildren='Funded by ZOMG'
|
||||||
|
unCheckedChildren='Funded by ZF'
|
||||||
|
onChange={this.handleSwitchFunder}
|
||||||
|
loading={store.proposalDetailSwitchingFunder}
|
||||||
|
checked={p.fundedByZomg} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{shouldShowChangeToAcceptedWithFunding &&
|
||||||
|
renderChangeToAcceptedWithFundingControl()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* DETAILS */}
|
{/* DETAILS */}
|
||||||
<Card title="Details" size="small">
|
<Card title='Details' size='small'>
|
||||||
{renderDeetItem('id', p.proposalId)}
|
{renderDeetItem('id', p.proposalId)}
|
||||||
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
||||||
{renderDeetItem(
|
{renderDeetItem(
|
||||||
|
@ -440,20 +628,26 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
formatDurationSeconds(p.deadlineDuration),
|
formatDurationSeconds(p.deadlineDuration),
|
||||||
)}
|
)}
|
||||||
{p.datePublished &&
|
{p.datePublished &&
|
||||||
renderDeetItem(
|
renderDeetItem(
|
||||||
'(deadline)',
|
'(deadline)',
|
||||||
formatDateSeconds(p.datePublished + p.deadlineDuration),
|
formatDateSeconds(p.datePublished + p.deadlineDuration),
|
||||||
)}
|
)}
|
||||||
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
|
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
|
||||||
{renderDeetItem('status', p.status)}
|
{renderDeetItem('status', p.status)}
|
||||||
{renderDeetItem('stage', p.stage)}
|
{renderDeetItem('stage', p.stage)}
|
||||||
{renderDeetItem('category', p.category)}
|
{renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)}
|
||||||
{renderDeetItem('target', p.target)}
|
|
||||||
{renderDeetItem('contributed', p.contributed)}
|
{renderDeetItem('contributed', p.contributed)}
|
||||||
{renderDeetItem('funded (inc. matching)', p.funded)}
|
{renderDeetItem(
|
||||||
|
'funded (inc. matching)',
|
||||||
|
p.isVersionTwo ? formatUsd(p.funded) : p.funded,
|
||||||
|
)}
|
||||||
{renderDeetItem('matching', p.contributionMatching)}
|
{renderDeetItem('matching', p.contributionMatching)}
|
||||||
{renderDeetItem('bounty', p.contributionBounty)}
|
{renderDeetItem('bounty', p.contributionBounty)}
|
||||||
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
|
||||||
|
{renderDeetItem(
|
||||||
|
'acceptedWithFunding',
|
||||||
|
JSON.stringify(p.acceptedWithFunding),
|
||||||
|
)}
|
||||||
{renderDeetItem(
|
{renderDeetItem(
|
||||||
'arbiter',
|
'arbiter',
|
||||||
<>
|
<>
|
||||||
|
@ -466,14 +660,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
</>,
|
</>,
|
||||||
)}
|
)}
|
||||||
{p.rfp &&
|
{p.rfp &&
|
||||||
renderDeetItem(
|
renderDeetItem(
|
||||||
'rfp',
|
'rfp',
|
||||||
<Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>,
|
<Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>,
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* TEAM */}
|
{/* TEAM */}
|
||||||
<Card title="Team" size="small">
|
<Card title='Team' size='small'>
|
||||||
{p.team.map(t => (
|
{p.team.map(t => (
|
||||||
<div key={t.userid}>
|
<div key={t.userid}>
|
||||||
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
|
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
|
||||||
|
@ -508,6 +702,20 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleChangeToAcceptedWithFunding = () => {
|
||||||
|
this.setState({ showChangeToAcceptedWithFundingPopover: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleChangeToAcceptWithFundingCancel = () => {
|
||||||
|
this.setState({ showChangeToAcceptedWithFundingPopover: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleChangeToAcceptWithFundingConfirm = () => {
|
||||||
|
if (!store.proposalDetail) return;
|
||||||
|
store.changeProposalToAcceptedWithFunding(store.proposalDetail.proposalId);
|
||||||
|
this.setState({ showChangeToAcceptedWithFundingPopover: false });
|
||||||
|
};
|
||||||
|
|
||||||
private getIdFromQuery = () => {
|
private getIdFromQuery = () => {
|
||||||
return Number(this.props.match.params.id);
|
return Number(this.props.match.params.id);
|
||||||
};
|
};
|
||||||
|
@ -526,43 +734,44 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
this.setState({ showCancelAndRefundPopover: false });
|
this.setState({ showCancelAndRefundPopover: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleApprove = () => {
|
private handleApproveDiscussion = async () => {
|
||||||
store.approveProposal(true);
|
await store.approveDiscussion(true);
|
||||||
|
message.info('Proposal now open for discussion');
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleReject = async (reason: string) => {
|
private handleRejectDiscussion = async (rejectReason: string) => {
|
||||||
await store.approveProposal(false, reason);
|
await store.approveDiscussion(false, rejectReason);
|
||||||
message.info('Proposal rejected');
|
message.info('Proposal changes requested');
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleToggleMatching = async () => {
|
private handleRejectPermanently = async (rejectReason: string) => {
|
||||||
if (store.proposalDetail) {
|
await store.rejectPermanentlyProposal(rejectReason);
|
||||||
// we lock this to be 1 or 0 for now, we may support more values later on
|
message.info('Proposal rejected permanently');
|
||||||
const contributionMatching =
|
|
||||||
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
|
|
||||||
await store.updateProposalDetail({ contributionMatching });
|
|
||||||
message.success('Updated matching');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleSetBounty = async () => {
|
private handleApproveKYC = async () => {
|
||||||
if (store.proposalDetail) {
|
await store.approveProposalKYC();
|
||||||
FeedbackModal.open({
|
message.info(`Proposal KYC approved`);
|
||||||
title: 'Set bounty?',
|
};
|
||||||
content:
|
|
||||||
'Set the bounty for this proposal. The bounty will count towards the funding goal.',
|
private handleAcceptProposal = async (
|
||||||
type: 'input',
|
isAccepted: boolean,
|
||||||
inputProps: {
|
withFunding: boolean,
|
||||||
addonBefore: 'Amount',
|
changesRequestedReason?: string,
|
||||||
addonAfter: 'ZEC',
|
) => {
|
||||||
placeholder: '1.5',
|
await store.acceptProposal(isAccepted, withFunding, changesRequestedReason);
|
||||||
},
|
message.info(`Proposal accepted ${withFunding ? 'with' : 'without'} funding`);
|
||||||
okText: 'Set bounty',
|
};
|
||||||
onOk: async contributionBounty => {
|
|
||||||
await store.updateProposalDetail({ contributionBounty });
|
private handleRejectProposal = async (changesRequestedReason: string) => {
|
||||||
message.success('Updated bounty');
|
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`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -572,6 +781,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
await store.markMilestonePaid(pid, mid, this.state.paidTxId);
|
await store.markMilestonePaid(pid, mid, this.state.paidTxId);
|
||||||
message.success('Marked milestone paid.');
|
message.success('Marked milestone paid.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleSwitchFunder = async (checkValue: boolean) => {
|
||||||
|
store.switchProposalFunder(checkValue);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
const ProposalDetail = withRouter(view(ProposalDetailNaked));
|
||||||
|
|
|
@ -2,13 +2,14 @@ import React from 'react';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin } from 'antd';
|
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin, Alert } from 'antd';
|
||||||
import Exception from 'ant-design-pro/lib/Exception';
|
import Exception from 'ant-design-pro/lib/Exception';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
import { formatDateSeconds } from 'util/time';
|
import { formatDateSeconds } from 'util/time';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { PROPOSAL_STATUS } from 'src/types';
|
import { PROPOSAL_STATUS } from 'src/types';
|
||||||
|
import { formatUsd } from 'src/util/formatters';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
type Props = RouteComponentProps<{ id?: string }>;
|
type Props = RouteComponentProps<{ id?: string }>;
|
||||||
|
@ -37,9 +38,11 @@ class RFPDetail extends React.Component<Props> {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING);
|
const pendingProposals = rfp.proposals.filter(
|
||||||
const acceptedProposals = rfp.proposals.filter(p =>
|
p => p.status === PROPOSAL_STATUS.PENDING,
|
||||||
p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED
|
);
|
||||||
|
const acceptedProposals = rfp.proposals.filter(
|
||||||
|
p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -66,6 +69,20 @@ class RFPDetail extends React.Component<Props> {
|
||||||
|
|
||||||
{/* RIGHT SIDE */}
|
{/* RIGHT SIDE */}
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
|
{rfp.ccr && (
|
||||||
|
<Alert
|
||||||
|
message="Linked CCR"
|
||||||
|
description={
|
||||||
|
<React.Fragment>
|
||||||
|
This RFP has been generated from a CCR{' '}
|
||||||
|
<Link to={`/ccrs/${rfp.ccr.ccrId}`}>here</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ACTIONS */}
|
{/* ACTIONS */}
|
||||||
<Card className="RFPDetail-actions" size="small">
|
<Card className="RFPDetail-actions" size="small">
|
||||||
<Link to={`/rfps/${rfp.id}/edit`}>
|
<Link to={`/rfps/${rfp.id}/edit`}>
|
||||||
|
@ -90,10 +107,15 @@ class RFPDetail extends React.Component<Props> {
|
||||||
{renderDeetItem('id', rfp.id)}
|
{renderDeetItem('id', rfp.id)}
|
||||||
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
|
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
|
||||||
{renderDeetItem('status', rfp.status)}
|
{renderDeetItem('status', rfp.status)}
|
||||||
{renderDeetItem('category', rfp.category)}
|
|
||||||
{renderDeetItem('matching', String(rfp.matching))}
|
{renderDeetItem('matching', String(rfp.matching))}
|
||||||
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
|
{renderDeetItem(
|
||||||
{renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
|
'bounty',
|
||||||
|
rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`,
|
||||||
|
)}
|
||||||
|
{renderDeetItem(
|
||||||
|
'dateCloses',
|
||||||
|
rfp.dateCloses && formatDateSeconds(rfp.dateCloses),
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* PROPOSALS */}
|
{/* PROPOSALS */}
|
||||||
|
|
|
@ -3,23 +3,10 @@ import moment from 'moment';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd';
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Icon,
|
|
||||||
Button,
|
|
||||||
message,
|
|
||||||
Spin,
|
|
||||||
Checkbox,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
DatePicker,
|
|
||||||
} from 'antd';
|
|
||||||
import Exception from 'ant-design-pro/lib/Exception';
|
import Exception from 'ant-design-pro/lib/Exception';
|
||||||
import { FormComponentProps } from 'antd/lib/form';
|
import { FormComponentProps } from 'antd/lib/form';
|
||||||
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
|
import { RFP_STATUS, RFPArgs } from 'src/types';
|
||||||
import { CATEGORY_UI } from 'util/ui';
|
|
||||||
import { typedKeys } from 'util/ts';
|
import { typedKeys } from 'util/ts';
|
||||||
import { RFP_STATUSES, getStatusById } from 'util/statuses';
|
import { RFP_STATUSES, getStatusById } from 'util/statuses';
|
||||||
import Markdown from 'components/Markdown';
|
import Markdown from 'components/Markdown';
|
||||||
|
@ -54,13 +41,14 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
title: '',
|
title: '',
|
||||||
brief: '',
|
brief: '',
|
||||||
content: '',
|
content: '',
|
||||||
category: '',
|
|
||||||
status: '',
|
status: '',
|
||||||
matching: false,
|
matching: false,
|
||||||
bounty: undefined,
|
bounty: undefined,
|
||||||
dateCloses: undefined,
|
dateCloses: undefined,
|
||||||
};
|
};
|
||||||
const rfpId = this.getRFPId();
|
const rfpId = this.getRFPId();
|
||||||
|
let isVersionTwo = true;
|
||||||
|
|
||||||
if (rfpId) {
|
if (rfpId) {
|
||||||
if (!store.rfpsFetched) {
|
if (!store.rfpsFetched) {
|
||||||
return <Spin />;
|
return <Spin />;
|
||||||
|
@ -72,12 +60,12 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
title: rfp.title,
|
title: rfp.title,
|
||||||
brief: rfp.brief,
|
brief: rfp.brief,
|
||||||
content: rfp.content,
|
content: rfp.content,
|
||||||
category: rfp.category,
|
|
||||||
status: rfp.status,
|
status: rfp.status,
|
||||||
matching: rfp.matching,
|
matching: rfp.matching,
|
||||||
bounty: rfp.bounty,
|
bounty: rfp.bounty,
|
||||||
dateCloses: rfp.dateCloses || undefined,
|
dateCloses: rfp.dateCloses || undefined,
|
||||||
};
|
};
|
||||||
|
isVersionTwo = rfp.isVersionTwo;
|
||||||
} else {
|
} else {
|
||||||
return <Exception type="404" desc="This RFP does not exist" />;
|
return <Exception type="404" desc="This RFP does not exist" />;
|
||||||
}
|
}
|
||||||
|
@ -88,6 +76,10 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
|
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
|
||||||
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
|
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
|
||||||
|
|
||||||
|
const bountyMatchRule = isVersionTwo
|
||||||
|
? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' }
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
|
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
|
||||||
<Back to="/rfps" text="RFPs" />
|
<Back to="/rfps" text="RFPs" />
|
||||||
|
@ -131,28 +123,6 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Form.Item label="Category">
|
|
||||||
{getFieldDecorator('category', {
|
|
||||||
initialValue: defaults.category,
|
|
||||||
rules: [
|
|
||||||
{ required: true, message: 'Category is required' },
|
|
||||||
{ max: 60, message: 'Max 60 chars' },
|
|
||||||
],
|
|
||||||
})(
|
|
||||||
<Select size="large" placeholder="Select a category">
|
|
||||||
{typedKeys(PROPOSAL_CATEGORY).map(c => (
|
|
||||||
<Select.Option value={c} key={c}>
|
|
||||||
<Icon
|
|
||||||
type={CATEGORY_UI[c].icon}
|
|
||||||
style={{ color: CATEGORY_UI[c].color }}
|
|
||||||
/>{' '}
|
|
||||||
{CATEGORY_UI[c].label}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="Brief description">
|
<Form.Item label="Brief description">
|
||||||
{getFieldDecorator('brief', {
|
{getFieldDecorator('brief', {
|
||||||
initialValue: defaults.brief,
|
initialValue: defaults.brief,
|
||||||
|
@ -199,26 +169,20 @@ class RFPForm extends React.Component<Props, State> {
|
||||||
<Form.Item className="RFPForm-bounty" label="Bounty">
|
<Form.Item className="RFPForm-bounty" label="Bounty">
|
||||||
{getFieldDecorator('bounty', {
|
{getFieldDecorator('bounty', {
|
||||||
initialValue: defaults.bounty,
|
initialValue: defaults.bounty,
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: 'Bounty is required' },
|
||||||
|
bountyMatchRule,
|
||||||
|
],
|
||||||
})(
|
})(
|
||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
name="bounty"
|
name="bounty"
|
||||||
placeholder="100"
|
placeholder="1000"
|
||||||
addonAfter="ZEC"
|
addonBefore={isVersionTwo ? '$' : undefined}
|
||||||
|
addonAfter={isVersionTwo ? undefined : 'ZEC'}
|
||||||
size="large"
|
size="large"
|
||||||
/>,
|
/>,
|
||||||
)}
|
)}
|
||||||
{getFieldDecorator('matching', {
|
|
||||||
initialValue: defaults.matching,
|
|
||||||
})(
|
|
||||||
<Checkbox
|
|
||||||
className="RFPForm-bounty-matching"
|
|
||||||
name="matching"
|
|
||||||
defaultChecked={defaults.matching}
|
|
||||||
>
|
|
||||||
Match community contributions for approved proposals
|
|
||||||
</Checkbox>,
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col sm={12} xs={24}>
|
<Col sm={12} xs={24}>
|
||||||
|
|
|
@ -51,18 +51,18 @@ class Template extends React.Component<Props> {
|
||||||
<span className="nav-text">Proposals</span>
|
<span className="nav-text">Proposals</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item key="ccrs">
|
||||||
|
<Link to="/ccrs">
|
||||||
|
<Icon type="solution" />
|
||||||
|
<span className="nav-text">CCRs</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item key="rfps">
|
<Menu.Item key="rfps">
|
||||||
<Link to="/rfps">
|
<Link to="/rfps">
|
||||||
<Icon type="notification" />
|
<Icon type="notification" />
|
||||||
<span className="nav-text">RFPs</span>
|
<span className="nav-text">RFPs</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</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">
|
<Menu.Item key="financials">
|
||||||
<Link to="/financials">
|
<Link to="/financials">
|
||||||
<Icon type="audit" />
|
<Icon type="audit" />
|
||||||
|
|
|
@ -2,16 +2,17 @@ import { pick } from 'lodash';
|
||||||
import { store } from 'react-easy-state';
|
import { store } from 'react-easy-state';
|
||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import {
|
import {
|
||||||
User,
|
CCR,
|
||||||
Proposal,
|
CommentArgs,
|
||||||
Contribution,
|
Contribution,
|
||||||
ContributionArgs,
|
ContributionArgs,
|
||||||
|
EmailExample,
|
||||||
|
PageData,
|
||||||
|
PageQuery,
|
||||||
|
Proposal,
|
||||||
RFP,
|
RFP,
|
||||||
RFPArgs,
|
RFPArgs,
|
||||||
EmailExample,
|
User,
|
||||||
PageQuery,
|
|
||||||
PageData,
|
|
||||||
CommentArgs,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// API
|
// API
|
||||||
|
@ -129,19 +130,64 @@ async function deleteProposal(id: number) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
|
async function approveDiscussion(
|
||||||
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
|
id: number,
|
||||||
isApprove,
|
isOpenForDiscussion: boolean,
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/discussion`, {
|
||||||
|
isOpenForDiscussion,
|
||||||
rejectReason,
|
rejectReason,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchProposalFunder(id: number, fundedByZomg: boolean) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/adjust-funder`, {fundedByZomg});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveProposalKYC(id: number) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/approve-kyc`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptProposal(
|
||||||
|
id: number,
|
||||||
|
isAccepted: boolean,
|
||||||
|
withFunding: boolean,
|
||||||
|
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) {
|
async function cancelProposal(id: number) {
|
||||||
const { data } = await api.put(`/admin/proposals/${id}/cancel`);
|
const { data } = await api.put(`/admin/proposals/${id}/cancel`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeProposalToAcceptedWithFunding(id: number) {
|
||||||
|
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchComments(params: Partial<PageQuery>) {
|
async function fetchComments(params: Partial<PageQuery>) {
|
||||||
const { data } = await api.get('/admin/comments', { params });
|
const { data } = await api.get('/admin/comments', { params });
|
||||||
return data;
|
return data;
|
||||||
|
@ -165,6 +211,35 @@ async function getEmailExample(type: string) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCCRDetail(id: number) {
|
||||||
|
const { data } = await api.get(`/admin/ccrs/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string) {
|
||||||
|
const { data } = await api.put(`/admin/ccrs/${id}/accept`, {
|
||||||
|
isAccepted,
|
||||||
|
rejectReason,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCCR(id: number) {
|
||||||
|
await api.delete(`/admin/ccrs/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function getRFPs() {
|
async function getRFPs() {
|
||||||
const { data } = await api.get(`/admin/rfps`);
|
const { data } = await api.get(`/admin/rfps`);
|
||||||
return data;
|
return data;
|
||||||
|
@ -204,6 +279,14 @@ async function editContribution(id: number, args: ContributionArgs) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QuarterData {
|
||||||
|
q1: string;
|
||||||
|
q2: string;
|
||||||
|
q3: string;
|
||||||
|
q4: string;
|
||||||
|
yearTotal: string;
|
||||||
|
}
|
||||||
|
|
||||||
// STORE
|
// STORE
|
||||||
const app = store({
|
const app = store({
|
||||||
/*** DATA ***/
|
/*** DATA ***/
|
||||||
|
@ -218,6 +301,7 @@ const app = store({
|
||||||
stats: {
|
stats: {
|
||||||
userCount: 0,
|
userCount: 0,
|
||||||
proposalCount: 0,
|
proposalCount: 0,
|
||||||
|
ccrPendingCount: 0,
|
||||||
proposalPendingCount: 0,
|
proposalPendingCount: 0,
|
||||||
proposalNoArbiterCount: 0,
|
proposalNoArbiterCount: 0,
|
||||||
proposalMilestonePayoutsCount: 0,
|
proposalMilestonePayoutsCount: 0,
|
||||||
|
@ -232,22 +316,13 @@ const app = store({
|
||||||
matching: '0',
|
matching: '0',
|
||||||
bounty: '0',
|
bounty: '0',
|
||||||
},
|
},
|
||||||
contributions: {
|
|
||||||
total: '0',
|
|
||||||
gross: '0',
|
|
||||||
staking: '0',
|
|
||||||
funding: '0',
|
|
||||||
funded: '0',
|
|
||||||
refunding: '0',
|
|
||||||
refunded: '0',
|
|
||||||
donations: '0',
|
|
||||||
},
|
|
||||||
payouts: {
|
payouts: {
|
||||||
total: '0',
|
total: '0',
|
||||||
due: '0',
|
due: '0',
|
||||||
paid: '0',
|
paid: '0',
|
||||||
future: '0',
|
future: '0',
|
||||||
},
|
},
|
||||||
|
payoutsByQuarter: {} as { [type: string]: QuarterData },
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
|
@ -277,11 +352,36 @@ const app = store({
|
||||||
|
|
||||||
proposalDetail: null as null | Proposal,
|
proposalDetail: null as null | Proposal,
|
||||||
proposalDetailFetching: false,
|
proposalDetailFetching: false,
|
||||||
proposalDetailApproving: false,
|
proposalDetailApprovingDiscussion: false,
|
||||||
|
proposalDetailMarkingChangesAsResolved: false,
|
||||||
|
proposalDetailAcceptingProposal: false,
|
||||||
|
proposalDetailApprovingKyc: false,
|
||||||
|
proposalDetailSwitchingFunder: false,
|
||||||
proposalDetailMarkingMilestonePaid: false,
|
proposalDetailMarkingMilestonePaid: false,
|
||||||
proposalDetailCanceling: false,
|
proposalDetailCanceling: false,
|
||||||
proposalDetailUpdating: false,
|
proposalDetailUpdating: false,
|
||||||
proposalDetailUpdated: false,
|
proposalDetailUpdated: false,
|
||||||
|
proposalDetailChangingToAcceptedWithFunding: false,
|
||||||
|
proposalDetailRejectingPermanently: false,
|
||||||
|
|
||||||
|
ccrs: {
|
||||||
|
page: createDefaultPageData<CCR>('CREATED:DESC'),
|
||||||
|
},
|
||||||
|
ccrSaving: false,
|
||||||
|
ccrSaved: false,
|
||||||
|
ccrDeleting: false,
|
||||||
|
ccrDeleted: false,
|
||||||
|
|
||||||
|
ccrDetail: null as null | CCR,
|
||||||
|
ccrDetailFetching: false,
|
||||||
|
ccrDetailApproving: false,
|
||||||
|
ccrDetailMarkingMilestonePaid: false,
|
||||||
|
ccrDetailCanceling: false,
|
||||||
|
ccrDetailUpdating: false,
|
||||||
|
ccrDetailUpdated: false,
|
||||||
|
ccrDetailChangingToAcceptedWithFunding: false,
|
||||||
|
ccrDetailRejectingPermanently: false,
|
||||||
|
ccrCreatedRFPId: null,
|
||||||
|
|
||||||
comments: {
|
comments: {
|
||||||
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
page: createDefaultPageData<Comment>('CREATED:DESC'),
|
||||||
|
@ -482,6 +582,71 @@ const app = store({
|
||||||
app.arbiterSaving = false;
|
app.arbiterSaving = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// CCRS
|
||||||
|
|
||||||
|
async fetchCCRs() {
|
||||||
|
return await pageFetch(app.ccrs, fetchCCRs);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCCRPageQuery(params: Partial<PageQuery>) {
|
||||||
|
setPageParams(app.ccrs, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetCCRPageQuery() {
|
||||||
|
resetPageParams(app.ccrs);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCCRDetail(id: number) {
|
||||||
|
app.ccrDetailFetching = true;
|
||||||
|
try {
|
||||||
|
app.ccrDetail = await fetchCCRDetail(id);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.ccrDetailFetching = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveCCR(isAccepted: boolean, rejectReason?: string) {
|
||||||
|
if (!app.ccrDetail) {
|
||||||
|
const m = 'store.approveCCR(): Expected ccrDetail to be populated!';
|
||||||
|
app.generalError.push(m);
|
||||||
|
console.error(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.ccrCreatedRFPId = null;
|
||||||
|
app.ccrDetailApproving = true;
|
||||||
|
try {
|
||||||
|
const { ccrId } = app.ccrDetail;
|
||||||
|
const res = await approveCCR(ccrId, isAccepted, rejectReason);
|
||||||
|
await app.fetchCCRs();
|
||||||
|
await app.fetchRFPs();
|
||||||
|
if (isAccepted) {
|
||||||
|
app.ccrCreatedRFPId = res.rfpId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.ccrDetailApproving = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
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
|
// Proposals
|
||||||
|
|
||||||
async fetchProposals() {
|
async fetchProposals() {
|
||||||
|
@ -536,22 +701,125 @@ const app = store({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async approveProposal(isApprove: boolean, rejectReason?: string) {
|
async switchProposalFunder(fundedByZomg: boolean) {
|
||||||
if (!app.proposalDetail) {
|
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);
|
app.generalError.push(m);
|
||||||
console.error(m);
|
console.error(m);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.proposalDetailApproving = true;
|
app.proposalDetailSwitchingFunder = true;
|
||||||
try {
|
try {
|
||||||
const { proposalId } = app.proposalDetail;
|
const { proposalId } = app.proposalDetail;
|
||||||
const res = await approveProposal(proposalId, isApprove, rejectReason);
|
const res = await switchProposalFunder(proposalId, fundedByZomg);
|
||||||
app.updateProposalInStore(res);
|
app.updateProposalInStore(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleApiError(e);
|
handleApiError(e);
|
||||||
}
|
}
|
||||||
app.proposalDetailApproving = false;
|
app.proposalDetailSwitchingFunder = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveProposalKYC() {
|
||||||
|
if (!app.proposalDetail) {
|
||||||
|
const m = 'store.acceptProposal(): Expected proposalDetail to be populated!';
|
||||||
|
app.generalError.push(m);
|
||||||
|
console.error(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.proposalDetailApprovingKyc = true;
|
||||||
|
try {
|
||||||
|
const { proposalId } = app.proposalDetail;
|
||||||
|
const res = await approveProposalKYC(proposalId);
|
||||||
|
app.updateProposalInStore(res);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.proposalDetailApprovingKyc = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptProposal(
|
||||||
|
isAccepted: boolean,
|
||||||
|
withFunding: boolean,
|
||||||
|
changesRequestedReason?: string,
|
||||||
|
) {
|
||||||
|
if (!app.proposalDetail) {
|
||||||
|
const m = 'store.acceptProposal(): Expected proposalDetail to be populated!';
|
||||||
|
app.generalError.push(m);
|
||||||
|
console.error(m);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.proposalDetailAcceptingProposal = true;
|
||||||
|
try {
|
||||||
|
const { proposalId } = app.proposalDetail;
|
||||||
|
const res = await acceptProposal(
|
||||||
|
proposalId,
|
||||||
|
isAccepted,
|
||||||
|
withFunding,
|
||||||
|
changesRequestedReason,
|
||||||
|
);
|
||||||
|
app.updateProposalInStore(res);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
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) {
|
async cancelProposal(id: number) {
|
||||||
|
@ -565,6 +833,19 @@ const app = store({
|
||||||
app.proposalDetailCanceling = false;
|
app.proposalDetailCanceling = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changeProposalToAcceptedWithFunding(id: number) {
|
||||||
|
app.proposalDetailChangingToAcceptedWithFunding = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await changeProposalToAcceptedWithFunding(id);
|
||||||
|
app.updateProposalInStore(res);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.proposalDetailChangingToAcceptedWithFunding = false;
|
||||||
|
},
|
||||||
|
|
||||||
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
|
||||||
app.proposalDetailMarkingMilestonePaid = true;
|
app.proposalDetailMarkingMilestonePaid = true;
|
||||||
try {
|
try {
|
||||||
|
@ -743,6 +1024,7 @@ function createDefaultPageData<T>(sort: string): PageData<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FNFetchPage = (params: PageQuery) => Promise<any>;
|
type FNFetchPage = (params: PageQuery) => Promise<any>;
|
||||||
|
|
||||||
interface PageParent<T> {
|
interface PageParent<T> {
|
||||||
page: PageData<T>;
|
page: PageData<T>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,8 @@ export interface Milestone {
|
||||||
index: number;
|
index: number;
|
||||||
content: string;
|
content: string;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
dateEstimated: number;
|
dateEstimated?: number;
|
||||||
|
daysEstimated?: string;
|
||||||
dateRequested: number;
|
dateRequested: number;
|
||||||
dateAccepted: number;
|
dateAccepted: number;
|
||||||
dateRejected: number;
|
dateRejected: number;
|
||||||
|
@ -41,18 +42,18 @@ export interface RFP {
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
proposals: Proposal[];
|
proposals: Proposal[];
|
||||||
matching: boolean;
|
matching: boolean;
|
||||||
bounty: string | null;
|
bounty: string | null;
|
||||||
dateCloses: number | null;
|
dateCloses: number | null;
|
||||||
|
isVersionTwo: boolean;
|
||||||
|
ccr?: CCR;
|
||||||
}
|
}
|
||||||
export interface RFPArgs {
|
export interface RFPArgs {
|
||||||
title: string;
|
title: string;
|
||||||
brief: string;
|
brief: string;
|
||||||
content: string;
|
content: string;
|
||||||
category: string;
|
|
||||||
matching: boolean;
|
matching: boolean;
|
||||||
dateCloses: number | null | undefined;
|
dateCloses: number | null | undefined;
|
||||||
bounty: string | null | undefined;
|
bounty: string | null | undefined;
|
||||||
|
@ -72,9 +73,12 @@ export interface ProposalArbiter {
|
||||||
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
|
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
|
||||||
export enum PROPOSAL_STATUS {
|
export enum PROPOSAL_STATUS {
|
||||||
DRAFT = 'DRAFT',
|
DRAFT = 'DRAFT',
|
||||||
|
LIVE_DRAFT = 'LIVE_DRAFT',
|
||||||
PENDING = 'PENDING',
|
PENDING = 'PENDING',
|
||||||
|
DISCUSSION = 'DISCUSSION',
|
||||||
APPROVED = 'APPROVED',
|
APPROVED = 'APPROVED',
|
||||||
REJECTED = 'REJECTED',
|
REJECTED = 'REJECTED',
|
||||||
|
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY',
|
||||||
LIVE = 'LIVE',
|
LIVE = 'LIVE',
|
||||||
DELETED = 'DELETED',
|
DELETED = 'DELETED',
|
||||||
STAKING = 'STAKING',
|
STAKING = 'STAKING',
|
||||||
|
@ -102,7 +106,6 @@ export interface Proposal {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
stage: PROPOSAL_STAGE;
|
stage: PROPOSAL_STAGE;
|
||||||
category: string;
|
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
currentMilestone?: Milestone;
|
currentMilestone?: Milestone;
|
||||||
team: User[];
|
team: User[];
|
||||||
|
@ -116,6 +119,12 @@ export interface Proposal {
|
||||||
rfpOptIn: null | boolean;
|
rfpOptIn: null | boolean;
|
||||||
rfp?: RFP;
|
rfp?: RFP;
|
||||||
arbiter: ProposalArbiter;
|
arbiter: ProposalArbiter;
|
||||||
|
acceptedWithFunding: boolean | null;
|
||||||
|
isVersionTwo: boolean;
|
||||||
|
changesRequestedDiscussion: boolean | null;
|
||||||
|
changesRequestedDiscussionReason: string | null;
|
||||||
|
kycApproved: null | boolean;
|
||||||
|
fundedByZomg: boolean;
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -199,6 +208,31 @@ export enum PROPOSAL_CATEGORY {
|
||||||
ACCESSIBILITY = 'ACCESSIBILITY',
|
ACCESSIBILITY = 'ACCESSIBILITY',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum CCR_STATUS {
|
||||||
|
DRAFT = 'DRAFT',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
APPROVED = 'APPROVED',
|
||||||
|
REJECTED = 'REJECTED',
|
||||||
|
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY',
|
||||||
|
LIVE = 'LIVE',
|
||||||
|
DELETED = 'DELETED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCR {
|
||||||
|
ccrId: number;
|
||||||
|
brief: string;
|
||||||
|
status: CCR_STATUS;
|
||||||
|
dateCreated: number;
|
||||||
|
dateApproved: number;
|
||||||
|
datePublished: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
target: string;
|
||||||
|
rejectReason: string;
|
||||||
|
rfp?: RFP;
|
||||||
|
author: User;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageQuery {
|
export interface PageQuery {
|
||||||
page: number;
|
page: number;
|
||||||
filters: string[];
|
filters: string[];
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
PROPOSAL_ARBITER_STATUSES,
|
PROPOSAL_ARBITER_STATUSES,
|
||||||
MILESTONE_STAGES,
|
MILESTONE_STAGES,
|
||||||
PROPOSAL_STAGES,
|
PROPOSAL_STAGES,
|
||||||
|
CCR_STATUSES,
|
||||||
} from './statuses';
|
} from './statuses';
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
@ -59,7 +60,21 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
|
||||||
color: s.tagColor,
|
color: s.tagColor,
|
||||||
group: 'Milestone',
|
group: 'Milestone',
|
||||||
})),
|
})),
|
||||||
);
|
)
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
id: 'ACCEPTED_WITH_FUNDING',
|
||||||
|
display: 'Accepted With Funding',
|
||||||
|
color: '#2D2A26',
|
||||||
|
group: 'Funding',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ACCEPTED_WITHOUT_FUNDING',
|
||||||
|
display: 'Accepted Without Funding',
|
||||||
|
color: '#108ee9',
|
||||||
|
group: 'Funding',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const proposalFilters: Filters = {
|
export const proposalFilters: Filters = {
|
||||||
list: PROPOSAL_FILTERS,
|
list: PROPOSAL_FILTERS,
|
||||||
|
@ -80,6 +95,20 @@ export const rfpFilters: Filters = {
|
||||||
getById: getFilterById(RFP_FILTERS),
|
getById: getFilterById(RFP_FILTERS),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CCR
|
||||||
|
|
||||||
|
const CCR_FILTERS = CCR_STATUSES.map(c => ({
|
||||||
|
id: `STATUS_${c.id}`,
|
||||||
|
display: `Status: ${c.tagDisplay}`,
|
||||||
|
color: c.tagColor,
|
||||||
|
group: 'Status',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ccrFilters: Filters = {
|
||||||
|
list: CCR_FILTERS,
|
||||||
|
getById: getFilterById(CCR_FILTERS),
|
||||||
|
};
|
||||||
|
|
||||||
// Contribution
|
// Contribution
|
||||||
|
|
||||||
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
||||||
|
@ -87,17 +116,20 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
|
||||||
display: `Status: ${s.tagDisplay}`,
|
display: `Status: ${s.tagDisplay}`,
|
||||||
color: s.tagColor,
|
color: s.tagColor,
|
||||||
group: 'Status',
|
group: 'Status',
|
||||||
})).concat([{
|
})).concat([
|
||||||
id: 'REFUNDABLE',
|
{
|
||||||
display: 'Refundable',
|
id: 'REFUNDABLE',
|
||||||
color: '#afd500',
|
display: 'Refundable',
|
||||||
group: 'Refundable',
|
color: '#afd500',
|
||||||
}, {
|
group: 'Refundable',
|
||||||
id: 'DONATION',
|
},
|
||||||
display: 'Donations',
|
{
|
||||||
color: '#afd500',
|
id: 'DONATION',
|
||||||
group: 'Donations',
|
display: 'Donations',
|
||||||
}]);
|
color: '#afd500',
|
||||||
|
group: 'Donations',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const contributionFilters: Filters = {
|
export const contributionFilters: Filters = {
|
||||||
list: CONTRIBUTION_FILTERS,
|
list: CONTRIBUTION_FILTERS,
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
|
||||||
|
const toFixed = (num: string, digits: number = 3) => {
|
||||||
|
const [integerPart, fractionPart = ''] = num.split('.');
|
||||||
|
if (fractionPart.length === digits) {
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
if (fractionPart.length < digits) {
|
||||||
|
return `${integerPart}.${fractionPart.padEnd(digits, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decimalPoint = integerPart.length;
|
||||||
|
|
||||||
|
const formattedFraction = fractionPart.slice(0, digits);
|
||||||
|
|
||||||
|
const integerArr = `${integerPart}${formattedFraction}`.split('').map(str => +str);
|
||||||
|
|
||||||
|
let carryOver = Math.floor((+fractionPart[digits] + 5) / 10);
|
||||||
|
|
||||||
|
// grade school addition / rounding
|
||||||
|
for (let i = integerArr.length - 1; i >= 0; i--) {
|
||||||
|
const currVal = integerArr[i] + carryOver;
|
||||||
|
const newVal = currVal % 10;
|
||||||
|
carryOver = Math.floor(currVal / 10);
|
||||||
|
integerArr[i] = newVal;
|
||||||
|
if (i === 0 && carryOver > 0) {
|
||||||
|
integerArr.unshift(0);
|
||||||
|
decimalPoint++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const strArr = integerArr.map(n => n.toString());
|
||||||
|
|
||||||
|
strArr.splice(decimalPoint, 0, '.');
|
||||||
|
|
||||||
|
if (strArr[strArr.length - 1] === '.') {
|
||||||
|
strArr.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return strArr.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatNumber(num: string, digits?: number): string {
|
||||||
|
const parts = toFixed(num, digits).split('.');
|
||||||
|
|
||||||
|
// Remove trailing zeroes on decimal (If there is a decimal)
|
||||||
|
if (parts[1]) {
|
||||||
|
parts[1] = parts[1].replace(/0+$/, '');
|
||||||
|
|
||||||
|
// If there's nothing left, remove decimal altogether
|
||||||
|
if (!parts[1]) {
|
||||||
|
parts.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commafy the whole numbers
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|
||||||
|
return parts.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsd(
|
||||||
|
amount: number | string | undefined | null,
|
||||||
|
includeDollarSign: boolean = true,
|
||||||
|
digits: number = 0,
|
||||||
|
) {
|
||||||
|
if (!amount) return includeDollarSign ? '$0' : '0';
|
||||||
|
const a = typeof amount === 'number' ? amount.toString() : amount;
|
||||||
|
const str = formatNumber(a, digits);
|
||||||
|
return includeDollarSign ? `$${str}` : str;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
PROPOSAL_STATUS,
|
PROPOSAL_STATUS,
|
||||||
|
CCR_STATUS,
|
||||||
RFP_STATUS,
|
RFP_STATUS,
|
||||||
CONTRIBUTION_STATUS,
|
CONTRIBUTION_STATUS,
|
||||||
PROPOSAL_ARBITER_STATUS,
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
@ -48,6 +49,53 @@ export const MILESTONE_STAGES: Array<StatusSoT<MILESTONE_STAGE>> = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const CCR_STATUSES: Array<StatusSoT<CCR_STATUS>> = [
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.APPROVED,
|
||||||
|
tagDisplay: 'Approved',
|
||||||
|
tagColor: '#afd500',
|
||||||
|
hint: 'Request has been approved and is awaiting being published by user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.DELETED,
|
||||||
|
tagDisplay: 'Deleted',
|
||||||
|
tagColor: '#bebebe',
|
||||||
|
hint: 'Request has been deleted and is not visible on the platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.DRAFT,
|
||||||
|
tagDisplay: 'Draft',
|
||||||
|
tagColor: '#8d8d8d',
|
||||||
|
hint: 'Request is being created by the user.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.LIVE,
|
||||||
|
tagDisplay: 'Live',
|
||||||
|
tagColor: '#108ee9',
|
||||||
|
hint: 'Request is live on the platform.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.PENDING,
|
||||||
|
tagDisplay: 'Awaiting Approval',
|
||||||
|
tagColor: '#ffaa00',
|
||||||
|
hint: 'User is waiting for admin to approve or request changes to this Request.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CCR_STATUS.REJECTED,
|
||||||
|
tagDisplay: 'Changes Requested',
|
||||||
|
tagColor: '#eb4118',
|
||||||
|
hint:
|
||||||
|
'Admin has requested changes for this Request. User may adjust it and resubmit for approval.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>> = [
|
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.APPROVED,
|
id: PROPOSAL_STATUS.APPROVED,
|
||||||
|
@ -55,6 +103,18 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
tagColor: '#afd500',
|
tagColor: '#afd500',
|
||||||
hint: 'Proposal has been approved and is awaiting being published by user.',
|
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,
|
id: PROPOSAL_STATUS.DELETED,
|
||||||
tagDisplay: 'Deleted',
|
tagDisplay: 'Deleted',
|
||||||
|
@ -77,14 +137,21 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
id: PROPOSAL_STATUS.PENDING,
|
id: PROPOSAL_STATUS.PENDING,
|
||||||
tagDisplay: 'Awaiting Approval',
|
tagDisplay: 'Awaiting Approval',
|
||||||
tagColor: '#ffaa00',
|
tagColor: '#ffaa00',
|
||||||
hint: 'User is waiting for admin to approve or reject this Proposal.',
|
hint: 'User is waiting for admin to approve or request changes to this Proposal.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.REJECTED,
|
id: PROPOSAL_STATUS.REJECTED,
|
||||||
tagDisplay: 'Approval Rejected',
|
tagDisplay: 'Changes Requested',
|
||||||
tagColor: '#eb4118',
|
tagColor: '#eb4118',
|
||||||
hint:
|
hint:
|
||||||
'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
|
'Admin has requested changes for this proposal. User may adjust it and resubmit for approval.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: PROPOSAL_STATUS.REJECTED_PERMANENTLY,
|
||||||
|
tagDisplay: 'Rejected Permanently',
|
||||||
|
tagColor: '#eb4118',
|
||||||
|
hint:
|
||||||
|
'Admin has rejected this proposal permanently. It cannot be resubmitted for approval.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: PROPOSAL_STATUS.STAKING,
|
id: PROPOSAL_STATUS.STAKING,
|
||||||
|
|
1413
admin/yarn.lock
1413
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -7,6 +7,7 @@ DATABASE_URL="sqlite:////tmp/dev.db"
|
||||||
REDISTOGO_URL="redis://localhost:6379"
|
REDISTOGO_URL="redis://localhost:6379"
|
||||||
SECRET_KEY="not-so-secret"
|
SECRET_KEY="not-so-secret"
|
||||||
SENDGRID_API_KEY="optional, but emails won't send without it"
|
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)
|
# set this so third-party cookie blocking doesn't kill backend sessions (production)
|
||||||
# SESSION_COOKIE_DOMAIN="zfnd.org"
|
# SESSION_COOKIE_DOMAIN="zfnd.org"
|
||||||
|
@ -39,4 +40,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
|
||||||
PROPOSAL_STAKING_AMOUNT=0.025
|
PROPOSAL_STAKING_AMOUNT=0.025
|
||||||
|
|
||||||
# Maximum amount for a proposal target, keep in sync with frontend .env
|
# Maximum amount for a proposal target, keep in sync with frontend .env
|
||||||
PROPOSAL_TARGET_MAX=10000
|
PROPOSAL_TARGET_MAX=999999
|
||||||
|
|
|
@ -69,6 +69,10 @@ To run all tests, run
|
||||||
|
|
||||||
flask test
|
flask test
|
||||||
|
|
||||||
|
To run only select test, Flask allows you to match against the test filename with ``-t` like so:
|
||||||
|
|
||||||
|
flask test -t proposal
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Whenever a database migration needs to be made. Run the following commands
|
Whenever a database migration needs to be made. Run the following commands
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Create an application instance."""
|
"""Create an application instance."""
|
||||||
|
from grant.patches import patch_werkzeug_set_samesite
|
||||||
|
patch_werkzeug_set_samesite()
|
||||||
from grant.app import create_app
|
from grant.app import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
|
@ -35,8 +35,17 @@ class FakeUpdate(object):
|
||||||
proposal_id = 123
|
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()
|
user = FakeUser()
|
||||||
proposal = FakeProposal()
|
proposal = FakeProposal()
|
||||||
|
ccr = FakeCCR()
|
||||||
milestone = FakeMilestone()
|
milestone = FakeMilestone()
|
||||||
contribution = FakeContribution()
|
contribution = FakeContribution()
|
||||||
update = FakeUpdate()
|
update = FakeUpdate()
|
||||||
|
@ -67,16 +76,55 @@ example_email_args = {
|
||||||
'recover_url': 'http://somerecoverurl.com',
|
'recover_url': 'http://somerecoverurl.com',
|
||||||
'contact_url': 'http://somecontacturl.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_approved': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'http://someproposal.com',
|
'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.',
|
'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_rejected': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'http://someproposal.com',
|
'proposal_url': 'http://someproposal.com',
|
||||||
'admin_note': 'We think that you’ve asked for too much money for the project you’ve proposed, and for such an inexperienced team. Feel free to change your target amount, or elaborate on why you need so much money, and try applying again.',
|
'admin_note': 'We think that you’ve asked for too much money for the project you’ve proposed, and for such an inexperienced team. Feel free to change your target amount, or elaborate on why you need so much money, and try applying again.',
|
||||||
},
|
},
|
||||||
|
'proposal_rejected_discussion': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_url': 'http://someproposal.com',
|
||||||
|
},
|
||||||
|
'proposal_rejected_permanently': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_url': 'http://someproposal.com',
|
||||||
|
'profile_rejected_url': 'http://someproposal.com/profile?tab=rejected',
|
||||||
|
'admin_note': 'We don\'t really think this is needed right now by the ecosystem. Feel free to elaborate and submit again',
|
||||||
|
},
|
||||||
|
'proposal_arbiter_assigned': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_url': 'http://someproposal.com'
|
||||||
|
},
|
||||||
|
'ccr_approved': {
|
||||||
|
'ccr': ccr,
|
||||||
|
'ccr_url': 'http://someproposal.com',
|
||||||
|
'admin_note': 'This proposal was the hottest stuff our team has seen yet. Great work.',
|
||||||
|
},
|
||||||
|
'ccr_rejected': {
|
||||||
|
'ccr': ccr,
|
||||||
|
'ccr_url': 'http://someproposal.com',
|
||||||
|
'admin_note': 'We don\'t really think this is needed right now by the ecosystem. Feel free to elaborate and submit again',
|
||||||
|
},
|
||||||
|
'ccr_rejected_permanently': {
|
||||||
|
'ccr': ccr,
|
||||||
|
'ccr_url': 'http://someproposal.com',
|
||||||
|
'profile_rejected_url': 'http://someproposal.com/profile?tab=rejected',
|
||||||
|
'admin_note': 'We don\'t really think this will ever be needed by the ecosystem.',
|
||||||
|
},
|
||||||
'proposal_contribution': {
|
'proposal_contribution': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'contribution': contribution,
|
'contribution': contribution,
|
||||||
|
@ -149,6 +197,10 @@ example_email_args = {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
},
|
},
|
||||||
|
'milestone_deadline': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||||
|
},
|
||||||
'milestone_reject': {
|
'milestone_reject': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
|
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
|
||||||
|
@ -170,6 +222,10 @@ example_email_args = {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||||
},
|
},
|
||||||
|
'admin_changes_resolved': {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||||
|
},
|
||||||
'admin_arbiter': {
|
'admin_arbiter': {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||||
|
@ -178,4 +234,17 @@ example_email_args = {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
|
||||||
},
|
},
|
||||||
|
'followed_proposal_milestone': {
|
||||||
|
"proposal": proposal,
|
||||||
|
"milestone": milestone,
|
||||||
|
"proposal_url": "http://someproposal.com",
|
||||||
|
},
|
||||||
|
'followed_proposal_update': {
|
||||||
|
"proposal": proposal,
|
||||||
|
"proposal_url": "http://someproposal.com",
|
||||||
|
},
|
||||||
|
'followed_proposal_revised': {
|
||||||
|
"proposal": proposal,
|
||||||
|
"proposal_url": "http://someproposal.com",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,11 @@ from functools import reduce
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from marshmallow import fields, validate
|
from marshmallow import fields, validate
|
||||||
from sqlalchemy import func, or_, text
|
from sqlalchemy import func, text
|
||||||
|
|
||||||
import grant.utils.admin as admin
|
import grant.utils.admin as admin
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
|
from grant.ccr.models import CCR, ccrs_schema, ccr_schema
|
||||||
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
|
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
|
||||||
from grant.email.send import generate_email, send_email
|
from grant.email.send import generate_email, send_email
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
|
@ -24,9 +25,8 @@ from grant.proposal.models import (
|
||||||
admin_proposal_contributions_schema,
|
admin_proposal_contributions_schema,
|
||||||
)
|
)
|
||||||
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
|
||||||
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
|
from grant.user.models import User, admin_users_schema, admin_user_schema
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from grant.utils.enums import Category
|
|
||||||
from grant.utils.enums import (
|
from grant.utils.enums import (
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
ProposalStage,
|
ProposalStage,
|
||||||
|
@ -34,6 +34,7 @@ from grant.utils.enums import (
|
||||||
ProposalArbiterStatus,
|
ProposalArbiterStatus,
|
||||||
MilestoneStage,
|
MilestoneStage,
|
||||||
RFPStatus,
|
RFPStatus,
|
||||||
|
CCRStatus
|
||||||
)
|
)
|
||||||
from grant.utils.misc import make_url, make_explore_url
|
from grant.utils.misc import make_url, make_explore_url
|
||||||
from .example_emails import example_email_args
|
from .example_emails import example_email_args
|
||||||
|
@ -137,6 +138,9 @@ def logout():
|
||||||
def stats():
|
def stats():
|
||||||
user_count = db.session.query(func.count(User.id)).scalar()
|
user_count = db.session.query(func.count(User.id)).scalar()
|
||||||
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
||||||
|
ccr_pending_count = db.session.query(func.count(CCR.id)) \
|
||||||
|
.filter(CCR.status == CCRStatus.PENDING) \
|
||||||
|
.scalar()
|
||||||
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
|
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
|
||||||
.filter(Proposal.status == ProposalStatus.PENDING) \
|
.filter(Proposal.status == ProposalStatus.PENDING) \
|
||||||
.scalar()
|
.scalar()
|
||||||
|
@ -145,6 +149,7 @@ def stats():
|
||||||
.filter(Proposal.status == ProposalStatus.LIVE) \
|
.filter(Proposal.status == ProposalStatus.LIVE) \
|
||||||
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
||||||
.filter(Proposal.stage != ProposalStage.CANCELED) \
|
.filter(Proposal.stage != ProposalStage.CANCELED) \
|
||||||
|
.filter(Proposal.accepted_with_funding == True) \
|
||||||
.scalar()
|
.scalar()
|
||||||
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
|
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
|
||||||
.join(Proposal.milestones) \
|
.join(Proposal.milestones) \
|
||||||
|
@ -153,26 +158,27 @@ def stats():
|
||||||
.filter(Milestone.stage == MilestoneStage.ACCEPTED) \
|
.filter(Milestone.stage == MilestoneStage.ACCEPTED) \
|
||||||
.scalar()
|
.scalar()
|
||||||
# Count contributions on proposals that didn't get funded for users who have specified a refund address
|
# 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)) \
|
# contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
|
||||||
.filter(ProposalContribution.refund_tx_id == None) \
|
# .filter(ProposalContribution.refund_tx_id == None) \
|
||||||
.filter(ProposalContribution.staking == False) \
|
# .filter(ProposalContribution.staking == False) \
|
||||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
# .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||||
.join(Proposal) \
|
# .join(Proposal) \
|
||||||
.filter(or_(
|
# .filter(or_(
|
||||||
Proposal.stage == ProposalStage.FAILED,
|
# Proposal.stage == ProposalStage.FAILED,
|
||||||
Proposal.stage == ProposalStage.CANCELED,
|
# Proposal.stage == ProposalStage.CANCELED,
|
||||||
)) \
|
# )) \
|
||||||
.join(ProposalContribution.user) \
|
# .join(ProposalContribution.user) \
|
||||||
.join(UserSettings) \
|
# .join(UserSettings) \
|
||||||
.filter(UserSettings.refund_address != None) \
|
# .filter(UserSettings.refund_address != None) \
|
||||||
.scalar()
|
# .scalar()
|
||||||
return {
|
return {
|
||||||
"userCount": user_count,
|
"userCount": user_count,
|
||||||
|
"ccrPendingCount": ccr_pending_count,
|
||||||
"proposalCount": proposal_count,
|
"proposalCount": proposal_count,
|
||||||
"proposalPendingCount": proposal_pending_count,
|
"proposalPendingCount": proposal_pending_count,
|
||||||
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
"proposalNoArbiterCount": proposal_no_arbiter_count,
|
||||||
"proposalMilestonePayoutsCount": proposal_milestone_payouts_count,
|
"proposalMilestonePayoutsCount": proposal_milestone_payouts_count,
|
||||||
"contributionRefundableCount": contribution_refundable_count,
|
"contributionRefundableCount": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -296,6 +302,9 @@ def set_arbiter(proposal_id, user_id):
|
||||||
if proposal.is_failed:
|
if proposal.is_failed:
|
||||||
return {"message": "Cannot set arbiter on failed proposal"}, 400
|
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()
|
user = User.query.filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
return {"message": "User not found"}, 404
|
return {"message": "User not found"}, 404
|
||||||
|
@ -313,9 +322,9 @@ def set_arbiter(proposal_id, user_id):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'proposal': proposal_schema.dump(proposal),
|
'proposal': proposal_schema.dump(proposal),
|
||||||
'user': admin_user_schema.dump(user)
|
'user': admin_user_schema.dump(user)
|
||||||
}, 200
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
# PROPOSALS
|
# PROPOSALS
|
||||||
|
@ -328,7 +337,7 @@ def get_proposals(page, filters, search, sort):
|
||||||
filters_workaround = request.args.getlist('filters[]')
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
page = pagination.proposal(
|
page = pagination.proposal(
|
||||||
schema=proposals_schema,
|
schema=proposals_schema,
|
||||||
query=Proposal.query,
|
query=Proposal.query.filter(Proposal.status.notin_([ProposalStatus.ARCHIVED])),
|
||||||
page=page,
|
page=page,
|
||||||
filters=filters_workaround,
|
filters=filters_workaround,
|
||||||
search=search,
|
search=search,
|
||||||
|
@ -352,43 +361,146 @@ def delete_proposal(id):
|
||||||
return {"message": "Not implemented."}, 400
|
return {"message": "Not implemented."}, 400
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>', methods=['PUT'])
|
@blueprint.route('/proposals/<proposal_id>/discussion', methods=['PUT'])
|
||||||
@body({
|
@body({
|
||||||
"contributionMatching": fields.Int(required=False, missing=None),
|
"isOpenForDiscussion": fields.Bool(required=True),
|
||||||
"contributionBounty": fields.Str(required=False, missing=None)
|
"rejectReason": fields.Str(required=False, missing=None)
|
||||||
})
|
})
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def update_proposal(id, contribution_matching, contribution_bounty):
|
def open_proposal_for_discussion(proposal_id, is_open_for_discussion, reject_reason=None):
|
||||||
proposal = Proposal.query.filter(Proposal.id == id).first()
|
proposal = Proposal.query.get(proposal_id)
|
||||||
if not proposal:
|
if not proposal:
|
||||||
return {"message": f"Could not find proposal with id {id}"}, 404
|
return {"message": "No Proposal found."}, 404
|
||||||
|
|
||||||
if contribution_matching is not None:
|
proposal.approve_discussion(is_open_for_discussion, reject_reason)
|
||||||
proposal.set_contribution_matching(contribution_matching)
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
if contribution_bounty is not None:
|
|
||||||
proposal.set_contribution_bounty(contribution_bounty)
|
@blueprint.route('/proposals/<id>/approve-kyc', methods=['PUT'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def approve_proposal_kyc(id):
|
||||||
|
proposal = Proposal.query.get(id)
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal found."}, 404
|
||||||
|
|
||||||
|
proposal.kyc_approved = True
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/proposals/<id>/adjust-funder', methods=['PUT'])
|
||||||
|
@body({
|
||||||
|
"fundedByZomg": fields.Bool(required=True),
|
||||||
|
})
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def adjust_funder(id, funded_by_zomg):
|
||||||
|
proposal = Proposal.query.get(id)
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal found."}, 404
|
||||||
|
|
||||||
|
proposal.funded_by_zomg = funded_by_zomg
|
||||||
|
db.session.add(proposal)
|
||||||
|
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.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
|
|
||||||
|
@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.add(proposal)
|
||||||
db.session.commit()
|
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)
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
|
@blueprint.route('/proposals/<proposal_id>/resolve', methods=['PUT'])
|
||||||
@body({
|
|
||||||
"isApprove": fields.Bool(required=True),
|
|
||||||
"rejectReason": fields.Str(required=False, missing=None)
|
|
||||||
})
|
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def approve_proposal(id, is_approve, reject_reason=None):
|
def resolve_changes_discussion(proposal_id):
|
||||||
proposal = Proposal.query.filter_by(id=id).first()
|
proposal = Proposal.query.get(proposal_id)
|
||||||
if proposal:
|
if not proposal:
|
||||||
proposal.approve_pending(is_approve, reject_reason)
|
return {"message": "No proposal found"}, 404
|
||||||
db.session.commit()
|
|
||||||
return proposal_schema.dump(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'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def change_proposal_to_accepted_with_funding(id):
|
||||||
|
proposal = Proposal.query.filter_by(id=id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal found."}, 404
|
||||||
|
if proposal.accepted_with_funding:
|
||||||
|
return {"message": "Proposal already accepted with funding."}, 404
|
||||||
|
if proposal.version != '2':
|
||||||
|
return {"message": "Only version two proposals can be accepted with funding"}, 404
|
||||||
|
if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.APPROVED:
|
||||||
|
return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404
|
||||||
|
|
||||||
|
proposal.update_proposal_with_funding()
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return proposal_schema.dump(proposal)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
|
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
|
||||||
|
@ -417,12 +529,14 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
||||||
return {"message": "Proposal is not fully funded"}, 400
|
return {"message": "Proposal is not fully funded"}, 400
|
||||||
for ms in proposal.milestones:
|
for ms in proposal.milestones:
|
||||||
if ms.id == int(mid):
|
if ms.id == int(mid):
|
||||||
|
is_final_milestone = False
|
||||||
ms.mark_paid(tx_id)
|
ms.mark_paid(tx_id)
|
||||||
db.session.add(ms)
|
db.session.add(ms)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
# check if this is the final ms, and update proposal.stage
|
# check if this is the final ms, and update proposal.stage
|
||||||
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
|
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
|
||||||
if num_paid == len(proposal.milestones):
|
if num_paid == len(proposal.milestones):
|
||||||
|
is_final_milestone = True
|
||||||
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
|
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
|
||||||
db.session.add(proposal)
|
db.session.add(proposal)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
@ -437,6 +551,18 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
||||||
'tx_explorer_url': make_explore_url(tx_id),
|
'tx_explorer_url': make_explore_url(tx_id),
|
||||||
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# email FOLLOWERS that milestone was accepted
|
||||||
|
proposal.send_follower_email(
|
||||||
|
"followed_proposal_milestone",
|
||||||
|
email_args={"milestone": ms},
|
||||||
|
url_suffix="?tab=milestones",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_final_milestone:
|
||||||
|
Milestone.set_v2_date_estimates(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
return proposal_schema.dump(proposal), 200
|
return proposal_schema.dump(proposal), 200
|
||||||
|
|
||||||
return {"message": "No milestone matching id"}, 404
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
@ -455,6 +581,99 @@ def get_email_example(type):
|
||||||
return email
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
# CCRs
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/ccrs", methods=["GET"])
|
||||||
|
@query(paginated_fields)
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def get_ccrs(page, filters, search, sort):
|
||||||
|
filters_workaround = request.args.getlist('filters[]')
|
||||||
|
page = pagination.ccr(
|
||||||
|
schema=ccrs_schema,
|
||||||
|
query=CCR.query,
|
||||||
|
page=page,
|
||||||
|
filters=filters_workaround,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<ccr_id>', methods=['DELETE'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def delete_ccr(ccr_id):
|
||||||
|
ccr = CCR.query.filter(CCR.id == ccr_id).first()
|
||||||
|
if not ccr:
|
||||||
|
return {"message": "No CCR matching that id"}, 404
|
||||||
|
|
||||||
|
db.session.delete(ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<id>', methods=['GET'])
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def get_ccr(id):
|
||||||
|
ccr = CCR.query.filter(CCR.id == id).first()
|
||||||
|
if ccr:
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
return {"message": f"Could not find ccr with id {id}"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/ccrs/<ccr_id>/accept', methods=['PUT'])
|
||||||
|
@body({
|
||||||
|
"isAccepted": fields.Bool(required=True),
|
||||||
|
"rejectReason": fields.Str(required=False, missing=None)
|
||||||
|
})
|
||||||
|
@admin.admin_auth_required
|
||||||
|
def approve_ccr(ccr_id, is_accepted, reject_reason=None):
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if ccr:
|
||||||
|
rfp_id = ccr.approve_pending(is_accepted, reject_reason)
|
||||||
|
if is_accepted:
|
||||||
|
return {"rfpId": rfp_id}, 201
|
||||||
|
else:
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
|
||||||
|
return {"message": "No CCR found."}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
# Requests for Proposal
|
||||||
|
|
||||||
|
|
||||||
|
@ -470,7 +689,6 @@ def get_rfps():
|
||||||
"title": fields.Str(required=True),
|
"title": fields.Str(required=True),
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"content": fields.Str(required=True),
|
"content": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
|
|
||||||
"bounty": fields.Str(required=False, missing=0),
|
"bounty": fields.Str(required=False, missing=0),
|
||||||
"matching": fields.Bool(required=False, missing=False),
|
"matching": fields.Bool(required=False, missing=False),
|
||||||
"dateCloses": fields.Int(required=False, missing=None)
|
"dateCloses": fields.Int(required=False, missing=None)
|
||||||
|
@ -502,13 +720,12 @@ def get_rfp(rfp_id):
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
|
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
|
||||||
"content": fields.Str(required=True),
|
"content": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
|
|
||||||
"bounty": fields.Str(required=False, allow_none=True, missing=None),
|
"bounty": fields.Str(required=False, allow_none=True, missing=None),
|
||||||
"matching": fields.Bool(required=False, default=False, missing=False),
|
"matching": fields.Bool(required=False, default=False, missing=False),
|
||||||
"dateCloses": fields.Int(required=False, missing=None),
|
"dateCloses": fields.Int(required=False, missing=None),
|
||||||
})
|
})
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status):
|
def update_rfp(rfp_id, title, brief, content, bounty, matching, date_closes, status):
|
||||||
rfp = RFP.query.filter(RFP.id == rfp_id).first()
|
rfp = RFP.query.filter(RFP.id == rfp_id).first()
|
||||||
if not rfp:
|
if not rfp:
|
||||||
return {"message": "No RFP matching that id"}, 404
|
return {"message": "No RFP matching that id"}, 404
|
||||||
|
@ -517,7 +734,6 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c
|
||||||
rfp.title = title
|
rfp.title = title
|
||||||
rfp.brief = brief
|
rfp.brief = brief
|
||||||
rfp.content = content
|
rfp.content = content
|
||||||
rfp.category = category
|
|
||||||
rfp.matching = matching
|
rfp.matching = matching
|
||||||
rfp.bounty = bounty
|
rfp.bounty = bounty
|
||||||
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
|
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
|
||||||
|
@ -587,8 +803,8 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||||
|
@ -660,8 +876,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
||||||
db.session.add(contribution)
|
db.session.add(contribution)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
# TODO: should this stay?
|
||||||
contribution.proposal.set_pending_when_ready()
|
contribution.proposal.set_pending_when_ready()
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_proposal_contribution_schema.dump(contribution), 200
|
return admin_proposal_contribution_schema.dump(contribution), 200
|
||||||
|
@ -711,7 +927,6 @@ def edit_comment(comment_id, hidden, reported):
|
||||||
@blueprint.route("/financials", methods=["GET"])
|
@blueprint.route("/financials", methods=["GET"])
|
||||||
@admin.admin_auth_required
|
@admin.admin_auth_required
|
||||||
def financials():
|
def financials():
|
||||||
|
|
||||||
nfmt = '999999.99999999' # smallest unit of ZEC
|
nfmt = '999999.99999999' # smallest unit of ZEC
|
||||||
|
|
||||||
def sql_pc(where: str):
|
def sql_pc(where: str):
|
||||||
|
@ -732,49 +947,60 @@ def financials():
|
||||||
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
|
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
|
||||||
FROM milestone as ms
|
FROM milestone as ms
|
||||||
INNER JOIN proposal as p ON ms.proposal_id = p.id
|
INNER JOIN proposal as p ON ms.proposal_id = p.id
|
||||||
WHERE {where}
|
WHERE p.version = '2' AND {where}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def ex(sql: str):
|
def ex(sql: str):
|
||||||
res = db.engine.execute(text(sql))
|
res = db.engine.execute(text(sql))
|
||||||
return [row[0] if row[0] else Decimal(0) for row in res][0].normalize()
|
return [row[0] if row[0] else Decimal(0) for row in res][0].normalize()
|
||||||
|
|
||||||
contributions = {
|
def gen_quarter_date_range(year, quarter):
|
||||||
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
if quarter == 1:
|
||||||
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
|
return f"{year}-1-1", f"{year}-3-31"
|
||||||
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
|
if quarter == 2:
|
||||||
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
|
return f"{year}-4-1", f"{year}-6-30"
|
||||||
# should have a refund_address
|
if quarter == 3:
|
||||||
'refunding': str(ex(sql_pc_p(
|
return f"{year}-7-1", f"{year}-9-30"
|
||||||
'''
|
if quarter == 4:
|
||||||
pc.status = 'CONFIRMED' AND
|
return f"{year}-10-1", f"{year}-12-31"
|
||||||
pc.staking = FALSE AND
|
|
||||||
pc.refund_tx_id IS NULL AND
|
# contributions = {
|
||||||
p.stage IN ('CANCELED', 'FAILED') AND
|
# 'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
|
||||||
us.refund_address IS NOT NULL
|
# '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(
|
||||||
# here we don't care about current refund_address of user, just that there has been a refund_tx_id
|
# ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
|
||||||
'refunded': str(ex(sql_pc_p(
|
# # should have a refund_address
|
||||||
'''
|
# 'refunding': str(ex(sql_pc_p(
|
||||||
pc.status = 'CONFIRMED' AND
|
# '''
|
||||||
pc.staking = FALSE AND
|
# pc.status = 'CONFIRMED' AND
|
||||||
pc.refund_tx_id IS NOT NULL AND
|
# pc.staking = FALSE AND
|
||||||
p.stage IN ('CANCELED', 'FAILED')
|
# pc.refund_tx_id IS NULL AND
|
||||||
'''
|
# p.stage IN ('CANCELED', 'FAILED') AND
|
||||||
))),
|
# us.refund_address IS NOT NULL
|
||||||
# if there is no user, or the user hasn't any refund_address
|
# '''
|
||||||
'donations': str(ex(sql_pc_p(
|
# ))),
|
||||||
'''
|
# # here we don't care about current refund_address of user, just that there has been a refund_tx_id
|
||||||
pc.status = 'CONFIRMED' AND
|
# 'refunded': str(ex(sql_pc_p(
|
||||||
pc.staking = FALSE AND
|
# '''
|
||||||
pc.refund_tx_id IS NULL AND
|
# pc.status = 'CONFIRMED' AND
|
||||||
(pc.user_id IS NULL OR us.refund_address IS NULL) AND
|
# pc.staking = FALSE AND
|
||||||
p.stage IN ('CANCELED', 'FAILED')
|
# pc.refund_tx_id IS NOT NULL AND
|
||||||
'''
|
# p.stage IN ('CANCELED', 'FAILED')
|
||||||
))),
|
# '''
|
||||||
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
|
# ))),
|
||||||
}
|
# # 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_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
|
po_paid = ex(sql_ms("ms.stage = 'PAID'")) # will catch paid ms from all proposals regardless of status/stage
|
||||||
|
@ -782,6 +1008,24 @@ def financials():
|
||||||
po_future = ex(sql_ms("ms.stage IN ('IDLE', 'REJECTED', 'REQUESTED') AND p.stage IN ('WIP', 'COMPLETED')"))
|
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
|
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 = {
|
payouts = {
|
||||||
'total': str(po_total),
|
'total': str(po_total),
|
||||||
'due': str(po_due),
|
'due': str(po_due),
|
||||||
|
@ -798,7 +1042,7 @@ def financials():
|
||||||
def add_str_dec(a: str, b: str):
|
def add_str_dec(a: str, b: str):
|
||||||
return str((Decimal(a) + Decimal(b)).quantize(Decimal('0.001'), rounding=ROUND_HALF_DOWN))
|
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:
|
for p in proposals:
|
||||||
# CANCELED proposals excluded, though they could have had milestones paid out with grant funds
|
# CANCELED proposals excluded, though they could have had milestones paid out with grant funds
|
||||||
|
@ -821,7 +1065,6 @@ def financials():
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'grants': grants,
|
'grants': grants,
|
||||||
'contributions': contributions,
|
|
||||||
'payouts': payouts,
|
'payouts': payouts,
|
||||||
'net': str(Decimal(contributions['gross']) - Decimal(payouts['paid']))
|
'payouts_by_quarter': payouts_by_quarter
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""The app module, containing the app factory function."""
|
"""The app module, containing the app factory function."""
|
||||||
import sentry_sdk
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
from animal_case import animalify
|
from animal_case import animalify
|
||||||
from flask import Flask, Response, jsonify, request, current_app, g
|
from flask import Flask, Response, jsonify, request, current_app, g
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore
|
||||||
from flask_sslify import SSLify
|
from flask_sslify import SSLify
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e
|
|
||||||
|
from grant import (
|
||||||
|
commands,
|
||||||
|
proposal,
|
||||||
|
user,
|
||||||
|
ccr,
|
||||||
|
comment,
|
||||||
|
milestone,
|
||||||
|
admin,
|
||||||
|
email,
|
||||||
|
task,
|
||||||
|
rfp,
|
||||||
|
e2e,
|
||||||
|
home
|
||||||
|
)
|
||||||
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
||||||
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
|
||||||
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
||||||
|
@ -29,6 +44,8 @@ class JSONResponse(Response):
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_objects=["grant.settings"]):
|
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 = Flask(__name__.split(".")[0])
|
||||||
app.response_class = JSONResponse
|
app.response_class = JSONResponse
|
||||||
|
|
||||||
|
@ -129,15 +146,16 @@ def register_extensions(app):
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register Flask blueprints."""
|
"""Register Flask blueprints."""
|
||||||
|
app.register_blueprint(ccr.views.blueprint)
|
||||||
app.register_blueprint(comment.views.blueprint)
|
app.register_blueprint(comment.views.blueprint)
|
||||||
app.register_blueprint(proposal.views.blueprint)
|
app.register_blueprint(proposal.views.blueprint)
|
||||||
app.register_blueprint(user.views.blueprint)
|
app.register_blueprint(user.views.blueprint)
|
||||||
app.register_blueprint(milestone.views.blueprint)
|
app.register_blueprint(milestone.views.blueprint)
|
||||||
app.register_blueprint(admin.views.blueprint)
|
app.register_blueprint(admin.views.blueprint)
|
||||||
app.register_blueprint(email.views.blueprint)
|
app.register_blueprint(email.views.blueprint)
|
||||||
app.register_blueprint(blockchain.views.blueprint)
|
|
||||||
app.register_blueprint(task.views.blueprint)
|
app.register_blueprint(task.views.blueprint)
|
||||||
app.register_blueprint(rfp.views.blueprint)
|
app.register_blueprint(rfp.views.blueprint)
|
||||||
|
app.register_blueprint(home.views.blueprint)
|
||||||
if E2E_TESTING and DEBUG:
|
if E2E_TESTING and DEBUG:
|
||||||
print('Warning: e2e end-points are open, this should only be the case for development or testing')
|
print('Warning: e2e end-points are open, this should only be the case for development or testing')
|
||||||
app.register_blueprint(e2e.views.blueprint)
|
app.register_blueprint(e2e.views.blueprint)
|
||||||
|
@ -162,5 +180,7 @@ def register_commands(app):
|
||||||
app.cli.add_command(commands.reset_db_chain_data)
|
app.cli.add_command(commands.reset_db_chain_data)
|
||||||
app.cli.add_command(proposal.commands.create_proposal)
|
app.cli.add_command(proposal.commands.create_proposal)
|
||||||
app.cli.add_command(proposal.commands.create_proposals)
|
app.cli.add_command(proposal.commands.create_proposals)
|
||||||
|
app.cli.add_command(proposal.commands.retire_v1_proposals)
|
||||||
app.cli.add_command(user.commands.set_admin)
|
app.cli.add_command(user.commands.set_admin)
|
||||||
|
app.cli.add_command(user.commands.mangle_users)
|
||||||
app.cli.add_command(task.commands.create_task)
|
app.cli.add_command(task.commands.create_task)
|
||||||
|
|
|
@ -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
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import models
|
||||||
|
from . import views
|
|
@ -0,0 +1,230 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
|
||||||
|
from grant.email.send import send_email
|
||||||
|
from grant.extensions import ma, db
|
||||||
|
from grant.utils.enums import CCRStatus
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from grant.utils.misc import make_admin_url, gen_random_id, dt_to_unix
|
||||||
|
|
||||||
|
|
||||||
|
def default_content():
|
||||||
|
return """# Overview
|
||||||
|
|
||||||
|
What you think should be accomplished
|
||||||
|
|
||||||
|
|
||||||
|
# Approach
|
||||||
|
|
||||||
|
How you expect a proposing team to accomplish your request
|
||||||
|
|
||||||
|
|
||||||
|
# Deliverable
|
||||||
|
|
||||||
|
The end result of a proposal the fulfills this request
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CCR(db.Model):
|
||||||
|
__tablename__ = "ccr"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
date_created = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
title = db.Column(db.String(255), nullable=True)
|
||||||
|
brief = db.Column(db.String(255), nullable=True)
|
||||||
|
content = db.Column(db.Text, nullable=True)
|
||||||
|
status = db.Column(db.String(255), nullable=False)
|
||||||
|
_target = db.Column("target", db.String(255), nullable=True)
|
||||||
|
reject_reason = db.Column(db.String())
|
||||||
|
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||||
|
author = db.relationship("User", back_populates="ccrs")
|
||||||
|
|
||||||
|
rfp_id = db.Column(db.Integer, db.ForeignKey("rfp.id"), nullable=True)
|
||||||
|
rfp = db.relationship("RFP", back_populates="ccr")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_user(user, statuses=[CCRStatus.LIVE]):
|
||||||
|
status_filter = or_(CCR.status == v for v in statuses)
|
||||||
|
return CCR.query \
|
||||||
|
.filter(CCR.user_id == user.id) \
|
||||||
|
.filter(status_filter) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(**kwargs):
|
||||||
|
ccr = CCR(
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
db.session.add(ccr)
|
||||||
|
db.session.flush()
|
||||||
|
return ccr
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def target(self):
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
@target.setter
|
||||||
|
def target(self, target: str):
|
||||||
|
if target and Decimal(target) > 0:
|
||||||
|
self._target = target
|
||||||
|
else:
|
||||||
|
self._target = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
content: str = default_content(),
|
||||||
|
target: str = '0',
|
||||||
|
status: str = CCRStatus.DRAFT,
|
||||||
|
):
|
||||||
|
assert CCRStatus.includes(status)
|
||||||
|
self.id = gen_random_id(CCR)
|
||||||
|
self.date_created = datetime.now()
|
||||||
|
self.title = title[:255]
|
||||||
|
self.brief = brief[:255]
|
||||||
|
self.content = content
|
||||||
|
self.target = target
|
||||||
|
self.status = status
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
title: str = '',
|
||||||
|
brief: str = '',
|
||||||
|
content: str = '',
|
||||||
|
target: str = '0',
|
||||||
|
):
|
||||||
|
self.title = title[:255]
|
||||||
|
self.brief = brief[:255]
|
||||||
|
self.content = content[:300000]
|
||||||
|
self._target = target[:255] if target != '' and target else '0'
|
||||||
|
|
||||||
|
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
||||||
|
def submit_for_approval(self):
|
||||||
|
self.validate_publishable()
|
||||||
|
allowed_statuses = [CCRStatus.DRAFT, CCRStatus.REJECTED]
|
||||||
|
# specific validation
|
||||||
|
if self.status not in allowed_statuses:
|
||||||
|
raise ValidationException(f"CCR status must be draft or rejected to submit for approval")
|
||||||
|
self.set_pending()
|
||||||
|
|
||||||
|
def send_admin_email(self, type: str):
|
||||||
|
from grant.user.models import User
|
||||||
|
admins = User.get_admins()
|
||||||
|
for a in admins:
|
||||||
|
send_email(a.email_address, type, {
|
||||||
|
'user': a,
|
||||||
|
'ccr': self,
|
||||||
|
'ccr_url': make_admin_url(f'/ccrs/{self.id}'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# state: status DRAFT -> PENDING
|
||||||
|
def set_pending(self):
|
||||||
|
self.send_admin_email('admin_approval_ccr')
|
||||||
|
self.status = CCRStatus.PENDING
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def validate_publishable(self):
|
||||||
|
# Require certain fields
|
||||||
|
required_fields = ['title', 'content', 'brief', 'target']
|
||||||
|
for field in required_fields:
|
||||||
|
if not hasattr(self, field):
|
||||||
|
raise ValidationException("Proposal must have a {}".format(field))
|
||||||
|
|
||||||
|
# Stricter limits on certain fields
|
||||||
|
if len(self.title) > 60:
|
||||||
|
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
||||||
|
if len(self.brief) > 140:
|
||||||
|
raise ValidationException("Brief cannot be longer than 140 characters")
|
||||||
|
if len(self.content) > 250000:
|
||||||
|
raise ValidationException("Content cannot be longer than 250,000 characters")
|
||||||
|
|
||||||
|
# state: status PENDING -> (LIVE || REJECTED)
|
||||||
|
def approve_pending(self, is_approve, reject_reason=None):
|
||||||
|
from grant.rfp.models import RFP
|
||||||
|
self.validate_publishable()
|
||||||
|
# specific validation
|
||||||
|
if not self.status == CCRStatus.PENDING:
|
||||||
|
raise ValidationException(f"CCR must be pending to approve or reject")
|
||||||
|
|
||||||
|
if is_approve:
|
||||||
|
self.status = CCRStatus.LIVE
|
||||||
|
rfp = RFP(
|
||||||
|
title=self.title,
|
||||||
|
brief=self.brief,
|
||||||
|
content=self.content,
|
||||||
|
bounty=self._target,
|
||||||
|
date_closes=datetime.now() + timedelta(days=90),
|
||||||
|
)
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.flush()
|
||||||
|
self.rfp_id = rfp.id
|
||||||
|
db.session.add(rfp)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# for emails
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
send_email(self.author.email_address, 'ccr_approved', {
|
||||||
|
'user': self.author,
|
||||||
|
'ccr': self,
|
||||||
|
'admin_note': f'Congratulations! Your Request has been accepted. There may be a delay between acceptance and final posting as required by the Zcash Foundation.'
|
||||||
|
})
|
||||||
|
return rfp.id
|
||||||
|
else:
|
||||||
|
if not reject_reason:
|
||||||
|
raise ValidationException("Please provide a reason for rejecting the ccr")
|
||||||
|
self.status = CCRStatus.REJECTED
|
||||||
|
self.reject_reason = reject_reason
|
||||||
|
# for emails
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
send_email(self.author.email_address, 'ccr_rejected', {
|
||||||
|
'user': self.author,
|
||||||
|
'ccr': self,
|
||||||
|
'admin_note': reject_reason
|
||||||
|
})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CCRSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = CCR
|
||||||
|
# Fields to expose
|
||||||
|
fields = (
|
||||||
|
"author",
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"brief",
|
||||||
|
"ccr_id",
|
||||||
|
"content",
|
||||||
|
"status",
|
||||||
|
"target",
|
||||||
|
"date_created",
|
||||||
|
"reject_reason",
|
||||||
|
"rfp"
|
||||||
|
)
|
||||||
|
|
||||||
|
rfp = ma.Nested("RFPSchema")
|
||||||
|
date_created = ma.Method("get_date_created")
|
||||||
|
author = ma.Nested("UserSchema")
|
||||||
|
ccr_id = ma.Method("get_ccr_id")
|
||||||
|
|
||||||
|
def get_date_created(self, obj):
|
||||||
|
return dt_to_unix(obj.date_created)
|
||||||
|
|
||||||
|
def get_ccr_id(self, obj):
|
||||||
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
|
ccr_schema = CCRSchema()
|
||||||
|
ccrs_schema = CCRSchema(many=True)
|
|
@ -0,0 +1,114 @@
|
||||||
|
from flask import Blueprint, g
|
||||||
|
from marshmallow import fields
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.extensions import limiter
|
||||||
|
from grant.parser import body
|
||||||
|
from grant.utils.auth import (
|
||||||
|
requires_auth,
|
||||||
|
requires_email_verified_auth,
|
||||||
|
get_authed_user
|
||||||
|
)
|
||||||
|
from grant.utils.auth import requires_ccr_owner_auth
|
||||||
|
from grant.utils.enums import CCRStatus
|
||||||
|
from grant.utils.exceptions import ValidationException
|
||||||
|
from .models import CCR, ccr_schema, ccrs_schema, db
|
||||||
|
|
||||||
|
blueprint = Blueprint("ccr", __name__, url_prefix="/api/v1/ccrs")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["GET"])
|
||||||
|
def get_ccr(ccr_id):
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if ccr:
|
||||||
|
if ccr.status != CCRStatus.LIVE:
|
||||||
|
if CCR.status == CCRStatus.DELETED:
|
||||||
|
return {"message": "CCR was deleted"}, 404
|
||||||
|
authed_user = get_authed_user()
|
||||||
|
|
||||||
|
if authed_user.id != ccr.author.id:
|
||||||
|
return {"message": "User cannot view this CCR"}, 404
|
||||||
|
return ccr_schema.dump(ccr)
|
||||||
|
else:
|
||||||
|
return {"message": "No CCR matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/drafts", methods=["POST"])
|
||||||
|
@limiter.limit("10/hour;3/minute")
|
||||||
|
@requires_email_verified_auth
|
||||||
|
def make_ccr_draft():
|
||||||
|
user = g.current_user
|
||||||
|
ccr = CCR.create(status=CCRStatus.DRAFT, user_id=user.id)
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(ccr), 201
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/drafts", methods=["GET"])
|
||||||
|
@requires_auth
|
||||||
|
def get_ccr_drafts():
|
||||||
|
ccrs = (
|
||||||
|
CCR.query
|
||||||
|
.filter_by(user_id=g.current_user.id)
|
||||||
|
.filter(or_(
|
||||||
|
CCR.status == CCRStatus.DRAFT,
|
||||||
|
CCR.status == CCRStatus.REJECTED,
|
||||||
|
))
|
||||||
|
.order_by(CCR.date_created.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return ccrs_schema.dump(ccrs), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["DELETE"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
def delete_ccr(ccr_id):
|
||||||
|
deleteable_statuses = [
|
||||||
|
CCRStatus.DRAFT,
|
||||||
|
CCRStatus.PENDING,
|
||||||
|
CCRStatus.APPROVED,
|
||||||
|
CCRStatus.REJECTED,
|
||||||
|
CCRStatus.REJECTED_PERMANENTLY
|
||||||
|
]
|
||||||
|
status = g.current_ccr.status
|
||||||
|
if status not in deleteable_statuses:
|
||||||
|
return {"message": "Cannot delete CCRs with %s status" % status}, 400
|
||||||
|
db.session.delete(g.current_ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 202
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>", methods=["PUT"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
@body({
|
||||||
|
"title": fields.Str(required=True),
|
||||||
|
"brief": fields.Str(required=True),
|
||||||
|
"content": fields.Str(required=True),
|
||||||
|
"target": fields.Str(required=True, allow_none=True),
|
||||||
|
})
|
||||||
|
def update_ccr(ccr_id, **kwargs):
|
||||||
|
try:
|
||||||
|
if g.current_ccr.status not in [CCRStatus.DRAFT,
|
||||||
|
CCRStatus.REJECTED]:
|
||||||
|
raise ValidationException(
|
||||||
|
f"CCR with status: {g.current_ccr.status} are not authorized for updates"
|
||||||
|
)
|
||||||
|
g.current_ccr.update(**kwargs)
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "{}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_ccr)
|
||||||
|
|
||||||
|
# Commit
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(g.current_ccr), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<ccr_id>/submit_for_approval", methods=["PUT"])
|
||||||
|
@requires_ccr_owner_auth
|
||||||
|
def submit_for_approval_ccr(ccr_id):
|
||||||
|
try:
|
||||||
|
g.current_ccr.submit_for_approval()
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": "{}".format(str(e))}, 400
|
||||||
|
db.session.add(g.current_ccr)
|
||||||
|
db.session.commit()
|
||||||
|
return ccr_schema.dump(g.current_ccr), 200
|
|
@ -4,10 +4,19 @@ from functools import reduce
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from grant.utils.ma_fields import UnixDate
|
from grant.utils.ma_fields import UnixDate
|
||||||
from grant.utils.misc import gen_random_id
|
from grant.utils.misc import gen_random_id
|
||||||
from sqlalchemy.orm import raiseload
|
from sqlalchemy.orm import raiseload, column_property
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
HIDDEN_CONTENT = '~~comment removed by admin~~'
|
HIDDEN_CONTENT = '~~comment removed by admin~~'
|
||||||
|
|
||||||
|
comment_liker = db.Table(
|
||||||
|
"comment_liker",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("comment_id", db.Integer, db.ForeignKey("comment.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Comment(db.Model):
|
class Comment(db.Model):
|
||||||
__tablename__ = "comment"
|
__tablename__ = "comment"
|
||||||
|
@ -25,6 +34,15 @@ class Comment(db.Model):
|
||||||
author = db.relationship("User", back_populates="comments")
|
author = db.relationship("User", back_populates="comments")
|
||||||
replies = db.relationship("Comment")
|
replies = db.relationship("Comment")
|
||||||
|
|
||||||
|
likes = db.relationship(
|
||||||
|
"User", secondary=comment_liker, back_populates="liked_comments"
|
||||||
|
)
|
||||||
|
likes_count = column_property(
|
||||||
|
select([func.count(comment_liker.c.comment_id)])
|
||||||
|
.where(comment_liker.c.comment_id == id)
|
||||||
|
.correlate_except(comment_liker)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, proposal_id, user_id, parent_comment_id, content):
|
def __init__(self, proposal_id, user_id, parent_comment_id, content):
|
||||||
self.id = gen_random_id(Comment)
|
self.id = gen_random_id(Comment)
|
||||||
self.proposal_id = proposal_id
|
self.proposal_id = proposal_id
|
||||||
|
@ -49,6 +67,28 @@ class Comment(db.Model):
|
||||||
self.hidden = hidden
|
self.hidden = hidden
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_liked(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(comment_liker)
|
||||||
|
.filter_by(user_id=authed.id, comment_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def like(self, user, is_liked):
|
||||||
|
if is_liked:
|
||||||
|
self.likes.append(user)
|
||||||
|
else:
|
||||||
|
self.likes.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
# are all of the replies hidden?
|
# are all of the replies hidden?
|
||||||
def all_hidden(replies):
|
def all_hidden(replies):
|
||||||
|
@ -74,6 +114,8 @@ class CommentSchema(ma.Schema):
|
||||||
"replies",
|
"replies",
|
||||||
"reported",
|
"reported",
|
||||||
"hidden",
|
"hidden",
|
||||||
|
"authed_liked",
|
||||||
|
"likes_count"
|
||||||
)
|
)
|
||||||
|
|
||||||
content = ma.Method("get_content")
|
content = ma.Method("get_content")
|
||||||
|
|
|
@ -1,4 +1,26 @@
|
||||||
from flask import Blueprint
|
from flask import Blueprint, g
|
||||||
|
|
||||||
|
from grant.utils.auth import requires_auth
|
||||||
|
from grant.parser import body
|
||||||
|
from marshmallow import fields
|
||||||
|
from .models import Comment, db, comment_schema
|
||||||
|
|
||||||
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
|
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<comment_id>/like", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isLiked": fields.Bool(required=True)})
|
||||||
|
def like_comment(comment_id, is_liked):
|
||||||
|
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure comment exists
|
||||||
|
comment = Comment.query.filter_by(id=comment_id).first()
|
||||||
|
if not comment:
|
||||||
|
return {"message": "No comment matching id"}, 404
|
||||||
|
|
||||||
|
comment.like(user, is_liked)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return comment_schema.dump(comment), 201
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
from .subscription_settings import EmailSubscription, is_subscribed
|
|
||||||
from sendgrid.helpers.mail import Email, Mail, Content
|
|
||||||
from python_http_client import HTTPError
|
|
||||||
from grant.utils.misc import make_url
|
|
||||||
from sentry_sdk import capture_exception
|
|
||||||
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI
|
|
||||||
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
|
|
||||||
import sendgrid
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from flask import render_template, Markup, current_app, g
|
|
||||||
|
|
||||||
|
import sendgrid
|
||||||
|
from flask import render_template, Markup, current_app, g
|
||||||
|
from python_http_client import HTTPError
|
||||||
|
from sendgrid.helpers.mail import Email, Mail, Content
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
|
||||||
|
from grant.settings import SENDGRID_DEFAULT_FROMNAME
|
||||||
|
from grant.utils.misc import make_url
|
||||||
|
from .subscription_settings import EmailSubscription, is_subscribed
|
||||||
|
|
||||||
default_template_args = {
|
default_template_args = {
|
||||||
'home_url': make_url('/'),
|
'home_url': make_url('/'),
|
||||||
'account_url': make_url('/profile'),
|
'account_url': make_url('/profile'),
|
||||||
|
'profile_rejected_url': make_url('/profile?tab=rejected'),
|
||||||
'email_settings_url': make_url('/profile/settings?tab=emails'),
|
'email_settings_url': make_url('/profile/settings?tab=emails'),
|
||||||
'unsubscribe_url': make_url('/profile/settings?tab=emails'),
|
'unsubscribe_url': make_url('/profile/settings?tab=emails'),
|
||||||
}
|
}
|
||||||
|
@ -68,22 +70,94 @@ def change_password_info(email_args):
|
||||||
|
|
||||||
def proposal_approved(email_args):
|
def proposal_approved(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has been approved!',
|
'subject': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
|
||||||
'title': 'Your proposal has been approved',
|
'title': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
|
||||||
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
|
'preview': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
|
||||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
'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!".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".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):
|
def proposal_rejected(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has been rejected',
|
'subject': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
|
||||||
'title': 'Your proposal has been rejected',
|
'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),
|
'preview': '{} has been rejected'.format(email_args['proposal'].title),
|
||||||
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
|
'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):
|
def proposal_contribution(email_args):
|
||||||
if email_args['contribution'].private:
|
if email_args['contribution'].private:
|
||||||
email_args['contributor'] = None
|
email_args['contributor'] = None
|
||||||
|
@ -101,7 +175,8 @@ def proposal_contribution(email_args):
|
||||||
|
|
||||||
def proposal_comment(email_args):
|
def proposal_comment(email_args):
|
||||||
return {
|
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',
|
'title': 'You got a comment',
|
||||||
'preview': '{} has added a comment to your proposal {}'.format(
|
'preview': '{} has added a comment to your proposal {}'.format(
|
||||||
email_args['author'].display_name,
|
email_args['author'].display_name,
|
||||||
|
@ -113,8 +188,8 @@ def proposal_comment(email_args):
|
||||||
|
|
||||||
def proposal_failed(email_args):
|
def proposal_failed(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal failed to get funding',
|
'subject': "Your proposal '{}' failed to get funding".format(email_args['proposal'].title),
|
||||||
'title': 'Proposal failed',
|
'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(
|
'preview': 'Your proposal entitled {} failed to get enough funding by the deadline'.format(
|
||||||
email_args['proposal'].title,
|
email_args['proposal'].title,
|
||||||
),
|
),
|
||||||
|
@ -126,7 +201,7 @@ def proposal_canceled(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Your proposal has been canceled',
|
'subject': 'Your proposal has been canceled',
|
||||||
'title': 'Proposal 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,
|
email_args['proposal'].title,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
@ -245,6 +320,17 @@ def milestone_request(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def milestone_deadline(email_args):
|
||||||
|
p = email_args['proposal']
|
||||||
|
ms = p.current_milestone
|
||||||
|
return {
|
||||||
|
'subject': f'Milestone deadline reached for {p.title} - {ms.title}',
|
||||||
|
'title': f'Milestone deadline reached',
|
||||||
|
'preview': f'The estimated deadline for milestone {ms.title} has been reached.',
|
||||||
|
'subscription': EmailSubscription.ARBITER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def milestone_reject(email_args):
|
def milestone_reject(email_args):
|
||||||
p = email_args['proposal']
|
p = email_args['proposal']
|
||||||
ms = p.current_milestone
|
ms = p.current_milestone
|
||||||
|
@ -263,7 +349,7 @@ def milestone_accept(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': f'Payout approved for {p.title} - {ms.title}!',
|
'subject': f'Payout approved for {p.title} - {ms.title}!',
|
||||||
'title': f'Milestone payout approved',
|
'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,
|
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,7 +361,7 @@ def milestone_paid(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': f'{p.title} - {ms.title} has been paid!',
|
'subject': f'{p.title} - {ms.title} has been paid!',
|
||||||
'title': f'Milestone 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,
|
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -289,6 +375,24 @@ def admin_approval(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_approval_ccr(email_args):
|
||||||
|
return {
|
||||||
|
'subject': f'Review needed for {email_args["ccr"].title}',
|
||||||
|
'title': f'CCR Review',
|
||||||
|
'preview': f'{email_args["ccr"].title} needs review, as an admin you can help.',
|
||||||
|
'subscription': EmailSubscription.ADMIN_APPROVAL_CCR,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_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):
|
def admin_arbiter(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
'subject': f'Arbiter needed for {email_args["proposal"].title}',
|
||||||
|
@ -307,6 +411,37 @@ def admin_payout(email_args):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def followed_proposal_milestone(email_args):
|
||||||
|
p = email_args["proposal"]
|
||||||
|
ms = email_args["milestone"]
|
||||||
|
return {
|
||||||
|
"subject": f"Milestone accepted for {p.title}",
|
||||||
|
"title": f"Milestone Accepted",
|
||||||
|
"preview": f"Followed proposal {p.title} has passed a milestone",
|
||||||
|
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def followed_proposal_update(email_args):
|
||||||
|
p = email_args["proposal"]
|
||||||
|
return {
|
||||||
|
"subject": f"Proposal update for {p.title}",
|
||||||
|
"title": f"Proposal Update",
|
||||||
|
"preview": f"Followed proposal {p.title} has an update",
|
||||||
|
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 = {
|
get_info_lookup = {
|
||||||
'signup': signup_info,
|
'signup': signup_info,
|
||||||
'team_invite': team_invite_info,
|
'team_invite': team_invite_info,
|
||||||
|
@ -314,8 +449,16 @@ get_info_lookup = {
|
||||||
'change_email': change_email_info,
|
'change_email': change_email_info,
|
||||||
'change_email_old': change_email_old_info,
|
'change_email_old': change_email_old_info,
|
||||||
'change_password': change_password_info,
|
'change_password': change_password_info,
|
||||||
|
'ccr_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': proposal_approved,
|
||||||
|
'proposal_approved_discussion': proposal_approved_discussion,
|
||||||
'proposal_rejected': proposal_rejected,
|
'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_contribution': proposal_contribution,
|
||||||
'proposal_comment': proposal_comment,
|
'proposal_comment': proposal_comment,
|
||||||
'proposal_failed': proposal_failed,
|
'proposal_failed': proposal_failed,
|
||||||
|
@ -330,12 +473,18 @@ get_info_lookup = {
|
||||||
'comment_reply': comment_reply,
|
'comment_reply': comment_reply,
|
||||||
'proposal_arbiter': proposal_arbiter,
|
'proposal_arbiter': proposal_arbiter,
|
||||||
'milestone_request': milestone_request,
|
'milestone_request': milestone_request,
|
||||||
|
'milestone_deadline': milestone_deadline,
|
||||||
'milestone_reject': milestone_reject,
|
'milestone_reject': milestone_reject,
|
||||||
'milestone_accept': milestone_accept,
|
'milestone_accept': milestone_accept,
|
||||||
'milestone_paid': milestone_paid,
|
'milestone_paid': milestone_paid,
|
||||||
'admin_approval': admin_approval,
|
'admin_approval': admin_approval,
|
||||||
|
'admin_approval_ccr': admin_approval_ccr,
|
||||||
|
'admin_changes_resolved': admin_changes_resolved,
|
||||||
'admin_arbiter': admin_arbiter,
|
'admin_arbiter': admin_arbiter,
|
||||||
'admin_payout': admin_payout
|
'admin_payout': admin_payout,
|
||||||
|
'followed_proposal_milestone': followed_proposal_milestone,
|
||||||
|
'followed_proposal_update': followed_proposal_update,
|
||||||
|
'followed_proposal_revised': followed_proposal_revised
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,14 @@ class EmailSubscription(Enum):
|
||||||
'bit': 14,
|
'bit': 14,
|
||||||
'key': 'admin_payout'
|
'key': 'admin_payout'
|
||||||
}
|
}
|
||||||
|
FOLLOWED_PROPOSAL = {
|
||||||
|
'bit': 15,
|
||||||
|
'key': 'followed_proposal'
|
||||||
|
}
|
||||||
|
ADMIN_APPROVAL_CCR = {
|
||||||
|
'bit': 16,
|
||||||
|
'key': 'admin_approval_ccr'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_email_sub_key(k: str):
|
def is_email_sub_key(k: str):
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.proposal.models import Proposal, proposals_schema
|
||||||
|
from grant.rfp.models import RFP, rfps_schema
|
||||||
|
from grant.utils.enums import ProposalStatus, ProposalStage, RFPStatus
|
||||||
|
|
||||||
|
blueprint = Blueprint("home", __name__, url_prefix="/api/v1/home")
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/latest", methods=["GET"])
|
||||||
|
def get_home_content():
|
||||||
|
latest_proposals = (
|
||||||
|
Proposal.query.filter_by(status=ProposalStatus.LIVE)
|
||||||
|
.filter(Proposal.stage != ProposalStage.CANCELED)
|
||||||
|
.filter(Proposal.stage != ProposalStage.FAILED)
|
||||||
|
.order_by(Proposal.date_created.desc())
|
||||||
|
.limit(3)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
latest_rfps = (
|
||||||
|
RFP.query.filter_by(status=RFPStatus.LIVE)
|
||||||
|
.filter(or_(RFP.date_closes == None, RFP.date_closes > datetime.now()))
|
||||||
|
.order_by(RFP.date_opened)
|
||||||
|
.limit(3)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latest_proposals": proposals_schema.dump(latest_proposals),
|
||||||
|
"latest_rfps": rfps_schema.dump(latest_rfps),
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ from grant.utils.enums import MilestoneStage
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.ma_fields import UnixDate
|
from grant.utils.ma_fields import UnixDate
|
||||||
from grant.utils.misc import gen_random_id
|
from grant.utils.misc import gen_random_id
|
||||||
|
from grant.task.jobs import MilestoneDeadline
|
||||||
|
|
||||||
|
|
||||||
class MilestoneException(Exception):
|
class MilestoneException(Exception):
|
||||||
|
@ -22,7 +23,8 @@ class Milestone(db.Model):
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
payout_percent = db.Column(db.String(255), nullable=False)
|
payout_percent = db.Column(db.String(255), nullable=False)
|
||||||
immediate_payout = db.Column(db.Boolean)
|
immediate_payout = db.Column(db.Boolean)
|
||||||
date_estimated = db.Column(db.DateTime, nullable=False)
|
date_estimated = db.Column(db.DateTime, nullable=True)
|
||||||
|
days_estimated = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
@ -46,7 +48,7 @@ class Milestone(db.Model):
|
||||||
index: int,
|
index: int,
|
||||||
title: str,
|
title: str,
|
||||||
content: str,
|
content: str,
|
||||||
date_estimated: datetime,
|
days_estimated: str,
|
||||||
payout_percent: str,
|
payout_percent: str,
|
||||||
immediate_payout: bool,
|
immediate_payout: bool,
|
||||||
stage: str = MilestoneStage.IDLE,
|
stage: str = MilestoneStage.IDLE,
|
||||||
|
@ -56,13 +58,14 @@ class Milestone(db.Model):
|
||||||
self.title = title[:255]
|
self.title = title[:255]
|
||||||
self.content = content[:255]
|
self.content = content[:255]
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
self.date_estimated = date_estimated
|
self.days_estimated = days_estimated[:255]
|
||||||
self.payout_percent = payout_percent[:255]
|
self.payout_percent = payout_percent[:255]
|
||||||
self.immediate_payout = immediate_payout
|
self.immediate_payout = immediate_payout
|
||||||
self.proposal_id = proposal_id
|
self.proposal_id = proposal_id
|
||||||
self.date_created = datetime.datetime.now()
|
self.date_created = datetime.datetime.now()
|
||||||
self.index = index
|
self.index = index
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def make(milestones_data, proposal):
|
def make(milestones_data, proposal):
|
||||||
if milestones_data:
|
if milestones_data:
|
||||||
|
@ -72,7 +75,7 @@ class Milestone(db.Model):
|
||||||
m = Milestone(
|
m = Milestone(
|
||||||
title=milestone_data["title"][:255],
|
title=milestone_data["title"][:255],
|
||||||
content=milestone_data["content"][:255],
|
content=milestone_data["content"][:255],
|
||||||
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
|
days_estimated=str(milestone_data["days_estimated"])[:255],
|
||||||
payout_percent=str(milestone_data["payout_percent"])[:255],
|
payout_percent=str(milestone_data["payout_percent"])[:255],
|
||||||
immediate_payout=milestone_data["immediate_payout"],
|
immediate_payout=milestone_data["immediate_payout"],
|
||||||
proposal_id=proposal.id,
|
proposal_id=proposal.id,
|
||||||
|
@ -80,6 +83,75 @@ class Milestone(db.Model):
|
||||||
)
|
)
|
||||||
db.session.add(m)
|
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`.
|
||||||
|
#
|
||||||
|
# As proposal creators now estimate their milestones in days (instead of picking months), this method allows us to
|
||||||
|
# keep `date_estimated` in sync throughout the lifecycle of a proposal. For example, if a user misses their
|
||||||
|
# first milestone deadline by a week, this method would take the actual completion date of that milestone and
|
||||||
|
# adjust the `date_estimated` of the remaining milestones accordingly.
|
||||||
|
#
|
||||||
|
@staticmethod
|
||||||
|
def set_v2_date_estimates(proposal):
|
||||||
|
if not proposal.date_approved:
|
||||||
|
raise MilestoneException(f'Cannot estimate milestone dates because proposal has no date_approved set')
|
||||||
|
|
||||||
|
# The milestone being actively worked on
|
||||||
|
current_milestone = proposal.current_milestone
|
||||||
|
|
||||||
|
if current_milestone.stage == MilestoneStage.PAID:
|
||||||
|
raise MilestoneException(f'Cannot estimate milestone dates because they are all completed')
|
||||||
|
|
||||||
|
# The starting point for `date_estimated` calculation for each uncompleted milestone
|
||||||
|
# We add `days_estimated` to `base_date` to calculate `date_estimated`
|
||||||
|
base_date = None
|
||||||
|
|
||||||
|
for index, milestone in enumerate(proposal.milestones):
|
||||||
|
if index == 0:
|
||||||
|
# If it's the first milestone, use the proposal approval date as a `base_date`
|
||||||
|
base_date = proposal.date_approved
|
||||||
|
|
||||||
|
if milestone.date_paid:
|
||||||
|
# If milestone has been paid, set `base_date` for the next milestone and noop out
|
||||||
|
base_date = milestone.date_paid
|
||||||
|
continue
|
||||||
|
|
||||||
|
days_estimated = milestone.days_estimated if not milestone.immediate_payout else "0"
|
||||||
|
date_estimated = base_date + datetime.timedelta(days=int(days_estimated))
|
||||||
|
milestone.date_estimated = date_estimated
|
||||||
|
|
||||||
|
# Set the `base_date` for the next milestone using the estimate completion date of the current milestone
|
||||||
|
base_date = date_estimated
|
||||||
|
db.session.add(milestone)
|
||||||
|
|
||||||
|
# Skip task creation if current milestone has an immediate payout
|
||||||
|
if current_milestone.immediate_payout:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create MilestoneDeadline task for the current milestone so arbiters will be alerted if the deadline is missed
|
||||||
|
task = MilestoneDeadline(proposal, current_milestone)
|
||||||
|
task.make_task()
|
||||||
|
|
||||||
def request_payout(self, user_id: int):
|
def request_payout(self, user_id: int):
|
||||||
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
||||||
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
||||||
|
@ -140,6 +212,7 @@ class MilestoneSchema(ma.Schema):
|
||||||
"date_rejected",
|
"date_rejected",
|
||||||
"date_accepted",
|
"date_accepted",
|
||||||
"date_paid",
|
"date_paid",
|
||||||
|
"days_estimated"
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = UnixDate(attribute='date_created')
|
date_created = UnixDate(attribute='date_created')
|
||||||
|
|
|
@ -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
|
|
@ -7,7 +7,7 @@ from flask.cli import with_appcontext
|
||||||
from .models import Proposal, db
|
from .models import Proposal, db
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.utils.enums import ProposalStatus, Category, ProposalStageEnum
|
from grant.utils.enums import ProposalStatus, Category, ProposalStage
|
||||||
from grant.user.models import User
|
from grant.user.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,9 +35,9 @@ def create_proposals(count):
|
||||||
user = User.query.filter_by().first()
|
user = User.query.filter_by().first()
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
if i < 5:
|
if i < 5:
|
||||||
stage = ProposalStageEnum.FUNDING_REQUIRED
|
stage = ProposalStage.WIP
|
||||||
else:
|
else:
|
||||||
stage = ProposalStageEnum.COMPLETED
|
stage = ProposalStage.COMPLETED
|
||||||
p = Proposal.create(
|
p = Proposal.create(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
status=ProposalStatus.LIVE,
|
status=ProposalStatus.LIVE,
|
||||||
|
@ -51,6 +51,10 @@ def create_proposals(count):
|
||||||
)
|
)
|
||||||
p.date_published = datetime.datetime.now()
|
p.date_published = datetime.datetime.now()
|
||||||
p.team.append(user)
|
p.team.append(user)
|
||||||
|
p.date_approved = datetime.datetime.now()
|
||||||
|
p.accepted_with_funding = True
|
||||||
|
p.version = '2'
|
||||||
|
p.fully_fund_contibution_bounty()
|
||||||
db.session.add(p)
|
db.session.add(p)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
num_ms = randint(1, 9)
|
num_ms = randint(1, 9)
|
||||||
|
@ -58,7 +62,7 @@ def create_proposals(count):
|
||||||
m = Milestone(
|
m = Milestone(
|
||||||
title=f'Fake MS {j}',
|
title=f'Fake MS {j}',
|
||||||
content=f'Fake milestone #{j} on fake proposal #{i}!',
|
content=f'Fake milestone #{j} on fake proposal #{i}!',
|
||||||
date_estimated=datetime.datetime.now(),
|
days_estimated='10',
|
||||||
payout_percent=str(floor(1 / num_ms * 100)),
|
payout_percent=str(floor(1 / num_ms * 100)),
|
||||||
immediate_payout=j == 0,
|
immediate_payout=j == 0,
|
||||||
proposal_id=p.id,
|
proposal_id=p.id,
|
||||||
|
@ -74,5 +78,119 @@ def create_proposals(count):
|
||||||
)
|
)
|
||||||
db.session.add(c)
|
db.session.add(c)
|
||||||
|
|
||||||
|
Milestone.set_v2_date_estimates(p)
|
||||||
|
db.session.add(p)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f'Added {count} LIVE fake proposals')
|
print(f'Added {count} LIVE fake proposals')
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument('dry', required=False)
|
||||||
|
@with_appcontext
|
||||||
|
def retire_v1_proposals(dry):
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
proposals_funding_required = Proposal.query.filter_by(stage="FUNDING_REQUIRED").all()
|
||||||
|
proposals_draft = Proposal.query.filter_by(status=ProposalStatus.DRAFT).all()
|
||||||
|
proposals_pending = Proposal.query.filter_by(status=ProposalStatus.PENDING).all()
|
||||||
|
proposals_staking = Proposal.query.filter_by(status=ProposalStatus.STAKING).all()
|
||||||
|
modified_funding_required_count = 0
|
||||||
|
modified_draft_count = 0
|
||||||
|
modified_pending_count = 0
|
||||||
|
modified_staking_count = 0
|
||||||
|
deleted_draft_count = 0
|
||||||
|
|
||||||
|
if not proposals_funding_required and not proposals_draft and not proposals_pending and not proposals_staking:
|
||||||
|
print("No proposals found. Exiting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Found {len(proposals_funding_required)} 'FUNDING_REQUIRED' proposals to modify")
|
||||||
|
print(f"Found {len(proposals_draft)} 'DRAFT' proposals to modify")
|
||||||
|
print(f"Found {len(proposals_pending)} 'PENDING' proposals to modify")
|
||||||
|
print(f"Found {len(proposals_staking)} 'STAKING' proposals to modify")
|
||||||
|
|
||||||
|
if dry:
|
||||||
|
print(f"This is a dry run. Changes will not be committed to the database")
|
||||||
|
|
||||||
|
confirm = input("Continue? (y/n) ")
|
||||||
|
|
||||||
|
if confirm != "y":
|
||||||
|
print("Exiting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
# move 'FUNDING_REQUIRED' proposals to a failed state
|
||||||
|
for p in proposals_funding_required:
|
||||||
|
if not dry:
|
||||||
|
new_deadline = (now - p.date_published).total_seconds()
|
||||||
|
p.stage = ProposalStage.FAILED
|
||||||
|
p.deadline_duration = int(new_deadline)
|
||||||
|
db.session.add(p)
|
||||||
|
modified_funding_required_count += 1
|
||||||
|
|
||||||
|
print(f"Modified 'FUNDING_REQUIRED' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
# reset proposal to draft state
|
||||||
|
def convert_proposal_to_v2_draft(proposal):
|
||||||
|
milestones = Milestone.query.filter_by(proposal_id=proposal.id).all()
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
# reset target because v2 estimates are in USD
|
||||||
|
proposal.target = '0'
|
||||||
|
proposal.version = '2'
|
||||||
|
proposal.stage = ProposalStage.PREVIEW
|
||||||
|
proposal.status = ProposalStatus.DRAFT
|
||||||
|
db.session.add(proposal)
|
||||||
|
|
||||||
|
for m in milestones:
|
||||||
|
# clear date estimated because v2 proposals use days_estimated (date_estimated is dynamically set)
|
||||||
|
m.date_estimated = None
|
||||||
|
db.session.add(m)
|
||||||
|
|
||||||
|
print(f"Modified {len(milestones)} milestones on proposal {p.id}")
|
||||||
|
|
||||||
|
# delete drafts that have no content
|
||||||
|
def delete_stale_draft(proposal):
|
||||||
|
if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if proposal.payout_address or proposal.milestones:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
db.session.delete(proposal)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
for p in proposals_draft:
|
||||||
|
is_stale = delete_stale_draft(p)
|
||||||
|
if is_stale:
|
||||||
|
deleted_draft_count += 1
|
||||||
|
print(f"Deleted stale 'DRAFT' proposal {p.id} - {p.title}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
convert_proposal_to_v2_draft(p)
|
||||||
|
modified_draft_count += 1
|
||||||
|
print(f"Modified 'DRAFT' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
for p in proposals_pending:
|
||||||
|
convert_proposal_to_v2_draft(p)
|
||||||
|
modified_pending_count += 1
|
||||||
|
print(f"Modified 'PENDING' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
for p in proposals_staking:
|
||||||
|
convert_proposal_to_v2_draft(p)
|
||||||
|
modified_staking_count += 1
|
||||||
|
print(f"Modified 'STAKING' proposal {p.id} - {p.title}")
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
print(f"Committing changes to database")
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(f"Modified {modified_funding_required_count} 'FUNDING_REQUIRED' proposals")
|
||||||
|
print(f"Modified {modified_draft_count} 'DRAFT' proposals")
|
||||||
|
print(f"Modified {modified_pending_count} 'PENDING' proposals")
|
||||||
|
print(f"Modified {modified_staking_count} 'STAKING' proposals")
|
||||||
|
print(f"Deleted {deleted_draft_count} stale 'DRAFT' proposals")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,30 +1,33 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from marshmallow import post_dump
|
from marshmallow import post_dump
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_, select, ForeignKey
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy.orm import column_property
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
|
from grant.milestone.models import Milestone
|
||||||
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
|
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
|
||||||
from grant.task.jobs import ContributionExpired
|
from grant.task.jobs import ContributionExpired
|
||||||
from grant.utils.enums import (
|
from grant.utils.enums import (
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
ProposalStage,
|
ProposalStage,
|
||||||
Category,
|
|
||||||
ContributionStatus,
|
ContributionStatus,
|
||||||
ProposalArbiterStatus,
|
ProposalArbiterStatus,
|
||||||
MilestoneStage
|
MilestoneStage,
|
||||||
|
ProposalChange
|
||||||
)
|
)
|
||||||
from grant.utils.exceptions import ValidationException
|
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.misc import dt_to_unix, make_url, make_admin_url, gen_random_id
|
||||||
from grant.utils.requests import blockchain_get
|
from grant.utils.requests import blockchain_get
|
||||||
from grant.utils.stubs import anonymous_user
|
from grant.utils.stubs import anonymous_user
|
||||||
|
from grant.utils.validate import is_z_address_valid
|
||||||
|
|
||||||
proposal_team = db.Table(
|
proposal_team = db.Table(
|
||||||
'proposal_team', db.Model.metadata,
|
'proposal_team', db.Model.metadata,
|
||||||
|
@ -32,6 +35,20 @@ proposal_team = db.Table(
|
||||||
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
proposal_follower = db.Table(
|
||||||
|
"proposal_follower",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal_liker = db.Table(
|
||||||
|
"proposal_liker",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProposalTeamInvite(db.Model):
|
class ProposalTeamInvite(db.Model):
|
||||||
__tablename__ = "proposal_team_invite"
|
__tablename__ = "proposal_team_invite"
|
||||||
|
@ -145,6 +162,8 @@ class ProposalContribution(db.Model):
|
||||||
raise ValidationException('Proposal ID is required')
|
raise ValidationException('Proposal ID is required')
|
||||||
# User ID (must belong to an existing user)
|
# User ID (must belong to an existing user)
|
||||||
if user_id:
|
if user_id:
|
||||||
|
from grant.user.models import User
|
||||||
|
|
||||||
user = User.query.filter(User.id == user_id).first()
|
user = User.query.filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise ValidationException('No user matching that ID')
|
raise ValidationException('No user matching that ID')
|
||||||
|
@ -212,32 +231,188 @@ class ProposalArbiter(db.Model):
|
||||||
raise ValidationException('User is not arbiter')
|
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 """### If you have any doubts about the questions below, please reach out to anyone on the ZOMG on the [Zcash forums](https://forum.zcashcommunity.com/).
|
||||||
|
|
||||||
|
# Description of Problem or Opportunity
|
||||||
|
In addition to describing the problem/opportunity, please give a sense of how serious or urgent of a need you believe this to be. What evidence do you have? What validation have you already done, or how do you think you could validate this?
|
||||||
|
|
||||||
|
# Proposed Solution
|
||||||
|
Describe the solution at a high level. Please be specific about who the users and stakeholders are and how they would interact with your solution. E.g. retail ZEC holders, Zcash core devs, wallet devs, DeFi users, potential Zcash community participants.
|
||||||
|
|
||||||
|
# Solution Format
|
||||||
|
What is the exact form of the deliverable you’re creating? E.g. code shipped within the zcashd and zebra code bases, a website, a feature within a wallet, a text/markdown file, user manuals, etc.
|
||||||
|
|
||||||
|
# Technical approach
|
||||||
|
Dive into the _how_ of your project. Describe your approaches, components, workflows, methodology, etc. Bullet points and diagrams are appreciated!
|
||||||
|
|
||||||
|
# How big of a problem would it be to not solve this problem?
|
||||||
|
|
||||||
|
# Execution risks
|
||||||
|
What obstacles do you expect? What is most likely to go wrong? Which unknown factors or dependencies could jeopardize success? Who would have to incorporate your work in order for it to be usable?
|
||||||
|
|
||||||
|
|
||||||
|
# Unintended Consequences Downsides
|
||||||
|
What are the negative ramifications if your project is successful? Consider usability, stability, privacy, integrity, availability, decentralization, interoperability, maintainability, technical debt, requisite education, etc.
|
||||||
|
|
||||||
|
# Evaluation plan
|
||||||
|
What metrics for success can you share with the community once you’re done? In addition to quantitative metrics, what qualitative metrics do you think you could report?
|
||||||
|
|
||||||
|
|
||||||
|
# Schedule and Milestones
|
||||||
|
What is your timeline for the project? Include concrete milestones and the major tasks required to complete each milestone.
|
||||||
|
|
||||||
|
# Budget and Payout Timeline
|
||||||
|
|
||||||
|
How much funding do you need, and how will it be allocated (e.g., compensation for your effort, specific equipment, specific external services)? Please tie your payout timelines to the milestones presented in the previous step. Convention has been for applicants to base their budget on hours of work and an hourly rate, but we are open to proposals based on the value of outcomes instead.
|
||||||
|
|
||||||
|
# Applicant background
|
||||||
|
Summarize you and/or your team’s background and experience. Demonstrate that you have the skills and expertise necessary for the project that you’re proposing. Institutional bona fides are not required, but we want to hear about your track record.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Proposal(db.Model):
|
class Proposal(db.Model):
|
||||||
__tablename__ = "proposal"
|
__tablename__ = "proposal"
|
||||||
|
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
date_created = db.Column(db.DateTime)
|
date_created = db.Column(db.DateTime)
|
||||||
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
|
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
|
||||||
|
version = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
# Content info
|
# Content info
|
||||||
status = db.Column(db.String(255), nullable=False)
|
status = db.Column(db.String(255), nullable=False)
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
brief = db.Column(db.String(255), nullable=False)
|
brief = db.Column(db.String(255), nullable=False)
|
||||||
stage = db.Column(db.String(255), nullable=False)
|
stage = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False, default=default_proposal_content())
|
||||||
category = db.Column(db.String(255), nullable=False)
|
category = db.Column(db.String(255), nullable=True)
|
||||||
date_approved = db.Column(db.DateTime)
|
date_approved = db.Column(db.DateTime)
|
||||||
date_published = db.Column(db.DateTime)
|
date_published = db.Column(db.DateTime)
|
||||||
reject_reason = db.Column(db.String())
|
reject_reason = db.Column(db.String())
|
||||||
|
kyc_approved = db.Column(db.Boolean(), nullable=True, default=False)
|
||||||
|
funded_by_zomg = db.Column(db.Boolean(), nullable=True, default=False)
|
||||||
|
|
||||||
|
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
|
# Payment info
|
||||||
target = db.Column(db.String(255), nullable=False)
|
target = db.Column(db.String(255), nullable=False)
|
||||||
payout_address = db.Column(db.String(255), nullable=False)
|
payout_address = db.Column(db.String(255), nullable=False)
|
||||||
deadline_duration = db.Column(db.Integer(), nullable=False)
|
deadline_duration = db.Column(db.Integer(), nullable=True)
|
||||||
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
||||||
contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
|
contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
|
||||||
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
|
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
|
||||||
contributed = db.column_property()
|
contributed = db.column_property()
|
||||||
|
tip_jar_address = db.Column(db.String(255), nullable=True)
|
||||||
|
tip_jar_view_key = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
# Relations
|
# Relations
|
||||||
team = db.relationship("User", secondary=proposal_team)
|
team = db.relationship("User", secondary=proposal_team)
|
||||||
|
@ -248,13 +423,35 @@ class Proposal(db.Model):
|
||||||
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
||||||
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
||||||
|
followers = db.relationship(
|
||||||
|
"User", secondary=proposal_follower, back_populates="followed_proposals"
|
||||||
|
)
|
||||||
|
followers_count = column_property(
|
||||||
|
select([func.count(proposal_follower.c.proposal_id)])
|
||||||
|
.where(proposal_follower.c.proposal_id == id)
|
||||||
|
.correlate_except(proposal_follower)
|
||||||
|
)
|
||||||
|
likes = db.relationship(
|
||||||
|
"User", secondary=proposal_liker, back_populates="liked_proposals"
|
||||||
|
)
|
||||||
|
likes_count = column_property(
|
||||||
|
select([func.count(proposal_liker.c.proposal_id)])
|
||||||
|
.where(proposal_liker.c.proposal_id == id)
|
||||||
|
.correlate_except(proposal_liker)
|
||||||
|
)
|
||||||
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
status: str = ProposalStatus.DRAFT,
|
status: str = ProposalStatus.DRAFT,
|
||||||
title: str = '',
|
title: str = '',
|
||||||
brief: str = '',
|
brief: str = '',
|
||||||
content: str = '',
|
content: str = default_proposal_content(),
|
||||||
stage: str = ProposalStage.PREVIEW,
|
stage: str = ProposalStage.PREVIEW,
|
||||||
target: str = '0',
|
target: str = '0',
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
|
@ -272,18 +469,17 @@ class Proposal(db.Model):
|
||||||
self.payout_address = payout_address
|
self.payout_address = payout_address
|
||||||
self.deadline_duration = deadline_duration
|
self.deadline_duration = deadline_duration
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
|
self.version = '2'
|
||||||
|
self.funded_by_zomg = True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def simple_validate(proposal):
|
def simple_validate(proposal):
|
||||||
# Validate fields to be database save-able.
|
# Validate fields to be database save-able.
|
||||||
# Stricter validation is done in validate_publishable.
|
# Stricter validation is done in validate_publishable.
|
||||||
stage = proposal.get('stage')
|
stage = proposal.get('stage')
|
||||||
category = proposal.get('category')
|
|
||||||
|
|
||||||
if stage and not ProposalStage.includes(stage):
|
if stage and not ProposalStage.includes(stage):
|
||||||
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
||||||
if category and not Category.includes(category):
|
|
||||||
raise ValidationException("Category {} not a valid category".format(category))
|
|
||||||
|
|
||||||
def validate_publishable_milestones(self):
|
def validate_publishable_milestones(self):
|
||||||
payout_total = 0.0
|
payout_total = 0.0
|
||||||
|
@ -316,7 +512,7 @@ class Proposal(db.Model):
|
||||||
self.validate_publishable_milestones()
|
self.validate_publishable_milestones()
|
||||||
|
|
||||||
# Require certain fields
|
# Require certain fields
|
||||||
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
required_fields = ['title', 'content', 'brief', 'target', 'payout_address']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if not hasattr(self, field):
|
if not hasattr(self, field):
|
||||||
raise ValidationException("Proposal must have a {}".format(field))
|
raise ValidationException("Proposal must have a {}".format(field))
|
||||||
|
@ -329,31 +525,42 @@ class Proposal(db.Model):
|
||||||
if len(self.content) > 250000:
|
if len(self.content) > 250000:
|
||||||
raise ValidationException("Content cannot be longer than 250,000 characters")
|
raise ValidationException("Content cannot be longer than 250,000 characters")
|
||||||
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
|
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
|
||||||
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
|
raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX))
|
||||||
if Decimal(self.target) < 0.0001:
|
if Decimal(self.target) < 0:
|
||||||
raise ValidationException("Target cannot be less than 0.0001")
|
raise ValidationException("Target cannot be less than 0")
|
||||||
|
if not self.target.isdigit():
|
||||||
|
raise ValidationException("Target must be a whole number")
|
||||||
if self.deadline_duration > 7776000:
|
if self.deadline_duration > 7776000:
|
||||||
raise ValidationException("Deadline duration cannot be more than 90 days")
|
raise ValidationException("Deadline duration cannot be more than 90 days")
|
||||||
|
|
||||||
# Check with node that the address is kosher
|
# Validate payout address
|
||||||
try:
|
if not is_z_address_valid(self.payout_address):
|
||||||
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
raise ValidationException("Payout address is not a valid z address")
|
||||||
except:
|
|
||||||
raise ValidationException(
|
# Validate tip jar address
|
||||||
"Could not validate your payout address due to an internal server error, please try again later")
|
if self.tip_jar_address and not is_z_address_valid(self.tip_jar_address):
|
||||||
if not res['valid']:
|
raise ValidationException("Tip address is not a valid z address")
|
||||||
raise ValidationException("Payout address is not a valid Zcash address")
|
|
||||||
|
|
||||||
# Then run through regular validation
|
# Then run through regular validation
|
||||||
Proposal.simple_validate(vars(self))
|
Proposal.simple_validate(vars(self))
|
||||||
|
|
||||||
# only do this when user submits for approval, there is a chance the dates will
|
def validate_milestone_days(self):
|
||||||
# be passed by the time admin approval / user publishing occurs
|
|
||||||
def validate_milestone_dates(self):
|
|
||||||
present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
for milestone in self.milestones:
|
for milestone in self.milestones:
|
||||||
if present > milestone.date_estimated:
|
if milestone.immediate_payout:
|
||||||
raise ValidationException("Milestone date estimate must be in the future ")
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
p = float(milestone.days_estimated)
|
||||||
|
if not p.is_integer():
|
||||||
|
raise ValidationException("Milestone days estimated must be whole numbers, no decimals")
|
||||||
|
if p <= 0:
|
||||||
|
raise ValidationException("Milestone days estimated must be greater than zero")
|
||||||
|
if p > 365:
|
||||||
|
raise ValidationException("Milestone days estimated must be less than 365")
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationException("Milestone days estimated must be a number")
|
||||||
|
return
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(**kwargs):
|
def create(**kwargs):
|
||||||
|
@ -372,7 +579,7 @@ class Proposal(db.Model):
|
||||||
return proposal
|
return proposal
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
status_filter = or_(Proposal.status == v for v in statuses)
|
||||||
return Proposal.query \
|
return Proposal.query \
|
||||||
.join(proposal_team) \
|
.join(proposal_team) \
|
||||||
|
@ -396,6 +603,7 @@ class Proposal(db.Model):
|
||||||
content: str = '',
|
content: str = '',
|
||||||
target: str = '0',
|
target: str = '0',
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
|
tip_jar_address: Optional[str] = None,
|
||||||
deadline_duration: int = 5184000 # 60 days
|
deadline_duration: int = 5184000 # 60 days
|
||||||
):
|
):
|
||||||
self.title = title[:255]
|
self.title = title[:255]
|
||||||
|
@ -404,25 +612,19 @@ class Proposal(db.Model):
|
||||||
self.content = content[:300000]
|
self.content = content[:300000]
|
||||||
self.target = target[:255] if target != '' else '0'
|
self.target = target[:255] if target != '' else '0'
|
||||||
self.payout_address = payout_address[:255]
|
self.payout_address = payout_address[:255]
|
||||||
|
self.tip_jar_address = tip_jar_address[:255] if tip_jar_address is not None else None
|
||||||
self.deadline_duration = deadline_duration
|
self.deadline_duration = deadline_duration
|
||||||
Proposal.simple_validate(vars(self))
|
Proposal.simple_validate(vars(self))
|
||||||
|
|
||||||
def update_rfp_opt_in(self, opt_in: bool):
|
def update_rfp_opt_in(self, opt_in: bool):
|
||||||
self.rfp_opt_in = opt_in
|
self.rfp_opt_in = opt_in
|
||||||
# add/remove matching and/or bounty values from RFP
|
|
||||||
if opt_in and self.rfp:
|
|
||||||
self.set_contribution_matching(1 if self.rfp.matching else 0)
|
|
||||||
self.set_contribution_bounty(self.rfp.bounty or '0')
|
|
||||||
else:
|
|
||||||
self.set_contribution_matching(0)
|
|
||||||
self.set_contribution_bounty('0')
|
|
||||||
|
|
||||||
def create_contribution(
|
def create_contribution(
|
||||||
self,
|
self,
|
||||||
amount,
|
amount,
|
||||||
user_id: int = None,
|
user_id: int = None,
|
||||||
staking: bool = False,
|
staking: bool = False,
|
||||||
private: bool = True,
|
private: bool = True,
|
||||||
):
|
):
|
||||||
contribution = ProposalContribution(
|
contribution = ProposalContribution(
|
||||||
proposal_id=self.id,
|
proposal_id=self.id,
|
||||||
|
@ -469,19 +671,15 @@ class Proposal(db.Model):
|
||||||
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
|
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
|
||||||
})
|
})
|
||||||
|
|
||||||
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
# state: status (DRAFT || REJECTED) -> (PENDING)
|
||||||
def submit_for_approval(self):
|
def submit_for_approval(self):
|
||||||
self.validate_publishable()
|
self.validate_publishable()
|
||||||
self.validate_milestone_dates()
|
self.validate_milestone_days()
|
||||||
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
||||||
# specific validation
|
# specific validation
|
||||||
if self.status not in allowed_statuses:
|
if self.status not in allowed_statuses:
|
||||||
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
||||||
# set to PENDING if staked, else STAKING
|
self.set_pending()
|
||||||
if self.is_staked:
|
|
||||||
self.status = ProposalStatus.PENDING
|
|
||||||
else:
|
|
||||||
self.status = ProposalStatus.STAKING
|
|
||||||
|
|
||||||
def set_pending_when_ready(self):
|
def set_pending_when_ready(self):
|
||||||
if self.status == ProposalStatus.STAKING and self.is_staked:
|
if self.status == ProposalStatus.STAKING and self.is_staked:
|
||||||
|
@ -489,31 +687,24 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
# state: status STAKING -> PENDING
|
# state: status STAKING -> PENDING
|
||||||
def set_pending(self):
|
def set_pending(self):
|
||||||
if self.status != ProposalStatus.STAKING:
|
|
||||||
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
|
|
||||||
if not self.is_staked:
|
|
||||||
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
|
|
||||||
self.send_admin_email('admin_approval')
|
self.send_admin_email('admin_approval')
|
||||||
self.status = ProposalStatus.PENDING
|
self.status = ProposalStatus.PENDING
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# state: status PENDING -> (APPROVED || REJECTED)
|
# approve a proposal moving from PENDING to DISCUSSION status
|
||||||
def approve_pending(self, is_approve, reject_reason=None):
|
# state: status PENDING -> (DISCUSSION || REJECTED)
|
||||||
self.validate_publishable()
|
def approve_discussion(self, is_open_for_discussion, reject_reason=None):
|
||||||
# specific validation
|
|
||||||
if not self.status == ProposalStatus.PENDING:
|
if not self.status == ProposalStatus.PENDING:
|
||||||
raise ValidationException(f"Proposal must be pending to approve or reject")
|
raise ValidationException("Proposal must be pending to open for public discussion")
|
||||||
|
|
||||||
if is_approve:
|
if is_open_for_discussion:
|
||||||
self.status = ProposalStatus.APPROVED
|
self.status = ProposalStatus.DISCUSSION
|
||||||
self.date_approved = datetime.datetime.now()
|
|
||||||
for t in self.team:
|
for t in self.team:
|
||||||
send_email(t.email_address, 'proposal_approved', {
|
send_email(t.email_address, 'proposal_approved_discussion', {
|
||||||
'user': t,
|
'user': t,
|
||||||
'proposal': self,
|
'proposal': self,
|
||||||
'proposal_url': make_url(f'/proposals/{self.id}'),
|
'proposal_url': make_url(f'/proposals/{self.id}')
|
||||||
'admin_note': 'Congratulations! Your proposal has been approved.'
|
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
if not reject_reason:
|
if not reject_reason:
|
||||||
|
@ -528,6 +719,77 @@ class Proposal(db.Model):
|
||||||
'admin_note': reject_reason
|
'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()
|
||||||
|
|
||||||
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
||||||
def publish(self):
|
def publish(self):
|
||||||
self.validate_publishable()
|
self.validate_publishable()
|
||||||
|
@ -536,28 +798,7 @@ class Proposal(db.Model):
|
||||||
raise ValidationException(f"Proposal status must be approved")
|
raise ValidationException(f"Proposal status must be approved")
|
||||||
self.date_published = datetime.datetime.now()
|
self.date_published = datetime.datetime.now()
|
||||||
self.status = ProposalStatus.LIVE
|
self.status = ProposalStatus.LIVE
|
||||||
self.stage = ProposalStage.FUNDING_REQUIRED
|
|
||||||
# If we had a bounty that pushed us into funding, skip straight into WIP
|
|
||||||
self.set_funded_when_ready()
|
|
||||||
|
|
||||||
def set_funded_when_ready(self):
|
|
||||||
if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded:
|
|
||||||
self.set_funded()
|
|
||||||
|
|
||||||
# state: stage FUNDING_REQUIRED -> WIP
|
|
||||||
def set_funded(self):
|
|
||||||
if self.status != ProposalStatus.LIVE:
|
|
||||||
raise ValidationException(f"Proposal status must be live in order transition to funded state")
|
|
||||||
if self.stage != ProposalStage.FUNDING_REQUIRED:
|
|
||||||
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
|
|
||||||
if not self.is_funded:
|
|
||||||
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
|
|
||||||
self.send_admin_email('admin_arbiter')
|
|
||||||
self.stage = ProposalStage.WIP
|
self.stage = ProposalStage.WIP
|
||||||
db.session.add(self)
|
|
||||||
db.session.flush()
|
|
||||||
# check the first step, if immediate payout bump it to accepted
|
|
||||||
self.current_milestone.accept_immediate()
|
|
||||||
|
|
||||||
def set_contribution_bounty(self, bounty: str):
|
def set_contribution_bounty(self, bounty: str):
|
||||||
# do not allow changes on funded/WIP proposals
|
# do not allow changes on funded/WIP proposals
|
||||||
|
@ -567,20 +808,9 @@ class Proposal(db.Model):
|
||||||
self.contribution_bounty = str(Decimal(bounty))
|
self.contribution_bounty = str(Decimal(bounty))
|
||||||
db.session.add(self)
|
db.session.add(self)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
self.set_funded_when_ready()
|
|
||||||
|
|
||||||
def set_contribution_matching(self, matching: float):
|
def fully_fund_contibution_bounty(self):
|
||||||
# do not allow on funded/WIP proposals
|
self.set_contribution_bounty(self.target)
|
||||||
if self.is_funded:
|
|
||||||
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
|
|
||||||
# enforce 1 or 0 for now
|
|
||||||
if matching == 0.0 or matching == 1.0:
|
|
||||||
self.contribution_matching = matching
|
|
||||||
db.session.add(self)
|
|
||||||
db.session.flush()
|
|
||||||
self.set_funded_when_ready()
|
|
||||||
else:
|
|
||||||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
if self.status != ProposalStatus.LIVE:
|
if self.status != ProposalStatus.LIVE:
|
||||||
|
@ -603,6 +833,33 @@ class Proposal(db.Model):
|
||||||
'account_settings_url': make_url('/profile/settings?tab=account')
|
'account_settings_url': make_url('/profile/settings?tab=account')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def follow(self, user, is_follow):
|
||||||
|
if is_follow:
|
||||||
|
self.followers.append(user)
|
||||||
|
else:
|
||||||
|
self.followers.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def like(self, user, is_liked):
|
||||||
|
if is_liked:
|
||||||
|
self.likes.append(user)
|
||||||
|
else:
|
||||||
|
self.likes.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
def send_follower_email(self, type: str, email_args={}, url_suffix=""):
|
||||||
|
for u in self.followers:
|
||||||
|
send_email(
|
||||||
|
u.email_address,
|
||||||
|
type,
|
||||||
|
{
|
||||||
|
"user": u,
|
||||||
|
"proposal": self,
|
||||||
|
"proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"),
|
||||||
|
**email_args,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def contributed(self):
|
def contributed(self):
|
||||||
contributions = ProposalContribution.query \
|
contributions = ProposalContribution.query \
|
||||||
|
@ -635,12 +892,7 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def is_staked(self):
|
def is_staked(self):
|
||||||
# Don't use self.contributed since that ignores stake contributions
|
return True
|
||||||
contributions = ProposalContribution.query \
|
|
||||||
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
|
|
||||||
.all()
|
|
||||||
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
|
|
||||||
return Decimal(funded) >= PROPOSAL_STAKING_AMOUNT
|
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def is_funded(self):
|
def is_funded(self):
|
||||||
|
@ -670,6 +922,147 @@ class Proposal(db.Model):
|
||||||
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
|
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
|
||||||
return d.values()
|
return d.values()
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_follows(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(proposal_follower)
|
||||||
|
.filter_by(user_id=authed.id, proposal_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_liked(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(proposal_liker)
|
||||||
|
.filter_by(user_id=authed.id, proposal_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def get_tip_jar_view_key(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if authed not in self.team:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self.tip_jar_view_key
|
||||||
|
|
||||||
|
# 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 ProposalSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -694,7 +1087,6 @@ class ProposalSchema(ma.Schema):
|
||||||
"updates",
|
"updates",
|
||||||
"milestones",
|
"milestones",
|
||||||
"current_milestone",
|
"current_milestone",
|
||||||
"category",
|
|
||||||
"team",
|
"team",
|
||||||
"payout_address",
|
"payout_address",
|
||||||
"deadline_duration",
|
"deadline_duration",
|
||||||
|
@ -703,13 +1095,30 @@ class ProposalSchema(ma.Schema):
|
||||||
"invites",
|
"invites",
|
||||||
"rfp",
|
"rfp",
|
||||||
"rfp_opt_in",
|
"rfp_opt_in",
|
||||||
"arbiter"
|
"arbiter",
|
||||||
|
"accepted_with_funding",
|
||||||
|
"is_version_two",
|
||||||
|
"authed_follows",
|
||||||
|
"followers_count",
|
||||||
|
"authed_liked",
|
||||||
|
"likes_count",
|
||||||
|
"tip_jar_address",
|
||||||
|
"tip_jar_view_key",
|
||||||
|
"changes_requested_discussion",
|
||||||
|
"changes_requested_discussion_reason",
|
||||||
|
"live_draft_id",
|
||||||
|
"kyc_approved",
|
||||||
|
"funded_by_zomg"
|
||||||
)
|
)
|
||||||
|
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
date_approved = ma.Method("get_date_approved")
|
date_approved = ma.Method("get_date_approved")
|
||||||
date_published = ma.Method("get_date_published")
|
date_published = ma.Method("get_date_published")
|
||||||
proposal_id = ma.Method("get_proposal_id")
|
proposal_id = ma.Method("get_proposal_id")
|
||||||
|
is_version_two = ma.Method("get_is_version_two")
|
||||||
|
tip_jar_view_key = ma.Method("get_tip_jar_view_key")
|
||||||
|
live_draft_id = ma.Method("get_live_draft_id")
|
||||||
|
funded_by_zomg = ma.Method("get_funded_by_zomg")
|
||||||
|
|
||||||
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
||||||
team = ma.Nested("UserSchema", many=True)
|
team = ma.Nested("UserSchema", many=True)
|
||||||
|
@ -719,6 +1128,14 @@ class ProposalSchema(ma.Schema):
|
||||||
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
||||||
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
|
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
|
||||||
|
|
||||||
|
def get_funded_by_zomg(self, obj):
|
||||||
|
if obj.funded_by_zomg is None:
|
||||||
|
return False
|
||||||
|
elif obj.funded_by_zomg is False:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
def get_proposal_id(self, obj):
|
def get_proposal_id(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
|
||||||
|
@ -731,6 +1148,15 @@ class ProposalSchema(ma.Schema):
|
||||||
def get_date_published(self, obj):
|
def get_date_published(self, obj):
|
||||||
return dt_to_unix(obj.date_published) if obj.date_published else None
|
return dt_to_unix(obj.date_published) if obj.date_published else None
|
||||||
|
|
||||||
|
def get_is_version_two(self, obj):
|
||||||
|
return True if obj.version == '2' else False
|
||||||
|
|
||||||
|
def get_tip_jar_view_key(self, obj):
|
||||||
|
return obj.get_tip_jar_view_key
|
||||||
|
|
||||||
|
def get_live_draft_id(self, obj):
|
||||||
|
return obj.live_draft.id if obj.live_draft else None
|
||||||
|
|
||||||
|
|
||||||
proposal_schema = ProposalSchema()
|
proposal_schema = ProposalSchema()
|
||||||
proposals_schema = ProposalSchema(many=True)
|
proposals_schema = ProposalSchema(many=True)
|
||||||
|
@ -747,7 +1173,12 @@ user_fields = [
|
||||||
"date_approved",
|
"date_approved",
|
||||||
"date_published",
|
"date_published",
|
||||||
"reject_reason",
|
"reject_reason",
|
||||||
|
"changes_requested_discussion_reason",
|
||||||
"team",
|
"team",
|
||||||
|
"accepted_with_funding",
|
||||||
|
"is_version_two",
|
||||||
|
"authed_follows",
|
||||||
|
"authed_liked"
|
||||||
]
|
]
|
||||||
user_proposal_schema = ProposalSchema(only=user_fields)
|
user_proposal_schema = ProposalSchema(only=user_fields)
|
||||||
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
||||||
|
@ -783,6 +1214,40 @@ proposal_update_schema = ProposalUpdateSchema()
|
||||||
proposals_update_schema = ProposalUpdateSchema(many=True)
|
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 ProposalTeamInviteSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProposalTeamInvite
|
model = ProposalTeamInvite
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, g, request, current_app
|
from flask import Blueprint, g, request, current_app
|
||||||
from marshmallow import fields, validate
|
from marshmallow import fields, validate
|
||||||
|
@ -13,7 +14,7 @@ from grant.milestone.models import Milestone
|
||||||
from grant.parser import body, query, paginated_fields
|
from grant.parser import body, query, paginated_fields
|
||||||
from grant.rfp.models import RFP
|
from grant.rfp.models import RFP
|
||||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||||
from grant.task.jobs import ProposalDeadline
|
from grant.task.jobs import ProposalDeadline, PruneDraft
|
||||||
from grant.user.models import User
|
from grant.user.models import User
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from grant.utils.auth import (
|
from grant.utils.auth import (
|
||||||
|
@ -24,8 +25,9 @@ from grant.utils.auth import (
|
||||||
get_authed_user,
|
get_authed_user,
|
||||||
internal_webhook
|
internal_webhook
|
||||||
)
|
)
|
||||||
|
from grant.utils.validate import is_z_address_valid
|
||||||
from grant.utils.enums import Category
|
from grant.utils.enums import Category
|
||||||
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, RFPStatus
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import is_email, make_url, from_zat, make_explore_url
|
from grant.utils.misc import is_email, make_url, from_zat, make_explore_url
|
||||||
from .models import (
|
from .models import (
|
||||||
|
@ -34,11 +36,13 @@ from .models import (
|
||||||
proposal_schema,
|
proposal_schema,
|
||||||
ProposalUpdate,
|
ProposalUpdate,
|
||||||
proposal_update_schema,
|
proposal_update_schema,
|
||||||
|
proposals_revisions_schema,
|
||||||
ProposalContribution,
|
ProposalContribution,
|
||||||
proposal_contribution_schema,
|
proposal_contribution_schema,
|
||||||
proposal_team,
|
proposal_team,
|
||||||
ProposalTeamInvite,
|
ProposalTeamInvite,
|
||||||
proposal_team_invite_schema,
|
proposal_team_invite_schema,
|
||||||
|
proposal_team_invites_schema,
|
||||||
proposal_proposal_contributions_schema,
|
proposal_proposal_contributions_schema,
|
||||||
db,
|
db,
|
||||||
)
|
)
|
||||||
|
@ -50,7 +54,9 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
|
||||||
def get_proposal(proposal_id):
|
def get_proposal(proposal_id):
|
||||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
if proposal:
|
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:
|
if proposal.status == ProposalStatus.DELETED:
|
||||||
return {"message": "Proposal was deleted"}, 404
|
return {"message": "Proposal was deleted"}, 404
|
||||||
authed_user = get_authed_user()
|
authed_user = get_authed_user()
|
||||||
|
@ -62,6 +68,19 @@ def get_proposal(proposal_id):
|
||||||
return {"message": "No proposal matching id"}, 404
|
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"])
|
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
|
||||||
@query(paginated_fields)
|
@query(paginated_fields)
|
||||||
def get_proposal_comments(proposal_id, page, filters, search, sort):
|
def get_proposal_comments(proposal_id, page, filters, search, sort):
|
||||||
|
@ -108,6 +127,9 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
||||||
if not proposal:
|
if not proposal:
|
||||||
return {"message": "No proposal matching id"}, 404
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
if proposal.status != ProposalStatus.LIVE 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
|
# Make sure the parent comment exists
|
||||||
parent = None
|
parent = None
|
||||||
if parent_comment_id:
|
if parent_comment_id:
|
||||||
|
@ -159,7 +181,10 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
||||||
@query(paginated_fields)
|
@query(paginated_fields)
|
||||||
def get_proposals(page, filters, search, sort):
|
def get_proposals(page, filters, search, sort):
|
||||||
filters_workaround = request.args.getlist('filters[]')
|
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.CANCELED) \
|
||||||
.filter(Proposal.stage != ProposalStage.FAILED)
|
.filter(Proposal.stage != ProposalStage.FAILED)
|
||||||
page = pagination.proposal(
|
page = pagination.proposal(
|
||||||
|
@ -187,15 +212,37 @@ def make_proposal_draft(rfp_id):
|
||||||
rfp = RFP.query.filter_by(id=rfp_id).first()
|
rfp = RFP.query.filter_by(id=rfp_id).first()
|
||||||
if not rfp:
|
if not rfp:
|
||||||
return {"message": "The request this proposal was made for doesn’t exist"}, 400
|
return {"message": "The request this proposal was made for doesn’t exist"}, 400
|
||||||
proposal.category = rfp.category
|
if datetime.now() > rfp.date_closes:
|
||||||
|
return {"message": "The request this proposal was made for has expired"}, 400
|
||||||
|
if rfp.status == RFPStatus.CLOSED:
|
||||||
|
return {"message": "The request this proposal was made for has been closed"}, 400
|
||||||
rfp.proposals.append(proposal)
|
rfp.proposals.append(proposal)
|
||||||
db.session.add(rfp)
|
db.session.add(rfp)
|
||||||
|
|
||||||
|
task = PruneDraft(proposal)
|
||||||
|
task.make_task()
|
||||||
|
|
||||||
db.session.add(proposal)
|
db.session.add(proposal)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return proposal_schema.dump(proposal), 201
|
return proposal_schema.dump(proposal), 201
|
||||||
|
|
||||||
|
|
||||||
|
@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"])
|
@blueprint.route("/drafts", methods=["GET"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def get_proposal_drafts():
|
def get_proposal_drafts():
|
||||||
|
@ -204,6 +251,7 @@ def get_proposal_drafts():
|
||||||
.filter(or_(
|
.filter(or_(
|
||||||
Proposal.status == ProposalStatus.DRAFT,
|
Proposal.status == ProposalStatus.DRAFT,
|
||||||
Proposal.status == ProposalStatus.REJECTED,
|
Proposal.status == ProposalStatus.REJECTED,
|
||||||
|
Proposal.status == ProposalStatus.LIVE_DRAFT
|
||||||
))
|
))
|
||||||
.join(proposal_team)
|
.join(proposal_team)
|
||||||
.filter(proposal_team.c.user_id == g.current_user.id)
|
.filter(proposal_team.c.user_id == g.current_user.id)
|
||||||
|
@ -219,11 +267,10 @@ def get_proposal_drafts():
|
||||||
# Length checks are to prevent database errors, not actual user limits imposed
|
# Length checks are to prevent database errors, not actual user limits imposed
|
||||||
"title": fields.Str(required=True),
|
"title": fields.Str(required=True),
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
|
|
||||||
"content": fields.Str(required=True),
|
"content": fields.Str(required=True),
|
||||||
"target": fields.Str(required=True),
|
"target": fields.Str(required=True),
|
||||||
"payoutAddress": fields.Str(required=True),
|
"payoutAddress": fields.Str(required=True),
|
||||||
"deadlineDuration": fields.Int(required=True),
|
"tipJarAddress": fields.Str(required=False, missing=None),
|
||||||
"milestones": fields.List(fields.Dict(), required=True),
|
"milestones": fields.List(fields.Dict(), required=True),
|
||||||
"rfpOptIn": fields.Bool(required=False, missing=None),
|
"rfpOptIn": fields.Bool(required=False, missing=None),
|
||||||
})
|
})
|
||||||
|
@ -231,6 +278,7 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
||||||
# Update the base proposal fields
|
# Update the base proposal fields
|
||||||
try:
|
try:
|
||||||
if g.current_proposal.status not in [ProposalStatus.DRAFT,
|
if g.current_proposal.status not in [ProposalStatus.DRAFT,
|
||||||
|
ProposalStatus.LIVE_DRAFT,
|
||||||
ProposalStatus.REJECTED]:
|
ProposalStatus.REJECTED]:
|
||||||
raise ValidationException(
|
raise ValidationException(
|
||||||
f"Proposal with status: {g.current_proposal.status} are not authorized for updates"
|
f"Proposal with status: {g.current_proposal.status} are not authorized for updates"
|
||||||
|
@ -251,6 +299,41 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/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({
|
||||||
|
"address": fields.Str(required=False, missing=None),
|
||||||
|
"viewKey": fields.Str(required=False, missing=None)
|
||||||
|
})
|
||||||
|
def update_proposal_tip_jar(proposal_id, address, view_key):
|
||||||
|
if address is not None 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:
|
||||||
|
g.current_proposal.tip_jar_address = address
|
||||||
|
|
||||||
|
if view_key is not None:
|
||||||
|
g.current_proposal.tip_jar_view_key = view_key
|
||||||
|
|
||||||
|
db.session.add(g.current_proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/rfp", methods=["DELETE"])
|
@blueprint.route("/<proposal_id>/rfp", methods=["DELETE"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
def unlink_proposal_from_rfp(proposal_id):
|
def unlink_proposal_from_rfp(proposal_id):
|
||||||
|
@ -269,6 +352,7 @@ def delete_proposal(proposal_id):
|
||||||
deleteable_statuses = [
|
deleteable_statuses = [
|
||||||
ProposalStatus.DRAFT,
|
ProposalStatus.DRAFT,
|
||||||
ProposalStatus.PENDING,
|
ProposalStatus.PENDING,
|
||||||
|
ProposalStatus.REJECTED_PERMANENTLY,
|
||||||
ProposalStatus.APPROVED,
|
ProposalStatus.APPROVED,
|
||||||
ProposalStatus.REJECTED,
|
ProposalStatus.REJECTED,
|
||||||
ProposalStatus.STAKING,
|
ProposalStatus.STAKING,
|
||||||
|
@ -293,17 +377,6 @@ def submit_for_approval_proposal(proposal_id):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
|
|
||||||
@requires_team_member_auth
|
|
||||||
def get_proposal_stake(proposal_id):
|
|
||||||
if g.current_proposal.status != ProposalStatus.STAKING:
|
|
||||||
return {"message": "ok"}, 400
|
|
||||||
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
|
|
||||||
if contribution:
|
|
||||||
return proposal_contribution_schema.dump(contribution)
|
|
||||||
return {"message": "ok"}, 404
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
def publish_proposal(proposal_id):
|
def publish_proposal(proposal_id):
|
||||||
|
@ -320,6 +393,42 @@ def publish_proposal(proposal_id):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
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"])
|
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
|
||||||
def get_proposal_updates(proposal_id):
|
def get_proposal_updates(proposal_id):
|
||||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
@ -343,6 +452,26 @@ def get_proposal_update(proposal_id, update_id):
|
||||||
return {"message": "No proposal matching id"}, 404
|
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"])
|
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
|
||||||
@limiter.limit("5/day;1/minute")
|
@limiter.limit("5/day;1/minute")
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
|
@ -367,10 +496,25 @@ def post_proposal_update(proposal_id, title, content):
|
||||||
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
|
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Send email to all followers
|
||||||
|
g.current_proposal.send_follower_email(
|
||||||
|
"followed_proposal_update", url_suffix="?tab=updates"
|
||||||
|
)
|
||||||
|
|
||||||
dumped_update = proposal_update_schema.dump(update)
|
dumped_update = proposal_update_schema.dump(update)
|
||||||
return dumped_update, 201
|
return dumped_update, 201
|
||||||
|
|
||||||
|
|
||||||
|
@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"])
|
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
||||||
@limiter.limit("30/day;10/minute")
|
@limiter.limit("30/day;10/minute")
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
|
@ -566,9 +710,6 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
||||||
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
|
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
|
||||||
})
|
})
|
||||||
|
|
||||||
# on funding target reached.
|
|
||||||
contribution.proposal.set_funded_when_ready()
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {"message": "ok"}, 200
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
@ -662,3 +803,37 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason):
|
||||||
return proposal_schema.dump(g.current_proposal), 200
|
return proposal_schema.dump(g.current_proposal), 200
|
||||||
|
|
||||||
return {"message": "No milestone matching id"}, 404
|
return {"message": "No milestone matching id"}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/follow", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isFollow": fields.Bool(required=True)})
|
||||||
|
def follow_proposal(proposal_id, is_follow):
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure proposal exists
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
proposal.follow(user, is_follow)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<proposal_id>/like", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isLiked": fields.Bool(required=True)})
|
||||||
|
def like_proposal(proposal_id, is_liked):
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure proposal exists
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No proposal matching id"}, 404
|
||||||
|
|
||||||
|
if 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()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,19 @@ from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from grant.extensions import ma, db
|
from grant.extensions import ma, db
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import column_property
|
||||||
from grant.utils.enums import RFPStatus
|
from grant.utils.enums import RFPStatus
|
||||||
from grant.utils.misc import dt_to_unix, gen_random_id
|
from grant.utils.misc import dt_to_unix, gen_random_id
|
||||||
from grant.utils.enums import Category
|
from grant.utils.enums import Category
|
||||||
|
|
||||||
|
rfp_liker = db.Table(
|
||||||
|
"rfp_liker",
|
||||||
|
db.Model.metadata,
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
|
||||||
|
db.Column("rfp_id", db.Integer, db.ForeignKey("rfp.id")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RFP(db.Model):
|
class RFP(db.Model):
|
||||||
__tablename__ = "rfp"
|
__tablename__ = "rfp"
|
||||||
|
@ -16,13 +25,16 @@ class RFP(db.Model):
|
||||||
title = db.Column(db.String(255), nullable=False)
|
title = db.Column(db.String(255), nullable=False)
|
||||||
brief = db.Column(db.String(255), nullable=False)
|
brief = db.Column(db.String(255), nullable=False)
|
||||||
content = db.Column(db.Text, nullable=False)
|
content = db.Column(db.Text, nullable=False)
|
||||||
category = db.Column(db.String(255), nullable=False)
|
category = db.Column(db.String(255), nullable=True)
|
||||||
status = db.Column(db.String(255), nullable=False)
|
status = db.Column(db.String(255), nullable=False)
|
||||||
matching = db.Column(db.Boolean, default=False, nullable=False)
|
matching = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
_bounty = db.Column("bounty", db.String(255), nullable=True)
|
_bounty = db.Column("bounty", db.String(255), nullable=True)
|
||||||
date_closes = db.Column(db.DateTime, nullable=True)
|
date_closes = db.Column(db.DateTime, nullable=True)
|
||||||
date_opened = db.Column(db.DateTime, nullable=True)
|
date_opened = db.Column(db.DateTime, nullable=True)
|
||||||
date_closed = db.Column(db.DateTime, nullable=True)
|
date_closed = db.Column(db.DateTime, nullable=True)
|
||||||
|
version = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
ccr = db.relationship("CCR", uselist=False, back_populates="rfp")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
proposals = db.relationship(
|
proposals = db.relationship(
|
||||||
|
@ -38,6 +50,15 @@ class RFP(db.Model):
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
likes = db.relationship(
|
||||||
|
"User", secondary=rfp_liker, back_populates="liked_rfps"
|
||||||
|
)
|
||||||
|
likes_count = column_property(
|
||||||
|
select([func.count(rfp_liker.c.rfp_id)])
|
||||||
|
.where(rfp_liker.c.rfp_id == id)
|
||||||
|
.correlate_except(rfp_liker)
|
||||||
|
)
|
||||||
|
|
||||||
@hybrid_property
|
@hybrid_property
|
||||||
def bounty(self):
|
def bounty(self):
|
||||||
return self._bounty
|
return self._bounty
|
||||||
|
@ -49,29 +70,50 @@ class RFP(db.Model):
|
||||||
else:
|
else:
|
||||||
self._bounty = None
|
self._bounty = None
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def authed_liked(self):
|
||||||
|
from grant.utils.auth import get_authed_user
|
||||||
|
|
||||||
|
authed = get_authed_user()
|
||||||
|
if not authed:
|
||||||
|
return False
|
||||||
|
res = (
|
||||||
|
db.session.query(rfp_liker)
|
||||||
|
.filter_by(user_id=authed.id, rfp_id=self.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if res:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def like(self, user, is_liked):
|
||||||
|
if is_liked:
|
||||||
|
self.likes.append(user)
|
||||||
|
else:
|
||||||
|
self.likes.remove(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
title: str,
|
title: str,
|
||||||
brief: str,
|
brief: str,
|
||||||
content: str,
|
content: str,
|
||||||
category: str,
|
|
||||||
bounty: str,
|
bounty: str,
|
||||||
date_closes: datetime,
|
date_closes: datetime,
|
||||||
matching: bool = False,
|
matching: bool = False,
|
||||||
status: str = RFPStatus.DRAFT,
|
status: str = RFPStatus.DRAFT,
|
||||||
):
|
):
|
||||||
assert RFPStatus.includes(status)
|
assert RFPStatus.includes(status)
|
||||||
assert Category.includes(category)
|
|
||||||
self.id = gen_random_id(RFP)
|
self.id = gen_random_id(RFP)
|
||||||
self.date_created = datetime.now()
|
self.date_created = datetime.now()
|
||||||
self.title = title[:255]
|
self.title = title[:255]
|
||||||
self.brief = brief[:255]
|
self.brief = brief[:255]
|
||||||
self.content = content
|
self.content = content
|
||||||
self.category = category
|
|
||||||
self.bounty = bounty
|
self.bounty = bounty
|
||||||
self.date_closes = date_closes
|
self.date_closes = date_closes
|
||||||
self.matching = matching
|
self.matching = matching
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.version = '2'
|
||||||
|
|
||||||
|
|
||||||
class RFPSchema(ma.Schema):
|
class RFPSchema(ma.Schema):
|
||||||
|
@ -83,7 +125,6 @@ class RFPSchema(ma.Schema):
|
||||||
"title",
|
"title",
|
||||||
"brief",
|
"brief",
|
||||||
"content",
|
"content",
|
||||||
"category",
|
|
||||||
"status",
|
"status",
|
||||||
"matching",
|
"matching",
|
||||||
"bounty",
|
"bounty",
|
||||||
|
@ -92,13 +133,19 @@ class RFPSchema(ma.Schema):
|
||||||
"date_opened",
|
"date_opened",
|
||||||
"date_closed",
|
"date_closed",
|
||||||
"accepted_proposals",
|
"accepted_proposals",
|
||||||
|
"authed_liked",
|
||||||
|
"likes_count",
|
||||||
|
"is_version_two",
|
||||||
|
"ccr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||||
status = ma.Method("get_status")
|
status = ma.Method("get_status")
|
||||||
date_closes = ma.Method("get_date_closes")
|
date_closes = ma.Method("get_date_closes")
|
||||||
date_opened = ma.Method("get_date_opened")
|
date_opened = ma.Method("get_date_opened")
|
||||||
date_closed = ma.Method("get_date_closed")
|
date_closed = ma.Method("get_date_closed")
|
||||||
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
||||||
|
is_version_two = ma.Method("get_is_version_two")
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
# Force it into closed state if date_closes is in the past
|
# Force it into closed state if date_closes is in the past
|
||||||
|
@ -115,6 +162,9 @@ class RFPSchema(ma.Schema):
|
||||||
def get_date_closed(self, obj):
|
def get_date_closed(self, obj):
|
||||||
return dt_to_unix(obj.date_closed) if obj.date_closed else None
|
return dt_to_unix(obj.date_closed) if obj.date_closed else None
|
||||||
|
|
||||||
|
def get_is_version_two(self, obj):
|
||||||
|
return True if obj.version == '2' else False
|
||||||
|
|
||||||
|
|
||||||
rfp_schema = RFPSchema()
|
rfp_schema = RFPSchema()
|
||||||
rfps_schema = RFPSchema(many=True)
|
rfps_schema = RFPSchema(many=True)
|
||||||
|
@ -129,7 +179,6 @@ class AdminRFPSchema(ma.Schema):
|
||||||
"title",
|
"title",
|
||||||
"brief",
|
"brief",
|
||||||
"content",
|
"content",
|
||||||
"category",
|
|
||||||
"status",
|
"status",
|
||||||
"matching",
|
"matching",
|
||||||
"bounty",
|
"bounty",
|
||||||
|
@ -138,14 +187,18 @@ class AdminRFPSchema(ma.Schema):
|
||||||
"date_opened",
|
"date_opened",
|
||||||
"date_closed",
|
"date_closed",
|
||||||
"proposals",
|
"proposals",
|
||||||
|
"is_version_two",
|
||||||
|
"ccr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
|
||||||
status = ma.Method("get_status")
|
status = ma.Method("get_status")
|
||||||
date_created = ma.Method("get_date_created")
|
date_created = ma.Method("get_date_created")
|
||||||
date_closes = ma.Method("get_date_closes")
|
date_closes = ma.Method("get_date_closes")
|
||||||
date_opened = ma.Method("get_date_opened")
|
date_opened = ma.Method("get_date_opened")
|
||||||
date_closed = ma.Method("get_date_closed")
|
date_closed = ma.Method("get_date_closed")
|
||||||
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
|
||||||
|
is_version_two = ma.Method("get_is_version_two")
|
||||||
|
|
||||||
def get_status(self, obj):
|
def get_status(self, obj):
|
||||||
# Force it into closed state if date_closes is in the past
|
# Force it into closed state if date_closes is in the past
|
||||||
|
@ -165,6 +218,9 @@ class AdminRFPSchema(ma.Schema):
|
||||||
def get_date_closed(self, obj):
|
def get_date_closed(self, obj):
|
||||||
return dt_to_unix(obj.date_closes) if obj.date_closes else None
|
return dt_to_unix(obj.date_closes) if obj.date_closes else None
|
||||||
|
|
||||||
|
def get_is_version_two(self, obj):
|
||||||
|
return True if obj.version == '2' else False
|
||||||
|
|
||||||
|
|
||||||
admin_rfp_schema = AdminRFPSchema()
|
admin_rfp_schema = AdminRFPSchema()
|
||||||
admin_rfps_schema = AdminRFPSchema(many=True)
|
admin_rfps_schema = AdminRFPSchema(many=True)
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
from flask import Blueprint
|
from flask import Blueprint, g
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from grant.utils.enums import RFPStatus
|
from grant.utils.enums import RFPStatus
|
||||||
from .models import RFP, rfp_schema, rfps_schema
|
from grant.utils.auth import requires_auth
|
||||||
|
from grant.parser import body
|
||||||
|
from .models import RFP, rfp_schema, rfps_schema, db
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
|
blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
|
||||||
|
|
||||||
|
@ -25,3 +28,20 @@ def get_rfp(rfp_id):
|
||||||
if not rfp or rfp.status == RFPStatus.DRAFT:
|
if not rfp or rfp.status == RFPStatus.DRAFT:
|
||||||
return {"message": "No RFP with that ID"}, 404
|
return {"message": "No RFP with that ID"}, 404
|
||||||
return rfp_schema.dump(rfp)
|
return rfp_schema.dump(rfp)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<rfp_id>/like", methods=["PUT"])
|
||||||
|
@requires_auth
|
||||||
|
@body({"isLiked": fields.Bool(required=True)})
|
||||||
|
def like_rfp(rfp_id, is_liked):
|
||||||
|
user = g.current_user
|
||||||
|
# Make sure rfp exists
|
||||||
|
rfp = RFP.query.filter_by(id=rfp_id).first()
|
||||||
|
if not rfp:
|
||||||
|
return {"message": "No RFP matching id"}, 404
|
||||||
|
if not rfp.status == RFPStatus.LIVE:
|
||||||
|
return {"message": "RFP is not live"}, 404
|
||||||
|
|
||||||
|
rfp.like(user, is_liked)
|
||||||
|
db.session.commit()
|
||||||
|
return {"message": "ok"}, 200
|
||||||
|
|
|
@ -31,6 +31,8 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
# so backend session cookies are first-party
|
# so backend session cookies are first-party
|
||||||
SESSION_COOKIE_DOMAIN = env.str('SESSION_COOKIE_DOMAIN', default=None)
|
SESSION_COOKIE_DOMAIN = env.str('SESSION_COOKIE_DOMAIN', default=None)
|
||||||
CORS_DOMAINS = env.str('CORS_DOMAINS', default='*')
|
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_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
||||||
SENDGRID_DEFAULT_FROM = "noreply@grants.zfnd.org"
|
SENDGRID_DEFAULT_FROM = "noreply@grants.zfnd.org"
|
||||||
|
@ -60,11 +62,14 @@ LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
|
||||||
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
|
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
|
||||||
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
||||||
|
|
||||||
|
STAGING_PASSWORD = env.str("STAGING_PASSWORD", default=None)
|
||||||
|
|
||||||
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
|
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
|
||||||
|
|
||||||
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
||||||
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
|
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
|
||||||
|
|
||||||
|
|
||||||
UI = {
|
UI = {
|
||||||
'NAME': 'ZF Grants',
|
'NAME': 'ZF Grants',
|
||||||
'PRIMARY': '#CF8A00',
|
'PRIMARY': '#CF8A00',
|
||||||
|
|
|
@ -2,7 +2,7 @@ from datetime import datetime, timedelta
|
||||||
|
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.utils.enums import ProposalStage, ContributionStatus
|
from grant.utils.enums import ProposalStage, ContributionStatus, ProposalStatus
|
||||||
from grant.utils.misc import make_url
|
from grant.utils.misc import make_url
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
@ -126,8 +126,117 @@ class ContributionExpired:
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class PruneDraft:
|
||||||
|
JOB_TYPE = 4
|
||||||
|
PRUNE_TIME = 259200 # 72 hours in seconds
|
||||||
|
|
||||||
|
def __init__(self, proposal):
|
||||||
|
self.proposal = proposal
|
||||||
|
|
||||||
|
def blobify(self):
|
||||||
|
return {
|
||||||
|
"proposal_id": self.proposal.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_task(self):
|
||||||
|
from .models import Task
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
job_type=self.JOB_TYPE,
|
||||||
|
blob=self.blobify(),
|
||||||
|
execute_after=self.proposal.date_created + timedelta(seconds=self.PRUNE_TIME),
|
||||||
|
)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_task(task):
|
||||||
|
from grant.proposal.models import Proposal, default_proposal_content
|
||||||
|
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
|
||||||
|
|
||||||
|
# If it was deleted or moved out of a draft, noop out
|
||||||
|
if not proposal or proposal.status != ProposalStatus.DRAFT:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If proposal content deviates from the default, noop out
|
||||||
|
if proposal.content != default_proposal_content():
|
||||||
|
return
|
||||||
|
|
||||||
|
# If any of the remaining proposal fields are filled, noop out
|
||||||
|
if proposal.title or proposal.brief or proposal.category or proposal.target != "0":
|
||||||
|
return
|
||||||
|
|
||||||
|
if proposal.payout_address or proposal.milestones:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Otherwise, delete the empty proposal
|
||||||
|
db.session.delete(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class MilestoneDeadline:
|
||||||
|
JOB_TYPE = 5
|
||||||
|
|
||||||
|
def __init__(self, proposal, milestone):
|
||||||
|
self.proposal = proposal
|
||||||
|
self.milestone = milestone
|
||||||
|
|
||||||
|
def blobify(self):
|
||||||
|
from grant.proposal.models import ProposalUpdate
|
||||||
|
|
||||||
|
update_count = len(ProposalUpdate.query.filter_by(proposal_id=self.proposal.id).all())
|
||||||
|
return {
|
||||||
|
"proposal_id": self.proposal.id,
|
||||||
|
"milestone_id": self.milestone.id,
|
||||||
|
"update_count": update_count
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_task(self):
|
||||||
|
from .models import Task
|
||||||
|
task = Task(
|
||||||
|
job_type=self.JOB_TYPE,
|
||||||
|
blob=self.blobify(),
|
||||||
|
execute_after=self.milestone.date_estimated,
|
||||||
|
)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_task(task):
|
||||||
|
from grant.proposal.models import Proposal, ProposalUpdate
|
||||||
|
from grant.milestone.models import Milestone
|
||||||
|
|
||||||
|
proposal_id = task.blob["proposal_id"]
|
||||||
|
milestone_id = task.blob["milestone_id"]
|
||||||
|
update_count = task.blob["update_count"]
|
||||||
|
|
||||||
|
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||||
|
milestone = Milestone.query.filter_by(id=milestone_id).first()
|
||||||
|
current_update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all())
|
||||||
|
|
||||||
|
# if proposal was deleted or cancelled, noop out
|
||||||
|
if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED:
|
||||||
|
return
|
||||||
|
|
||||||
|
# if milestone was deleted, noop out
|
||||||
|
if not milestone:
|
||||||
|
return
|
||||||
|
|
||||||
|
# if milestone payout has been requested or an update has been posted, noop out
|
||||||
|
if current_update_count > update_count or milestone.date_requested:
|
||||||
|
return
|
||||||
|
|
||||||
|
# send email to arbiter notifying milestone deadline has been missed
|
||||||
|
send_email(proposal.arbiter.user.email_address, 'milestone_deadline', {
|
||||||
|
'proposal': proposal,
|
||||||
|
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
JOBS = {
|
JOBS = {
|
||||||
1: ProposalReminder.process_task,
|
1: ProposalReminder.process_task,
|
||||||
2: ProposalDeadline.process_task,
|
2: ProposalDeadline.process_task,
|
||||||
3: ContributionExpired.process_task,
|
3: ContributionExpired.process_task,
|
||||||
|
4: PruneDraft.process_task,
|
||||||
|
5: MilestoneDeadline.process_task
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
<a href="{{ args.ccr_url }}" target="_blank">
|
||||||
|
{{ args.ccr.title }}</a
|
||||||
|
>
|
||||||
|
is awaiting approval. As an admin you can help out by reviewing it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.ccr_url }}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
Review Request
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{ args.ccr.title }} is awaiting approval. As an admin you can help out by reviewing it.
|
||||||
|
|
||||||
|
Visit the request and review: {{ args.ccr_url }}
|
|
@ -0,0 +1,33 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
Team members of proposal
|
||||||
|
<a href="{{ args.proposal_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }}</a
|
||||||
|
>
|
||||||
|
have marked requested changes as resolved. As an admin you can help out by reviewing it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
Review Proposal
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,5 @@
|
||||||
|
Team members of proposal {{ args.proposal.title }} have marked requested changes as resolved.
|
||||||
|
|
||||||
|
As an admin you can help out by reviewing it.
|
||||||
|
|
||||||
|
Visit the proposal and review: {{ args.proposal_url }}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ args.proposal_url }}
|
|
@ -0,0 +1,19 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your request has changes requested. You're free to modify it
|
||||||
|
and try submitting again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
|
||||||
|
Please note that repeated submissions without significant changes or with
|
||||||
|
content that doesn't match the platform guidelines may result in a removal
|
||||||
|
of your submission privileges.
|
||||||
|
</p>
|
|
@ -0,0 +1,12 @@
|
||||||
|
Your request has changes requested. You're free to modify it
|
||||||
|
and try submitting again.
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Please note that repeated submissions without significant changes or with
|
||||||
|
content that doesn't match the platform guidelines may result in a removal
|
||||||
|
of your submission privileges.
|
|
@ -0,0 +1,13 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your request has been rejected. Your request won't be publicly visible on ZF Grants.
|
||||||
|
<a href="{{ args.profile_rejected_url }}" >Visit your profile</a> to delete this request.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
Your request has been rejected. Your request won't be publicly visible on ZF Grants. Visit your profile to delete this request:
|
||||||
|
|
||||||
|
{{ args.profile_rejected_url }}
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,31 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has had its
|
||||||
|
{{ args.milestone.title }}
|
||||||
|
milestone accepted!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has been revised!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has been revised!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -0,0 +1,29 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your followed proposal {{ args.proposal.title }} has an update!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_url }}"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Check it out
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
Your followed proposal {{ args.proposal.title }} has an update!
|
||||||
|
|
||||||
|
Check it out: {{ args.proposal_url }}
|
|
@ -3,7 +3,7 @@
|
||||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
{{ 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>
|
||||||
|
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
The proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
|
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!
|
You will receive payment shortly!
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
The estimated deadline has been reached for proposal milestone
|
||||||
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
|
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="center"
|
||||||
|
style="border-radius: 3px;"
|
||||||
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ args.proposal_milestones_url }}"
|
||||||
|
target="_blank"
|
||||||
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||||
|
UI.PRIMARY
|
||||||
|
}}; display: inline-block;"
|
||||||
|
>
|
||||||
|
View the milestone
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
|
@ -0,0 +1,3 @@
|
||||||
|
The estimated deadline has been reached for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}".
|
||||||
|
|
||||||
|
View the milestone: {{ args.proposal_milestones_url }}
|
|
@ -1,5 +1,5 @@
|
||||||
<p style="margin: 0 0 20px;">
|
<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">
|
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||||
{{ args.proposal.title }} - {{ args.milestone.title }}</a
|
{{ args.proposal.title }} - {{ args.milestone.title }}</a
|
||||||
>! You can view the transaction below:
|
>! You can view the transaction below:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.milestone.title }}"!
|
Hooray! ${{args.amount}} in ZEC has been paid out for "{{ args.proposal.title }} - {{args.milestone.title }}"!
|
||||||
You can view the transaction below:
|
You can view the transaction below:
|
||||||
|
|
||||||
{{ args.tx_explorer_url }}
|
{{ args.tx_explorer_url }}
|
||||||
|
|
|
@ -1,34 +1,13 @@
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
Congratulations on your approval! We look forward to seeing the support your
|
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. <a href='https://grants.zfnd.org/kyc'>Click here</a> for instructions on documentation you need to submit before the Zcash Foundation can transfer funds.
|
||||||
proposal receives. To get your campaign started, click below and follow the
|
|
||||||
instructions to publish your proposal.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
<p style="margin: 20px 0 0;">
|
<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>
|
||||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
“{{ args.admin_note }}”
|
“{{ args.admin_note }}”
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="border-radius: 3px;" bgcolor="{{ UI.PRIMARY }}">
|
|
||||||
<a
|
|
||||||
href="{{ args.proposal_url }}"
|
|
||||||
target="_blank"
|
|
||||||
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{ UI.PRIMARY }}; display: inline-block;"
|
|
||||||
>
|
|
||||||
Publish your proposal
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
|
@ -1,9 +1,8 @@
|
||||||
Congratulations on your approval! We look forward to seeing the support your
|
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.
|
||||||
proposal receives. To start the fundraising (and the clock) go to the URL
|
|
||||||
below and publish your proposal.
|
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
A note from the admin team was attached to your approval:
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
> {{ args.admin_note }}
|
> {{ args.admin_note }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your proposal has been approved for public discussion and community feedback on ZF Grants. The Zcash Foundation reviews open grant applications on an ongoing basis, and may request additional revisions based on open feedback before making a final funding determination. Please <a href="https://forum.zcashcommunity.com/c/Grants/Applications/">post your proposal to the Zcash Forums</a> for community feedback!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
Your proposal has been approved for public discussion and community feedback on ZF Grants. The Zcash Foundation reviews open grant applications on an ongoing basis, and may request additional revisions based on open feedback before making a final funding determination. Please post your proposal to the Zcash Forums (https://forum.zcashcommunity.com/c/Grants/Applications) for community feedback!
|
||||||
|
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ args.proposal_url }}
|
|
@ -0,0 +1,13 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your proposal has been reviewed by the Zcash Foundation and has been listed on ZF Grants for community donations. Although the Zcash Foundation won't be providing funding to your proposal directly, the community will have an opportunity to provide funding to your 'tip address'.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
|
||||||
|
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ args.proposal_url }}
|
|
@ -0,0 +1,6 @@
|
||||||
|
<p style="margin: 0 0 20px;">
|
||||||
|
Your proposal {{ args.proposal.title }} is ready for payout requests.
|
||||||
|
<a href="{{ args.proposal_url }}" target="_blank">
|
||||||
|
Visit your proposal</a
|
||||||
|
> to see more.
|
||||||
|
</p>
|
|
@ -0,0 +1,5 @@
|
||||||
|
Your proposal {{ args.proposal.title }} is ready for payout requests.
|
||||||
|
|
||||||
|
Visit your proposal to see more:
|
||||||
|
|
||||||
|
{{ args.proposal_url }}
|
|
@ -1,7 +1,6 @@
|
||||||
<p style="margin: 0 0 20px;">
|
<p style="margin: 0 0 20px;">
|
||||||
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
|
||||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
has been canceled.
|
||||||
shortly.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
This notice is to inform you that your proposal "{{ args.proposal.title }}"
|
||||||
has been canceled. We've let your contributors know, and they should be expecting refunds
|
has been canceled.
|
||||||
shortly.
|
|
||||||
|
|
||||||
If you have any further questions, please contact support for more information:
|
If you have any further questions, please contact support for more information:
|
||||||
{{ args.support_url }}
|
{{ args.support_url }}
|
|
@ -1,11 +1,11 @@
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
Your proposal has unfortunately been rejected. You're free to modify it
|
Your proposal has changes requested. You're free to modify it
|
||||||
and try submitting again.
|
and try submitting again.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
<p style="margin: 20px 0 0;">
|
<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>
|
||||||
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
“{{ args.admin_note }}”
|
“{{ args.admin_note }}”
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
Your proposal has unfortunately been rejected. You're free to modify it
|
Your proposal has changes requested. You're free to modify it
|
||||||
and try submitting again.
|
and try submitting again.
|
||||||
|
|
||||||
{% if args.admin_note %}
|
{% if args.admin_note %}
|
||||||
A note from the admin team was attached to your rejection:
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
> {{ args.admin_note }}
|
> {{ args.admin_note }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your proposal is still open for public discussion, but the ZF team has requested changes.
|
||||||
|
Please make the necessary edits and mark the changes as resolved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
|
||||||
|
Please note that repeated submissions without significant changes or with
|
||||||
|
content that doesn't match the platform guidelines may result in a removal
|
||||||
|
of your submission privileges.
|
||||||
|
</p>
|
|
@ -0,0 +1,12 @@
|
||||||
|
Your proposal is still open for public discussion, but the ZF team has requested changes.
|
||||||
|
Please make the necessary edits and mark the changes as resolved.
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Please note that repeated submissions without significant changes or with
|
||||||
|
content that doesn't match the platform guidelines may result in a removal
|
||||||
|
of your submission privileges.
|
|
@ -0,0 +1,13 @@
|
||||||
|
<p style="margin: 0;">
|
||||||
|
Your proposal has been rejected.
|
||||||
|
<a href="{{ args.profile_rejected_url }}" >Visit your profile</a> to delete this proposal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
<p style="margin: 20px 0 0;">
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
|
||||||
|
“{{ args.admin_note }}”
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
Your proposal has been rejected. Visit your profile to delete this proposal:
|
||||||
|
|
||||||
|
{{ args.profile_rejected_url }}
|
||||||
|
|
||||||
|
{% if args.admin_note %}
|
||||||
|
A note from the Zcash Foundation:
|
||||||
|
|
||||||
|
> {{ args.admin_note }}
|
||||||
|
{% endif %}
|
|
@ -91,7 +91,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px 10px 40px 10px;" valign="top">
|
<td align="center" style="padding: 40px 10px 40px 10px;" valign="top">
|
||||||
<a href="{{ args.home_url }}" target="_blank">
|
<a href="{{ args.home_url }}" target="_blank">
|
||||||
<img alt="ZF Grants logo" border="0" height="44" src="https://s3.us-east-2.amazonaws.com/zf-grants-prod/email-logo.png"
|
<img alt="ZF Grants logo" border="0" height="44" src="https://i.imgur.com/tYx0apf.png"
|
||||||
style="display: block; width: 220px; max-width: 220px; min-width: 220px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
|
style="display: block; width: 220px; max-width: 220px; min-width: 220px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
|
||||||
width="220">
|
width="220">
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from .models import User, db
|
from .models import User, db, SocialMedia
|
||||||
|
from grant.task.models import Task
|
||||||
|
from grant.settings import STAGING_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
# @click.command()
|
# @click.command()
|
||||||
|
@ -23,7 +25,6 @@ from .models import User, db
|
||||||
# 'account address, or email address of an ' \
|
# 'account address, or email address of an ' \
|
||||||
# 'existing user.')
|
# 'existing user.')
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.argument('identity')
|
@click.argument('identity')
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
|
@ -36,6 +37,7 @@ def set_admin(identity):
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
user.set_admin(True)
|
user.set_admin(True)
|
||||||
|
user.email_verification.has_verified = True
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
|
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
|
||||||
|
@ -43,3 +45,28 @@ def set_admin(identity):
|
||||||
raise click.BadParameter('''Invalid user identity. Must be a userid,
|
raise click.BadParameter('''Invalid user identity. Must be a userid,
|
||||||
'account address, or email address of an
|
'account address, or email address of an
|
||||||
'existing user.''')
|
'existing user.''')
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@with_appcontext
|
||||||
|
def mangle_users():
|
||||||
|
if STAGING_PASSWORD:
|
||||||
|
print("Mangling all users")
|
||||||
|
for i, user in enumerate(User.query.all()):
|
||||||
|
user.email_address = "random" + str(i) + "@grant.io"
|
||||||
|
user.password = STAGING_PASSWORD
|
||||||
|
# DELETE TOTP SECRET
|
||||||
|
user.totp_secret = None
|
||||||
|
# DELETE BACKUP CODES
|
||||||
|
user.backup_codes = None
|
||||||
|
db.session.add(user)
|
||||||
|
|
||||||
|
# DELETE ALL TASKS
|
||||||
|
for task in Task.query.all():
|
||||||
|
db.session.delete(task)
|
||||||
|
|
||||||
|
# REMOVE ALL SOCIAL MEDIA
|
||||||
|
for social in SocialMedia.query.all():
|
||||||
|
db.session.delete(social)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
|
@ -3,6 +3,7 @@ from flask_security.core import current_user
|
||||||
from flask_security.utils import hash_password, verify_and_update_password, login_user
|
from flask_security.utils import hash_password, verify_and_update_password, login_user
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.email.models import EmailVerification, EmailRecovery
|
from grant.email.models import EmailVerification, EmailRecovery
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.email.subscription_settings import (
|
from grant.email.subscription_settings import (
|
||||||
|
@ -58,6 +59,8 @@ class UserSettings(db.Model):
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||||
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
|
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
|
||||||
refund_address = db.Column(db.String(255), unique=False, nullable=True)
|
refund_address = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
tip_jar_address = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
tip_jar_view_key = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
|
||||||
user = db.relationship("User", back_populates="settings")
|
user = db.relationship("User", back_populates="settings")
|
||||||
|
|
||||||
|
@ -123,6 +126,7 @@ class User(db.Model, UserMixin):
|
||||||
# relations
|
# relations
|
||||||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
||||||
comments = db.relationship(Comment, backref="user", lazy=True)
|
comments = db.relationship(Comment, backref="user", lazy=True)
|
||||||
|
ccrs = db.relationship(CCR, back_populates="author", lazy=True, cascade="all, delete-orphan")
|
||||||
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
||||||
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
|
||||||
lazy=True, cascade="all, delete-orphan")
|
lazy=True, cascade="all, delete-orphan")
|
||||||
|
@ -133,6 +137,18 @@ class User(db.Model, UserMixin):
|
||||||
roles = db.relationship('Role', secondary='roles_users',
|
roles = db.relationship('Role', secondary='roles_users',
|
||||||
backref=db.backref('users', lazy='dynamic'))
|
backref=db.backref('users', lazy='dynamic'))
|
||||||
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
||||||
|
followed_proposals = db.relationship(
|
||||||
|
"Proposal", secondary="proposal_follower", back_populates="followers"
|
||||||
|
)
|
||||||
|
liked_proposals = db.relationship(
|
||||||
|
"Proposal", secondary="proposal_liker", back_populates="likes"
|
||||||
|
)
|
||||||
|
liked_comments = db.relationship(
|
||||||
|
"Comment", secondary="comment_liker", back_populates="likes"
|
||||||
|
)
|
||||||
|
liked_rfps = db.relationship(
|
||||||
|
"RFP", secondary="rfp_liker", back_populates="likes"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -343,13 +359,15 @@ class UserSchema(ma.Schema):
|
||||||
"avatar",
|
"avatar",
|
||||||
"display_name",
|
"display_name",
|
||||||
"userid",
|
"userid",
|
||||||
"email_verified"
|
"email_verified",
|
||||||
|
"tip_jar_address"
|
||||||
)
|
)
|
||||||
|
|
||||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||||
avatar = ma.Nested("AvatarSchema")
|
avatar = ma.Nested("AvatarSchema")
|
||||||
userid = ma.Method("get_userid")
|
userid = ma.Method("get_userid")
|
||||||
email_verified = ma.Method("get_email_verified")
|
email_verified = ma.Method("get_email_verified")
|
||||||
|
tip_jar_address = ma.Method("get_tip_jar_address")
|
||||||
|
|
||||||
def get_userid(self, obj):
|
def get_userid(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
@ -357,6 +375,9 @@ class UserSchema(ma.Schema):
|
||||||
def get_email_verified(self, obj):
|
def get_email_verified(self, obj):
|
||||||
return obj.email_verification.has_verified
|
return obj.email_verification.has_verified
|
||||||
|
|
||||||
|
def get_tip_jar_address(self, obj):
|
||||||
|
return obj.settings.tip_jar_address
|
||||||
|
|
||||||
|
|
||||||
user_schema = UserSchema()
|
user_schema = UserSchema()
|
||||||
users_schema = UserSchema(many=True)
|
users_schema = UserSchema(many=True)
|
||||||
|
@ -399,6 +420,8 @@ class UserSettingsSchema(ma.Schema):
|
||||||
fields = (
|
fields = (
|
||||||
"email_subscriptions",
|
"email_subscriptions",
|
||||||
"refund_address",
|
"refund_address",
|
||||||
|
"tip_jar_address",
|
||||||
|
"tip_jar_view_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,24 +4,26 @@ from flask import Blueprint, g, current_app
|
||||||
from marshmallow import fields
|
from marshmallow import fields
|
||||||
from validate_email import validate_email
|
from validate_email import validate_email
|
||||||
from webargs import validate
|
from webargs import validate
|
||||||
|
from grant.email.send import send_email
|
||||||
|
from grant.utils.misc import make_url
|
||||||
|
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
from grant.comment.models import Comment, user_comments_schema
|
from grant.comment.models import Comment, user_comments_schema
|
||||||
from grant.email.models import EmailRecovery
|
from grant.email.models import EmailRecovery
|
||||||
|
from grant.ccr.models import CCR, ccrs_schema
|
||||||
from grant.extensions import limiter
|
from grant.extensions import limiter
|
||||||
from grant.parser import query, body
|
from grant.parser import query, body
|
||||||
from grant.proposal.models import (
|
from grant.proposal.models import (
|
||||||
Proposal,
|
Proposal,
|
||||||
ProposalTeamInvite,
|
ProposalTeamInvite,
|
||||||
invites_with_proposal_schema,
|
invites_with_proposal_schema,
|
||||||
ProposalContribution,
|
|
||||||
user_proposal_contributions_schema,
|
user_proposal_contributions_schema,
|
||||||
user_proposals_schema,
|
user_proposals_schema,
|
||||||
user_proposal_arbiters_schema
|
user_proposal_arbiters_schema
|
||||||
)
|
)
|
||||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
from grant.proposal.models import ProposalContribution
|
||||||
|
from grant.utils.enums import ProposalStatus, ContributionStatus, CCRStatus
|
||||||
from grant.utils.exceptions import ValidationException
|
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.social import verify_social, get_social_login_url, VerifySocialException
|
||||||
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
||||||
from .models import (
|
from .models import (
|
||||||
|
@ -33,6 +35,7 @@ from .models import (
|
||||||
user_settings_schema,
|
user_settings_schema,
|
||||||
db
|
db
|
||||||
)
|
)
|
||||||
|
from grant.utils.validate import is_z_address_valid
|
||||||
|
|
||||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||||
|
|
||||||
|
@ -50,14 +53,21 @@ def get_me():
|
||||||
"withComments": fields.Bool(required=False, missing=None),
|
"withComments": fields.Bool(required=False, missing=None),
|
||||||
"withFunded": fields.Bool(required=False, missing=None),
|
"withFunded": fields.Bool(required=False, missing=None),
|
||||||
"withPending": fields.Bool(required=False, missing=None),
|
"withPending": fields.Bool(required=False, missing=None),
|
||||||
"withArbitrated": fields.Bool(required=False, missing=None)
|
"withArbitrated": fields.Bool(required=False, missing=None),
|
||||||
|
"withRequests": fields.Bool(required=False, missing=None),
|
||||||
|
"withRejectedPermanently": fields.Bool(required=False, missing=None)
|
||||||
|
|
||||||
})
|
})
|
||||||
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated):
|
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests, with_rejected_permanently):
|
||||||
user = User.get_by_id(user_id)
|
user = User.get_by_id(user_id)
|
||||||
if user:
|
if user:
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
authed_user = auth.get_authed_user()
|
authed_user = auth.get_authed_user()
|
||||||
is_self = authed_user and authed_user.id == user.id
|
is_self = authed_user and authed_user.id == user.id
|
||||||
|
if with_requests:
|
||||||
|
requests = CCR.get_by_user(user)
|
||||||
|
requests_dump = ccrs_schema.dump(requests)
|
||||||
|
result["requests"] = requests_dump
|
||||||
if with_proposals:
|
if with_proposals:
|
||||||
proposals = Proposal.get_by_user(user)
|
proposals = Proposal.get_by_user(user)
|
||||||
proposals_dump = user_proposals_schema.dump(proposals)
|
proposals_dump = user_proposals_schema.dump(proposals)
|
||||||
|
@ -75,16 +85,33 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
||||||
comments_dump = user_comments_schema.dump(comments)
|
comments_dump = user_comments_schema.dump(comments)
|
||||||
result["comments"] = comments_dump
|
result["comments"] = comments_dump
|
||||||
if with_pending and is_self:
|
if with_pending and is_self:
|
||||||
pending = Proposal.get_by_user(user, [
|
pending_proposals = Proposal.get_by_user(user, [
|
||||||
ProposalStatus.STAKING,
|
ProposalStatus.STAKING,
|
||||||
ProposalStatus.PENDING,
|
ProposalStatus.PENDING,
|
||||||
ProposalStatus.APPROVED,
|
ProposalStatus.APPROVED,
|
||||||
ProposalStatus.REJECTED,
|
ProposalStatus.REJECTED,
|
||||||
])
|
])
|
||||||
pending_dump = user_proposals_schema.dump(pending)
|
pending_proposals_dump = user_proposals_schema.dump(pending_proposals)
|
||||||
result["pendingProposals"] = pending_dump
|
result["pendingProposals"] = pending_proposals_dump
|
||||||
|
pending_ccrs = CCR.get_by_user(user, [
|
||||||
|
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:
|
if with_arbitrated and is_self:
|
||||||
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
||||||
|
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
|
return result
|
||||||
else:
|
else:
|
||||||
|
@ -348,10 +375,11 @@ def get_user_settings(user_id):
|
||||||
@auth.requires_same_user_auth
|
@auth.requires_same_user_auth
|
||||||
@body({
|
@body({
|
||||||
"emailSubscriptions": fields.Dict(required=False, missing=None),
|
"emailSubscriptions": fields.Dict(required=False, missing=None),
|
||||||
"refundAddress": fields.Str(required=False, missing=None,
|
"refundAddress": fields.Str(required=False, missing=None),
|
||||||
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r}))
|
"tipJarAddress": fields.Str(required=False, missing=None),
|
||||||
|
"tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here
|
||||||
})
|
})
|
||||||
def set_user_settings(user_id, email_subscriptions, refund_address):
|
def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_address, tip_jar_view_key):
|
||||||
if email_subscriptions:
|
if email_subscriptions:
|
||||||
try:
|
try:
|
||||||
email_subscriptions = keys_to_snake_case(email_subscriptions)
|
email_subscriptions = keys_to_snake_case(email_subscriptions)
|
||||||
|
@ -359,11 +387,21 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
|
||||||
except ValidationException as e:
|
except ValidationException as e:
|
||||||
return {"message": str(e)}, 400
|
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:
|
if refund_address == '' and g.current_user.settings.refund_address:
|
||||||
return {"message": "Refund address cannot be unset, only changed"}, 400
|
return {"message": "Refund address cannot be unset, only changed"}, 400
|
||||||
if refund_address:
|
if refund_address:
|
||||||
g.current_user.settings.refund_address = refund_address
|
g.current_user.settings.refund_address = refund_address
|
||||||
|
|
||||||
|
if tip_jar_address is not None 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:
|
||||||
|
g.current_user.settings.tip_jar_address = tip_jar_address
|
||||||
|
|
||||||
|
if tip_jar_view_key is not None:
|
||||||
|
g.current_user.settings.tip_jar_view_key = tip_jar_view_key
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return user_settings_schema.dump(g.current_user.settings)
|
return user_settings_schema.dump(g.current_user.settings)
|
||||||
|
|
||||||
|
@ -381,6 +419,14 @@ def set_user_arbiter(user_id, proposal_id, is_accept):
|
||||||
|
|
||||||
if is_accept:
|
if is_accept:
|
||||||
proposal.arbiter.accept_nomination(g.current_user.id)
|
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
|
return {"message": "Accepted nomination"}, 200
|
||||||
else:
|
else:
|
||||||
proposal.arbiter.reject_nomination(g.current_user.id)
|
proposal.arbiter.reject_nomination(g.current_user.id)
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
from functools import wraps
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from flask import request, g, jsonify, session, current_app
|
from flask import request, g, jsonify, session, current_app
|
||||||
from flask_security.core import current_user
|
from flask_security.core import current_user
|
||||||
from flask_security.utils import logout_user
|
from flask_security.utils import logout_user
|
||||||
from grant.proposal.models import Proposal
|
|
||||||
from grant.settings import BLOCKCHAIN_API_SECRET
|
from grant.settings import BLOCKCHAIN_API_SECRET
|
||||||
from grant.user.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class AuthException(Exception):
|
class AuthException(Exception):
|
||||||
|
@ -28,7 +27,7 @@ def throw_on_banned(user):
|
||||||
raise AuthException("You are banned")
|
raise AuthException("You are banned")
|
||||||
|
|
||||||
|
|
||||||
def is_auth_fresh(minutes: int=20):
|
def is_auth_fresh(minutes: int = 20):
|
||||||
if 'last_login_time' in session:
|
if 'last_login_time' in session:
|
||||||
last = session['last_login_time']
|
last = session['last_login_time']
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
@ -41,6 +40,8 @@ def is_email_verified():
|
||||||
|
|
||||||
|
|
||||||
def auth_user(email, password):
|
def auth_user(email, password):
|
||||||
|
from grant.user.models import User
|
||||||
|
|
||||||
existing_user = User.get_by_email(email)
|
existing_user = User.get_by_email(email)
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
raise AuthException("No user exists with that email")
|
raise AuthException("No user exists with that email")
|
||||||
|
@ -85,6 +86,8 @@ def requires_auth(f):
|
||||||
def requires_same_user_auth(f):
|
def requires_same_user_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.user.models import User
|
||||||
|
|
||||||
user_id = kwargs["user_id"]
|
user_id = kwargs["user_id"]
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500
|
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500
|
||||||
|
@ -114,6 +117,8 @@ def requires_email_verified_auth(f):
|
||||||
def requires_team_member_auth(f):
|
def requires_team_member_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.proposal.models import Proposal
|
||||||
|
|
||||||
proposal_id = kwargs["proposal_id"]
|
proposal_id = kwargs["proposal_id"]
|
||||||
if not proposal_id:
|
if not proposal_id:
|
||||||
return jsonify(message="Decorator requires_team_member_auth requires path variable <proposal_id>"), 500
|
return jsonify(message="Decorator requires_team_member_auth requires path variable <proposal_id>"), 500
|
||||||
|
@ -131,9 +136,33 @@ def requires_team_member_auth(f):
|
||||||
return requires_email_verified_auth(decorated)
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
|
def requires_ccr_owner_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.ccr.models import CCR
|
||||||
|
|
||||||
|
ccr_id = kwargs["ccr_id"]
|
||||||
|
if not ccr_id:
|
||||||
|
return jsonify(message="Decorator requires_ccr_owner_auth requires path variable <ccr_id>"), 500
|
||||||
|
|
||||||
|
ccr = CCR.query.filter_by(id=ccr_id).first()
|
||||||
|
if not ccr:
|
||||||
|
return jsonify(message="No CCR exists with id {}".format(ccr_id)), 404
|
||||||
|
|
||||||
|
if g.current_user.id != ccr.author.id:
|
||||||
|
return jsonify(message="You are not authorized to modify this CCR"), 403
|
||||||
|
|
||||||
|
g.current_ccr = ccr
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return requires_email_verified_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
def requires_arbiter_auth(f):
|
def requires_arbiter_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
from grant.proposal.models import Proposal
|
||||||
|
|
||||||
proposal_id = kwargs["proposal_id"]
|
proposal_id = kwargs["proposal_id"]
|
||||||
if not proposal_id:
|
if not proposal_id:
|
||||||
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500
|
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500
|
||||||
|
|
|
@ -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
|
|
@ -11,12 +11,29 @@ class CustomEnum():
|
||||||
not attr.startswith('__')]
|
not attr.startswith('__')]
|
||||||
|
|
||||||
|
|
||||||
class ProposalStatusEnum(CustomEnum):
|
class CCRStatusEnum(CustomEnum):
|
||||||
DRAFT = 'DRAFT'
|
DRAFT = 'DRAFT'
|
||||||
PENDING = 'PENDING'
|
PENDING = 'PENDING'
|
||||||
STAKING = 'STAKING'
|
|
||||||
APPROVED = 'APPROVED'
|
APPROVED = 'APPROVED'
|
||||||
REJECTED = 'REJECTED'
|
REJECTED = 'REJECTED'
|
||||||
|
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY'
|
||||||
|
LIVE = 'LIVE'
|
||||||
|
DELETED = 'DELETED'
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
LIVE = 'LIVE'
|
||||||
DELETED = 'DELETED'
|
DELETED = 'DELETED'
|
||||||
|
|
||||||
|
@ -34,7 +51,6 @@ ProposalSort = ProposalSortEnum()
|
||||||
|
|
||||||
class ProposalStageEnum(CustomEnum):
|
class ProposalStageEnum(CustomEnum):
|
||||||
PREVIEW = 'PREVIEW'
|
PREVIEW = 'PREVIEW'
|
||||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
|
||||||
WIP = 'WIP'
|
WIP = 'WIP'
|
||||||
COMPLETED = 'COMPLETED'
|
COMPLETED = 'COMPLETED'
|
||||||
FAILED = 'FAILED'
|
FAILED = 'FAILED'
|
||||||
|
@ -91,3 +107,20 @@ class ProposalArbiterStatusEnum(CustomEnum):
|
||||||
|
|
||||||
|
|
||||||
ProposalArbiterStatus = ProposalArbiterStatusEnum()
|
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()
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import abc
|
import abc
|
||||||
from sqlalchemy import or_, and_
|
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from grant.ccr.models import CCR
|
||||||
from grant.comment.models import Comment, comments_schema
|
from grant.comment.models import Comment, comments_schema
|
||||||
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
|
||||||
from grant.comment.models import Comment, comments_schema
|
|
||||||
from grant.user.models import User, UserSettings, users_schema
|
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
|
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
||||||
|
from grant.user.models import User, UserSettings, users_schema
|
||||||
|
from .enums import CCRStatus, ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, \
|
||||||
|
MilestoneStage
|
||||||
|
|
||||||
|
|
||||||
def extract_filters(sw, strings):
|
def extract_filters(sw, strings):
|
||||||
|
@ -39,13 +41,13 @@ class Pagination(abc.ABC):
|
||||||
# consider moving these args into __init__ and attaching to self
|
# consider moving these args into __init__ and attaching to self
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema,
|
schema: ma.Schema,
|
||||||
query: db.Query,
|
query: db.Query,
|
||||||
page: int,
|
page: int,
|
||||||
filters: list,
|
filters: list,
|
||||||
search: str,
|
search: str,
|
||||||
sort: str,
|
sort: str,
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -58,6 +60,7 @@ class ProposalPagination(Pagination):
|
||||||
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
|
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
|
||||||
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
|
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
|
||||||
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
|
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
|
||||||
|
self.FILTERS.extend(['ACCEPTED_WITH_FUNDING', 'ACCEPTED_WITHOUT_FUNDING'])
|
||||||
self.PAGE_SIZE = 9
|
self.PAGE_SIZE = 9
|
||||||
self.SORT_MAP = {
|
self.SORT_MAP = {
|
||||||
'CREATED:DESC': Proposal.date_created.desc(),
|
'CREATED:DESC': Proposal.date_created.desc(),
|
||||||
|
@ -67,13 +70,13 @@ class ProposalPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema,
|
schema: ma.Schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='PUBLISHED:DESC',
|
sort: str = 'PUBLISHED:DESC',
|
||||||
):
|
):
|
||||||
query = query or Proposal.query
|
query = query or Proposal.query
|
||||||
sort = sort or 'PUBLISHED:DESC'
|
sort = sort or 'PUBLISHED:DESC'
|
||||||
|
@ -102,6 +105,10 @@ class ProposalPagination(Pagination):
|
||||||
if milestone_filters:
|
if milestone_filters:
|
||||||
query = query.join(Proposal.milestones) \
|
query = query.join(Proposal.milestones) \
|
||||||
.filter(Milestone.stage.in_(milestone_filters))
|
.filter(Milestone.stage.in_(milestone_filters))
|
||||||
|
if 'ACCEPTED_WITH_FUNDING' in filters:
|
||||||
|
query = query.filter(Proposal.accepted_with_funding == True)
|
||||||
|
if 'ACCEPTED_WITHOUT_FUNDING' in filters:
|
||||||
|
query = query.filter(Proposal.accepted_with_funding == False)
|
||||||
|
|
||||||
# SORT (see self.SORT_MAP)
|
# SORT (see self.SORT_MAP)
|
||||||
if sort:
|
if sort:
|
||||||
|
@ -137,13 +144,13 @@ class ContributionPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=proposal_contributions_schema,
|
schema: ma.Schema = proposal_contributions_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='PUBLISHED:DESC',
|
sort: str = 'PUBLISHED:DESC',
|
||||||
):
|
):
|
||||||
query = query or ProposalContribution.query
|
query = query or ProposalContribution.query
|
||||||
sort = sort or 'CREATED:DESC'
|
sort = sort or 'CREATED:DESC'
|
||||||
|
@ -162,9 +169,9 @@ class ContributionPagination(Pagination):
|
||||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||||
.join(Proposal) \
|
.join(Proposal) \
|
||||||
.filter(or_(
|
.filter(or_(
|
||||||
Proposal.stage == ProposalStage.FAILED,
|
Proposal.stage == ProposalStage.FAILED,
|
||||||
Proposal.stage == ProposalStage.CANCELED,
|
Proposal.stage == ProposalStage.CANCELED,
|
||||||
)) \
|
)) \
|
||||||
.join(ProposalContribution.user) \
|
.join(ProposalContribution.user) \
|
||||||
.join(UserSettings) \
|
.join(UserSettings) \
|
||||||
.filter(UserSettings.refund_address != None)
|
.filter(UserSettings.refund_address != None)
|
||||||
|
@ -174,9 +181,9 @@ class ContributionPagination(Pagination):
|
||||||
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
|
||||||
.join(Proposal) \
|
.join(Proposal) \
|
||||||
.filter(or_(
|
.filter(or_(
|
||||||
Proposal.stage == ProposalStage.FAILED,
|
Proposal.stage == ProposalStage.FAILED,
|
||||||
Proposal.stage == ProposalStage.CANCELED,
|
Proposal.stage == ProposalStage.CANCELED,
|
||||||
)) \
|
)) \
|
||||||
.join(ProposalContribution.user, isouter=True) \
|
.join(ProposalContribution.user, isouter=True) \
|
||||||
.join(UserSettings, isouter=True) \
|
.join(UserSettings, isouter=True) \
|
||||||
.filter(UserSettings.refund_address == None)
|
.filter(UserSettings.refund_address == None)
|
||||||
|
@ -217,13 +224,13 @@ class UserPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=users_schema,
|
schema: ma.Schema = users_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='EMAIL:DESC',
|
sort: str = 'EMAIL:DESC',
|
||||||
):
|
):
|
||||||
query = query or Proposal.query
|
query = query or Proposal.query
|
||||||
sort = sort or 'EMAIL:DESC'
|
sort = sort or 'EMAIL:DESC'
|
||||||
|
@ -273,13 +280,13 @@ class CommentPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
def paginate(
|
def paginate(
|
||||||
self,
|
self,
|
||||||
schema: ma.Schema=comments_schema,
|
schema: ma.Schema = comments_schema,
|
||||||
query: db.Query=None,
|
query: db.Query = None,
|
||||||
page: int=1,
|
page: int = 1,
|
||||||
filters: list=None,
|
filters: list = None,
|
||||||
search: str=None,
|
search: str = None,
|
||||||
sort: str='CREATED:DESC',
|
sort: str = 'CREATED:DESC',
|
||||||
):
|
):
|
||||||
query = query or Comment.query
|
query = query or Comment.query
|
||||||
sort = sort or 'CREATED:DESC'
|
sort = sort or 'CREATED:DESC'
|
||||||
|
@ -315,7 +322,58 @@ class CommentPagination(Pagination):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CCRPagination(Pagination):
|
||||||
|
def __init__(self):
|
||||||
|
self.FILTERS = [f'STATUS_{s}' for s in CCRStatus.list()]
|
||||||
|
self.PAGE_SIZE = 9
|
||||||
|
self.SORT_MAP = {
|
||||||
|
'CREATED:DESC': CCR.date_created.desc(),
|
||||||
|
'CREATED:ASC': CCR.date_created
|
||||||
|
}
|
||||||
|
|
||||||
|
def paginate(
|
||||||
|
self,
|
||||||
|
schema: ma.Schema,
|
||||||
|
query: db.Query = None,
|
||||||
|
page: int = 1,
|
||||||
|
filters: list = None,
|
||||||
|
search: str = None,
|
||||||
|
sort: str = 'CREATED:DESC',
|
||||||
|
):
|
||||||
|
query = query or CCR.query
|
||||||
|
sort = sort or 'CREATED:DESC'
|
||||||
|
|
||||||
|
# FILTER
|
||||||
|
if filters:
|
||||||
|
self.validate_filters(filters)
|
||||||
|
status_filters = extract_filters('STATUS_', filters)
|
||||||
|
|
||||||
|
if status_filters:
|
||||||
|
query = query.filter(CCR.status.in_(status_filters))
|
||||||
|
|
||||||
|
# SORT (see self.SORT_MAP)
|
||||||
|
if sort:
|
||||||
|
self.validate_sort(sort)
|
||||||
|
query = query.order_by(self.SORT_MAP[sort])
|
||||||
|
|
||||||
|
# SEARCH
|
||||||
|
if search:
|
||||||
|
query = query.filter(CCR.title.ilike(f'%{search}%'))
|
||||||
|
|
||||||
|
res = query.paginate(page, self.PAGE_SIZE, False)
|
||||||
|
return {
|
||||||
|
'page': res.page,
|
||||||
|
'total': res.total,
|
||||||
|
'page_size': self.PAGE_SIZE,
|
||||||
|
'items': schema.dump(res.items),
|
||||||
|
'filters': filters,
|
||||||
|
'search': search,
|
||||||
|
'sort': sort
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# expose pagination methods here
|
# expose pagination methods here
|
||||||
|
ccr = CCRPagination().paginate
|
||||||
proposal = ProposalPagination().paginate
|
proposal = ProposalPagination().paginate
|
||||||
contribution = ContributionPagination().paginate
|
contribution = ContributionPagination().paginate
|
||||||
comment = CommentPagination().paginate
|
comment = CommentPagination().paginate
|
||||||
|
|
|
@ -29,17 +29,6 @@ def blockchain_get(path, params=None):
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
def validate_blockchain_get(path, params=None):
|
|
||||||
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):
|
def blockchain_post(path, data=None):
|
||||||
if E2E_TESTING:
|
if E2E_TESTING:
|
||||||
return blockchain_rest_e2e(path, data)
|
return blockchain_rest_e2e(path, data)
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
from grant.utils.bech32 import bech32_decode
|
||||||
|
|
||||||
|
|
||||||
|
def is_z_address_valid(addr: str):
|
||||||
|
if type(addr) != str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if addr[:3] != 'zs1':
|
||||||
|
return False
|
||||||
|
|
||||||
|
hrp, data = bech32_decode(addr)
|
||||||
|
|
||||||
|
if hrp is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
|
@ -0,0 +1,192 @@
|
||||||
|
import warnings
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
from time import time, gmtime
|
||||||
|
|
||||||
|
from werkzeug._compat import to_bytes, string_types, text_type, PY2, integer_types
|
||||||
|
from werkzeug._internal import _make_cookie_domain, _cookie_quote
|
||||||
|
from werkzeug.urls import iri_to_uri
|
||||||
|
|
||||||
|
|
||||||
|
def dump_cookie(
|
||||||
|
key,
|
||||||
|
value="",
|
||||||
|
max_age=None,
|
||||||
|
expires=None,
|
||||||
|
path="/",
|
||||||
|
domain=None,
|
||||||
|
secure=False,
|
||||||
|
httponly=False,
|
||||||
|
charset="utf-8",
|
||||||
|
sync_expires=True,
|
||||||
|
max_size=4093,
|
||||||
|
samesite=None,
|
||||||
|
):
|
||||||
|
"""Creates a new Set-Cookie header without the ``Set-Cookie`` prefix
|
||||||
|
The parameters are the same as in the cookie Morsel object in the
|
||||||
|
Python standard library but it accepts unicode data, too.
|
||||||
|
|
||||||
|
On Python 3 the return value of this function will be a unicode
|
||||||
|
string, on Python 2 it will be a native string. In both cases the
|
||||||
|
return value is usually restricted to ascii as the vast majority of
|
||||||
|
values are properly escaped, but that is no guarantee. If a unicode
|
||||||
|
string is returned it's tunneled through latin1 as required by
|
||||||
|
PEP 3333.
|
||||||
|
|
||||||
|
The return value is not ASCII safe if the key contains unicode
|
||||||
|
characters. This is technically against the specification but
|
||||||
|
happens in the wild. It's strongly recommended to not use
|
||||||
|
non-ASCII values for the keys.
|
||||||
|
|
||||||
|
:param max_age: should be a number of seconds, or `None` (default) if
|
||||||
|
the cookie should last only as long as the client's
|
||||||
|
browser session. Additionally `timedelta` objects
|
||||||
|
are accepted, too.
|
||||||
|
:param expires: should be a `datetime` object or unix timestamp.
|
||||||
|
:param path: limits the cookie to a given path, per default it will
|
||||||
|
span the whole domain.
|
||||||
|
:param domain: Use this if you want to set a cross-domain cookie. For
|
||||||
|
example, ``domain=".example.com"`` will set a cookie
|
||||||
|
that is readable by the domain ``www.example.com``,
|
||||||
|
``foo.example.com`` etc. Otherwise, a cookie will only
|
||||||
|
be readable by the domain that set it.
|
||||||
|
:param secure: The cookie will only be available via HTTPS
|
||||||
|
:param httponly: disallow JavaScript to access the cookie. This is an
|
||||||
|
extension to the cookie standard and probably not
|
||||||
|
supported by all browsers.
|
||||||
|
:param charset: the encoding for unicode values.
|
||||||
|
:param sync_expires: automatically set expires if max_age is defined
|
||||||
|
but expires not.
|
||||||
|
:param max_size: Warn if the final header value exceeds this size. The
|
||||||
|
default, 4093, should be safely `supported by most browsers
|
||||||
|
<cookie_>`_. Set to 0 to disable this check.
|
||||||
|
:param samesite: Limits the scope of the cookie such that it will only
|
||||||
|
be attached to requests if those requests are "same-site".
|
||||||
|
|
||||||
|
.. _`cookie`: http://browsercookielimits.squawky.net/
|
||||||
|
"""
|
||||||
|
key = to_bytes(key, charset)
|
||||||
|
value = to_bytes(value, charset)
|
||||||
|
|
||||||
|
if path is not None:
|
||||||
|
path = iri_to_uri(path, charset)
|
||||||
|
domain = _make_cookie_domain(domain)
|
||||||
|
if isinstance(max_age, timedelta):
|
||||||
|
max_age = (max_age.days * 60 * 60 * 24) + max_age.seconds
|
||||||
|
if expires is not None:
|
||||||
|
if not isinstance(expires, string_types):
|
||||||
|
expires = cookie_date(expires)
|
||||||
|
elif max_age is not None and sync_expires:
|
||||||
|
expires = to_bytes(cookie_date(time() + max_age))
|
||||||
|
|
||||||
|
samesite = samesite.title() if samesite else None
|
||||||
|
if samesite not in ("Strict", "Lax", 'None', None):
|
||||||
|
raise ValueError("invalid SameSite value; must be 'Strict', 'Lax', 'None', or None")
|
||||||
|
|
||||||
|
buf = [key + b"=" + _cookie_quote(value)]
|
||||||
|
|
||||||
|
# XXX: In theory all of these parameters that are not marked with `None`
|
||||||
|
# should be quoted. Because stdlib did not quote it before I did not
|
||||||
|
# want to introduce quoting there now.
|
||||||
|
for k, v, q in (
|
||||||
|
(b"Domain", domain, True),
|
||||||
|
(b"Expires", expires, False),
|
||||||
|
(b"Max-Age", max_age, False),
|
||||||
|
(b"Secure", secure, None),
|
||||||
|
(b"HttpOnly", httponly, None),
|
||||||
|
(b"Path", path, False),
|
||||||
|
(b"SameSite", samesite, False),
|
||||||
|
):
|
||||||
|
if q is None:
|
||||||
|
if v:
|
||||||
|
buf.append(k)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmp = bytearray(k)
|
||||||
|
if not isinstance(v, (bytes, bytearray)):
|
||||||
|
v = to_bytes(text_type(v), charset)
|
||||||
|
if q:
|
||||||
|
v = _cookie_quote(v)
|
||||||
|
tmp += b"=" + v
|
||||||
|
buf.append(bytes(tmp))
|
||||||
|
|
||||||
|
# The return value will be an incorrectly encoded latin1 header on
|
||||||
|
# Python 3 for consistency with the headers object and a bytestring
|
||||||
|
# on Python 2 because that's how the API makes more sense.
|
||||||
|
rv = b"; ".join(buf)
|
||||||
|
if not PY2:
|
||||||
|
rv = rv.decode("latin1")
|
||||||
|
|
||||||
|
# Warn if the final value of the cookie is less than the limit. If the
|
||||||
|
# cookie is too large, then it may be silently ignored, which can be quite
|
||||||
|
# hard to debug.
|
||||||
|
cookie_size = len(rv)
|
||||||
|
|
||||||
|
if max_size and cookie_size > max_size:
|
||||||
|
value_size = len(value)
|
||||||
|
warnings.warn(
|
||||||
|
'The "{key}" cookie is too large: the value was {value_size} bytes'
|
||||||
|
" but the header required {extra_size} extra bytes. The final size"
|
||||||
|
" was {cookie_size} bytes but the limit is {max_size} bytes."
|
||||||
|
" Browsers may silently ignore cookies larger than this.".format(
|
||||||
|
key=key,
|
||||||
|
value_size=value_size,
|
||||||
|
extra_size=cookie_size - value_size,
|
||||||
|
cookie_size=cookie_size,
|
||||||
|
max_size=max_size,
|
||||||
|
),
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return rv
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_date(expires=None):
|
||||||
|
"""Formats the time to ensure compatibility with Netscape's cookie
|
||||||
|
standard.
|
||||||
|
|
||||||
|
Accepts a floating point number expressed in seconds since the epoch in, a
|
||||||
|
datetime object or a timetuple. All times in UTC. The :func:`parse_date`
|
||||||
|
function can be used to parse such a date.
|
||||||
|
|
||||||
|
Outputs a string in the format ``Wdy, DD-Mon-YYYY HH:MM:SS GMT``.
|
||||||
|
|
||||||
|
:param expires: If provided that date is used, otherwise the current.
|
||||||
|
"""
|
||||||
|
return _dump_date(expires, "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_date(d, delim):
|
||||||
|
"""Used for `http_date` and `cookie_date`."""
|
||||||
|
if d is None:
|
||||||
|
d = gmtime()
|
||||||
|
elif isinstance(d, datetime):
|
||||||
|
d = d.utctimetuple()
|
||||||
|
elif isinstance(d, (integer_types, float)):
|
||||||
|
d = gmtime(d)
|
||||||
|
return "%s, %02d%s%s%s%s %02d:%02d:%02d GMT" % (
|
||||||
|
("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")[d.tm_wday],
|
||||||
|
d.tm_mday,
|
||||||
|
delim,
|
||||||
|
(
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
)[d.tm_mon - 1],
|
||||||
|
delim,
|
||||||
|
str(d.tm_year),
|
||||||
|
d.tm_hour,
|
||||||
|
d.tm_min,
|
||||||
|
d.tm_sec,
|
||||||
|
)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue