Merge pull request #386 from grant-project/develop

Release 1.0.0
This commit is contained in:
Daniel Ternyak 2019-03-14 23:46:37 -05:00 committed by GitHub
commit 4abf840c0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
212 changed files with 7048 additions and 7187 deletions

119
DISCLOSURE.md Normal file
View File

@ -0,0 +1,119 @@
# Responsible Disclosure Policy
We greatly appreciate any and all disclosures of bugs and vulnerabilities that are done in a responsible manner. We will engage responsible disclosures according to this policy and put forth our best effort to fix disclosed vulnerabilities as well as reaching out to numerous node operators to deploy fixes in a timely manner.
## Responsible Disclosure Guidelines
Non-critical bugs can be repoted by creating an issue on [GitHub](https://github.com/grant-project/zcash-grant-system). Do not disclose critical bug or vulnerability on public forums, message boards, mailing lists, etc. prior to responsibly disclosing to the Zcash Foundation / Grant.io teams and giving sufficient time for the issue to be fixed and deployed.
## Reporting a Bug or Vulnerability
When reporting a bug or vulnerability, please provide the following to contact@grant.io and CC contact@zfnd.org.
* A short summary of the potential impact of the issue (if known).
* Details explaining how to reproduce the issue or how an exploit may be formed.
* Your name (optional). If provided, we will provide credit for disclosure. Otherwise, you will be treated anonymously and your privacy will be respected.
* Your email or other means of contacting you.
* A PGP key/fingerprint for us to provide encrypted responses to your disclosure. If this is not provided, we cannot guarantee that you will receive a response prior to a fix being made and deployed.
## Encrypting the Disclosure
We highly encourage all disclosures to be encrypted to prevent interception and exploitation by third-parties prior to a fix being developed and deployed. Please encrypt using the PGP public key with fingerprint: `46CD57E95AF395A1499C18A3F01C867EEB456C7A`
It may be obtained via:
```
gpg --recv-keys 46CD57E95AF395A1499C18A3F01C867EEB456C7A
```
Alternatively, it may be optained by copying the following into a file, and imported it via:
```
gpg --import <filename>
```
Signing example:
```
gpg --encrypt --sign --armor -r contact@grant.io <path/to/filename>
```
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: Keybase OpenPGP v2.1.0
Comment: https://keybase.io/crypto
xsFNBFyIGnABEAC864WlC/UVmKfaDPWgCt5EppPV8H5KXnKRy/iwXmDPetWpWiYC
UmcvFuXc+cD+RLuqwmei5K/74QgmGxCNiuWsa22cXF5xkQDHwSSAhw6DisoT//OK
ftcn9HBB88nIzzGuRedv1eyGV7fc4syACkSZS1PgplLC5R3mvKIGUXz9mbSdL2HM
7ao4mTNFo6wgPkebBX4w1CHQgyU327HJAVRt59VMrI85ahoU9b2K9UjVfbFartQs
cU276EmOXC3Sd+3JLyqfOXsK5W+bInvC6hnoXlQpPA9Zv5RIshHHTSW31M9w6inI
SkJa35emvt/UIVPEAbm3UtkYzD6YstOBQnGqUBQzRSU14fvPuuX4FQXUORSEcYv5
KdqNzj0BCD5BNr992L8+FRcQnHm1+d8LgCYHzg2lpaQ5bgXYi0lL5HitlD9+Je9k
btqKYF0qESQRDMLYyTYV06Ka7Uu0Yd1V/7+URc4OkkgMRBBAw/RVBWzgrz1Vu+T7
EZynhATn4z7StXf3RLikuShqL9y0nFgIzJuwInFdwlngX0WNetDIOvi8sif28N8K
C5Fq9+Js1hKii4YAxz+kkAXFjYvkebr5BhEJsWfek2Y5Bq4a1ZYJHeqxl9EwUYpF
nKy6sLWIfxUckfGWb26YSOONhFkxzDbPt+JTFSgS0Plt0FTI7cqCbXJlcQARAQAB
zRtHcmFudC5pbyA8Y29udGFjdEBncmFudC5pbz7CwXQEEwEKAB4FAlyIGnACGwMD
CwkHAxUKCAIeAQIXgAMWAgECGQEACgkQ8ByGfutFbHr9uRAAuIF/L9tve5TNjqBC
X1Vku3+VgN1sLQu8JWzTDmwmAp0UHd9wXV7Yw6NR6jny1Os4SEibBA1LgWU/f56W
m3y39xzZGFnbD81BucGh676PB7JNnfSscLhggrZOtAP+sEFAlg+0vJM46l/TnXtD
6+tc7/J+skHrcwKUBNamZh6UkE+1E/Qi7EHCemhJlW9QAN8CUPKhM05OSb8wypBF
HY50QROA+/FpvUUHY4iumJmZujUWQ2os+NM+KKvFQtkQp06vsk5jCpqEGC+YTVr3
GArkIEQtJgsiM+h1KsYxBzQfmBVabzire+Xi1csskzY/vuQbqk4FaeaHjExuRcGk
vyblBdAvSIgjW3PNrZauWrlu92Rxmlpb2+gtPcQ+hxKxaGWKghrOTR3hx6maV993
T5m00OGRAk/7yc+yZPUCBuZt6qDtcBWOZfkK5KJb/gGSiH/Xyt4v115qmaTHnZya
lxzZrkAFBaa4qTp5xmu2bK+KQ9kj5PS9X8l8aGCICMDZgjQdCC9APUNbqTuDqUqo
SkBPzfheBCD+5dlZ+M4ToZvG3sXd7QF6OKsb717sz9SFAfG5gDMtO7E66kspclh6
KAflOyKp/J4irJzmV+bJ2L/nRbCgaGxAL8mA812QW9VICG1LH+2FmAolenwXrFNj
g9dUFE6qBNRPKuMqze/6/qAf67bOwE0EXIgacAEIANXkJ2EM0HepjvrCI3m/VIEY
PSejIDgU90l3miNiziJE8tfUrhjXIa5w7xp9bNyzLQW3W10oP0ZEw7nwWweuccyg
jjVj2GlgdDjZ/GngxbKxSqyeKeomy6hYnX91lEY6FIhoceSdi6YM6XUc/8vBv0l9
ErRXm0g+iFILXSsVLf8HlB7iWr53FG54MHh8+VD4Q+kykX/eyEdIClwvaIrlTc74
xJmQwAMv9RZcjcAaMjd8xTHd4qHvo/bf82DRXdnwfdMUwNF0DGL05TNOohACPddx
qUMq6mn4hhfpp7QN4z1IwkshyNyWZHRxXckNIqW2ACCSmCj38dkEquaVNrK4LksA
EQEAAcLChAQYAQoADwUCXIgacAUJDwmcAAIbDAEpCRDwHIZ+60VsesBdIAQZAQoA
BgUCXIgacAAKCRDDgnzS4GP2HYeUCADPtnAf+Q2y6bMSInS+J7kgnRYANYQptVPC
lAC2PrSrJFtcjaF0LYUvdoXZHoNRx1EqaAVpfT/lBHsMxIo+jBR555yPIPZdVXcL
W0WfvqvQ37rznHPEsGTMwHnVfr4gPkr1SvdGHhbTvmJTPeYAqG6+7I1QBbvRxnnD
iE+4HXPu+l1uCa4aA196S7QrBKAIQiLEIKlSefGNcZrITPnqybO8FCVfbx4sJKac
0zYFxuJ7ZNAMUOjCwrQD08CCX3Po6SWtmrH6LfaQU5DIO/9CX+9jp4b7FdRrbl/K
otTQVIIw3yPaVeMUczOhdtvVrBvtubMhohwdc3LJB8RJ/VLZti0GjzIQAJXjmfrV
TDk84NBtigorHO0WU1iUdHpw3J2LUX3SczkywQZ3Q/p5j3C2J813FoBJ433fh+ED
s/YS/FO/lJSYaXdBAE8Er6EUvQyJIabKIpPrZtbshRV+An9Psq/M5sP2alLX38i0
UqocUbdUGI6jOrmjNDKf5G5mCvE27jJVLBytKOHN9EJGr5WT0g8VuL5JCLrRHJ5D
BVLfocH6q9OfW3cAh5ULH/ZHizTecZg93gDdpjGxY46SyYyTmdyzsKxIKx74vLw6
6rsIh+Hv0zw7bjTBwFpLVy7poRn4gNpD90n9U1FAYDSwNzdZlEAAPUiIO1BCSaf6
20Bt6pB4gg3cWXRFuQBjFYmlyHdZwns9iO2gbsA7iNrKHn7o+vnRbEgEiAyEg3cF
y5x4j4U34WcvPbmYPjUEiIzBt0VtnEtuBJa1GHcINICotgfMOM1W7fFQwXVK2kvF
K7/8BcXQ9KYvzPgDIijIdIgg1jwqrZcAbSF+q2ogsyfyowptQtOeigqYFdXehXwR
lgMX8DTjUQ5rcpGSHPDaEOvA68RtR9IWv4r3EIbKVLvoGHePr9L+3FmxcU5ZbG2s
kRLI94eOJvt9sHLq24+SDd0Nekd1MLT4FK1HWDwllAoxPES0qw0sO1P3QtYT0jE/
7rzyL4QKUfaPhQzqmr3G+bp+3KkdMrqQkrnIzsBNBFyIGnABCADYrqV/3RaMwWsl
umkiv569p4TGwDGivbMbIp+OXSGDRygmIcSsK58HTLUK3GYntSspUinVk6mDGT1I
ndTX+GtOXDs3A6x6Z3zFEfKKjBYp0FshD6Ite3sTLUX7rEbMVlyB3qGakVR0PYFD
O7FXsGlMewvgJ82pna9sRGEB8ZwspSm3qVlUvgL3+Lj14+i5+pINrx8Oslcb3Tqq
XqDHv7/6scThVHDVIjBWNp1V9G+8lGYuromosMEtfjjctvexCdcuM5ecWkfl4lhJ
Y7Y+2mb8ZPKPRBxJm9jU9ROCJYiOAhDB51QMfukc+sOtAWy+M7jmdK4Y5StqDkH2
zbNCYQvdABEBAAHCwoQEGAEKAA8FAlyIGnAFCQ8JnAACGyIBKQkQ8ByGfutFbHrA
XSAEGQEKAAYFAlyIGnAACgkQRzZ4O9hHqSAKQwgAlspfIcY1jQEq6KA1NPEBWHKR
hIiB7RPI+dcZ9YBKVxWXSMj13XSWor+eWL1Hkimks0Khf+TjgAzP9x032ecbeZr3
xinFAE4FagQkim52z9lRAa62tqOETKBsvmV91FszphZj8pcJazfxB7U7Sssmg+LY
TVLe0qLmJ3RZbS7SuknJ+kRz5gs9NrFWLszhfWdKM7soznkOg0ld6Ut2iuI63ZzM
9UJns4as7ZXA8sCGbcMGmekyf2YdRhTK5+UuC97YjF9NmX5RojxRfQpvffAA2j5r
+/f4Xc3QdJxXhqEYJKea4+3xslfT+rV8QeG+H22ooPC5OO3auq5p4KZIUZO9p95v
EACZNXsgPv3OHSftqmJ1d5Dq76sbNeeQDQ24S/YKGyHI7KJlQcQumMBonK4gLiww
GBcnkdTrvhZHTRxURhrUgnPnlYuEDZpuH5BN8HUxFrNk+2AV04efco8uH8Jo+dR9
RG9ymM+SUsL1u+09ve+dkUlcM+uUU5QC+HqNcj6XePeYNcXMKGaP+W1DNvWtdQjs
HLtPqCJ9/ZneTy51jmfq1+MEVIuWDePuzrSzgMr2hmZpMRJP8DrPqxdlGjy4ydAg
WOA1GngILfgAjhn+WvXYAGl/u4dMxGTm93Debp3qMiA/3U9Mp6ZtBqLqkRHsbT8P
ow4ZDHDO/4SGoKCJJyp91MM8bq1tRrZnpmtqN8D9rmvJPRbksmgnzYVif3rYyei+
iyp8dN8llNrAlP/dOSTS+dVlN6tJXvqp/wbhghxQ88Gl0h5E+bAtBaact6A7ypg8
3UEJD8vbZi/SlIrmBE4wRNkcmGhT7SBCbt04o36ZgX57P3KMZgnFv3g2AIWmhL8C
szKKmnciuRky8/Rp35UZygxNlSMfwFNz3TIPu0rTqrEZ/TqIzBI4Do82PCBb8uRu
YuE2wMSvdPhaQSs1siFICIBrCu/nH1AcgLO2R20vtWi3azx+zLq20l1mQXgUDvfB
Xy9U/6jQi/pDWSFTLF/tj9ctvfGJXs03lkTrKyp7xAu5MA==
=1KTe
-----END PGP PUBLIC KEY BLOCK-----
```
##### Inspired by [this](https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/DISCLOSURE_POLICY.md) dislosure policy

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2019 Grant.io Inc
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.

View File

@ -4,24 +4,16 @@ This is a collection of the various services and components that make up the Zca
### Setup
---
##### Locally
Instructions for each respective component can be found in:
- `/backend/README.md`
- `/frontend/README.md`
- `/admin/README.md`
- `/e2e/README.md`
- `/blockchain/README.md`
We currently only offer instructions for unix based systems. Windows may or may not be compatible.
### Testing
To run tests across all components simultaneously, use the following command
TBD
### Deployment
TBD

View File

@ -13,7 +13,7 @@
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-commit": "lint-staged && yarn run tsc",
"pre-push": "yarn run lint && yarn run tsc"
}
},
@ -104,9 +104,9 @@
"url-loader": "^1.1.1",
"webpack": "^4.19.0",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.1.8",
"webpack-dev-server": "3.2.1",
"webpack-hot-middleware": "^2.24.0",
"xss": "1.0.3"
"xss": "^1.0.3"
},
"devDependencies": {
"@types/bn.js": "4.11.1",

View File

@ -19,6 +19,7 @@ import RFPDetail from 'components/RFPDetail';
import Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail';
import Financials from 'components/Financials';
import Moderation from 'components/Moderation';
import Settings from 'components/Settings';
@ -54,6 +55,7 @@ class Routes extends React.Component<Props> {
<Route path="/contributions/:id/edit" component={ContributionForm} />
<Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} />
<Route path="/financials" component={Financials} />
<Route path="/emails/:type?" component={Emails} />
<Route path="/moderation" component={Moderation} />
<Route path="/settings/2fa-reset" render={() => <MFAuth isReset={true} />} />

View File

@ -137,6 +137,7 @@ class ContributionDetail extends React.Component<Props, State> {
: <em>N/A</em>
)}
{renderDeetItem('staking tx', JSON.stringify(c.staking))}
{renderDeetItem('no refund', JSON.stringify(c.noRefund))}
</Card>
</Col>
</Row>

View File

@ -92,6 +92,11 @@ export default [
title: 'Contribution proposal canceled',
description: 'Sent to contributors when an admin cancels the proposal after funding',
},
{
id: 'contribution_expired',
title: 'Contribution expired',
description: 'Sent 24 hours after a contribution is made with no confirmation',
},
{
id: 'comment_reply',
title: 'Comment reply',

View File

@ -1,18 +1,31 @@
import React, { ReactNode } from 'react';
import { Modal, Input, Button } from 'antd';
import { ModalFuncProps } from 'antd/lib/modal';
import TextArea from 'antd/lib/input/TextArea';
import TextArea, { TextAreaProps } from 'antd/lib/input/TextArea';
import { InputProps } from 'antd/lib/input';
import './index.less';
interface OpenProps extends ModalFuncProps {
label: ReactNode;
label?: ReactNode;
inputProps?: InputProps;
textAreaProps?: TextAreaProps;
type?: 'textArea' | 'input';
onOk: (feedback: string) => void;
}
const open = (p: OpenProps) => {
// NOTE: display=none antd buttons and using our own to control things more
const ref = { text: '' };
const { label, content, okText, cancelText, ...rest } = p;
const {
label,
content,
type,
inputProps,
textAreaProps,
okText,
cancelText,
...rest
} = p;
const modal = Modal.confirm({
maskClosable: true,
icon: <></>,
@ -21,6 +34,9 @@ const open = (p: OpenProps) => {
<Feedback
label={label}
content={content}
type={type || 'textArea'}
inputProps={inputProps}
textAreaProps={textAreaProps}
okText={okText}
cancelText={cancelText}
onCancel={() => {
@ -40,7 +56,10 @@ const open = (p: OpenProps) => {
// Feedback content
interface OwnProps {
onChange: (t: string) => void;
label: ReactNode;
label?: ReactNode;
type: 'textArea' | 'input';
inputProps?: InputProps;
textAreaProps?: TextAreaProps;
onOk: ModalFuncProps['onOk'];
onCancel: ModalFuncProps['onCancel'];
okText?: ReactNode;
@ -58,27 +77,51 @@ type State = typeof STATE;
class Feedback extends React.Component<Props, State> {
state = STATE;
input: null | TextArea = null;
input: null | TextArea | Input = null;
componentDidMount() {
if (this.input) this.input.focus();
}
render() {
const { text } = this.state;
const { label, onOk, onCancel, content, okText, cancelText } = this.props;
const {
label,
type,
textAreaProps,
inputProps,
onOk,
onCancel,
content,
okText,
cancelText,
} = this.props;
return (
<div>
{content && <p>{content}</p>}
<div className="FeedbackModal-label">{label}</div>
<Input.TextArea
ref={ta => (this.input = ta)}
rows={4}
required={true}
value={text}
onChange={e => {
this.setState({ text: e.target.value });
this.props.onChange(e.target.value);
}}
/>
{label && <div className="FeedbackModal-label">{label}</div>}
{type === 'textArea' && (
<Input.TextArea
ref={ta => (this.input = ta)}
rows={4}
required={true}
value={text}
onChange={e => {
this.setState({ text: e.target.value });
this.props.onChange(e.target.value);
}}
{...textAreaProps}
/>
)}
{type === 'input' && (
<Input
ref={ta => (this.input = ta)}
value={text}
onChange={e => {
this.setState({ text: e.target.value });
this.props.onChange(e.target.value);
}}
{...inputProps}
/>
)}
<div className="FeedbackModal-controls">
<Button onClick={onCancel}>{cancelText || 'Cancel'}</Button>
<Button onClick={onOk} disabled={text.length === 0} type="primary">

View File

@ -0,0 +1,56 @@
.Financials {
&-zcash {
font-size: 0.8rem;
vertical-align: text-top;
display: inline-block;
padding-top: 0.15rem;
}
&-bottomLine {
& > div {
display: flex;
justify-content: space-between;
&.is-net {
margin-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.65);
padding-top: 0.5rem;
}
& .Info {
font-size: 0.9rem;
}
& > div + div {
font-family: 'Courier New', Courier, monospace;
min-width: 250px;
}
small {
display: inline-block;
padding-right: 0.1rem;
}
}
font-size: 1.2rem;
}
h1 {
font-size: 1.5rem;
}
.ant-card {
margin-bottom: 16px;
}
.antd-pro-charts-pie-total {
.pie-sub-title {
margin: 0;
}
}
.antd-pro-charts-pie-legend {
li {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,143 @@
import React from 'react';
import { Spin, Card, Row, Col } from 'antd';
import { Charts } from 'ant-design-pro';
import { view } from 'react-easy-state';
import store from '../../store';
import Info from 'components/Info';
import './index.less';
class Financials extends React.Component {
componentDidMount() {
store.fetchFinancials();
}
render() {
const { contributions, grants, payouts } = store.financials;
if (!store.financialsFetched) {
return <Spin tip="Loading financials..." />;
}
return (
<div className="Financials">
<Row gutter={16}>
<Col lg={8} md={12} sm={24}>
<Card size="small" title="Contributions">
<Charts.Pie
hasLegend
title="Contributions"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + contributions.total,
}}
/>
)}
data={[
{ x: 'funded', y: parseFloat(contributions.funded) },
{ x: 'funding', y: parseFloat(contributions.funding) },
{ x: 'refunding', y: parseFloat(contributions.refunding) },
{ x: 'refunded', y: parseFloat(contributions.refunded) },
{ x: 'donation', y: parseFloat(contributions.donations) },
{ x: 'staking', y: parseFloat(contributions.staking) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
title={
<Info
content={
<>
<p>
Matching and bounty obligations for active and completed
proposals.
</p>
<b>matching</b> - total matching amount pleged
<br />
<b>bounties</b> - total bounty amount pledged
<br />
</>
}
>
Grants
</Info>
}
>
<Charts.Pie
hasLegend
title="Grants"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + grants.total,
}}
/>
)}
data={[
{ x: 'bounties', y: parseFloat(grants.bounty) },
{ x: 'matching', y: parseFloat(grants.matching) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
title={
<Info
content={
<>
<p>Milestone payouts.</p>
<b>due</b> - payouts currently accepted but not paid
<br />
<b>future</b> - payouts that are not yet paid, but expected to be
requested in the future
<br />
<b>paid</b> - total milestone payouts marked as paid, regardless of
proposal status
<br />
</>
}
>
Payouts
</Info>
}
>
<Charts.Pie
hasLegend
title="Payouts"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + payouts.total,
}}
/>
)}
data={[
{ x: 'due', y: parseFloat(payouts.due) },
{ x: 'future', y: parseFloat(payouts.future) },
{ x: 'paid', y: parseFloat(payouts.paid) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
</Row>
</div>
);
}
}
export default view(Financials);

View File

@ -173,7 +173,7 @@ class MFAuth extends React.Component<Props, State> {
<ol>
<li>Save two-factor recovery codes</li>
<li>
Setup up TOTP authentication device, typically a smartphone with Google
Setup TOTP authentication device, typically a smartphone with Google
Authenticator, Authy, 1Password or other compatible authenticator app.
</li>
</ol>

View File

@ -5,12 +5,13 @@ import './index.less';
interface Props extends React.HTMLAttributes<HTMLDivElement> {
source: string;
reduced?: boolean;
}
export default class Markdown extends React.PureComponent<Props> {
render() {
const { source, ...rest } = this.props;
const html = mdToHtml(source);
const { source, reduced, ...rest } = this.props;
const html = mdToHtml(source, reduced);
// TS types seem to be fighting over react prop defs for div
const divProps = rest as any;
return (

View File

@ -54,7 +54,7 @@ class ModerationItem extends React.Component<Comment> {
}
description={
<ShowMore height={100}>
<Markdown source={p.content} />
<Markdown source={p.content} reduced />
</ShowMore>
}
/>

View File

@ -18,7 +18,7 @@
font-size: 0.7rem;
position: absolute;
opacity: 0.8;
top: 1rem;
bottom: -0.7rem;
}
}

View File

@ -36,6 +36,7 @@ type Props = RouteComponentProps<any>;
const STATE = {
paidTxId: '',
showCancelAndRefundPopover: false,
};
type State = typeof STATE;
@ -57,7 +58,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
const needsArbiter =
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
p.status === PROPOSAL_STATUS.LIVE &&
!p.isFailed;
!p.isFailed &&
p.stage !== PROPOSAL_STAGE.COMPLETED;
const refundablePct = p.milestones.reduce((prev, m) => {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100);
@ -75,36 +77,41 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Popconfirm>
);
const renderCancelControl = () => (
<Popconfirm
title={
<p>
Are you sure you want to cancel proposal and begin
<br />
the refund process? This cannot be undone.
</p>
}
placement="left"
cancelText="cancel"
okText="confirm"
okButtonProps={{ loading: store.proposalDetailCanceling }}
onConfirm={this.handleCancel}
>
<Button
icon="close-circle"
className="ProposalDetail-controls-control"
loading={store.proposalDetailCanceling}
disabled={
p.status !== PROPOSAL_STATUS.LIVE ||
p.stage === PROPOSAL_STAGE.FAILED ||
p.stage === PROPOSAL_STAGE.CANCELED
const renderCancelControl = () => {
const disabled = this.getCancelAndRefundDisabled();
return (
<Popconfirm
title={
<p>
Are you sure you want to cancel proposal and begin
<br />
the refund process? This cannot be undone.
</p>
}
block
placement="left"
cancelText="cancel"
okText="confirm"
visible={this.state.showCancelAndRefundPopover}
okButtonProps={{
loading: store.proposalDetailCanceling,
}}
onCancel={this.handleCancelCancel}
onConfirm={this.handleConfirmCancel}
>
Cancel & refund
</Button>
</Popconfirm>
);
<Button
icon="close-circle"
className="ProposalDetail-controls-control"
loading={store.proposalDetailCanceling}
onClick={this.handleCancelAndRefundClick}
disabled={disabled}
block
>
Cancel & refund
</Button>
</Popconfirm>
);
};
const renderArbiterControl = () => (
<ArbiterControl
@ -113,7 +120,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
type: 'default',
className: 'ProposalDetail-controls-control',
block: true,
disabled: p.status !== PROPOSAL_STATUS.LIVE || p.isFailed,
disabled:
p.status !== PROPOSAL_STATUS.LIVE ||
p.isFailed ||
p.stage === PROPOSAL_STAGE.COMPLETED,
}}
/>
);
@ -141,7 +151,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
>
<Switch
checked={p.contributionMatching === 1}
loading={false}
loading={store.proposalDetailUpdating}
disabled={
p.isFailed ||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
@ -164,6 +174,23 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</div>
);
const renderBountyControl = () => (
<div className="ProposalDetail-controls-control">
<Button
icon="dollar"
className="ProposalDetail-controls-control"
loading={store.proposalDetailUpdating}
onClick={this.handleSetBounty}
disabled={
p.isFailed || [PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
}
block
>
Set bounty
</Button>
</div>
);
const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
<Alert
@ -374,7 +401,6 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Markdown source={p.content} />
</Collapse.Panel>
{/* TODO - comments, milestones, updates &etc. */}
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(p, null, 4)}</pre>
</Collapse.Panel>
@ -388,6 +414,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeleteControl()}
{renderCancelControl()}
{renderArbiterControl()}
{renderBountyControl()}
{renderMatchingControl()}
</Card>
@ -395,7 +422,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Card title="Details" size="small">
{renderDeetItem('id', p.proposalId)}
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
{renderDeetItem('published', formatDateSeconds(p.datePublished))}
{renderDeetItem(
'published',
p.datePublished ? formatDateSeconds(p.datePublished) : 'n/a',
)}
{renderDeetItem(
'deadlineDuration',
formatDurationSeconds(p.deadlineDuration),
@ -413,6 +443,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
{renderDeetItem('bounty', p.contributionBounty)}
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
{renderDeetItem(
'arbiter',
<>
@ -439,14 +471,34 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</div>
))}
</Card>
{/* TODO: contributors here? */}
</Col>
</Row>
</div>
);
}
private getCancelAndRefundDisabled = () => {
const { proposalDetail: p } = store;
if (!p) {
return true;
}
return (
p.status !== PROPOSAL_STATUS.LIVE ||
p.stage === PROPOSAL_STAGE.FAILED ||
p.stage === PROPOSAL_STAGE.CANCELED ||
p.isFailed
);
};
private handleCancelAndRefundClick = () => {
const disabled = this.getCancelAndRefundDisabled();
if (!disabled) {
if (!this.state.showCancelAndRefundPopover) {
this.setState({ showCancelAndRefundPopover: true });
}
}
};
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
@ -460,9 +512,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
store.deleteProposal(store.proposalDetail.proposalId);
};
private handleCancel = () => {
private handleCancelCancel = () => {
this.setState({ showCancelAndRefundPopover: false });
};
private handleConfirmCancel = () => {
if (!store.proposalDetail) return;
store.cancelProposal(store.proposalDetail.proposalId);
this.setState({ showCancelAndRefundPopover: false });
};
private handleApprove = () => {
@ -479,7 +536,29 @@ class ProposalDetailNaked extends React.Component<Props, State> {
// we lock this to be 1 or 0 for now, we may support more values later on
const contributionMatching =
store.proposalDetail.contributionMatching === 0 ? 1 : 0;
store.updateProposalDetail({ contributionMatching });
await store.updateProposalDetail({ contributionMatching });
message.success('Updated matching');
}
};
private handleSetBounty = async () => {
if (store.proposalDetail) {
FeedbackModal.open({
title: 'Set bounty?',
content:
'Set the bounty for this proposal. The bounty will count towards the funding goal.',
type: 'input',
inputProps: {
addonBefore: 'Amount',
addonAfter: 'ZEC',
placeholder: '1.5',
},
okText: 'Set bounty',
onOk: async contributionBounty => {
await store.updateProposalDetail({ contributionBounty });
message.success('Updated bounty');
},
});
}
};

View File

@ -1,8 +1,7 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Popconfirm, Tag, Tooltip, List } from 'antd';
import { Tag, Tooltip, List } from 'antd';
import { Link } from 'react-router-dom';
import store from 'src/store';
import { Proposal, PROPOSAL_STATUS } from 'src/types';
import { PROPOSAL_STATUSES, PROPOSAL_STAGES, getStatusById } from 'util/statuses';
import { formatDateSeconds } from 'util/time';
@ -16,45 +15,32 @@ class ProposalItemNaked extends React.Component<Proposal> {
const p = this.props;
const status = getStatusById(PROPOSAL_STATUSES, p.status);
const stage = getStatusById(PROPOSAL_STAGES, p.stage);
const actions = [
<Popconfirm
key="delete"
onConfirm={this.handleDelete}
title="Are you sure?"
okText="Delete"
okType="danger"
placement="left"
>
<a>delete</a>
</Popconfirm>,
];
return (
<List.Item key={p.proposalId} className="ProposalItem" actions={actions}>
<List.Item key={p.proposalId} className="ProposalItem">
<Link to={`/proposals/${p.proposalId}`}>
<h2>
{p.title || '(no title)'}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
{p.status === PROPOSAL_STATUS.LIVE &&
{p.status === PROPOSAL_STATUS.LIVE && (
<Tooltip title={stage.hint}>
<Tag color={stage.tagColor}>{stage.tagDisplay}</Tag>
</Tooltip>
}
)}
</h2>
<p>Created: {formatDateSeconds(p.dateCreated)}</p>
<p>{p.brief}</p>
{p.rfp && (
<p>Submitted for RFP: <strong>{p.rfp.title}</strong></p>
<p>
Submitted for RFP: <strong>{p.rfp.title}</strong>
</p>
)}
</Link>
</List.Item>
);
}
private handleDelete = () => {
store.deleteProposal(this.props.proposalId);
};
}
const ProposalItem = view(ProposalItemNaked);

View File

@ -63,6 +63,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">Contributions</span>
</Link>
</Menu.Item>
<Menu.Item key="financials">
<Link to="/financials">
<Icon type="audit" />
<span className="nav-text">Financials</span>
</Link>
</Menu.Item>
<Menu.Item key="emails">
<Link to="/emails">
<Icon type="mail" />

View File

@ -270,7 +270,11 @@ class UserDetailNaked extends React.Component<Props, State> {
</Link>{' '}
at {formatDateMs(c.dateCreated)}
</div>
<Markdown source={c.content} className="UserDetail-comment" />
<Markdown
source={c.content}
reduced
className="UserDetail-comment"
/>
</>
}
/>

View File

@ -74,6 +74,11 @@ async function fetchStats() {
return data;
}
async function fetchFinancials() {
const { data } = await api.get('/admin/financials');
return data;
}
async function fetchUsers(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/users', { params });
return data;
@ -219,6 +224,32 @@ const app = store({
contributionRefundableCount: 0,
},
financialsFetched: false,
financialsFetching: false,
financials: {
grants: {
total: '0',
matching: '0',
bounty: '0',
},
contributions: {
total: '0',
gross: '0',
staking: '0',
funding: '0',
funded: '0',
refunding: '0',
refunded: '0',
donations: '0',
},
payouts: {
total: '0',
due: '0',
paid: '0',
future: '0',
},
},
users: {
page: createDefaultPageData<User>('EMAIL:DESC'),
},
@ -249,6 +280,8 @@ const app = store({
proposalDetailApproving: false,
proposalDetailMarkingMilestonePaid: false,
proposalDetailCanceling: false,
proposalDetailUpdating: false,
proposalDetailUpdated: false,
comments: {
page: createDefaultPageData<Comment>('CREATED:DESC'),
@ -344,6 +377,17 @@ const app = store({
app.statsFetching = false;
},
async fetchFinancials() {
app.financialsFetching = true;
try {
app.financials = await fetchFinancials();
app.financialsFetched = true;
} catch (e) {
handleApiError(e);
}
app.financialsFetching = false;
},
// Users
async fetchUsers() {
@ -466,15 +510,19 @@ const app = store({
if (!app.proposalDetail) {
return;
}
app.proposalDetailUpdating = true;
app.proposalDetailUpdated = false;
try {
const res = await updateProposal({
...updates,
proposalId: app.proposalDetail.proposalId,
});
app.updateProposalInStore(res);
app.proposalDetailUpdated = true;
} catch (e) {
handleApiError(e);
}
app.proposalDetailUpdating = false;
},
async deleteProposal(id: number) {

View File

@ -111,6 +111,8 @@ export interface Proposal {
funded: string;
rejectReason: string;
contributionMatching: number;
contributionBounty: string;
rfpOptIn: null | boolean;
rfp?: RFP;
arbiter: ProposalArbiter;
}
@ -149,6 +151,7 @@ export interface Contribution {
memo: string;
};
staking: boolean;
noRefund: boolean;
refundAddress?: string;
refundTxId?: string;
}

View File

@ -92,6 +92,11 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
display: 'Refundable',
color: '#afd500',
group: 'Refundable',
}, {
id: 'DONATION',
display: 'Donations',
color: '#afd500',
group: 'Donations',
}]);
export const contributionFilters: Filters = {

View File

@ -1,4 +1,5 @@
import Showdown from 'showdown';
import xss from 'xss';
const showdownConverter = new Showdown.Converter({
simplifiedAutoLink: true,
@ -9,6 +10,41 @@ const showdownConverter = new Showdown.Converter({
excludeTrailingPunctuationFromURLs: true,
});
export const mdToHtml = (text: string) => {
return showdownConverter.makeHtml(text);
export const mdToHtml = (text: string, reduced: boolean = false) => {
const html = showdownConverter.makeHtml(text);
return reduced ? xss(html, reducedXssOpts) : xss(html);
};
const reducedXssOpts = {
stripIgnoreTag: true,
whiteList: {
a: ['target', 'href', 'title'],
b: [],
blockquote: [],
br: [],
code: [],
del: [],
em: [],
h4: [],
h5: [],
h6: [],
hr: [],
i: [],
li: [],
ol: [],
p: [],
pre: [],
small: [],
sub: [],
sup: [],
strong: [],
table: ['width', 'border', 'align', 'valign'],
tbody: ['align', 'valign'],
td: ['width', 'rowspan', 'colspan', 'align', 'valign'],
tfoot: ['align', 'valign'],
th: ['width', 'rowspan', 'colspan', 'align', 'valign'],
thead: ['align', 'valign'],
tr: ['rowspan', 'align', 'valign'],
ul: [],
},
};

View File

@ -41,6 +41,16 @@ module.exports = {
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-class-properties',
['import', { libraryName: 'antd', style: true }],
[
'import',
{
libraryName: 'ant-design-pro',
libraryDirectory: 'lib',
style: true,
camel2DashComponentName: false,
},
'antdproimport',
],
],
presets: ['@babel/react', ['@babel/env', { useBuiltIns: 'entry' }]],
},

View File

@ -2631,6 +2631,13 @@ debug@^3.1.0, debug@^3.2.5:
dependencies:
ms "^2.1.1"
debug@^4.1.0, debug@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
dependencies:
ms "^2.1.1"
decamelize@^1.0.0, decamelize@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
@ -2661,11 +2668,12 @@ deep-is@~0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
default-gateway@^2.6.0:
version "2.7.2"
resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.7.2.tgz#b7ef339e5e024b045467af403d50348db4642d0f"
default-gateway@^4.0.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.1.2.tgz#b49196b51b26609e5d1af636287517a11a9aaf42"
integrity sha512-xhJUAp3u02JsBGovj0V6B6uYhKCUOmiNc8xGmReUwGu77NmvcpxPVB0pCielxMFumO7CmXBG02XjM8HB97k8Hw==
dependencies:
execa "^0.10.0"
execa "^1.0.0"
ip-regex "^2.1.0"
define-properties@^1.1.2:
@ -2741,9 +2749,10 @@ detect-libc@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
detect-node@^2.0.3:
detect-node@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
detect-port-alt@1.1.6:
version "1.1.6"
@ -3165,6 +3174,19 @@ execa@^0.9.0:
signal-exit "^3.0.0"
strip-eof "^1.0.0"
execa@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
dependencies:
cross-spawn "^6.0.0"
get-stream "^4.0.0"
is-stream "^1.1.0"
npm-run-path "^2.0.0"
p-finally "^1.0.0"
signal-exit "^3.0.0"
strip-eof "^1.0.0"
exenv@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
@ -3599,6 +3621,13 @@ get-stream@^3.0.0:
version "3.0.0"
resolved "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
get-stream@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
dependencies:
pump "^3.0.0"
get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@ -3750,9 +3779,10 @@ hammerjs@^2.0.8:
resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1"
integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=
handle-thing@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
handle-thing@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754"
integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ==
har-schema@^2.0.0:
version "2.0.0"
@ -3939,18 +3969,20 @@ http-parser-js@>=0.4.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.0.tgz#d65edbede84349d0dc30320815a15d39cc3cbbd8"
http-proxy-middleware@~0.18.0:
version "0.18.0"
resolved "http://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz#0987e6bb5a5606e5a69168d8f967a87f15dd8aab"
http-proxy-middleware@^0.19.1:
version "0.19.1"
resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz#183c7dc4aa1479150306498c210cdaf96080a43a"
integrity sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==
dependencies:
http-proxy "^1.16.2"
http-proxy "^1.17.0"
is-glob "^4.0.0"
lodash "^4.17.5"
micromatch "^3.1.9"
lodash "^4.17.11"
micromatch "^3.1.10"
http-proxy@^1.16.2:
http-proxy@^1.17.0:
version "1.17.0"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a"
integrity sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g==
dependencies:
eventemitter3 "^3.0.0"
follow-redirects "^1.0.0"
@ -4090,12 +4122,13 @@ inquirer@3.3.0:
strip-ansi "^4.0.0"
through "^2.3.6"
internal-ip@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-3.0.1.tgz#df5c99876e1d2eb2ea2d74f520e3f669a00ece27"
internal-ip@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.2.0.tgz#46e81b638d84c338e5c67e42b1a17db67d0814fa"
integrity sha512-ZY8Rk+hlvFeuMmG5uH1MXhhdeMntmIaxaInvAmzMq/SHV8rv4Kh+6GiQNNDQd0wZFrcO+FiTBo8lui/osKOyJw==
dependencies:
default-gateway "^2.6.0"
ipaddr.js "^1.5.2"
default-gateway "^4.0.1"
ipaddr.js "^1.9.0"
interpret@^1.1.0:
version "1.1.0"
@ -4131,9 +4164,10 @@ ipaddr.js@1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e"
ipaddr.js@^1.5.2:
version "1.8.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.1.tgz#fa4b79fa47fd3def5e3b159825161c0a519c9427"
ipaddr.js@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
is-accessor-descriptor@^0.1.6:
version "0.1.6"
@ -4956,7 +4990,7 @@ memoize-one@^5.0.0:
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.0.tgz#d55007dffefb8de7546659a1722a5d42e128286e"
integrity sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw==
memory-fs@^0.4.0, memory-fs@~0.4.1:
memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
dependencies:
@ -4997,7 +5031,7 @@ micromatch@^2.3.11:
parse-glob "^3.0.4"
regex-cache "^0.4.2"
micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8, micromatch@^3.1.9:
micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8:
version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
dependencies:
@ -5460,7 +5494,7 @@ object.values@^1.0.4:
function-bind "^1.1.0"
has "^1.0.1"
obuf@^1.0.0, obuf@^1.1.1:
obuf@^1.0.0, obuf@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
@ -5985,6 +6019,14 @@ pump@^2.0.0, pump@^2.0.1:
end-of-stream "^1.1.0"
once "^1.3.1"
pump@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
pumpify@^1.3.3:
version "1.5.1"
resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
@ -6701,7 +6743,7 @@ read-pkg@^4.0.1:
parse-json "^4.0.0"
pify "^3.0.0"
"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.3, readable-stream@^2.3.6:
"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6:
version "2.3.6"
resolved "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
dependencies:
@ -6722,6 +6764,15 @@ readable-stream@1.0:
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@^3.0.6:
version "3.2.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.2.0.tgz#de17f229864c120a9f56945756e4f32c4045245d"
integrity sha512-RV20kLjdmpZuTF1INEb9IA3L68Nmi+Ri7ppZqo78wj//Pn62fCoJyV9zalccNzDD/OuJpMG4f+pfMl8+L6QdGw==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readdirp@^2.0.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
@ -7135,7 +7186,7 @@ semver-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0:
"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
@ -7417,28 +7468,28 @@ spdx-license-ids@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz#e2a303236cac54b04031fa7a5a79c7e701df852f"
spdy-transport@^2.0.18:
version "2.1.0"
resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.0.tgz#4bbb15aaffed0beefdd56ad61dbdc8ba3e2cb7a1"
spdy-transport@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"
integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==
dependencies:
debug "^2.6.8"
detect-node "^2.0.3"
debug "^4.1.0"
detect-node "^2.0.4"
hpack.js "^2.1.6"
obuf "^1.1.1"
readable-stream "^2.2.9"
safe-buffer "^5.0.1"
wbuf "^1.7.2"
obuf "^1.1.2"
readable-stream "^3.0.6"
wbuf "^1.7.3"
spdy@^3.4.1:
version "3.4.7"
resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
spdy@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.0.tgz#81f222b5a743a329aa12cea6a390e60e9b613c52"
integrity sha512-ot0oEGT/PGUpzf/6uk4AWLqkq+irlqHXkrdbk51oWONh3bxQmBuljxPNl66zlRRcIJStWq0QkLUCPOPjgjvU0Q==
dependencies:
debug "^2.6.8"
handle-thing "^1.2.5"
debug "^4.1.0"
handle-thing "^2.0.0"
http-deceiver "^1.2.7"
safe-buffer "^5.0.1"
select-hose "^2.0.0"
spdy-transport "^2.0.18"
spdy-transport "^3.0.0"
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
@ -7567,6 +7618,13 @@ string_decoder@^1.0.0, string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
string_decoder@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d"
integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==
dependencies:
safe-buffer "~5.1.0"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
@ -7622,12 +7680,19 @@ supports-color@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
supports-color@^5.1.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0:
supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
dependencies:
has-flag "^3.0.0"
supports-color@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
dependencies:
has-flag "^3.0.0"
svgo@^1.0.5:
version "1.1.1"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.1.1.tgz#12384b03335bcecd85cfa5f4e3375fed671cb985"
@ -8077,7 +8142,7 @@ use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
util-deprecate@~1.0.1:
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@ -8188,7 +8253,7 @@ watchpack@^1.5.0:
graceful-fs "^4.1.2"
neo-async "^2.5.0"
wbuf@^1.1.0, wbuf@^1.7.2:
wbuf@^1.1.0, wbuf@^1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
dependencies:
@ -8213,31 +8278,33 @@ webpack-cli@^3.1.0:
v8-compile-cache "^2.0.2"
yargs "^12.0.2"
webpack-dev-middleware@3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.4.0.tgz#1132fecc9026fd90f0ecedac5cbff75d1fb45890"
webpack-dev-middleware@^3.5.1:
version "3.6.0"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.6.0.tgz#71f1b04e52ff8d442757af2be3a658237d53a3e5"
integrity sha512-oeXA3m+5gbYbDBGo4SvKpAHJJEGMoekUbHgo1RK7CP1sz7/WOSeu/dWJtSTk+rzDCLkPwQhGocgIq6lQqOyOwg==
dependencies:
memory-fs "~0.4.1"
memory-fs "^0.4.1"
mime "^2.3.1"
range-parser "^1.0.3"
webpack-log "^2.0.0"
webpack-dev-server@^3.1.8:
version "3.1.10"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.1.10.tgz#507411bee727ee8d2fdffdc621b66a64ab3dea2b"
webpack-dev-server@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.2.1.tgz#1b45ce3ecfc55b6ebe5e36dab2777c02bc508c4e"
integrity sha512-sjuE4mnmx6JOh9kvSbPYw3u/6uxCLHNWfhWaIPwcXWsvWOPN+nc5baq4i9jui3oOBRXGonK9+OI0jVkaz6/rCw==
dependencies:
ansi-html "0.0.7"
bonjour "^3.5.0"
chokidar "^2.0.0"
compression "^1.5.2"
connect-history-api-fallback "^1.3.0"
debug "^3.1.0"
debug "^4.1.1"
del "^3.0.0"
express "^4.16.2"
html-entities "^1.2.0"
http-proxy-middleware "~0.18.0"
http-proxy-middleware "^0.19.1"
import-local "^2.0.0"
internal-ip "^3.0.1"
internal-ip "^4.2.0"
ip "^1.1.5"
killable "^1.0.0"
loglevel "^1.4.1"
@ -8245,13 +8312,15 @@ webpack-dev-server@^3.1.8:
portfinder "^1.0.9"
schema-utils "^1.0.0"
selfsigned "^1.9.1"
semver "^5.6.0"
serve-index "^1.7.2"
sockjs "0.3.19"
sockjs-client "1.3.0"
spdy "^3.4.1"
spdy "^4.0.0"
strip-ansi "^3.0.0"
supports-color "^5.1.0"
webpack-dev-middleware "3.4.0"
supports-color "^6.1.0"
url "^0.11.0"
webpack-dev-middleware "^3.5.1"
webpack-log "^2.0.0"
yargs "12.0.2"
@ -8408,9 +8477,10 @@ xregexp@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020"
xss@1.0.3:
xss@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.3.tgz#d04bd2558fd6c29c46113824d5e8b2a910054e23"
integrity sha512-LTpz3jXPLUphMMmyufoZRSKnqMj41OVypZ8uYGzvjkMV9C1EdACrhQl/EM8Qfh5htSAuMIQFOejmKAZGkJfaCg==
dependencies:
commander "^2.9.0"
cssfilter "0.0.10"

View File

@ -7,6 +7,9 @@ REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"
SENDGRID_API_KEY="optional, but emails won't send without it"
# set this so third-party cookie blocking doesn't kill backend sessions (production)
# SESSION_COOKIE_DOMAIN="zfnd.org"
# SENTRY_DSN="https://PUBLICKEY@sentry.io/PROJECTID"
# SENTRY_RELEASE="optional, provides sentry logging with release info"
@ -26,8 +29,8 @@ BLOCKCHAIN_REST_API_URL="http://localhost:5051"
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
# Blockchain explorer to link to. Top for mainnet, bottom for testnet.
# EXPLORER_URL="https://explorer.zcha.in/"
EXPLORER_URL="https://testnet.zcha.in/"
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
# Amount for staking a proposal in ZEC
PROPOSAL_STAKING_AMOUNT=0.025

View File

@ -1,12 +0,0 @@
Copyright (c) 2018, DevDAO
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of DevDAO nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -147,7 +147,7 @@ To set a user to admin
These instructions are for `development`, for `production` simply replace all hostnames/ips/ports with the proper production hostname.
1. Create Github oauth app https://github.com/settings/developers
1. Create GitHub oauth app https://github.com/settings/developers
1. select tab **OAuth Apps** > click **New OAuth App** button
1. set **Homepage URL** to `http://localhost:3000`
@ -162,13 +162,4 @@ These instructions are for `development`, for `production` simply replace all ho
1. set **Callback URLs** to `http://127.0.0.1:3000/callback/twitter`
1. fill out other required fields
1. after create, select **Keys and tokens** tab
1. save **Consumer API key** and **Consumer API secret key** to `.env` `TWITTER_CLIENT_ID` & `TWITTER_CLIENT_SECRET` respectively.
1. Create Linkedin oauth app https://www.linkedin.com/developer/apps/new
1. set **Website URL** to `http://localhost:3000`
1. fill out other necessary fields & submit
1. select the **Authentication** tab in app details
1. check the **r_basicprofile** box under **Default Application Permissions**
1. Under **OAuth 2.0** > **Authorized Redirect URLs** add `http://localhost:3000/callback/linkedin`
1. click **update** button
1. save **Client ID** and **Client Secret** to `.env` `LINKEDIN_CLIENT_ID` & `LINKEDIN_CLIENT_SECRET` respectively.
1. save **Consumer API key** and **Consumer API secret key** to `.env` `TWITTER_CLIENT_ID` & `TWITTER_CLIENT_SECRET` respectively.

View File

@ -1,14 +1,5 @@
# -*- coding: utf-8 -*-
"""Create an application instance."""
from grant.app import create_app
from grant.blockchain.bootstrap import send_bootstrap_data
app = create_app()
@app.before_first_request
def bootstrap_watcher():
try:
send_bootstrap_data()
except:
print('Failed to send bootstrap data, watcher must be offline')

View File

@ -37,6 +37,7 @@ class FakeUpdate(object):
user = FakeUser()
proposal = FakeProposal()
milestone = FakeMilestone()
contribution = FakeContribution()
update = FakeUpdate()
@ -126,6 +127,13 @@ example_email_args = {
'refund_address': 'ztqdzvnK2SE27FCWg69EdissCBn7twnfd1XWLrftiZaT4rSFCkp7eQGQDSWXBF43sM5cyA4c8qyVjBP9Cf4zTcFJxf71ve8',
'account_settings_url': 'http://accountsettingsurl.com/',
},
'contribution_expired': {
'proposal': proposal,
'contribution': contribution,
'contact_url': 'http://somecontacturl.com',
'profile_url': 'http://someprofile.com',
'proposal_url': 'http://someproposal.com',
},
'comment_reply': {
'author': user,
'proposal': proposal,
@ -153,6 +161,7 @@ example_email_args = {
},
'milestone_paid': {
'proposal': proposal,
'milestone': milestone,
'amount': '33',
'tx_explorer_url': 'http://someblockexplorer.com/tx/271857129857192579125',
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',

View File

@ -1,13 +1,18 @@
from functools import reduce
from flask import Blueprint, request, session
from flask_yoloapi import endpoint, parameter
from decimal import Decimal
from datetime import datetime
from sqlalchemy import text
from decimal import Decimal
from functools import reduce
from flask import Blueprint, request
from marshmallow import fields, validate
from sqlalchemy import func, or_, text
import grant.utils.admin as admin
import grant.utils.auth as auth
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
from grant.email.send import generate_email, send_email
from grant.extensions import db
from grant.milestone.models import Milestone
from grant.parser import body, query, paginated_fields
from grant.proposal.models import (
Proposal,
ProposalArbiter,
@ -18,12 +23,10 @@ from grant.proposal.models import (
admin_proposal_contribution_schema,
admin_proposal_contributions_schema,
)
from grant.milestone.models import Milestone
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
import grant.utils.admin as admin
import grant.utils.auth as auth
from grant.utils.misc import make_url
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
from grant.utils import pagination
from grant.utils.enums import Category
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
@ -32,10 +35,7 @@ from grant.utils.enums import (
MilestoneStage,
RFPStatus,
)
from grant.utils import pagination
from grant.settings import EXPLORER_URL
from sqlalchemy import func, or_
from grant.utils.misc import make_url, make_explore_url
from .example_emails import example_email_args
blueprint = Blueprint('admin', __name__, url_prefix='/api/v1/admin')
@ -59,16 +59,15 @@ def make_login_state():
@blueprint.route("/checklogin", methods=["GET"])
@endpoint.api()
def loggedin():
return make_login_state()
@blueprint.route("/login", methods=["POST"])
@endpoint.api(
parameter('username', type=str, required=False),
parameter('password', type=str, required=False),
)
@body({
"username": fields.Str(required=False, missing=None),
"password": fields.Str(required=False, missing=None)
})
def login(username, password):
if auth.auth_user(username, password):
if admin.admin_is_authed():
@ -77,9 +76,9 @@ def login(username, password):
@blueprint.route("/refresh", methods=["POST"])
@endpoint.api(
parameter('password', type=str, required=True),
)
@body({
"password": fields.Str(required=True)
})
def refresh(password):
if auth.refresh_auth(password):
return make_login_state()
@ -88,7 +87,6 @@ def refresh(password):
@blueprint.route("/2fa", methods=["GET"])
@endpoint.api()
def get_2fa():
if not admin.admin_is_authed():
return {"message": "Must be authenticated"}, 403
@ -96,18 +94,17 @@ def get_2fa():
@blueprint.route("/2fa/init", methods=["GET"])
@endpoint.api()
def get_2fa_init():
admin.throw_on_2fa_not_allowed()
return admin.make_2fa_setup()
@blueprint.route("/2fa/enable", methods=["POST"])
@endpoint.api(
parameter('backupCodes', type=list, required=True),
parameter('totpSecret', type=str, required=True),
parameter('verifyCode', type=str, required=True),
)
@body({
"backupCodes": fields.List(fields.Str(), required=True),
"totpSecret": fields.Str(required=True),
"verifyCode": fields.Str(required=True)
})
def post_2fa_enable(backup_codes, totp_secret, verify_code):
admin.throw_on_2fa_not_allowed()
admin.check_and_set_2fa_setup(backup_codes, totp_secret, verify_code)
@ -116,9 +113,9 @@ def post_2fa_enable(backup_codes, totp_secret, verify_code):
@blueprint.route("/2fa/verify", methods=["POST"])
@endpoint.api(
parameter('verifyCode', type=str, required=True),
)
@body({
"verifyCode": fields.Str(required=True)
})
def post_2fa_verify(verify_code):
admin.throw_on_2fa_not_allowed(allow_stale=True)
admin.admin_auth_2fa(verify_code)
@ -127,7 +124,6 @@ def post_2fa_verify(verify_code):
@blueprint.route("/logout", methods=["GET"])
@endpoint.api()
def logout():
admin.logout()
return {
@ -137,7 +133,6 @@ def logout():
@blueprint.route("/stats", methods=["GET"])
@endpoint.api()
@admin.admin_auth_required
def stats():
user_count = db.session.query(func.count(User.id)).scalar()
@ -159,6 +154,7 @@ def stats():
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
@ -183,7 +179,6 @@ def stats():
@blueprint.route('/users/<user_id>', methods=['DELETE'])
@endpoint.api()
@admin.admin_auth_required
def delete_user(user_id):
user = User.query.filter(User.id == user_id).first()
@ -192,16 +187,11 @@ def delete_user(user_id):
db.session.delete(user)
db.session.commit()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/users", methods=["GET"])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@query(paginated_fields)
@admin.admin_auth_required
def get_users(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
@ -217,7 +207,6 @@ def get_users(page, filters, search, sort):
@blueprint.route('/users/<id>', methods=['GET'])
@endpoint.api()
@admin.admin_auth_required
def get_user(id):
user_db = User.query.filter(User.id == id).first()
@ -235,12 +224,12 @@ def get_user(id):
@blueprint.route('/users/<user_id>', methods=['PUT'])
@endpoint.api(
parameter('silenced', type=bool, required=False),
parameter('banned', type=bool, required=False),
parameter('bannedReason', type=str, required=False),
parameter('isAdmin', type=bool, required=False)
)
@body({
"silenced": fields.Bool(required=False, missing=None),
"banned": fields.Bool(required=False, missing=None),
"bannedReason": fields.Str(required=False, missing=None),
"isAdmin": fields.Bool(required=False, missing=None),
})
@admin.admin_auth_required
def edit_user(user_id, silenced, banned, banned_reason, is_admin):
user = User.query.filter(User.id == user_id).first()
@ -266,9 +255,9 @@ def edit_user(user_id, silenced, banned, banned_reason, is_admin):
@blueprint.route("/arbiters", methods=["GET"])
@endpoint.api(
parameter('search', type=str, required=False),
)
@query({
"search": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def get_arbiters(search):
results = []
@ -289,10 +278,10 @@ def get_arbiters(search):
@blueprint.route('/arbiters', methods=['PUT'])
@endpoint.api(
parameter('proposalId', type=int, required=True),
parameter('userId', type=int, required=True)
)
@body({
"proposalId": fields.Int(required=True),
"userId": fields.Int(required=True),
})
@admin.admin_auth_required
def set_arbiter(proposal_id, user_id):
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
@ -332,12 +321,7 @@ def set_arbiter(proposal_id, user_id):
@blueprint.route("/proposals", methods=["GET"])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@query(paginated_fields)
@admin.admin_auth_required
def get_proposals(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
@ -353,7 +337,6 @@ def get_proposals(page, filters, search, sort):
@blueprint.route('/proposals/<id>', methods=['GET'])
@endpoint.api()
@admin.admin_auth_required
def get_proposal(id):
proposal = Proposal.query.filter(Proposal.id == id).first()
@ -363,18 +346,18 @@ def get_proposal(id):
@blueprint.route('/proposals/<id>', methods=['DELETE'])
@endpoint.api()
@admin.admin_auth_required
def delete_proposal(id):
return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>', methods=['PUT'])
@endpoint.api(
parameter('contributionMatching', type=float, required=False, default=None)
)
@body({
"contributionMatching": fields.Int(required=False, missing=None),
"contributionBounty": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def update_proposal(id, contribution_matching):
def update_proposal(id, contribution_matching, contribution_bounty):
proposal = Proposal.query.filter(Proposal.id == id).first()
if not proposal:
return {"message": f"Could not find proposal with id {id}"}, 404
@ -382,6 +365,9 @@ def update_proposal(id, contribution_matching):
if contribution_matching is not None:
proposal.set_contribution_matching(contribution_matching)
if contribution_bounty is not None:
proposal.set_contribution_bounty(contribution_bounty)
db.session.add(proposal)
db.session.commit()
@ -389,10 +375,10 @@ def update_proposal(id, contribution_matching):
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
@endpoint.api(
parameter('isApprove', type=bool, required=True),
parameter('rejectReason', type=str, required=False)
)
@body({
"isApprove": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def approve_proposal(id, is_approve, reject_reason=None):
proposal = Proposal.query.filter_by(id=id).first()
@ -405,7 +391,6 @@ def approve_proposal(id, is_approve, reject_reason=None):
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
@endpoint.api()
@admin.admin_auth_required
def cancel_proposal(id):
proposal = Proposal.query.filter_by(id=id).first()
@ -419,9 +404,9 @@ def cancel_proposal(id):
@blueprint.route("/proposals/<id>/milestone/<mid>/paid", methods=["PUT"])
@endpoint.api(
parameter('txId', type=str, required=True),
)
@body({
"txId": fields.Str(required=True),
})
@admin.admin_auth_required
def paid_milestone_payout_request(id, mid, tx_id):
proposal = Proposal.query.filter_by(id=id).first()
@ -446,8 +431,9 @@ def paid_milestone_payout_request(id, mid, tx_id):
for member in proposal.team:
send_email(member.email_address, 'milestone_paid', {
'proposal': proposal,
'milestone': ms,
'amount': amount,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{tx_id}',
'tx_explorer_url': make_explore_url(tx_id),
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
})
return proposal_schema.dump(proposal), 200
@ -459,7 +445,6 @@ def paid_milestone_payout_request(id, mid, tx_id):
@blueprint.route('/email/example/<type>', methods=['GET'])
@endpoint.api()
@admin.admin_auth_required
def get_email_example(type):
email = generate_email(type, example_email_args.get(type))
@ -473,7 +458,6 @@ def get_email_example(type):
@blueprint.route('/rfps', methods=['GET'])
@endpoint.api()
@admin.admin_auth_required
def get_rfps():
rfps = RFP.query.all()
@ -481,15 +465,15 @@ def get_rfps():
@blueprint.route('/rfps', methods=['POST'])
@endpoint.api(
parameter('title', type=str),
parameter('brief', type=str),
parameter('content', type=str),
parameter('category', type=str),
parameter('bounty', type=str),
parameter('matching', type=bool, default=False),
parameter('dateCloses', type=int),
)
@body({
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"content": fields.Str(required=True),
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
"bounty": fields.Str(required=False, missing=0),
"matching": fields.Bool(required=False, missing=False),
"dateCloses": fields.Int(required=True)
})
@admin.admin_auth_required
def create_rfp(date_closes, **kwargs):
rfp = RFP(
@ -498,11 +482,10 @@ def create_rfp(date_closes, **kwargs):
)
db.session.add(rfp)
db.session.commit()
return admin_rfp_schema.dump(rfp), 201
return admin_rfp_schema.dump(rfp), 200
@blueprint.route('/rfps/<rfp_id>', methods=['GET'])
@endpoint.api()
@admin.admin_auth_required
def get_rfp(rfp_id):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
@ -513,16 +496,16 @@ def get_rfp(rfp_id):
@blueprint.route('/rfps/<rfp_id>', methods=['PUT'])
@endpoint.api(
parameter('title', type=str),
parameter('brief', type=str),
parameter('content', type=str),
parameter('category', type=str),
parameter('bounty', type=str),
parameter('matching', type=bool, default=False),
parameter('dateCloses', type=int),
parameter('status', type=str),
)
@body({
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
"content": fields.Str(required=True),
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
"bounty": fields.Str(required=False, allow_none=True, missing=None),
"matching": fields.Bool(required=False, default=False, missing=False),
"dateCloses": fields.Int(required=False, missing=None),
})
@admin.admin_auth_required
def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
@ -534,8 +517,8 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c
rfp.brief = brief
rfp.content = content
rfp.category = category
rfp.bounty = bounty
rfp.matching = matching
rfp.bounty = bounty
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
# Update timestamps if status changed
@ -552,7 +535,6 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c
@blueprint.route('/rfps/<rfp_id>', methods=['DELETE'])
@endpoint.api()
@admin.admin_auth_required
def delete_rfp(rfp_id):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
@ -561,19 +543,14 @@ def delete_rfp(rfp_id):
db.session.delete(rfp)
db.session.commit()
return None, 200
return {"message": "ok"}, 200
# Contributions
@blueprint.route('/contributions', methods=['GET'])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@query(paginated_fields)
@admin.admin_auth_required
def get_contributions(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
@ -588,13 +565,13 @@ def get_contributions(page, filters, search, sort):
@blueprint.route('/contributions', methods=['POST'])
@endpoint.api(
parameter('proposalId', type=int, required=True),
parameter('userId', type=int, required=False, default=None),
parameter('status', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txId', type=str, required=False),
)
@body({
"proposalId": fields.Int(required=True),
"userId": fields.Int(required=True),
"status": fields.Str(required=True, validate=validate.OneOf(choices=ContributionStatus.list())),
"amount": fields.Str(required=True),
"txId": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def create_contribution(proposal_id, user_id, status, amount, tx_id):
# Some fields set manually since we're admin, and normally don't do this
@ -617,7 +594,6 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
@blueprint.route('/contributions/<contribution_id>', methods=['GET'])
@endpoint.api()
@admin.admin_auth_required
def get_contribution(contribution_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
@ -628,14 +604,14 @@ def get_contribution(contribution_id):
@blueprint.route('/contributions/<contribution_id>', methods=['PUT'])
@endpoint.api(
parameter('proposalId', type=int, required=False),
parameter('userId', type=int, required=False),
parameter('status', type=str, required=False),
parameter('amount', type=str, required=False),
parameter('txId', type=str, required=False),
parameter('refundTxId', type=str, required=False),
)
@body({
"proposalId": fields.Int(required=False, missing=None),
"userId": fields.Int(required=False, missing=None),
"status": fields.Str(required=True, validate=validate.OneOf(choices=ContributionStatus.list())),
"amount": fields.Str(required=False, missing=None),
"txId": fields.Str(required=False, missing=None),
"refundTxId": fields.Str(required=False, allow_none=True, missing=None),
})
@admin.admin_auth_required
def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id, refund_tx_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
@ -694,12 +670,7 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
@blueprint.route('/comments', methods=['GET'])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@body(paginated_fields)
@admin.admin_auth_required
def get_comments(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
@ -714,10 +685,10 @@ def get_comments(page, filters, search, sort):
@blueprint.route('/comments/<comment_id>', methods=['PUT'])
@endpoint.api(
parameter('hidden', type=bool, required=False),
parameter('reported', type=bool, required=False),
)
@body({
"hidden": fields.Bool(required=False, missing=None),
"reported": fields.Bool(required=False, missing=None),
})
@admin.admin_auth_required
def edit_comment(comment_id, hidden, reported):
comment = Comment.query.filter(Comment.id == comment_id).first()
@ -732,3 +703,102 @@ def edit_comment(comment_id, hidden, reported):
db.session.commit()
return admin_comment_schema.dump(comment)
# Financials
@blueprint.route("/financials", methods=["GET"])
@admin.admin_auth_required
def financials():
nfmt = '999999.99999999' # smallest unit of ZEC
def sql_pc(where: str):
return f"SELECT SUM(TO_NUMBER(amount, '{nfmt}')) FROM proposal_contribution WHERE {where}"
def sql_pc_p(where: str):
return f'''
SELECT SUM(TO_NUMBER(amount, '{nfmt}'))
FROM proposal_contribution as pc
INNER JOIN proposal as p ON pc.proposal_id = p.id
WHERE {where}
'''
def sql_ms(where: str):
return f'''
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
FROM milestone as ms
INNER JOIN proposal as p ON ms.proposal_id = p.id
WHERE {where}
'''
def ex(sql: str):
res = db.engine.execute(text(sql))
return [row[0] if row[0] else Decimal(0) for row in res][0].normalize()
contributions = {
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
'refunding': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NULL AND p.stage IN ('CANCELED', 'FAILED')"
))),
'refunded': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NOT NULL AND p.stage IN ('CANCELED', 'FAILED')"
))),
'donations': str(ex(sql_pc_p(
"(pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.refund_tx_id IS NULL) AND (pc.no_refund = TRUE OR pc.user_id IS NULL) AND p.stage IN ('CANCELED', 'FAILED')"
))),
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
}
po_due = ex(sql_ms("ms.stage = 'ACCEPTED'")) # payments accepted but not yet marked as paid
po_paid = ex(sql_ms("ms.stage = 'PAID'")) # will catch paid ms from all proposals regardless of status/stage
# expected payments
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
payouts = {
'total': str(po_total),
'due': str(po_due),
'paid': str(po_paid),
'future': str(po_future),
}
grants = {
'total': '0',
'matching': '0',
'bounty': '0',
}
def add_str_dec(a: str, b: str):
return str(Decimal(a) + Decimal(b))
proposals = Proposal.query.all()
for p in proposals:
# CANCELED proposals excluded, though they could have had milestones paid out with grant funds
if p.stage in [ProposalStage.WIP, ProposalStage.COMPLETED]:
# matching
matching = Decimal(p.contributed) * Decimal(p.contribution_matching)
remaining = Decimal(p.target) - Decimal(p.contributed)
if matching > remaining:
matching = remaining
# bounty
bounty = Decimal(p.contribution_bounty)
remaining = Decimal(p.target) - (matching + Decimal(p.contributed))
if bounty > remaining:
bounty = remaining
grants['matching'] = add_str_dec(grants['matching'], matching)
grants['bounty'] = add_str_dec(grants['bounty'], bounty)
grants['total'] = add_str_dec(grants['total'], matching + bounty)
return {
'grants': grants,
'contributions': contributions,
'payouts': payouts,
'net': str(Decimal(contributions['gross']) - Decimal(payouts['paid']))
}

View File

@ -1,19 +1,84 @@
# -*- coding: utf-8 -*-
"""The app module, containing the app factory function."""
import sentry_sdk
from flask import Flask
import logging
import traceback
from animal_case import animalify
from flask import Flask, Response, jsonify, request, current_app, g
from flask_cors import CORS
from flask_security import SQLAlchemyUserDatastore
from flask_sslify import SSLify
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp
from grant.extensions import bcrypt, migrate, db, ma, security
from grant.settings import SENTRY_RELEASE, ENV
from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
from grant.utils.exceptions import ValidationException
class JSONResponse(Response):
@classmethod
def force_type(cls, rv, environ=None):
if isinstance(rv, dict) or isinstance(rv, list) or isinstance(rv, tuple):
rv = jsonify(animalify(rv))
elif rv is None:
rv = jsonify(data=None), 204
return super(JSONResponse, cls).force_type(rv, environ)
def create_app(config_objects=["grant.settings"]):
app = Flask(__name__.split(".")[0])
app.response_class = JSONResponse
@app.after_request
def send_emails(response):
if 'email_sender' in g:
# starting email sender
g.email_sender.start()
return response
# Return validation errors
@app.errorhandler(ValidationException)
def handle_validation_error(err):
return jsonify({"message": str(err)}), 400
@app.errorhandler(422)
@app.errorhandler(400)
def handle_error(err):
headers = err.data.get("headers", None)
messages = err.data.get("messages", "Invalid request.")
error_message = "Something was wrong with your request"
if type(messages) == dict:
if 'json' in messages:
error_message = messages['json'][0]
else:
current_app.logger.warn(
f"Unexpected error occurred: {messages}"
)
if headers:
return jsonify({"message": error_message}), err.code, headers
else:
return jsonify({"message": error_message}), err.code
@app.errorhandler(404)
def handle_notfound_error(err):
error_message = "Unknown route '{} {}'".format(request.method, request.path)
return jsonify({"message": error_message}), 404
@app.errorhandler(429)
def handle_limit_error(err):
app.logger.warn(f'Rate limited request to {request.method} {request.path} from ip {request.remote_addr}')
return jsonify({"message": "Youve done that too many times, please wait and try again later"}), 429
@app.errorhandler(Exception)
def handle_exception(err):
sentry_sdk.capture_exception(err)
app.logger.debug(traceback.format_exc())
app.logger.debug("Uncaught exception at {} {}, see above for traceback".format(request.method, request.path))
return jsonify({"message": "Something went wrong"}), 500
for conf in config_objects:
app.config.from_object(conf)
app.url_map.strict_slashes = False
@ -22,11 +87,15 @@ def create_app(config_objects=["grant.settings"]):
register_shellcontext(app)
register_commands(app)
if not app.config.get("TESTING"):
if not (app.config.get("TESTING") or E2E_TESTING):
sentry_logging = LoggingIntegration(
level=logging.INFO,
event_level=logging.ERROR
)
sentry_sdk.init(
environment=ENV,
release=SENTRY_RELEASE,
integrations=[FlaskIntegration()]
integrations=[FlaskIntegration(), sentry_logging]
)
# handle all AuthExceptions thusly
@ -47,6 +116,7 @@ def register_extensions(app):
db.init_app(app)
migrate.init_app(app, db)
ma.init_app(app)
limiter.init_app(app)
user_datastore = SQLAlchemyUserDatastore(db, user.models.User, user.models.Role)
security.init_app(app, datastore=user_datastore, register_blueprint=False)
@ -67,6 +137,9 @@ def register_blueprints(app):
app.register_blueprint(blockchain.views.blueprint)
app.register_blueprint(task.views.blueprint)
app.register_blueprint(rfp.views.blueprint)
if E2E_TESTING and DEBUG:
print('Warning: e2e end-points are open, this should only be the case for development or testing')
app.register_blueprint(e2e.views.blueprint)
def register_shellcontext(app):
@ -85,7 +158,6 @@ def register_commands(app):
app.cli.add_command(commands.lint)
app.cli.add_command(commands.clean)
app.cli.add_command(commands.urls)
app.cli.add_command(proposal.commands.create_proposal)
app.cli.add_command(proposal.commands.create_proposals)
app.cli.add_command(user.commands.set_admin)

View File

@ -1,9 +1,6 @@
from datetime import datetime, timedelta
from grant.proposal.models import (
ProposalContribution,
proposal_contributions_schema,
)
from grant.proposal.models import ProposalContribution
from grant.utils.requests import blockchain_post
from grant.utils.enums import ContributionStatus
@ -17,8 +14,9 @@ def make_bootstrap_data():
.filter_by(status=ContributionStatus.CONFIRMED) \
.order_by(ProposalContribution.date_created.desc()) \
.first()
serialized_pending_contributions = list(map(lambda c: {"id": c.id}, pending_contributions))
return {
"pendingContributions": proposal_contributions_schema.dump(pending_contributions),
"pendingContributions": serialized_pending_contributions,
"latestTxId": latest_contribution.tx_id if latest_contribution else None,
}

View File

@ -1,5 +1,5 @@
from flask import Blueprint
from flask_yoloapi import endpoint
from flask import Blueprint, current_app
from grant.blockchain.bootstrap import send_bootstrap_data
from grant.utils.auth import internal_webhook
@ -8,8 +8,7 @@ blueprint = Blueprint("blockchain", __name__, url_prefix="/api/v1/blockchain")
@blueprint.route("/bootstrap", methods=["GET"])
@internal_webhook
@endpoint.api()
def get_bootstrap_info():
print('Bootstrap data requested from blockchain watcher microservice...')
current_app.logger.info('Bootstrap data requested from blockchain watcher microservice...')
send_bootstrap_data()
return True
return {"message": "ok"}, 200

View File

@ -3,6 +3,7 @@ import datetime
from functools import reduce
from grant.extensions import ma, db
from grant.utils.ma_fields import UnixDate
from grant.utils.misc import gen_random_id
from sqlalchemy.orm import raiseload
HIDDEN_CONTENT = '~~comment removed by admin~~'
@ -25,6 +26,7 @@ class Comment(db.Model):
replies = db.relationship("Comment")
def __init__(self, proposal_id, user_id, parent_comment_id, content):
self.id = gen_random_id(Comment)
self.proposal_id = proposal_id
self.user_id = user_id
self.parent_comment_id = parent_comment_id

View File

@ -1,14 +1,4 @@
from flask import Blueprint
from flask_yoloapi import endpoint
from .models import Comment, comments_schema
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
# Unused
# @blueprint.route("/", methods=["GET"])
# @endpoint.api()
# def get_comments():
# all_comments = Comment.query.all()
# result = comments_schema.dump(all_comments)
# return result

View File

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

150
backend/grant/e2e/views.py Normal file
View File

@ -0,0 +1,150 @@
from datetime import datetime
from random import randint
from math import floor
from flask import Blueprint
from grant.comment.models import Comment
from grant.extensions import db
from grant.milestone.models import Milestone
from grant.proposal.models import (
Proposal,
ProposalArbiter,
ProposalContribution,
)
from grant.user.models import User, admin_user_schema
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
Category,
ContributionStatus,
)
last_email = None
def create_proposals(count, category, stage, with_comments=False):
user = User.query.filter_by().first()
for i in range(count):
p = Proposal.create(
stage=stage,
status=ProposalStatus.LIVE,
title=f'Fake Proposal #{i} {category} {stage}',
content=f'My fake proposal content, numero {i}',
brief=f'This is proposal {i} generated by e2e testing',
category=category,
target="123.456",
payout_address="fake123",
deadline_duration=100
)
p.date_published = datetime.now()
p.team.append(user)
db.session.add(p)
db.session.flush()
num_ms = randint(1, 9)
for j in range(num_ms):
m = Milestone(
title=f'Fake MS {j}',
content=f'Fake milestone #{j} on fake proposal #{i} {category} {stage}!',
date_estimated=datetime.now(),
payout_percent=str(floor(1 / num_ms * 100)),
immediate_payout=j == 0,
proposal_id=p.id,
index=j
)
db.session.add(m)
# limit comment creation as it is slow
if i == 0 and with_comments:
for j in range(31):
c = Comment(
proposal_id=p.id,
user_id=user.id,
parent_comment_id=None,
content=f'Fake comment #{j} on fake proposal #{i} {category} {stage}!'
)
db.session.add(c)
if stage == ProposalStage.FUNDING_REQUIRED:
stake = p.create_contribution('1', None, True)
stake.confirm('fakestaketxid', '1')
db.session.add(stake)
db.session.flush()
fund = p.create_contribution('100', None, False)
fund.confirm('fakefundtxid0', '100')
db.session.add(fund)
db.session.flush()
p.status = ProposalStatus.LIVE
db.session.add(p)
db.session.flush()
# db.session.flush()
def create_user(key: str):
pw = f"e2epassword{key}"
user = User.create(
email_address=f"{key}@testing.e2e",
password=pw,
display_name=f"{key} Endtoenderson",
title=f"title{key}",
)
user.email_verification.has_verified = True
db.session.add(user)
db.session.flush()
dump = admin_user_schema.dump(user)
dump['password'] = pw
return dump
blueprint = Blueprint('e2e', __name__, url_prefix='/api/v1/e2e')
@blueprint.route("/setup", methods=["GET"])
def setup():
db.session.commit() # important, otherwise drop_all hangs
db.drop_all()
db.session.commit()
db.create_all()
db.session.commit()
default_user = create_user('default')
other_user = create_user('other')
create_proposals(12, Category.COMMUNITY, ProposalStage.FUNDING_REQUIRED, True)
create_proposals(13, Category.CORE_DEV, ProposalStage.WIP, False)
create_proposals(15, Category.DOCUMENTATION, ProposalStage.COMPLETED)
create_proposals(5, Category.ACCESSIBILITY, ProposalStage.FAILED)
db.session.commit()
return {
'default_user': default_user,
'other_user': other_user,
'proposalCounts': {
'categories': [
{"key": Category.COMMUNITY, "count": 12},
{"key": Category.CORE_DEV, "count": 13},
{"key": Category.DOCUMENTATION, "count": 15},
{"key": Category.ACCESSIBILITY, "count": 5},
],
'stages': [
{"key": ProposalStage.FUNDING_REQUIRED, "count": 12},
{"key": ProposalStage.WIP, "count": 13},
{"key": ProposalStage.COMPLETED, "count": 15},
{"key": ProposalStage.FAILED, "count": 5},
]
}
}
@blueprint.route("/email", methods=["GET"])
def get_email():
return last_email
@blueprint.route("/contribution/confirm", methods=["GET"])
def confirm_contributions():
contributions = ProposalContribution.query \
.filter(ProposalContribution.status == ContributionStatus.PENDING).all()
for c in contributions:
c.confirm('fakefundedtxid1', '23.456')
db.session.add(c)
c.proposal.set_funded_when_ready()
db.session.commit()
return {}

View File

@ -1,11 +1,14 @@
import sendgrid
from flask import render_template, Markup, current_app
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI
from grant.utils.misc import make_url
from python_http_client import HTTPError
from sendgrid.helpers.mail import Email, Mail, Content
from .subscription_settings import EmailSubscription, is_subscribed
from sendgrid.helpers.mail import Email, Mail, Content
from python_http_client import HTTPError
from grant.utils.misc import make_url
from sentry_sdk import capture_exception
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
import sendgrid
from threading import Thread
from flask import render_template, Markup, current_app, g
default_template_args = {
'home_url': make_url('/'),
@ -33,8 +36,8 @@ def team_invite_info(email_args):
def recover_info(email_args):
return {
'subject': '{} account recovery'.format(UI['NAME']),
'title': '{} account recovery'.format(UI['NAME']),
'subject': 'Recover your account',
'title': 'Recover your account',
'preview': 'Use the link to recover your account.'
}
@ -199,6 +202,18 @@ def contribution_proposal_canceled(email_args):
}
def contribution_expired(email_args):
return {
'subject': 'Your contribution expired',
'title': 'Contribution expired',
'preview': 'Your {} ZEC contribution to {} could not be confirmed, and has expired'.format(
email_args['contribution'].amount,
email_args['proposal'].title,
),
'subscription': EmailSubscription.FUNDED_PROPOSAL_CONTRIBUTION,
}
def comment_reply(email_args):
return {
'subject': 'New reply from {}'.format(email_args['author'].display_name),
@ -254,7 +269,7 @@ def milestone_accept(email_args):
def milestone_paid(email_args):
p = email_args['proposal']
a = email_args['amount']
ms = p.current_milestone
ms = email_args['milestone']
return {
'subject': f'{p.title} - {ms.title} has been paid!',
'title': f'Milestone paid',
@ -282,6 +297,7 @@ get_info_lookup = {
'contribution_refunded': contribution_refunded,
'contribution_proposal_failed': contribution_proposal_failed,
'contribution_proposal_canceled': contribution_proposal_canceled,
'contribution_expired': contribution_expired,
'comment_reply': comment_reply,
'proposal_arbiter': proposal_arbiter,
'milestone_request': milestone_request,
@ -304,11 +320,10 @@ def generate_email(type, email_args, user=None):
UI=UI,
)
template_args = { **default_template_args }
template_args = {**default_template_args}
if user:
template_args['unsubscribe_url'] = make_url('/email/unsubscribe?code={}'.format(user.email_verification.code))
html = render_template(
'emails/template.html',
args={
@ -336,8 +351,14 @@ def generate_email(type, email_args, user=None):
def send_email(to, type, email_args):
if 'email_sender' not in g:
g.email_sender = EmailSender(current_app._get_current_object())
g.email_sender.add(to, type, email_args)
def make_envelope(to, type, email_args):
if current_app and current_app.config.get("TESTING"):
return
return None
from grant.user.models import User
user = User.get_by_email(to)
@ -346,25 +367,60 @@ def send_email(to, type, email_args):
if user and 'subscription' in info:
sub = info['subscription']
if user and not is_subscribed(user.settings.email_subscriptions, sub):
print(f'Ignoring send_email to {to} of type {type} because user is unsubscribed.')
return
current_app.logger.debug(f'Ignoring send_email to {to} of type {type} because user is unsubscribed.')
return None
email = generate_email(type, email_args, user)
mail = Mail(
from_email=Email(SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME),
to_email=Email(to),
subject=email['info']['subject'],
)
mail.add_content(Content('text/plain', email['text']))
mail.add_content(Content('text/html', email['html']))
mail.___type = type
mail.___to = to
return mail
def sendgrid_send(mail, app=current_app):
to = mail.___to
type = mail.___type
try:
email = generate_email(type, email_args, user)
sg = sendgrid.SendGridAPIClient(apikey=SENDGRID_API_KEY)
mail = Mail(
from_email=Email(SENDGRID_DEFAULT_FROM),
to_email=Email(to),
subject=email['info']['subject'],
)
mail.add_content(Content('text/plain', email['text']))
mail.add_content(Content('text/html', email['html']))
res = sg.client.mail.send.post(request_body=mail.get())
print('Just sent an email to %s of type %s, response code: %s' % (to, type, res.status_code))
if E2E_TESTING:
from grant.e2e import views
views.last_email = mail.get()
app.logger.info(f'Just set last_email for e2e to pickup, to: {to}, type: {type}')
else:
res = sg.client.mail.send.post(request_body=mail.get())
app.logger.info('Just sent an email to %s of type %s, response code: %s' %
(to, type, res.status_code))
except HTTPError as e:
print('An HTTP error occured while sending an email to %s - %s: %s' % (to, e.__class__.__name__, e))
print(e.body)
app.logger.info('An HTTP error occured while sending an email to %s - %s: %s' %
(to, e.__class__.__name__, e))
app.logger.debug(e.body)
capture_exception(e)
except Exception as e:
print('An unknown error occured while sending an email to %s - %s: %s' % (to, e.__class__.__name__, e))
app.logger.info('An unknown error occured while sending an email to %s - %s: %s' %
(to, e.__class__.__name__, e))
app.logger.debug(e)
capture_exception(e)
class EmailSender(Thread):
def __init__(self, app):
Thread.__init__(self)
self.envelopes = []
self.app = app
def add(self, to, type, email_args):
env = make_envelope(to, type, email_args)
if env:
self.envelopes.append(env)
def run(self):
for envelope in self.envelopes:
sendgrid_send(envelope, self.app)

View File

@ -1,14 +1,11 @@
from flask import Blueprint
from flask_yoloapi import endpoint
from .models import EmailVerification, db
from grant.utils.enums import ProposalArbiterStatus
blueprint = Blueprint("email", __name__, url_prefix="/api/v1/email")
@blueprint.route("/<code>/verify", methods=["POST"])
@endpoint.api()
def verify_email(code):
ev = EmailVerification.query.filter_by(code=code).first()
if ev:
@ -20,7 +17,6 @@ def verify_email(code):
@blueprint.route("/<code>/unsubscribe", methods=["POST"])
@endpoint.api()
def unsubscribe_email(code):
ev = EmailVerification.query.filter_by(code=code).first()
if ev:
@ -32,7 +28,6 @@ def unsubscribe_email(code):
@blueprint.route("/<code>/arbiter/<proposal_id>", methods=["POST"])
@endpoint.api()
def accept_arbiter(code, proposal_id):
ev = EmailVerification.query.filter_by(code=code).first()
if ev:

View File

@ -5,9 +5,12 @@ from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_security import Security
from flask_sqlalchemy import SQLAlchemy
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
bcrypt = Bcrypt()
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
security = Security()
limiter = Limiter(key_func=get_remote_address)

View File

@ -1,9 +1,10 @@
import datetime
from grant.extensions import ma, db
from grant.utils.enums import MilestoneStage
from grant.utils.exceptions import ValidationException
from grant.utils.ma_fields import UnixDate
from grant.utils.enums import MilestoneStage
from grant.utils.misc import gen_random_id
class MilestoneException(Exception):
@ -21,7 +22,6 @@ class Milestone(db.Model):
content = db.Column(db.Text, nullable=False)
payout_percent = db.Column(db.String(255), nullable=False)
immediate_payout = db.Column(db.Boolean)
# TODO: change to estimated_duration (sec or ms) -- FE can calc from dates on draft
date_estimated = db.Column(db.DateTime, nullable=False)
stage = db.Column(db.String(255), nullable=False)
@ -52,6 +52,7 @@ class Milestone(db.Model):
stage: str = MilestoneStage.IDLE,
proposal_id=int,
):
self.id = gen_random_id(Milestone)
self.title = title
self.content = content
self.stage = stage
@ -62,6 +63,23 @@ class Milestone(db.Model):
self.date_created = datetime.datetime.now()
self.index = index
@staticmethod
def make(milestones_data, proposal):
if milestones_data:
# Delete & re-add milestones
[db.session.delete(x) for x in proposal.milestones]
for i, milestone_data in enumerate(milestones_data):
m = Milestone(
title=milestone_data["title"],
content=milestone_data["content"],
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
payout_percent=str(milestone_data["payout_percent"]),
immediate_payout=milestone_data["immediate_payout"],
proposal_id=proposal.id,
index=i
)
db.session.add(m)
@staticmethod
def validate(milestone):
if len(milestone.title) > 60:

View File

@ -1,14 +1,4 @@
from flask import Blueprint
from flask_yoloapi import endpoint
from .models import Milestone, milestones_schema
blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
# Unused
# @blueprint.route("/", methods=["GET"])
# @endpoint.api()
# def get_milestones():
# milestones = Milestone.query.all()
# result = milestones_schema.dump(milestones)
# return result

91
backend/grant/parser.py Normal file
View File

@ -0,0 +1,91 @@
import functools
from animal_case import animalify
from webargs.core import dict2schema
from webargs.flaskparser import FlaskParser, abort
from marshmallow import fields
try:
from collections.abc import Mapping
except ImportError:
from collections import Mapping
class Parser(FlaskParser):
DEFAULT_VALIDATION_STATUS = 400
def use_kwargs(self, *args, **kwargs):
kwargs["as_kwargs"] = True
return self.use_args(*args, **kwargs)
def use_args(
self,
argmap,
req=None,
locations=None,
as_kwargs=False,
validate=None,
error_status_code=None,
error_headers=None,
):
locations = locations or self.locations
request_obj = req
# Optimization: If argmap is passed as a dictionary, we only need
# to generate a Schema once
if isinstance(argmap, Mapping):
argmap = dict2schema(argmap)()
def decorator(func):
req_ = request_obj
@functools.wraps(func)
def wrapper(*args, **kwargs):
req_obj = req_
if not req_obj:
req_obj = self.get_request_from_view_args(func, args, kwargs)
# NOTE: At this point, argmap may be a Schema, or a callable
parsed_args = self.parse(
argmap,
req=req_obj,
locations=locations,
validate=validate,
error_status_code=error_status_code,
error_headers=error_headers,
)
if as_kwargs:
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# ONLY CHANGE FROM ORIGINAL
kwargs.update(animalify(parsed_args, types='snake'))
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
return func(*args, **kwargs)
else:
# Add parsed_args after other positional arguments
new_args = args + (parsed_args,)
return func(*new_args, **kwargs)
wrapper.__wrapped__ = func
return wrapper
return decorator
def handle_invalid_json_error(self, error, req, *args, **kwargs):
print(error)
abort(400, exc=error, messages={"json": ["Invalid JSON body."]})
parser = Parser()
use_args = parser.use_args
use_kwargs = parser.use_kwargs
# default kwargs
query = functools.partial(use_kwargs, locations=("query",))
body = functools.partial(use_kwargs, locations=("json",))
paginated_fields = {
"page": fields.Int(required=False, missing=None),
"filters": fields.List(fields.Str(), required=False, missing=None),
"search": fields.Str(required=False, missing=None),
"sort": fields.Str(required=False, missing=None)
}

View File

@ -1,17 +1,18 @@
import datetime
from decimal import Decimal
from functools import reduce
from flask import current_app
from marshmallow import post_dump
from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property
from decimal import Decimal
from marshmallow import post_dump
from flask import current_app
from grant.comment.models import Comment
from grant.email.send import send_email
from grant.extensions import ma, db
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url
from grant.utils.requests import blockchain_get
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.task.jobs import ContributionExpired
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
@ -20,6 +21,9 @@ from grant.utils.enums import (
ProposalArbiterStatus,
MilestoneStage
)
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url, gen_random_id
from grant.utils.requests import blockchain_get
from grant.utils.stubs import anonymous_user
proposal_team = db.Table(
@ -64,6 +68,7 @@ class ProposalUpdate(db.Model):
content = db.Column(db.Text, nullable=False)
def __init__(self, proposal_id: int, title: str, content: str):
self.id = gen_random_id(ProposalUpdate)
self.proposal_id = proposal_id
self.title = title
self.content = content
@ -83,6 +88,7 @@ class ProposalContribution(db.Model):
tx_id = db.Column(db.String(255), nullable=True)
refund_tx_id = db.Column(db.String(255), nullable=True)
staking = db.Column(db.Boolean, nullable=False)
no_refund = db.Column(db.Boolean, nullable=False)
user = db.relationship("User")
@ -92,20 +98,23 @@ class ProposalContribution(db.Model):
amount: str,
user_id: int = None,
staking: bool = False,
no_refund: bool = False,
):
self.proposal_id = proposal_id
self.amount = amount
self.user_id = user_id
self.staking = staking
self.no_refund = no_refund
self.date_created = datetime.datetime.now()
self.status = ContributionStatus.PENDING
@staticmethod
def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
def get_existing_contribution(user_id: int, proposal_id: int, amount: str, no_refund: bool = False):
return ProposalContribution.query.filter_by(
user_id=user_id,
proposal_id=proposal_id,
amount=amount,
no_refund=no_refund,
status=ContributionStatus.PENDING,
).first()
@ -180,6 +189,7 @@ class ProposalArbiter(db.Model):
user = db.relationship("User", uselist=False, lazy=True, back_populates="arbiter_proposals")
def __init__(self, proposal_id: int, user_id: int = None, status: str = ProposalArbiterStatus.MISSING):
self.id = gen_random_id(ProposalArbiter)
self.proposal_id = proposal_id
self.user_id = user_id
self.status = status
@ -225,6 +235,8 @@ class Proposal(db.Model):
payout_address = db.Column(db.String(255), nullable=False)
deadline_duration = db.Column(db.Integer(), nullable=False)
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'"))
rfp_opt_in = db.Column(db.Boolean(), nullable=True)
contributed = db.column_property()
# Relations
@ -249,6 +261,7 @@ class Proposal(db.Model):
deadline_duration: int = 5184000, # 60 days
category: str = ''
):
self.id = gen_random_id(Proposal)
self.date_created = datetime.datetime.now()
self.status = status
self.title = title
@ -261,10 +274,11 @@ class Proposal(db.Model):
self.stage = stage
@staticmethod
def validate(proposal):
def simple_validate(proposal):
title = proposal.get('title')
stage = proposal.get('stage')
category = proposal.get('category')
if title and len(title) > 60:
raise ValidationException("Proposal title cannot be longer than 60 characters")
if stage and not ProposalStage.includes(stage):
@ -272,24 +286,60 @@ class Proposal(db.Model):
if category and not Category.includes(category):
raise ValidationException("Category {} not a valid category".format(category))
def validate_publishable_milestones(self):
payout_total = 0.0
for i, milestone in enumerate(self.milestones):
if milestone.immediate_payout and i != 0:
raise ValidationException("Only the first milestone can have an immediate payout")
if len(milestone.title) > 60:
raise ValidationException("Milestone title must be no more than 60 chars")
if len(milestone.content) > 200:
raise ValidationException("Milestone content must be no more than 200 chars")
payout_total += float(milestone.payout_percent)
try:
present = datetime.datetime.today().replace(day=1)
if present > milestone.date_estimated:
raise ValidationException("Milestone date_estimated must be in the future ")
except Exception as e:
current_app.logger.warn(
f"Unexpected validation error - client prohibits {e}"
)
raise ValidationException("date_estimated does not convert to a datetime")
if payout_total != 100.0:
raise ValidationException("payoutPercent across milestones must sum to exactly 100")
def validate_publishable(self):
self.validate_publishable_milestones()
# Require certain fields
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
for field in required_fields:
if not hasattr(self, field):
raise ValidationException("Proposal must have a {}".format(field))
# Check with node that the address is kosher
res = blockchain_get('/validate/address', {'address': self.payout_address})
try:
res = blockchain_get('/validate/address', {'address': self.payout_address})
except:
raise ValidationException(
"Could not validate your payout address due to an internal server error, please try again later")
if not res['valid']:
raise ValidationException("Payout address is not a valid Zcash address")
# Then run through regular validation
Proposal.validate(vars(self))
Proposal.simple_validate(vars(self))
@staticmethod
def create(**kwargs):
Proposal.validate(kwargs)
Proposal.simple_validate(kwargs)
proposal = Proposal(
**kwargs
)
@ -334,32 +384,53 @@ class Proposal(db.Model):
self.brief = brief
self.category = category
self.content = content
self.target = target
self.target = target if target != '' else None
self.payout_address = payout_address
self.deadline_duration = deadline_duration
Proposal.validate(vars(self))
Proposal.simple_validate(vars(self))
def create_contribution(self, amount, user_id: int = None, staking: bool = False):
def update_rfp_opt_in(self, opt_in: bool):
self.rfp_opt_in = opt_in
# add/remove matching and/or bounty values from RFP
if opt_in and self.rfp:
self.set_contribution_matching(1 if self.rfp.matching else 0)
self.set_contribution_bounty(self.rfp.bounty or '0')
else:
self.set_contribution_matching(0)
self.set_contribution_bounty('0')
def create_contribution(
self,
amount,
user_id: int = None,
staking: bool = False,
no_refund: bool = False,
):
contribution = ProposalContribution(
proposal_id=self.id,
amount=amount,
user_id=user_id,
staking=staking,
no_refund=no_refund,
)
db.session.add(contribution)
db.session.flush()
if user_id:
task = ContributionExpired(contribution)
task.make_task()
db.session.commit()
return contribution
def get_staking_contribution(self, user_id: int):
contribution = None
remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.contributed)
remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.amount_staked)
# check funding
if remaining > 0:
# find pending contribution for any user of remaining amount
# TODO: Filter by staking=True?
contribution = ProposalContribution.query.filter_by(
proposal_id=self.id,
status=ProposalStatus.PENDING,
staking=True,
).first()
if not contribution:
contribution = self.create_contribution(
@ -457,6 +528,16 @@ class Proposal(db.Model):
# check the first step, if immediate payout bump it to accepted
self.current_milestone.accept_immediate()
def set_contribution_bounty(self, bounty: str):
# do not allow changes on funded/WIP proposals
if self.is_funded:
raise ValidationException("Cannot change contribution bounty on fully-funded proposal")
# wrap in Decimal so it throws for non-decimal strings
self.contribution_bounty = str(Decimal(bounty))
db.session.add(self)
db.session.flush()
self.set_funded_when_ready()
def set_contribution_matching(self, matching: float):
# do not allow on funded/WIP proposals
if self.is_funded:
@ -471,24 +552,23 @@ class Proposal(db.Model):
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
def cancel(self):
print(self.status)
if self.status != ProposalStatus.LIVE:
raise ValidationException("Cannot cancel a proposal until it's live")
self.stage = ProposalStage.CANCELED
db.session.add(self)
db.session.flush()
# Send emails to team & contributors
for u in self.team:
send_email(u.email_address, 'proposal_canceled', {
'proposal': self,
'support_url': make_url('/contact'),
})
for c in self.contributions:
send_email(c.user.email_address, 'contribution_proposal_canceled', {
'contribution': c,
for u in self.contributors:
send_email(u.email_address, 'contribution_proposal_canceled', {
'proposal': self,
'refund_address': c.user.settings.refund_address,
'refund_address': u.settings.refund_address,
'account_settings_url': make_url('/profile/settings?tab=account')
})
@ -500,14 +580,23 @@ class Proposal(db.Model):
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
return str(funded)
@hybrid_property
def amount_staked(self):
contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED, staking=True) \
.all()
amount = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
return str(amount)
@hybrid_property
def funded(self):
target = Decimal(self.target)
# apply matching multiplier
funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching)
# apply bounty, if available
if self.rfp:
funded = funded + Decimal(self.rfp.bounty)
# apply bounty
if self.contribution_bounty:
funded = funded + Decimal(self.contribution_bounty)
# if funded > target, just set as target
if funded > target:
return str(target)
@ -546,6 +635,11 @@ class Proposal(db.Model):
return self.milestones[-1] # return last one if all PAID
return None
@hybrid_property
def contributors(self):
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
return d.values()
class ProposalSchema(ma.Schema):
class Meta:
@ -575,8 +669,10 @@ class ProposalSchema(ma.Schema):
"payout_address",
"deadline_duration",
"contribution_matching",
"contribution_bounty",
"invites",
"rfp",
"rfp_opt_in",
"arbiter"
)
@ -677,9 +773,6 @@ proposal_team_invite_schema = ProposalTeamInviteSchema()
proposal_team_invites_schema = ProposalTeamInviteSchema(many=True)
# TODO: Find a way to extend ProposalTeamInviteSchema instead of redefining
class InviteWithProposalSchema(ma.Schema):
class Meta:
model = ProposalTeamInvite
@ -729,12 +822,12 @@ class ProposalContributionSchema(ma.Schema):
def get_addresses(self, obj):
# Omit 'memo' and 'sprout' for now
# TODO: Add back in 'sapling' when ready
# NOTE: Add back in 'sapling' when ready
addresses = blockchain_get('/contribution/addresses', {'contributionId': obj.id})
return {
'transparent': addresses['transparent'],
}
def get_is_anonymous(self, obj):
return not obj.user_id
@ -768,7 +861,8 @@ class AdminProposalContributionSchema(ma.Schema):
"addresses",
"refund_address",
"refund_tx_id",
"staking"
"staking",
"no_refund",
)
proposal = ma.Nested("ProposalSchema")

View File

@ -1,13 +1,20 @@
from dateutil.parser import parse
from decimal import Decimal
from flask import Blueprint, g, request
from flask_yoloapi import endpoint, parameter
from flask import Blueprint, g, request, current_app
from marshmallow import fields, validate
from sqlalchemy import or_
from sentry_sdk import capture_message
from grant.extensions import limiter
from grant.comment.models import Comment, comment_schema, comments_schema
from grant.email.send import send_email
from grant.milestone.models import Milestone
from grant.settings import EXPLORER_URL, PROPOSAL_STAKING_AMOUNT
from grant.user.models import User
from grant.parser import body, query, paginated_fields
from grant.rfp.models import RFP
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.task.jobs import ProposalDeadline
from grant.user.models import User
from grant.utils import pagination
from grant.utils.auth import (
requires_auth,
requires_team_member_auth,
@ -16,14 +23,10 @@ from grant.utils.auth import (
get_authed_user,
internal_webhook
)
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url, from_zat
from grant.utils.enums import Category
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus
from grant.utils import pagination
from grant.task.jobs import ProposalDeadline
from sqlalchemy import or_
from datetime import datetime
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url, from_zat, make_explore_url
from .models import (
Proposal,
proposals_schema,
@ -43,7 +46,6 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
@blueprint.route("/<proposal_id>", methods=["GET"])
@endpoint.api()
def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
@ -60,12 +62,7 @@ def get_proposal(proposal_id):
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@query(paginated_fields)
def get_proposal_comments(proposal_id, page, filters, search, sort):
# only using page, currently
filters_workaround = request.args.getlist('filters[]')
@ -82,7 +79,6 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
@blueprint.route("/<proposal_id>/comments/<comment_id>/report", methods=["PUT"])
@requires_email_verified_auth
@endpoint.api()
def report_proposal_comment(proposal_id, comment_id):
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
@ -95,15 +91,16 @@ def report_proposal_comment(proposal_id, comment_id):
comment.report(True)
db.session.commit()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
@limiter.limit("30/hour;2/minute")
@requires_email_verified_auth
@endpoint.api(
parameter('comment', type=str, required=True),
parameter('parentCommentId', type=int, required=False)
)
@body({
"comment": fields.Str(required=True),
"parentCommentId": fields.Int(required=False, missing=None),
})
def post_proposal_comments(proposal_id, comment, parent_comment_id):
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
@ -125,6 +122,9 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
if g.current_user.silenced:
return {"message": "Your account has been silenced, commenting is disabled."}, 403
if len(comment) > 1000:
return {"message": "Please make sure your comment is less than 1000 characters long"}, 400
# Make the comment
comment = Comment(
proposal_id=proposal_id,
@ -136,13 +136,13 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
db.session.commit()
dumped_comment = comment_schema.dump(comment)
# TODO: Email proposal team if top-level comment
# Email proposal team if top-level comment
if not parent:
for member in proposal.team:
send_email(member.email_address, 'proposal_comment', {
'author': g.current_user,
'proposal': proposal,
'comment_url': make_url(f'/proposal/{proposal.id}?tab=discussions&comment={comment.id}'),
'comment_url': make_url(f'/proposals/{proposal.id}?tab=discussions&comment={comment.id}'),
'author_url': make_url(f'/profile/{comment.author.id}'),
})
# Email parent comment creator, if it's not themselves
@ -150,7 +150,7 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
send_email(parent.author.email_address, 'comment_reply', {
'author': g.current_user,
'proposal': proposal,
'comment_url': make_url(f'/proposal/{proposal.id}?tab=discussions&comment={comment.id}'),
'comment_url': make_url(f'/proposals/{proposal.id}?tab=discussions&comment={comment.id}'),
'author_url': make_url(f'/profile/{comment.author.id}'),
})
@ -158,12 +158,7 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
@blueprint.route("/", methods=["GET"])
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@query(paginated_fields)
def get_proposals(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
query = Proposal.query.filter_by(status=ProposalStatus.LIVE) \
@ -181,10 +176,11 @@ def get_proposals(page, filters, search, sort):
@blueprint.route("/drafts", methods=["POST"])
@limiter.limit("10/hour;3/minute")
@requires_email_verified_auth
@endpoint.api(
parameter('rfpId', type=int),
)
@body({
"rfpId": fields.Int(required=False, missing=None)
})
def make_proposal_draft(rfp_id):
proposal = Proposal.create(status=ProposalStatus.DRAFT)
proposal.team.append(g.current_user)
@ -194,8 +190,6 @@ def make_proposal_draft(rfp_id):
if not rfp:
return {"message": "The request this proposal was made for doesnt exist"}, 400
proposal.category = rfp.category
if rfp.matching:
proposal.contribution_matching = 1.0
rfp.proposals.append(proposal)
db.session.add(rfp)
@ -206,55 +200,52 @@ def make_proposal_draft(rfp_id):
@blueprint.route("/drafts", methods=["GET"])
@requires_auth
@endpoint.api()
def get_proposal_drafts():
proposals = (
Proposal.query
.filter(or_(
.filter(or_(
Proposal.status == ProposalStatus.DRAFT,
Proposal.status == ProposalStatus.REJECTED,
))
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
.all()
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
.all()
)
return proposals_schema.dump(proposals), 200
@blueprint.route("/<proposal_id>", methods=["PUT"])
@requires_team_member_auth
@endpoint.api(
parameter('title', type=str),
parameter('brief', type=str),
parameter('category', type=str),
parameter('content', type=str),
parameter('target', type=str),
parameter('payoutAddress', type=str),
parameter('deadlineDuration', type=int),
parameter('milestones', type=list)
)
def update_proposal(milestones, proposal_id, **kwargs):
@body({
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
"content": fields.Str(required=True),
"target": fields.Str(required=True),
"payoutAddress": fields.Str(required=True),
"deadlineDuration": fields.Int(required=True),
"milestones": fields.List(fields.Dict(), required=True),
"rfpOptIn": fields.Bool(required=False, missing=None)
})
def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
# Update the base proposal fields
try:
if g.current_proposal.status not in [ProposalStatus.DRAFT,
ProposalStatus.REJECTED]:
raise ValidationException(
f"Proposal with status: {g.current_proposal.status} are not authorized for updates"
)
g.current_proposal.update(**kwargs)
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
db.session.add(g.current_proposal)
# Delete & re-add milestones
[db.session.delete(x) for x in g.current_proposal.milestones]
if milestones:
for i, mdata in enumerate(milestones):
m = Milestone(
title=mdata["title"],
content=mdata["content"],
date_estimated=datetime.fromtimestamp(mdata["dateEstimated"]),
payout_percent=str(mdata["payoutPercent"]),
immediate_payout=mdata["immediatePayout"],
proposal_id=g.current_proposal.id,
index=i
)
db.session.add(m)
# twiddle rfp opt-in (modifies proposal matching and/or bounty)
if rfp_opt_in is not None:
g.current_proposal.update_rfp_opt_in(rfp_opt_in)
Milestone.make(milestones, g.current_proposal)
# Commit
db.session.commit()
@ -263,9 +254,11 @@ def update_proposal(milestones, proposal_id, **kwargs):
@blueprint.route("/<proposal_id>/rfp", methods=["DELETE"])
@requires_team_member_auth
@endpoint.api()
def unlink_proposal_from_rfp(proposal_id):
g.current_proposal.rfp_id = None
# this will zero matching and bounty
g.current_proposal.update_rfp_opt_in(False)
g.current_proposal.rfp_opt_in = None
db.session.add(g.current_proposal)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@ -273,7 +266,6 @@ def unlink_proposal_from_rfp(proposal_id):
@blueprint.route("/<proposal_id>", methods=["DELETE"])
@requires_team_member_auth
@endpoint.api()
def delete_proposal(proposal_id):
deleteable_statuses = [
ProposalStatus.DRAFT,
@ -287,12 +279,11 @@ def delete_proposal(proposal_id):
return {"message": "Cannot delete proposals with %s status" % status}, 400
db.session.delete(g.current_proposal)
db.session.commit()
return None, 202
return {"message": "ok"}, 202
@blueprint.route("/<proposal_id>/submit_for_approval", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def submit_for_approval_proposal(proposal_id):
try:
g.current_proposal.submit_for_approval()
@ -305,19 +296,17 @@ def submit_for_approval_proposal(proposal_id):
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
@requires_team_member_auth
@endpoint.api()
def get_proposal_stake(proposal_id):
if g.current_proposal.status != ProposalStatus.STAKING:
return None, 400
return {"message": "ok"}, 400
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
if contribution:
return proposal_contribution_schema.dump(contribution)
return None, 404
return {"message": "ok"}, 404
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def publish_proposal(proposal_id):
try:
g.current_proposal.publish()
@ -333,7 +322,6 @@ def publish_proposal(proposal_id):
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
@endpoint.api()
def get_proposal_updates(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
@ -344,7 +332,6 @@ def get_proposal_updates(proposal_id):
@blueprint.route("/<proposal_id>/updates/<update_id>", methods=["GET"])
@endpoint.api()
def get_proposal_update(proposal_id, update_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
@ -358,11 +345,12 @@ def get_proposal_update(proposal_id, update_id):
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
@limiter.limit("5/day;1/minute")
@requires_team_member_auth
@endpoint.api(
parameter('title', type=str, required=True),
parameter('content', type=str, required=True)
)
@body({
"title": fields.Str(required=True, validate=lambda p: 3 <= len(p) <= 30),
"content": fields.Str(required=True, validate=lambda p: 5 <= len(p) <= 10000),
})
def post_proposal_update(proposal_id, title, content):
update = ProposalUpdate(
proposal_id=g.current_proposal.id,
@ -372,26 +360,32 @@ def post_proposal_update(proposal_id, title, content):
db.session.add(update)
db.session.commit()
# Send email to all contributors (even if contribution failed)
contributions = ProposalContribution.query.filter_by(proposal_id=proposal_id).all()
for c in contributions:
if c.user:
send_email(c.user.email_address, 'contribution_update', {
'proposal': g.current_proposal,
'proposal_update': update,
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
# Send email to all contributors
for u in g.current_proposal.contributors:
send_email(u.email_address, 'contribution_update', {
'proposal': g.current_proposal,
'proposal_update': update,
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
@limiter.limit("30/day;10/minute")
@requires_team_member_auth
@endpoint.api(
parameter('address', type=str, required=True)
)
@body({
"address": fields.Str(required=True),
})
def post_proposal_team_invite(proposal_id, address):
existing_invite = ProposalTeamInvite.query.filter_by(
proposal_id=proposal_id,
address=address
).first()
if existing_invite:
return {"message": f"You've already invited {address}"}, 400
invite = ProposalTeamInvite(
proposal_id=proposal_id,
address=address
@ -400,7 +394,6 @@ def post_proposal_team_invite(proposal_id, address):
db.session.commit()
# Send email
# TODO: Move this to some background task / after request action
email = address
user = User.get_by_email(email_address=address)
if user:
@ -419,7 +412,6 @@ def post_proposal_team_invite(proposal_id, address):
@blueprint.route("/<proposal_id>/invite/<id_or_address>", methods=["DELETE"])
@requires_team_member_auth
@endpoint.api()
def delete_proposal_team_invite(proposal_id, id_or_address):
invite = ProposalTeamInvite.query.filter(
(ProposalTeamInvite.id == id_or_address) |
@ -432,34 +424,33 @@ def delete_proposal_team_invite(proposal_id, id_or_address):
db.session.delete(invite)
db.session.commit()
return None, 202
return {"message": "ok"}, 202
@blueprint.route("/<proposal_id>/contributions", methods=["GET"])
@endpoint.api()
def get_proposal_contributions(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
top_contributions = ProposalContribution.query \
.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
staking=False,
) \
.order_by(ProposalContribution.amount.desc()) \
.limit(5) \
.all()
latest_contributions = ProposalContribution.query \
.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
staking=False,
) \
.order_by(ProposalContribution.date_created.desc()) \
.limit(5) \
.all()
top_contributions = ProposalContribution.query.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
staking=False,
).order_by(
ProposalContribution.amount.desc()
).limit(
5
).all()
latest_contributions = ProposalContribution.query.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
staking=False,
).order_by(
ProposalContribution.date_created.desc()
).limit(
5
).all()
return {
'top': proposal_proposal_contributions_schema.dump(top_contributions),
@ -468,25 +459,26 @@ def get_proposal_contributions(proposal_id):
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
@endpoint.api()
def get_proposal_contribution(proposal_id, contribution_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
contribution = ProposalContribution.query.filter_by(id=contribution_id).first()
if contribution:
return proposal_contribution_schema.dump(contribution)
else:
return {"message": "No contribution matching id"}
else:
if not proposal:
return {"message": "No proposal matching id"}, 404
contribution = ProposalContribution.query.filter_by(id=contribution_id).first()
if not contribution:
return {"message": "No contribution matching id"}, 404
return proposal_contribution_schema.dump(contribution)
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
@endpoint.api(
parameter('amount', type=str, required=True),
parameter('anonymous', type=bool, required=False)
)
def post_proposal_contribution(proposal_id, amount, anonymous):
@limiter.limit("30/day;10/hour;2/minute")
@body({
"amount": fields.Str(required=True, validate=lambda p: 0.0001 <= float(p) <= 1000000),
"anonymous": fields.Bool(required=False, missing=None),
"noRefund": fields.Bool(required=False, missing=False),
})
def post_proposal_contribution(proposal_id, amount, anonymous, no_refund):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
@ -499,12 +491,14 @@ def post_proposal_contribution(proposal_id, amount, anonymous):
user = get_authed_user()
if user:
contribution = ProposalContribution.get_existing_contribution(user.id, proposal_id, amount)
contribution = ProposalContribution \
.get_existing_contribution(user.id, proposal_id, amount, no_refund)
if not contribution:
code = 201
contribution = proposal.create_contribution(
amount=amount,
no_refund=no_refund,
user_id=user.id if user else None,
)
@ -515,23 +509,24 @@ def post_proposal_contribution(proposal_id, amount, anonymous):
# Can't use <proposal_id> since webhook doesn't know proposal id
@blueprint.route("/contribution/<contribution_id>/confirm", methods=["POST"])
@internal_webhook
@endpoint.api(
parameter('to', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txid', type=str, required=True),
)
@body({
"to": fields.Str(required=True),
"amount": fields.Str(required=True),
"txid": fields.Str(required=True),
})
def post_contribution_confirmation(contribution_id, to, amount, txid):
contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
# TODO: Log in sentry
print(f'Unknown contribution {contribution_id} confirmed with txid {txid}')
msg = f'Unknown contribution {contribution_id} confirmed with txid {txid}, amount {amount}'
capture_message(msg)
current_app.logger.warn(msg)
return {"message": "No contribution matching id"}, 404
if contribution.status == ContributionStatus.CONFIRMED:
# Duplicates can happen, just return ok
return None, 200
return {"message": "ok"}, 200
# Convert to whole zcash coins from zats
zec_amount = str(from_zat(int(amount)))
@ -547,7 +542,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'tx_explorer_url': make_explore_url(txid),
'fully_staked': contribution.proposal.is_staked,
'stake_target': str(PROPOSAL_STAKING_AMOUNT.normalize()),
})
@ -558,7 +553,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'tx_explorer_url': make_explore_url(txid),
})
# Send to the full proposal gang
@ -572,20 +567,17 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
})
# TODO: Once we have a task queuer in place, queue emails to everyone
# on funding target reached.
contribution.proposal.set_funded_when_ready()
db.session.commit()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/contribution/<contribution_id>", methods=["DELETE"])
@requires_auth
@endpoint.api()
def delete_proposal_contribution(contribution_id):
contribution = contribution = ProposalContribution.query.filter_by(
contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
return {"message": "No contribution matching id"}, 404
@ -599,13 +591,12 @@ def delete_proposal_contribution(contribution_id):
contribution.status = ContributionStatus.DELETED
db.session.add(contribution)
db.session.commit()
return None, 202
return {"message": "ok"}, 202
# request MS payout
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/request", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def request_milestone_payout(proposal_id, milestone_id):
if not g.current_proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400
@ -627,7 +618,6 @@ def request_milestone_payout(proposal_id, milestone_id):
# accept MS payout (arbiter)
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/accept", methods=["PUT"])
@requires_arbiter_auth
@endpoint.api()
def accept_milestone_payout_request(proposal_id, milestone_id):
if not g.current_proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400
@ -652,9 +642,9 @@ def accept_milestone_payout_request(proposal_id, milestone_id):
# reject MS payout (arbiter) (reason)
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/reject", methods=["PUT"])
@requires_arbiter_auth
@endpoint.api(
parameter('reason', type=str, required=True),
)
@body({
"reason": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
})
def reject_milestone_payout_request(proposal_id, milestone_id, reason):
if not g.current_proposal.is_funded:
return {"message": "Proposal is not fully funded"}, 400

View File

@ -1,7 +1,10 @@
from datetime import datetime
from decimal import Decimal
from grant.extensions import ma, db
from sqlalchemy.ext.hybrid import hybrid_property
from grant.utils.enums import RFPStatus
from grant.utils.misc import dt_to_unix
from grant.utils.misc import dt_to_unix, gen_random_id
from grant.utils.enums import Category
class RFP(db.Model):
@ -16,7 +19,7 @@ class RFP(db.Model):
category = db.Column(db.String(255), nullable=False)
status = db.Column(db.String(255), nullable=False)
matching = db.Column(db.Boolean, default=False, nullable=False)
bounty = db.Column(db.String(255), nullable=True)
_bounty = db.Column("bounty", db.String(255), nullable=True)
date_closes = db.Column(db.DateTime, nullable=True)
date_opened = db.Column(db.DateTime, nullable=True)
date_closed = db.Column(db.DateTime, nullable=True)
@ -35,6 +38,17 @@ class RFP(db.Model):
cascade="all, delete-orphan",
)
@hybrid_property
def bounty(self):
return self._bounty
@bounty.setter
def bounty(self, bounty: str):
if bounty and Decimal(bounty) > 0:
self._bounty = bounty
else:
self._bounty = None
def __init__(
self,
title: str,
@ -46,6 +60,9 @@ class RFP(db.Model):
matching: bool = False,
status: str = RFPStatus.DRAFT,
):
assert RFPStatus.includes(status)
assert Category.includes(category)
self.id = gen_random_id(RFP)
self.date_created = datetime.now()
self.title = title
self.brief = brief
@ -98,6 +115,7 @@ class RFPSchema(ma.Schema):
def get_date_closed(self, obj):
return dt_to_unix(obj.date_closed) if obj.date_closed else None
rfp_schema = RFPSchema()
rfps_schema = RFPSchema(many=True)
@ -137,15 +155,16 @@ class AdminRFPSchema(ma.Schema):
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_date_closes(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None
def get_date_opened(self, obj):
return dt_to_unix(obj.date_opened) if obj.date_opened else None
def get_date_closed(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None
admin_rfp_schema = AdminRFPSchema()
admin_rfps_schema = AdminRFPSchema(many=True)

View File

@ -1,5 +1,4 @@
from flask import Blueprint, g
from flask_yoloapi import endpoint, parameter
from flask import Blueprint
from sqlalchemy import or_
from grant.utils.enums import RFPStatus
@ -9,20 +8,18 @@ blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
@blueprint.route("/", methods=["GET"])
@endpoint.api()
def get_rfps():
rfps = RFP.query \
.filter(or_(
RFP.status == RFPStatus.LIVE,
RFP.status == RFPStatus.CLOSED,
)) \
RFP.status == RFPStatus.LIVE,
RFP.status == RFPStatus.CLOSED,
)) \
.order_by(RFP.date_created.desc()) \
.all()
return rfps_schema.dump(rfps)
@blueprint.route("/<rfp_id>", methods=["GET"])
@endpoint.api()
def get_rfp(rfp_id):
rfp = RFP.query.filter_by(id=rfp_id).first()
if not rfp or rfp.status == RFPStatus.DRAFT:

View File

@ -15,8 +15,10 @@ env.read_env()
ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
SITE_URL = env.str('SITE_URL', default='https://zfnd.org')
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
SQLALCHEMY_ECHO = False # True will print queries to log
E2E_TESTING = env.str("E2E_TESTING", default=None)
E2E_DATABASE_URL = env.str("E2E_DATABASE_URL", default=None)
SQLALCHEMY_DATABASE_URI = E2E_DATABASE_URL if E2E_TESTING else env.str("DATABASE_URL")
SQLALCHEMY_ECHO = False # True will print queries to log
QUEUES = ["default"]
SECRET_KEY = env.str("SECRET_KEY")
BCRYPT_LOG_ROUNDS = env.int("BCRYPT_LOG_ROUNDS", default=13)
@ -25,8 +27,12 @@ DEBUG_TB_INTERCEPT_REDIRECTS = False
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
SQLALCHEMY_TRACK_MODIFICATIONS = False
# so backend session cookies are first-party
SESSION_COOKIE_DOMAIN = env.str('SESSION_COOKIE_DOMAIN', default=None)
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
SENDGRID_DEFAULT_FROM = "noreply@zfnd.org"
SENDGRID_DEFAULT_FROM = "noreply@grants.zfnd.org"
SENDGRID_DEFAULT_FROMNAME = "ZF Grants"
SENTRY_DSN = env.str("SENTRY_DSN", default=None)
SENTRY_RELEASE = env.str("SENTRY_RELEASE", default=None)
@ -52,7 +58,7 @@ LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))

View File

@ -2,8 +2,9 @@ from datetime import datetime, timedelta
from grant.extensions import db
from grant.email.send import send_email
from grant.utils.enums import ProposalStage
from grant.utils.enums import ProposalStage, ContributionStatus
from grant.utils.misc import make_url
from flask import current_app
class ProposalReminder:
@ -30,7 +31,6 @@ class ProposalReminder:
assert task.job_type == 1, "Job type: {} is incorrect for ProposalReminder".format(task.job_type)
from grant.proposal.models import Proposal
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
# TODO - replace with email
print(proposal)
task.completed = True
db.session.add(task)
@ -42,12 +42,12 @@ class ProposalDeadline:
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(
@ -63,31 +63,71 @@ class ProposalDeadline:
from grant.proposal.models import Proposal
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
# If it was deleted or successful, just noop out
if not proposal or proposal.is_funded:
# If it was deleted, canceled, or successful, just noop out
if not proposal or proposal.is_funded or proposal.stage != ProposalStage.FUNDING_REQUIRED:
return
# Otherwise, mark it as failed and inform everyone
proposal.stage = ProposalStage.FAILED
db.session.add(proposal)
db.session.commit()
# TODO: Bulk-send emails instead of one per email
# Send emails to team & contributors
for u in proposal.team:
send_email(u.email_address, 'proposal_failed', {
'proposal': proposal,
})
for c in proposal.contributions:
if c.user:
send_email(c.user.email_address, 'contribution_proposal_failed', {
'contribution': c,
'proposal': proposal,
'refund_address': c.user.settings.refund_address,
'account_settings_url': make_url('/profile/settings?tab=account')
})
for u in proposal.contributors:
send_email(u.email_address, 'contribution_proposal_failed', {
'proposal': proposal,
'refund_address': u.settings.refund_address,
'account_settings_url': make_url('/profile/settings?tab=account')
})
class ContributionExpired:
JOB_TYPE = 3
def __init__(self, contribution):
self.contribution = contribution
def blobify(self):
return {
"contribution_id": self.contribution.id,
}
def make_task(self):
from .models import Task
task = Task(
job_type=self.JOB_TYPE,
blob=self.blobify(),
execute_after=self.contribution.date_created + timedelta(hours=24),
)
db.session.add(task)
db.session.commit()
@staticmethod
def process_task(task):
from grant.proposal.models import ProposalContribution
contribution = ProposalContribution.query.filter_by(id=task.blob["contribution_id"]).first()
# If it's missing or not pending, noop out
if not contribution or contribution.status != ContributionStatus.PENDING:
return
# Otherwise, inform the user (if not anonymous)
if contribution.user:
send_email(contribution.user.email_address, 'contribution_expired', {
'contribution': contribution,
'proposal': contribution.proposal,
'contact_url': make_url('/contact'),
'profile_url': make_url(f'/profile/{contribution.user.id}'),
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
})
JOBS = {
1: ProposalReminder.process_task,
2: ProposalDeadline.process_task,
3: ContributionExpired.process_task,
}

View File

@ -1,6 +1,8 @@
from datetime import datetime
from flask import Blueprint, jsonify, current_app
from sentry_sdk import capture_exception
from traceback import format_exc
from flask import Blueprint, jsonify
from grant.task.jobs import JOBS
from grant.task.models import Task, tasks_schema
from grant.extensions import db
@ -17,7 +19,7 @@ def task():
each_task.completed = True
db.session.add(each_task)
except Exception as e:
# replace with Sentry logging
print("Oops, something went wrong: {}".format(e))
current_app.logger.info("Task #{} failed: {}".format(each_task.id, e))
capture_exception(e)
db.session.commit()
return jsonify(tasks_schema.dump(tasks))

View File

@ -0,0 +1,22 @@
<p style="margin: 0 0 20px;">
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution to
<strong>{{ args.proposal.title}}</strong> could not be confirmed on-chain,
and has expired.
</p>
<p style="margin: 0 0 20px;">
<strong>If you did not send the contribution</strong>:
You have nothing to worry about, you can simply delete the contribution from
<a href="{{ args.profile_url }}">your profile</a>. Just make that sure you do
not send any money to the contribution address. If you'd still like to make a
contribution, you can start a new one on the
<a href="{{ args.proposal_url }}">proposal page</a>.
</p>
<p style="margin: 0;">
<strong>If you're sure you sent the contribution</strong>:
Please contact our team using a method from the
<a href="{{ args.contact_url }}">contact page</a> with details about the
contribution, such as a transaction ID or payment
disclosure from the transaction.
</p>

View File

@ -0,0 +1,13 @@
Your {{ args.contribution.amount }} ZEC contribution to "{{ args.proposal.title}}"
could not be confirmed on-chain, and has expired.
If you did not send the contribution, you have nothing to worry about, you can
simply delete the contribution from your profile. Just make that sure you do not
send any money to the contribution address. If you'd still like to make a
contribution, you can start a new one on the proposal page.
If you're sure you've sent the contribution, please contact our team using a method
from the link below with details about the contribution, such as a transaction ID or
payment disclosure from the transaction.
Contact us at {{ args.contact_url }}

View File

@ -1,24 +1,33 @@
<p style="margin: 0 0 20px;">
A proposal you follow, <strong>{{ args.proposal.title }}</strong>, has
posted an update entitled "<strong>{{ args.proposal_update.title }}</strong>"
A proposal you contributed to, <strong>{{ args.proposal.title }}</strong
>, has posted an update entitled "<strong>{{
args.proposal_update.title
}}</strong
>"
</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.update_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;"
>
Read the Update
</a>
</td>
</tr>
</table>
</td>
</tr>
<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.update_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;"
>
Read the Update
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -1,4 +1,4 @@
A proposal you follow, "{{ args.proposal.title }}", has posted an update
A proposal you contributed to, "{{ args.proposal.title }}", has posted an update
entitled "{{ args.proposal_update.title }}".
Go here to read it: {{ args.update_url }}

View File

@ -1,7 +1,7 @@
<p style="margin: 0 0 20px;">
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
{{ args.proposal.title }} - {{ args.milestone.title }}</a
>! You can view the transaction below:
</p>

View File

@ -1,4 +1,4 @@
Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"!
Hooray! {{args.amount}} ZEC has been paid out for "{{ args.proposal.title }} - {{args.milestone.title }}"!
You can view the transaction below:
{{ args.tx_explorer_url }}

View File

@ -91,9 +91,9 @@
<tr>
<td align="center" style="padding: 40px 10px 40px 10px;" valign="top">
<a href="{{ args.home_url }}" target="_blank">
<img alt="Logo" border="0" height="44" src="https://i.imgur.com/WIuJxYB.png"
style="display: block; width: 180px; max-width: 180px; min-width: 180px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
width="180">
<img alt="ZF Grants logo" border="0" height="44" src="https://s3.us-east-2.amazonaws.com/zf-grants-prod/email-logo.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;"
width="220">
</a>
</td>
</tr>
@ -197,7 +197,9 @@
<td align="center" bgcolor="#f4f4f4"
style="padding: 0px 30px 30px 30px; color: #AAAAAA; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; line-height: 18px;">
<p style="margin: 0;">
Zcash Foundation, 123 Address Street, Somewhere, NY 11211
Zcash Foundation
1390 Chain Bridge Road, #A132
McLean, VA 22101
</p>
</td>
</tr>

View File

@ -2,8 +2,8 @@
===============
{{ UI.NAME }}
123 Address Street
City, ST 12345
Zcash Foundation
1390 Chain Bridge Road, #A132
McLean, VA 22101
Don't want any more emails? Unsubscribe here: {{ args.unsubscribe_url }}
Don't want any more emails? Unsubscribe here: {{ args.unsubscribe_url }}

View File

@ -1,6 +1,7 @@
from flask_security import UserMixin, RoleMixin
from flask_security.core import current_user
from flask_security.utils import hash_password, verify_and_update_password, login_user
from sqlalchemy.ext.hybrid import hybrid_property
from grant.comment.models import Comment
from grant.email.models import EmailVerification, EmailRecovery
from grant.email.send import send_email
@ -10,11 +11,11 @@ from grant.email.subscription_settings import (
email_subscriptions_to_dict
)
from grant.extensions import ma, db, security
from grant.utils.misc import make_url
from grant.utils.misc import make_url, gen_random_id, is_email
from grant.utils.social import generate_social_url
from grant.utils.upload import extract_avatar_filename, construct_avatar_url
from grant.utils import totp_2fa
from sqlalchemy.ext.hybrid import hybrid_property
from grant.utils.exceptions import ValidationException
def is_current_authed_user_id(user_id):
@ -96,6 +97,7 @@ class Avatar(db.Model):
self._image_url = extract_avatar_filename(image_url)
def __init__(self, image_url, user_id):
self.id = gen_random_id(Avatar)
self.image_url = image_url
self.user_id = user_id
@ -132,8 +134,6 @@ class User(db.Model, UserMixin):
backref=db.backref('users', lazy='dynamic'))
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
# TODO - add create and validate methods
def __init__(
self,
email_address,
@ -143,11 +143,28 @@ class User(db.Model, UserMixin):
display_name=None,
title=None,
):
self.id = gen_random_id(User)
self.email_address = email_address
self.display_name = display_name
self.title = title
self.password = password
@staticmethod
def validate(user):
em = user.get('email_address')
if not em:
raise ValidationException('Must have email address')
if not is_email(em):
raise ValidationException('Email address looks invalid')
t = user.get('title')
if t and len(t) > 255:
raise ValidationException('Title is too long')
dn = user.get('display_name')
if dn and len(dn) > 255:
raise ValidationException('Display name is too long')
@staticmethod
def create(email_address=None, password=None, display_name=None, title=None, _send_email=True):
user = security.datastore.create_user(
@ -156,6 +173,7 @@ class User(db.Model, UserMixin):
display_name=display_name,
title=title
)
User.validate(vars(user))
security.datastore.commit()
# user settings
@ -247,7 +265,6 @@ class User(db.Model, UserMixin):
db.session.flush()
def set_admin(self, is_admin: bool):
# TODO: audit entry & possibly email user
self.is_admin = is_admin
db.session.add(self)
db.session.flush()

View File

@ -1,11 +1,16 @@
import validators
from animal_case import keys_to_snake_case
from flask import Blueprint, g
from flask_yoloapi import endpoint, parameter
from flask import Blueprint, g, current_app
from marshmallow import fields
from validate_email import validate_email
import grant.utils.auth as auth
from grant.comment.models import Comment, user_comments_schema
from grant.email.models import EmailRecovery
from grant.extensions import limiter
from grant.parser import query, body
from grant.proposal.models import (
Proposal,
proposal_team,
ProposalTeamInvite,
invites_with_proposal_schema,
ProposalContribution,
@ -13,19 +18,17 @@ from grant.proposal.models import (
user_proposals_schema,
user_proposal_arbiters_schema
)
import grant.utils.auth as auth
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.utils.exceptions import ValidationException
from grant.utils.requests import validate_blockchain_get
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
from grant.utils.enums import ProposalStatus, ContributionStatus
from flask import current_app
from .models import (
User,
SocialMedia,
Avatar,
self_user_schema,
user_schema,
users_schema,
user_settings_schema,
db
)
@ -33,42 +36,21 @@ from .models import (
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@blueprint.route("/", methods=["GET"])
@endpoint.api(
parameter('proposalId', type=str, required=False)
)
def get_users(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
users = User.query.all()
else:
users = (
User.query
.join(proposal_team)
.join(Proposal)
.filter(proposal_team.c.proposal_id == proposal.id)
.all()
)
result = users_schema.dump(users)
return result
@blueprint.route("/me", methods=["GET"])
@auth.requires_auth
@endpoint.api()
def get_me():
dumped_user = self_user_schema.dump(g.current_user)
return dumped_user
@blueprint.route("/<user_id>", methods=["GET"])
@endpoint.api(
parameter("withProposals", type=bool, required=False),
parameter("withComments", type=bool, required=False),
parameter("withFunded", type=bool, required=False),
parameter("withPending", type=bool, required=False),
parameter("withArbitrated", type=bool, required=False)
)
@query({
"withProposals": fields.Bool(required=False, missing=None),
"withComments": fields.Bool(required=False, missing=None),
"withFunded": fields.Bool(required=False, missing=None),
"withPending": fields.Bool(required=False, missing=None),
"withArbitrated": fields.Bool(required=False, missing=None)
})
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated):
user = User.get_by_id(user_id)
if user:
@ -109,12 +91,13 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
@blueprint.route("/", methods=["POST"])
@endpoint.api(
parameter('emailAddress', type=str, required=True),
parameter('password', type=str, required=True),
parameter('displayName', type=str, required=True),
parameter('title', type=str, required=True)
)
@limiter.limit("30/day;5/minute")
@body({
"emailAddress": fields.Str(required=True, validate=lambda e: validate_email(e)),
"password": fields.Str(required=True),
"displayName": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
"title": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
})
def create_user(
email_address,
password,
@ -137,10 +120,10 @@ def create_user(
@blueprint.route("/auth", methods=["POST"])
@endpoint.api(
parameter('email', type=str, required=True),
parameter('password', type=str, required=True)
)
@body({
"email": fields.Str(required=True),
"password": fields.Str(required=True)
})
def auth_user(email, password):
authed_user = auth.auth_user(email, password)
return self_user_schema.dump(authed_user)
@ -148,49 +131,49 @@ def auth_user(email, password):
@blueprint.route("/me/password", methods=["PUT"])
@auth.requires_auth
@endpoint.api(
parameter('currentPassword', type=str, required=True),
parameter('password', type=str, required=True),
)
@body({
"currentPassword": fields.Str(required=True),
"password": fields.Str(required=True)
})
def update_user_password(current_password, password):
if not g.current_user.check_password(current_password):
return {"message": "Current password incorrect"}, 403
g.current_user.set_password(password)
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/me/email", methods=["PUT"])
@auth.requires_auth
@endpoint.api(
parameter('email', type=str, required=True),
parameter('password', type=str, required=True)
)
@body({
"email": fields.Str(required=True, validate=lambda e: validate_email(e)),
"password": fields.Str(required=True)
})
def update_user_email(email, password):
if not g.current_user.check_password(password):
return {"message": "Password is incorrect"}, 403
current_app.logger.info(
f"Updating userId: {g.current_user.id} with current email: {g.current_user.email_address} to new email: {email}"
)
g.current_user.set_email(email)
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/me/resend-verification", methods=["PUT"])
@auth.requires_auth
@endpoint.api()
def resend_email_verification():
g.current_user.send_verification_email()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/logout", methods=["POST"])
@auth.requires_auth
@endpoint.api()
def logout_user():
auth.logout_current_user()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/social/<service>/authurl", methods=["GET"])
@auth.requires_auth
@endpoint.api()
def get_user_social_auth_url(service):
try:
return {"url": get_social_login_url(service)}
@ -201,9 +184,9 @@ def get_user_social_auth_url(service):
@blueprint.route("/social/<service>/verify", methods=["POST"])
@auth.requires_auth
@endpoint.api(
parameter('code', type=str, required=True)
)
@body({
"code": fields.Str(required=True)
})
def verify_user_social(service, code):
try:
# 1. verify with 3rd party
@ -227,22 +210,23 @@ def verify_user_social(service, code):
@blueprint.route("/recover", methods=["POST"])
@endpoint.api(
parameter('email', type=str, required=True)
)
@limiter.limit("10/day;2/minute")
@body({
"email": fields.Str(required=True)
})
def recover_user(email):
existing_user = User.get_by_email(email)
if not existing_user:
return {"message": "No user exists with that email"}, 400
auth.throw_on_banned(existing_user)
existing_user.send_recovery_email()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/recover/<code>", methods=["POST"])
@endpoint.api(
parameter('password', type=str, required=True),
)
@body({
"password": fields.Str(required=True)
})
def recover_email(code, password):
er = EmailRecovery.query.filter_by(code=code).first()
if er:
@ -252,16 +236,17 @@ def recover_email(code, password):
er.user.set_password(password)
db.session.delete(er)
db.session.commit()
return None, 200
return {"message": "ok"}, 200
return {"message": "Invalid reset code"}, 400
@blueprint.route("/avatar", methods=["POST"])
@limiter.limit("20/day;3/minute")
@auth.requires_auth
@endpoint.api(
parameter('mimetype', type=str, required=True)
)
@body({
"mimetype": fields.Str(required=True)
})
def upload_avatar(mimetype):
user = g.current_user
try:
@ -273,9 +258,9 @@ def upload_avatar(mimetype):
@blueprint.route("/avatar", methods=["DELETE"])
@auth.requires_auth
@endpoint.api(
parameter('url', type=str, required=True)
)
@body({
"url": fields.Str(required=True)
})
def delete_avatar(url):
user = g.current_user
remove_avatar(url, user.id)
@ -284,12 +269,12 @@ def delete_avatar(url):
@blueprint.route("/<user_id>", methods=["PUT"])
@auth.requires_auth
@auth.requires_same_user_auth
@endpoint.api(
parameter('displayName', type=str, required=True),
parameter('title', type=str, required=True),
parameter('socialMedias', type=list, required=True),
parameter('avatar', type=str, required=True)
)
@body({
"displayName": fields.Str(required=True, validate=lambda d: 2 <= len(d) <= 60),
"title": fields.Str(required=True, validate=lambda t: 2 <= len(t) <= 60),
"socialMedias": fields.List(fields.Dict(), required=True),
"avatar": fields.Str(required=True, allow_none=True, validate=lambda d: validators.url(d))
})
def update_user(user_id, display_name, title, social_medias, avatar):
user = g.current_user
@ -324,7 +309,6 @@ def update_user(user_id, display_name, title, social_medias, avatar):
@blueprint.route("/<user_id>/invites", methods=["GET"])
@auth.requires_same_user_auth
@endpoint.api()
def get_user_invites(user_id):
invites = ProposalTeamInvite.get_pending_for_user(g.current_user)
return invites_with_proposal_schema.dump(invites)
@ -332,9 +316,9 @@ def get_user_invites(user_id):
@blueprint.route("/<user_id>/invites/<invite_id>/respond", methods=["PUT"])
@auth.requires_same_user_auth
@endpoint.api(
parameter('response', type=bool, required=True)
)
@body({
"response": fields.Bool(required=True)
})
def respond_to_invite(user_id, invite_id, response):
invite = ProposalTeamInvite.query.filter_by(id=invite_id).first()
if not invite:
@ -348,22 +332,22 @@ def respond_to_invite(user_id, invite_id, response):
db.session.add(invite)
db.session.commit()
return None, 200
return {"message": "ok"}, 200
@blueprint.route("/<user_id>/settings", methods=["GET"])
@auth.requires_same_user_auth
@endpoint.api()
def get_user_settings(user_id):
return user_settings_schema.dump(g.current_user.settings)
@blueprint.route("/<user_id>/settings", methods=["PUT"])
@auth.requires_same_user_auth
@endpoint.api(
parameter('emailSubscriptions', type=dict),
parameter('refundAddress', type=str)
)
@body({
"emailSubscriptions": fields.Dict(required=False, missing=None),
"refundAddress": fields.Str(required=False, missing=None,
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r}))
})
def set_user_settings(user_id, email_subscriptions, refund_address):
if email_subscriptions:
try:
@ -372,6 +356,8 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
except ValidationException as e:
return {"message": str(e)}, 400
if refund_address == '' and g.current_user.settings.refund_address:
return {"message": "Refund address cannot be unset, only changed"}, 400
if refund_address:
g.current_user.settings.refund_address = refund_address
@ -381,9 +367,9 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
@blueprint.route("/<user_id>/arbiter/<proposal_id>", methods=["PUT"])
@auth.requires_same_user_auth
@endpoint.api(
parameter('isAccept', type=bool)
)
@body({
"isAccept": fields.Bool(required=False, missing=None)
})
def set_user_arbiter(user_id, proposal_id, is_accept):
try:
proposal = Proposal.query.filter_by(id=int(proposal_id)).first()
@ -399,5 +385,3 @@ def set_user_arbiter(user_id, proposal_id, is_accept):
except ValidationException as e:
return {"message": str(e)}, 400
return user_settings_schema.dump(g.current_user.settings)

View File

@ -2,7 +2,7 @@ from functools import wraps
from datetime import datetime, timedelta
import sentry_sdk
from flask import request, g, jsonify, session
from flask import request, g, jsonify, session, current_app
from flask_security.core import current_user
from flask_security.utils import logout_user
from grant.proposal.models import Proposal
@ -156,10 +156,10 @@ def internal_webhook(f):
def decorated(*args, **kwargs):
secret = request.headers.get('authorization')
if not secret:
print('Internal webhook missing "Authorization" header')
current_app.logger.warn('Internal webhook missing "Authorization" header')
return jsonify(message="Invalid 'Authorization' header"), 403
if BLOCKCHAIN_API_SECRET not in secret:
print(f'Internal webhook provided invalid "Authorization" header: {secret}')
current_app.logger.warn(f'Internal webhook provided invalid "Authorization" header: {secret}')
return jsonify(message="Invalid 'Authorization' header"), 403
return f(*args, **kwargs)

View File

@ -4,7 +4,7 @@ import re
import string
import time
from grant.settings import SITE_URL
from grant.settings import SITE_URL, EXPLORER_URL
epoch = datetime.datetime.utcfromtimestamp(0)
RANDOM_CHARS = string.ascii_letters + string.digits
@ -37,6 +37,10 @@ def make_url(path: str):
return f'{SITE_URL}{path}'
def make_explore_url(txid: str):
return EXPLORER_URL.replace('<txid>', txid)
def is_email(email: str):
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
@ -64,3 +68,16 @@ def make_preview(content: str, max_length: int):
truncated = True
return content + '...' if truncated else content
def gen_random_id(model):
min_id = 100000
max_id = pow(2, 31) - 1
random_id = random.randint(min_id, max_id)
# If it already exists, generate a new one (recursively)
existing = model.query.filter_by(id=random_id).first()
if existing:
random_id = gen_random_id(model)
return random_id

View File

@ -121,7 +121,7 @@ class ProposalPagination(Pagination):
class ContributionPagination(Pagination):
def __init__(self):
self.FILTERS = [f'STATUS_{s}' for s in ContributionStatus.list()]
self.FILTERS.extend(['REFUNDABLE'])
self.FILTERS.extend(['REFUNDABLE', 'DONATION'])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': ProposalContribution.date_created.desc(),
@ -153,6 +153,7 @@ class ContributionPagination(Pagination):
if 'REFUNDABLE' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
@ -161,7 +162,20 @@ class ContributionPagination(Pagination):
)) \
.join(ProposalContribution.user) \
.join(UserSettings) \
.filter(UserSettings.refund_address != None) \
.filter(UserSettings.refund_address != None)
if 'DONATION' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.filter(or_(
ProposalContribution.no_refund == True,
ProposalContribution.user_id == None,
)) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
))
# SORT (see self.SORT_MAP)

View File

@ -1,5 +1,8 @@
import requests
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET
from flask import current_app
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET, E2E_TESTING
from grant.utils.exceptions import ValidationException
### REST API ###
@ -12,18 +15,55 @@ def handle_res(res):
def blockchain_get(path, params=None):
res = requests.get(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={'authorization': BLOCKCHAIN_API_SECRET},
params=params,
)
return handle_res(res)
if E2E_TESTING:
return blockchain_rest_e2e(path, params)
try:
res = requests.get(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={'authorization': BLOCKCHAIN_API_SECRET},
params=params,
)
return handle_res(res)
except Exception as e:
current_app.logger.error(f"Unable to contact node: {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):
res = requests.post(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={'authorization': BLOCKCHAIN_API_SECRET},
json=data,
)
return handle_res(res)
if E2E_TESTING:
return blockchain_rest_e2e(path, data)
try:
res = requests.post(
f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={'authorization': BLOCKCHAIN_API_SECRET},
json=data,
)
return handle_res(res)
except Exception as e:
current_app.logger.error(f"Unable to contact node: {e}")
raise e
def blockchain_rest_e2e(path, data):
if '/bootstrap' in path:
return {
'startHeight': 123,
'currentHeight': 456,
}
if '/contribution/addresses' in path:
return {
'transparent': 't123',
}
raise Exception(f'blockchain_post_e2e does not recognize path: {path}')

View File

@ -1,30 +0,0 @@
"""basic comment table moderation fields - hidden, reported
Revision ID: 02acd43b4357
Revises: 27975c4a04a4
Create Date: 2019-02-17 17:17:17.677275
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '02acd43b4357'
down_revision = '27975c4a04a4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('comment', sa.Column('hidden', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False))
op.add_column('comment', sa.Column('reported', sa.Boolean(), server_default=sa.text('FALSE'), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('comment', 'reported')
op.drop_column('comment', 'hidden')
# ### end Alembic commands ###

View File

@ -0,0 +1,247 @@
"""empty message
Revision ID: 0f08974b4118
Revises:
Create Date: 2019-03-14 14:38:40.894594
"""
from alembic import op
import sqlalchemy as sa
from grant.task.models import JsonEncodedDict
# revision identifiers, used by Alembic.
revision = '0f08974b4118'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('rfp',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('brief', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=255), nullable=False),
sa.Column('matching', sa.Boolean(), nullable=False),
sa.Column('bounty', sa.String(length=255), nullable=True),
sa.Column('date_closes', sa.DateTime(), nullable=True),
sa.Column('date_opened', sa.DateTime(), nullable=True),
sa.Column('date_closed', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('role',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=80), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('job_type', sa.Integer(), nullable=False),
sa.Column('blob', JsonEncodedDict(), nullable=False),
sa.Column('execute_after', sa.DateTime(), nullable=False),
sa.Column('completed', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email_address', sa.String(length=255), nullable=False),
sa.Column('password', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('is_admin', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False),
sa.Column('totp_secret', sa.String(length=255), nullable=True),
sa.Column('backup_codes', sa.String(), nullable=True),
sa.Column('silenced', sa.Boolean(), nullable=True),
sa.Column('banned', sa.Boolean(), nullable=True),
sa.Column('banned_reason', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email_address')
)
op.create_table('avatar',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('image_url', sa.String(length=255), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('email_recovery',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
sa.UniqueConstraint('code')
)
op.create_table('email_verification',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('has_verified', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
sa.UniqueConstraint('code')
)
op.create_table('proposal',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('rfp_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('brief', sa.String(length=255), nullable=False),
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('date_approved', sa.DateTime(), nullable=True),
sa.Column('date_published', sa.DateTime(), nullable=True),
sa.Column('reject_reason', sa.String(), nullable=True),
sa.Column('target', sa.String(length=255), nullable=False),
sa.Column('payout_address', sa.String(length=255), nullable=False),
sa.Column('deadline_duration', sa.Integer(), nullable=False),
sa.Column('contribution_matching', sa.Float(), server_default=sa.text('0'), nullable=False),
sa.Column('contribution_bounty', sa.String(length=255), server_default=sa.text("'0'"), nullable=False),
sa.Column('rfp_opt_in', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('roles_users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('social_media',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('service', sa.String(length=255), nullable=False),
sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('email_subscriptions', sa.Integer(), nullable=True),
sa.Column('refund_address', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('comment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('hidden', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False),
sa.Column('reported', sa.Boolean(), server_default=sa.text('FALSE'), nullable=True),
sa.Column('parent_comment_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['parent_comment_id'], ['comment.id'], ),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('milestone',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('index', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('payout_percent', sa.String(length=255), nullable=False),
sa.Column('immediate_payout', sa.Boolean(), nullable=True),
sa.Column('date_estimated', sa.DateTime(), nullable=False),
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('date_requested', sa.DateTime(), nullable=True),
sa.Column('requested_user_id', sa.Integer(), nullable=True),
sa.Column('date_rejected', sa.DateTime(), nullable=True),
sa.Column('reject_reason', sa.String(length=255), nullable=True),
sa.Column('reject_arbiter_id', sa.Integer(), nullable=True),
sa.Column('date_accepted', sa.DateTime(), nullable=True),
sa.Column('accept_arbiter_id', sa.Integer(), nullable=True),
sa.Column('date_paid', sa.DateTime(), nullable=True),
sa.Column('paid_tx_id', sa.String(length=255), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['accept_arbiter_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['reject_arbiter_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['requested_user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_arbiter',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_contribution',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.Column('amount', sa.String(length=255), nullable=False),
sa.Column('tx_id', sa.String(length=255), nullable=True),
sa.Column('refund_tx_id', sa.String(length=255), nullable=True),
sa.Column('staking', sa.Boolean(), nullable=False),
sa.Column('no_refund', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_team',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
)
op.create_table('proposal_team_invite',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('address', sa.String(length=255), nullable=False),
sa.Column('accepted', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_update',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('proposal_update')
op.drop_table('proposal_team_invite')
op.drop_table('proposal_team')
op.drop_table('proposal_contribution')
op.drop_table('proposal_arbiter')
op.drop_table('milestone')
op.drop_table('comment')
op.drop_table('user_settings')
op.drop_table('social_media')
op.drop_table('roles_users')
op.drop_table('proposal')
op.drop_table('email_verification')
op.drop_table('email_recovery')
op.drop_table('avatar')
op.drop_table('user')
op.drop_table('task')
op.drop_table('role')
op.drop_table('rfp')
# ### end Alembic commands ###

View File

@ -1,32 +0,0 @@
"""user banned & silenced fields
Revision ID: 27975c4a04a4
Revises: d39bb526eef4
Create Date: 2019-02-14 10:30:47.596818
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '27975c4a04a4'
down_revision = 'd39bb526eef4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('banned', sa.Boolean(), nullable=True))
op.add_column('user', sa.Column('banned_reason', sa.String(), nullable=True))
op.add_column('user', sa.Column('silenced', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'silenced')
op.drop_column('user', 'banned_reason')
op.drop_column('user', 'banned')
# ### end Alembic commands ###

View File

@ -1,30 +0,0 @@
"""empty message
Revision ID: 310dca400b81
Revises: fa1fedf4ca08
Create Date: 2019-02-05 11:24:11.291158
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '310dca400b81'
down_revision = 'fa1fedf4ca08'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('arbiter_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'proposal', 'user', ['arbiter_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'proposal', type_='foreignkey')
op.drop_column('proposal', 'arbiter_id')
# ### end Alembic commands ###

View File

@ -1,26 +0,0 @@
"""remove linkedin social_media items
Revision ID: 332a15eba9d8
Revises: 7c7cecfe5e6c
Create Date: 2019-02-23 19:51:16.284007
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '332a15eba9d8'
down_revision = '7c7cecfe5e6c'
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
connection.execute("DELETE FROM social_media WHERE service = 'LINKEDIN'")
def downgrade():
# there is no going back, all your precious linkedin profiles are gone now
pass

View File

@ -1,29 +0,0 @@
"""Add staking boolean column to proposal_contribution
Revision ID: 3514aaf4648f
Revises: c0646a888d4f
Create Date: 2019-02-21 12:24:15.800835
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import expression
# revision identifiers, used by Alembic.
revision = '3514aaf4648f'
down_revision = 'c0646a888d4f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('staking', sa.Boolean(), nullable=False, server_default=expression.false()))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('proposal_contribution', 'staking')
# ### end Alembic commands ###

View File

@ -1,52 +0,0 @@
"""milestone payment fields
Revision ID: 3793d9a71e27
Revises: 86d300cb6d69
Create Date: 2019-02-11 11:01:44.703413
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3793d9a71e27'
down_revision = '86d300cb6d69'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('milestone', sa.Column('accept_arbiter_id', sa.Integer(), nullable=True))
op.add_column('milestone', sa.Column('date_accepted', sa.DateTime(), nullable=True))
op.add_column('milestone', sa.Column('date_paid', sa.DateTime(), nullable=True))
op.add_column('milestone', sa.Column('date_rejected', sa.DateTime(), nullable=True))
op.add_column('milestone', sa.Column('date_requested', sa.DateTime(), nullable=True))
op.add_column('milestone', sa.Column('index', sa.Integer(), nullable=False))
op.add_column('milestone', sa.Column('paid_tx_id', sa.String(length=255), nullable=True))
op.add_column('milestone', sa.Column('reject_arbiter_id', sa.Integer(), nullable=True))
op.add_column('milestone', sa.Column('reject_reason', sa.String(length=255), nullable=True))
op.add_column('milestone', sa.Column('requested_user_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'milestone', 'user', ['accept_arbiter_id'], ['id'])
op.create_foreign_key(None, 'milestone', 'user', ['reject_arbiter_id'], ['id'])
op.create_foreign_key(None, 'milestone', 'user', ['requested_user_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'milestone', type_='foreignkey')
op.drop_constraint(None, 'milestone', type_='foreignkey')
op.drop_constraint(None, 'milestone', type_='foreignkey')
op.drop_column('milestone', 'requested_user_id')
op.drop_column('milestone', 'reject_reason')
op.drop_column('milestone', 'reject_arbiter_id')
op.drop_column('milestone', 'paid_tx_id')
op.drop_column('milestone', 'index')
op.drop_column('milestone', 'date_requested')
op.drop_column('milestone', 'date_rejected')
op.drop_column('milestone', 'date_paid')
op.drop_column('milestone', 'date_accepted')
op.drop_column('milestone', 'accept_arbiter_id')
# ### end Alembic commands ###

View File

@ -1,173 +0,0 @@
"""empty message
Revision ID: 4af29f8b2143
Revises:
Create Date: 2019-01-09 16:35:34.349666
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '4af29f8b2143'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('proposal',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('brief', sa.String(length=255), nullable=False),
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('date_approved', sa.DateTime(), nullable=True),
sa.Column('date_published', sa.DateTime(), nullable=True),
sa.Column('reject_reason', sa.String(), nullable=True),
sa.Column('target', sa.String(length=255), nullable=False),
sa.Column('payout_address', sa.String(length=255), nullable=False),
sa.Column('deadline_duration', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('role',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=80), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email_address', sa.String(length=255), nullable=False),
sa.Column('password', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email_address')
)
op.create_table('avatar',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('image_url', sa.String(length=255), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('comment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('parent_comment_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['parent_comment_id'], ['comment.id'], ),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('email_recovery',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
sa.UniqueConstraint('code')
)
op.create_table('email_verification',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('has_verified', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
sa.UniqueConstraint('code')
)
op.create_table('milestone',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('payout_percent', sa.String(length=255), nullable=False),
sa.Column('immediate_payout', sa.Boolean(), nullable=True),
sa.Column('date_estimated', sa.DateTime(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_contribution',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.Column('amount', sa.String(length=255), nullable=False),
sa.Column('tx_id', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_team',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
)
op.create_table('proposal_team_invite',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('address', sa.String(length=255), nullable=False),
sa.Column('accepted', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('proposal_update',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('roles_users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('social_media',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('service', sa.String(length=255), nullable=False),
sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('social_media')
op.drop_table('roles_users')
op.drop_table('proposal_update')
op.drop_table('proposal_team_invite')
op.drop_table('proposal_team')
op.drop_table('proposal_contribution')
op.drop_table('milestone')
op.drop_table('email_verification')
op.drop_table('email_recovery')
op.drop_table('comment')
op.drop_table('avatar')
op.drop_table('user')
op.drop_table('role')
op.drop_table('proposal')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""user is_admin field
Revision ID: 4e5d9f481f22
Revises: 3514aaf4648f
Create Date: 2019-02-20 11:30:30.376869
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4e5d9f481f22'
down_revision = '3514aaf4648f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('is_admin', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'is_admin')
# ### end Alembic commands ###

View File

@ -1,35 +0,0 @@
"""empty message
Revision ID: 722b4e7f7a58
Revises: e0d970ed6500
Create Date: 2019-01-28 20:46:13.497530
"""
from alembic import op
import sqlalchemy as sa
from grant.task.models import JsonEncodedDict
# revision identifiers, used by Alembic.
revision = '722b4e7f7a58'
down_revision = 'e0d970ed6500'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('job_type', sa.Integer(), nullable=False),
sa.Column('blob', JsonEncodedDict(), nullable=False),
sa.Column('execute_after', sa.DateTime(), nullable=False),
sa.Column('completed', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('task')
# ### end Alembic commands ###

View File

@ -1,26 +0,0 @@
"""Convert REFUNDING stage to FAILED
Revision ID: 7c7cecfe5e6c
Revises: 9ad68ecf85aa
Create Date: 2019-02-22 13:15:44.997884
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7c7cecfe5e6c'
down_revision = '9ad68ecf85aa'
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
connection.execute("UPDATE proposal SET stage = 'FAILED' WHERE stage = 'REFUNDING'")
def downgrade():
connection = op.get_bind()
connection.execute("UPDATE proposal SET stage = 'REFUNDING' WHERE stage = 'FAILED'")

View File

@ -1,40 +0,0 @@
"""proposal_arbiter table
Revision ID: 86d300cb6d69
Revises: 310dca400b81
Create Date: 2019-02-08 13:06:39.201691
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '86d300cb6d69'
down_revision = '310dca400b81'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('proposal_arbiter',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey')
op.drop_column('proposal', 'arbiter_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True))
op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id'])
op.drop_table('proposal_arbiter')
# ### end Alembic commands ###

View File

@ -1,30 +0,0 @@
"""2fa user fields: backup_codes & totp_secret
Revision ID: 9ad68ecf85aa
Revises: 4e5d9f481f22
Create Date: 2019-02-21 13:26:32.715454
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9ad68ecf85aa'
down_revision = '4e5d9f481f22'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('backup_codes', sa.String(), nullable=True))
op.add_column('user', sa.Column('totp_secret', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'totp_secret')
op.drop_column('user', 'backup_codes')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""Add refund_tx_id to proposal_contributions
Revision ID: c0646a888d4f
Revises: ebccb1298297
Create Date: 2019-02-17 11:36:45.851391
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c0646a888d4f'
down_revision = 'ebccb1298297'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('refund_tx_id', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('proposal_contribution', 'refund_tx_id')
# ### end Alembic commands ###

View File

@ -1,51 +0,0 @@
"""Adds RFP bounty, matching, and date fields
Revision ID: d39bb526eef4
Revises: 3793d9a71e27
Create Date: 2019-02-07 15:09:11.548655
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import expression
from datetime import datetime, timedelta
# revision identifiers, used by Alembic.
revision = 'd39bb526eef4'
down_revision = '3793d9a71e27'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('rfp', sa.Column('bounty', sa.String(length=255), nullable=True))
op.add_column('rfp', sa.Column('date_closed', sa.DateTime(), nullable=True))
op.add_column('rfp', sa.Column('date_closes', sa.DateTime(), nullable=True))
op.add_column('rfp', sa.Column('date_opened', sa.DateTime(), nullable=True))
op.add_column('rfp', sa.Column('matching', sa.Boolean(), nullable=False, server_default=expression.false()))
# ### end Alembic commands ###
# Set columns for times based on status.
connection = op.get_bind()
connection.execute("UPDATE rfp SET date_opened = now() - INTERVAL '1 DAY' WHERE status = 'LIVE' OR status = 'CLOSED'")
connection.execute("UPDATE rfp SET date_closed = now() WHERE status = 'CLOSED'")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rfp', 'matching')
op.drop_column('rfp', 'date_opened')
op.drop_column('rfp', 'date_closes')
op.drop_column('rfp', 'date_closed')
op.drop_column('rfp', 'bounty')
op.create_table('rfp_proposal',
sa.Column('rfp_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('proposal_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name='rfp_proposal_proposal_id_fkey'),
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], name='rfp_proposal_rfp_id_fkey'),
sa.UniqueConstraint('proposal_id', name='rfp_proposal_proposal_id_key')
)
# ### end Alembic commands ###

View File

@ -1,33 +0,0 @@
"""empty message
Revision ID: e0d970ed6500
Revises: 4af29f8b2143
Create Date: 2019-01-10 14:44:42.536248
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'e0d970ed6500'
down_revision = '4af29f8b2143'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email_subscriptions', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_settings')
# ### end Alembic commands ###

View File

@ -1,28 +0,0 @@
"""Adds refund_address to user_settings
Revision ID: ebccb1298297
Revises: 02acd43b4357
Create Date: 2019-02-16 11:37:46.900729
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ebccb1298297'
down_revision = '02acd43b4357'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user_settings', sa.Column('refund_address', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_settings', 'refund_address')
# ### end Alembic commands ###

View File

@ -1,29 +0,0 @@
"""empty message
Revision ID: eddbe541cff1
Revises: 722b4e7f7a58
Create Date: 2019-01-24 11:20:32.989266
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eddbe541cff1'
down_revision = '722b4e7f7a58'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('contribution_matching',
sa.Float(), server_default=sa.text('0'), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('proposal', 'contribution_matching')
# ### end Alembic commands ###

View File

@ -1,45 +0,0 @@
"""empty message
Revision ID: edf057ef742a
Revises: eddbe541cff1
Create Date: 2019-01-25 14:37:07.858965
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'edf057ef742a'
down_revision = 'eddbe541cff1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('rfp',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('brief', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('rfp_proposal',
sa.Column('rfp_id', sa.Integer(), nullable=True),
sa.Column('proposal_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], ),
sa.UniqueConstraint('proposal_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('rfp_proposal')
op.drop_table('rfp')
# ### end Alembic commands ###

View File

@ -1,38 +0,0 @@
"""empty message
Revision ID: fa1fedf4ca08
Revises: edf057ef742a
Create Date: 2019-01-30 19:12:04.385472
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fa1fedf4ca08'
down_revision = 'edf057ef742a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('rfp_proposal')
op.add_column('proposal', sa.Column('rfp_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'proposal', 'rfp', ['rfp_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'proposal', type_='foreignkey')
op.drop_column('proposal', 'rfp_id')
op.create_table('rfp_proposal',
sa.Column('rfp_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('proposal_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], name='rfp_proposal_proposal_id_fkey'),
sa.ForeignKeyConstraint(['rfp_id'], ['rfp.id'], name='rfp_proposal_rfp_id_fkey'),
sa.UniqueConstraint('proposal_id', name='rfp_proposal_proposal_id_key')
)
# ### end Alembic commands ###

View File

@ -53,11 +53,8 @@ markdownify
# email
sendgrid==5.6.0
# input validation
flask-yolo2API==0.2.6
#sentry
sentry-sdk[flask]==0.5.5
sentry-sdk[flask]==0.7.6
#boto3 (AWS sdk)
boto3==1.9.52
@ -71,5 +68,20 @@ Flask-Security==3.0.0
# oauth
requests-oauthlib==1.0.0
# request parsing
webargs==5.1.3
# 2fa - totp
pyotp==2.2.7
# JSON formatting
animal_case==0.4.1
# Rate limiting
Flask-Limiter==1.0.1
# validate email
validate_email==1.3
# validate URLS
validators==0.12.4

View File

@ -72,9 +72,9 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def assert_autherror(self, resp, contains):
# this should be 403
self.assert500(resp)
print(f'...check that [{resp.json["data"]}] contains [{contains}]')
self.assertTrue(contains in resp.json['data'])
self.assert403(resp)
print(f'...check that [{resp.json["message"]}] contains [{contains}]')
self.assertTrue(contains in resp.json['message'])
# happy path (mostly)
def test_admin_2fa_setup_flow(self):
@ -245,22 +245,24 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def test_update_proposal(self):
self.login_admin()
# set to 1 (on)
resp_on = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 1})
resp_on = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
data=json.dumps({"contributionMatching": 1}))
self.assert200(resp_on)
self.assertEqual(resp_on.json['contributionMatching'], 1)
resp_off = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 0})
resp_off = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}",
data=json.dumps({"contributionMatching": 0}))
self.assert200(resp_off)
self.assertEqual(resp_off.json['contributionMatching'], 0)
def test_update_proposal_no_auth(self):
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 1})
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 1}))
self.assert401(resp)
def test_update_proposal_bad_matching(self):
self.login_admin()
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 2})
self.assert500(resp)
self.assertIn('Bad value', resp.json['data'])
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data=json.dumps({"contributionMatching": 2}))
self.assert400(resp)
self.assertTrue(resp.json['message'])
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_approve_proposal(self, mock_get):
@ -272,7 +274,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# approve
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data={"isApprove": True}
data=json.dumps({"isApprove": True})
)
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.APPROVED)
@ -287,7 +289,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# reject
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
data={"isApprove": False, "rejectReason": "Funnzies."}
data=json.dumps({"isApprove": False, "rejectReason": "Funnzies."})
)
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
@ -301,10 +303,41 @@ class TestAdminAPI(BaseProposalCreatorConfig):
# nominate arbiter
resp = self.app.put(
"/api/v1/admin/arbiters",
data={
data=json.dumps({
'proposalId': self.proposal.id,
'userId': self.other_user.id
}
})
)
self.assert200(resp)
# TODO - more tests
def test_create_rfp_succeeds(self):
self.login_admin()
resp = self.app.post(
"/api/v1/admin/rfps",
data=json.dumps({
"brief": "Some brief",
"category": "CORE_DEV",
"content": "CONTENT",
"dateCloses": 1553980004,
"status": "DRAFT",
"title": "TITLE"
})
)
self.assert200(resp)
def test_create_rfp_fails_with_bad_category(self):
self.login_admin()
resp = self.app.post(
"/api/v1/admin/rfps",
data=json.dumps({
"brief": "Some brief",
"category": "NOT_CORE_DEV",
"content": "CONTENT",
"dateCloses": 1553980004,
"status": "DRAFT",
"title": "TITLE"
})
)
self.assert400(resp)

View File

@ -1,14 +1,18 @@
import json
from mock import patch
from datetime import datetime
from datetime import timedelta
from flask_testing import TestCase
from mock import patch
from grant.app import create_app
from grant.proposal.models import Proposal, ProposalContribution
from grant.extensions import limiter
from grant.milestone.models import Milestone
from grant.proposal.models import Proposal
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.task.jobs import ProposalReminder
from grant.user.models import User, SocialMedia, db, Avatar
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.utils.enums import ProposalStatus
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests
@ -17,6 +21,7 @@ class BaseTestConfig(TestCase):
def create_app(self):
app = create_app(['grant.settings', 'tests.settings'])
app.config.from_object('tests.settings')
limiter.enabled = False
return app
def setUp(self):
@ -34,7 +39,7 @@ class BaseTestConfig(TestCase):
"""
message = message or 'HTTP Status %s expected but got %s. Response json: %s' \
% (status_code, response.status_code, response.json or response.data)
% (status_code, response.status_code, response.json or response.data)
self.assertEqual(response.status_code, status_code, message)
assert_status = assertStatus
@ -127,6 +132,26 @@ class BaseProposalCreatorConfig(BaseUserConfig):
)
self._proposal.team.append(self.user)
db.session.add(self._proposal)
db.session.flush()
milestones = [
{
"title": "Milestone 1",
"content": "Content 1",
"date_estimated": (datetime.now() + timedelta(days=364)).timestamp(), # random unix time in the future
"payout_percent": 50,
"immediate_payout": True
},
{
"title": "Milestone 2",
"content": "Content 2",
"date_estimated": (datetime.now() + timedelta(days=365)).timestamp(), # random unix time in the future
"payout_percent": 50,
"immediate_payout": False
}
]
Milestone.make(milestones, self._proposal)
self._other_proposal = Proposal.create(status=ProposalStatus.DRAFT)
self._other_proposal.team.append(self.other_user)

View File

@ -1,10 +1,10 @@
import json
from mock import patch
from grant.proposal.models import Proposal, db
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus
from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal, mock_blockchain_api_requests, mock_invalid_address
@ -45,7 +45,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
data=json.dumps(new_proposal),
content_type='application/json'
)
print(resp)
self.assert200(resp)
self.assertEqual(resp.json["title"], new_title)
self.assertEqual(self.proposal.title, new_title)
@ -62,6 +62,51 @@ class TestProposalAPI(BaseProposalCreatorConfig):
)
self.assert401(resp)
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_update_live_proposal_fails(self, mock_get):
self.login_default_user()
self.proposal.status = ProposalStatus.APPROVED
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert200(resp)
self.assertEqual(resp.json["status"], "LIVE")
resp = self.app.put(
"/api/v1/proposals/{}".format(self.proposal.id),
data=json.dumps(test_proposal),
content_type='application/json'
)
self.assert400(resp)
def test_update_pending_proposal_fails(self):
self.login_default_user()
self.proposal.status = ProposalStatus.PENDING
db.session.add(self.proposal)
db.session.commit()
resp = self.app.get("/api/v1/proposals/{}".format(self.proposal.id))
self.assert200(resp)
self.assertEqual(resp.json["status"], "PENDING")
resp = self.app.put(
"/api/v1/proposals/{}".format(self.proposal.id),
data=json.dumps(test_proposal),
content_type='application/json'
)
self.assert400(resp)
def test_update_rejected_proposal_succeeds(self):
self.login_default_user()
self.proposal.status = ProposalStatus.REJECTED
db.session.add(self.proposal)
db.session.commit()
resp = self.app.get("/api/v1/proposals/{}".format(self.proposal.id))
self.assert200(resp)
self.assertEqual(resp.json["status"], "REJECTED")
resp = self.app.put(
"/api/v1/proposals/{}".format(self.proposal.id),
data=json.dumps(test_proposal),
content_type='application/json'
)
self.assert200(resp)
def test_invalid_proposal_update_proposal_draft(self):
new_title = "Updated!"
new_proposal = test_proposal.copy()

View File

@ -2,9 +2,8 @@ import json
from grant.proposal.models import Proposal, db
from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig
from ..test_data import test_comment, test_reply
from ..test_data import test_comment, test_reply, test_comment_large
class TestProposalCommentAPI(BaseUserConfig):
@ -112,6 +111,25 @@ class TestProposalCommentAPI(BaseUserConfig):
self.assertStatus(comment_res, 403)
def test_create_new_proposal_comment_fails_with_large_comment(self):
self.login_default_user()
proposal = Proposal(
status="LIVE"
)
db.session.add(proposal)
db.session.commit()
proposal_id = proposal.id
comment_res = self.app.post(
"/api/v1/proposals/{}/comments".format(proposal_id),
data=json.dumps(test_comment_large),
content_type='application/json'
)
self.assertStatus(comment_res, 400)
self.assertIn('less than', comment_res.json['message'])
def test_create_new_proposal_comment_fails_with_silenced_user(self):
self.login_default_user()
self.user.set_silenced(True)

View File

@ -1,6 +1,5 @@
from .mocks import mock_request
from grant.utils.enums import Category
from .mocks import mock_request
test_user = {
"displayName": 'Groot',
@ -39,7 +38,6 @@ milestones = [
test_proposal = {
"team": test_team,
"crowdFundContractAddress": "0x20000",
"content": "## My Proposal",
"title": "Give Me Money",
"brief": "$$$",
@ -54,6 +52,26 @@ test_comment = {
"comment": "Test comment"
}
test_comment_large = {
"comment": """
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"""
}
test_reply = {
"comment": "Test reply"
# Fill in parentCommentId in test

View File

@ -47,7 +47,10 @@ class TestUserInviteAPI(BaseProposalCreatorConfig):
self.assertStatus(invites_res, 200)
# Make sure we made the team, coach
self.assertTrue(len(self.other_proposal.team) == 2) # TODO: More thorough check than length
print(self.other_proposal.team)
self.assertTrue(len(self.other_proposal.team) == 2)
team_ids = [t.id for t in self.other_proposal.team]
self.assertIn(self.user.id, team_ids, 'user should be in team')
def test_put_user_invite_response_reject(self):
invite = ProposalTeamInvite(
@ -67,7 +70,9 @@ class TestUserInviteAPI(BaseProposalCreatorConfig):
self.assertStatus(invites_res, 200)
# Make sure we made the team, coach
self.assertTrue(len(self.other_proposal.team) == 1) # TODO: More thorough check than length
self.assertTrue(len(self.other_proposal.team) == 1)
team_ids = [t.id for t in self.other_proposal.team]
self.assertNotIn(self.user.id, team_ids, 'user should NOT be in team')
def test_no_auth_put_user_invite_response(self):
invite = ProposalTeamInvite(

View File

@ -34,14 +34,6 @@ class TestUserAPI(BaseUserConfig):
# should not be able to add social
self.assertFalse(user_db.social_medias)
def test_get_all_users(self):
users_get_resp = self.app.get(
"/api/v1/users/"
)
self.assert200(users_get_resp)
users_json = users_get_resp.json
self.assertEqual(users_json[0]["displayName"], self.user.display_name)
def test_get_single_user_by_id(self):
users_get_resp = self.app.get(
"/api/v1/users/{}".format(self.user.id)
@ -104,10 +96,10 @@ class TestUserAPI(BaseUserConfig):
}),
content_type="application/json"
)
# self.assert403(user_auth_resp)
# self.assertTrue(user_auth_resp.json['message'] is not None)
self.assert500(user_auth_resp)
self.assertIn('Invalid pass', user_auth_resp.json['data'])
self.assert403(user_auth_resp)
self.assertTrue(user_auth_resp.json['message'] is not None)
# self.assert500(user_auth_resp)
# self.assertIn('Invalid pass', user_auth_resp.json['data'])
def test_user_auth_bad_email(self):
user_auth_resp = self.app.post(
@ -118,10 +110,10 @@ class TestUserAPI(BaseUserConfig):
}),
content_type="application/json"
)
# self.assert400(user_auth_resp)
# self.assertTrue(user_auth_resp.json['message'] is not None)
self.assert500(user_auth_resp)
self.assertIn('No user', user_auth_resp.json['data'])
self.assert403(user_auth_resp)
self.assertTrue(user_auth_resp.json['message'] is not None)
# self.assert500(user_auth_resp)
# self.assertIn('No user', user_auth_resp.json['data'])
def test_user_auth_banned(self):
self.user.set_banned(True, 'reason for banning')
@ -134,8 +126,8 @@ class TestUserAPI(BaseUserConfig):
content_type="application/json"
)
# in test mode we get 500s instead of 403
self.assert500(user_auth_resp)
self.assertIn('banned', user_auth_resp.json['data'])
self.assert403(user_auth_resp)
self.assertIn('banned', user_auth_resp.json['message'])
def test_create_user_duplicate_400(self):
# self.user is identical to test_user, should throw
@ -152,7 +144,7 @@ class TestUserAPI(BaseUserConfig):
self.login_default_user()
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
updated_user["displayName"] = 'new display name'
updated_user["avatar"] = {}
updated_user["avatar"] = ''
updated_user["socialMedias"] = []
user_update_resp = self.app.put(
@ -163,11 +155,12 @@ class TestUserAPI(BaseUserConfig):
self.assert200(user_update_resp, user_update_resp.json)
user_json = user_update_resp.json
print(user_json)
self.assertFalse(user_json["avatar"])
self.assertFalse(len(user_json["socialMedias"]))
self.assertEqual(user_json["displayName"], updated_user["displayName"])
self.assertEqual(user_json["title"], updated_user["title"])
mock_remove_avatar.assert_called_with(test_user["avatar"]["link"], 1)
mock_remove_avatar.assert_called_with(test_user["avatar"]["link"], self.user.id)
def test_update_user_400_when_required_param_not_passed(self):
self.login_default_user()
@ -253,8 +246,9 @@ class TestUserAPI(BaseUserConfig):
content_type='application/json'
)
# 404 outside testing mode
self.assertStatus(response, 500)
self.assertIn('banned', response.json['data'])
self.assertStatus(response, 403)
print(response.json)
self.assertIn('banned', response.json['message'])
def test_recover_user_no_user(self):
response = self.app.post(
@ -301,8 +295,8 @@ class TestUserAPI(BaseUserConfig):
content_type='application/json'
)
# 403 outside of testing mode
self.assertStatus(reset_resp, 500)
self.assertIn('banned', reset_resp.json['data'])
self.assertStatus(reset_resp, 403)
self.assertIn('banned', reset_resp.json['message'])
@patch('grant.user.views.verify_social')
def test_user_verify_social(self, mock_verify_social):

View File

@ -30,7 +30,7 @@
"typescript": "^3.2.1"
},
"dependencies": {
"@sentry/node": "4.4.2",
"@sentry/node": "4.6.4",
"@types/cors": "2.8.4",
"@types/dotenv": "^6.1.0",
"@types/ws": "^6.0.1",

View File

@ -16,7 +16,7 @@ async function start() {
log.info("============== Starting services ==============");
await initNode();
await RestServer.start();
await Webhooks.start();
Webhooks.start();
log.info("===============================================");
}

View File

@ -33,7 +33,6 @@ export interface VOut {
scriptPubKey: ScriptPubKey;
}
export interface Transaction {
txid: string;
hex: string;
@ -46,7 +45,7 @@ export interface Transaction {
time: number;
vin: VIn[];
vout: VOut[];
// TODO: fill me out, what is this?
// unclear what vjoinsplit is
vjoinsplit: any[];
}
@ -74,7 +73,6 @@ export interface BlockWithTransactionIds extends Block {
tx: string[];
}
export interface BlockWithTransactions extends Block {
tx: Transaction[];
}
@ -107,33 +105,44 @@ export interface ValidationResponse {
isvalid: boolean;
}
// TODO: Type all methods with signatures from
// https://github.com/zcash/zcash/blob/master/doc/payment-api.md
interface ZCashNode {
getblockchaininfo: () => Promise<BlockChainInfo>;
getblockcount: () => Promise<number>;
getblock: {
(numberOrHash: string | number, verbosity?: 1): Promise<BlockWithTransactionIds>;
(numberOrHash: string | number, verbosity: 2): Promise<BlockWithTransactions>;
(numberOrHash: string | number, verbosity?: 1): Promise<
BlockWithTransactionIds
>;
(numberOrHash: string | number, verbosity: 2): Promise<
BlockWithTransactions
>;
(numberOrHash: string | number, verbosity: 0): Promise<string>;
}
};
gettransaction: (txid: string) => Promise<Transaction>;
validateaddress: (address: string) => Promise<ValidationResponse>;
z_getbalance: (address: string, minConf?: number) => Promise<number>;
z_getnewaddress: (type?: 'sprout' | 'sapling') => Promise<string>;
z_getnewaddress: (type?: "sprout" | "sapling") => Promise<string>;
z_listaddresses: () => Promise<string[]>;
z_listreceivedbyaddress: (address: string, minConf?: number) => Promise<Receipt[]>;
z_importviewingkey: (key: string, rescan?: 'yes' | 'no' | 'whenkeyisnew', startHeight?: number) => Promise<void>;
z_listreceivedbyaddress: (
address: string,
minConf?: number
) => Promise<Receipt[]>;
z_importviewingkey: (
key: string,
rescan?: "yes" | "no" | "whenkeyisnew",
startHeight?: number
) => Promise<void>;
z_exportviewingkey: (zaddr: string) => Promise<string>;
z_validatepaymentdisclosure: (disclosure: string) => Promise<DisclosedPayment>;
z_validatepaymentdisclosure: (
disclosure: string
) => Promise<DisclosedPayment>;
z_validateaddress: (address: string) => Promise<ValidationResponse>;
}
export const rpcOptions = {
url: env.ZCASH_NODE_URL,
username: env.ZCASH_NODE_USERNAME,
password: env.ZCASH_NODE_PASSWORD,
password: env.ZCASH_NODE_PASSWORD
};
const node: ZCashNode = stdrpc(rpcOptions);
@ -152,28 +161,31 @@ export async function initNode() {
}
if (info.chain.includes("test")) {
network = bitcore.Networks.testnet;
}
else {
} else {
network = bitcore.Networks.mainnet;
}
}
catch(err) {
} catch (err) {
captureException(err);
log.error(err.response ? err.response.data : err);
log.error('Failed to connect to zcash node with the following credentials:\r\n', rpcOptions);
log.error(
"Failed to connect to zcash node with the following credentials:\r\n",
rpcOptions
);
process.exit(1);
}
// Check if sprout address is readable
try {
if (!env.SPROUT_ADDRESS) {
console.error('Missing SPROUT_ADDRESS environment variable, exiting');
console.error("Missing SPROUT_ADDRESS environment variable, exiting");
process.exit(1);
}
await node.z_getbalance(env.SPROUT_ADDRESS as string);
} catch(err) {
} catch (err) {
if (!env.SPROUT_VIEWKEY) {
log.error('Unable to view SPROUT_ADDRESS and missing SPROUT_VIEWKEY environment variable, exiting');
log.error(
"Unable to view SPROUT_ADDRESS and missing SPROUT_VIEWKEY environment variable, exiting"
);
process.exit(1);
}
await node.z_importviewingkey(env.SPROUT_VIEWKEY as string);
@ -183,7 +195,7 @@ export async function initNode() {
export function getNetwork() {
if (!network) {
throw new Error('Called getNetwork before initNode');
throw new Error("Called getNetwork before initNode");
}
return network;
}
@ -194,10 +206,15 @@ export async function getBootstrapBlockHeight(txid: string | undefined) {
try {
const tx = await node.gettransaction(txid);
const block = await node.getblock(tx.blockhash);
return block.height.toString();
} catch(err) {
console.warn(`Attempted to get block height for tx ${txid} but failed with the following error:\n`, err);
console.warn('Falling back to hard-coded starter blocks');
const height =
block.height - parseInt(env.MINIMUM_BLOCK_CONFIRMATIONS, 10);
return height.toString();
} catch (err) {
console.warn(
`Attempted to get block height for tx ${txid} but failed with the following error:\n`,
err
);
console.warn("Falling back to hard-coded starter blocks");
}
}
@ -206,11 +223,10 @@ export async function getBootstrapBlockHeight(txid: string | undefined) {
const net = getNetwork();
if (net === bitcore.Networks.mainnet) {
return env.MAINNET_START_BLOCK;
}
else if (net === bitcore.Networks.testnet && !net.regtestEnabled) {
} else if (net === bitcore.Networks.testnet && !net.regtestEnabled) {
return env.TESTNET_START_BLOCK;
}
// Regtest or otherwise unknown networks should start at the bottom
return '0';
return "0";
}

View File

@ -21,8 +21,7 @@ export function authenticate(secret: string) {
return hash === sha256(secret);
}
// TODO: Not fully confident in compatibility with most bip32 wallets,
// do more work to ensure this is reliable.
// NOTE: this is just one way to derive t-addrs
export function deriveTransparentAddress(index: number, network: any) {
const root = new HDPublicKey(env.BIP32_XPUB);
const child = root.derive(`m/0/${index}`);
@ -39,14 +38,16 @@ export function removeItem<T>(arr: T[], remove: T) {
}
export function encodeHexMemo(memo: string) {
return new Buffer(memo, 'utf8').toString('hex');
return new Buffer(memo, "utf8").toString("hex");
}
export function decodeHexMemo(memoHex: string) {
return new Buffer(memoHex, 'hex')
.toString()
// Remove null bytes from zero padding
.replace(/\0.*$/g, '');
return (
new Buffer(memoHex, "hex")
.toString()
// Remove null bytes from zero padding
.replace(/\0.*$/g, "")
);
}
export function makeContributionMemo(contributionId: number) {
@ -54,14 +55,15 @@ export function makeContributionMemo(contributionId: number) {
}
export function getContributionIdFromMemo(memoHex: string) {
const matches = decodeHexMemo(memoHex).match(/Contribution ([0-9]+) on Grant\.io/);
const matches = decodeHexMemo(memoHex).match(
/Contribution ([0-9]+) on Grant\.io/
);
if (matches && matches[1]) {
return parseInt(matches[1], 10);
}
return false;
}
// TODO: Make this more robust
export function toBaseUnit(unit: number) {
return Math.floor(100000000 * unit);
}

View File

@ -17,7 +17,13 @@ const MIN_BLOCK_CONF = parseInt(env.MINIMUM_BLOCK_CONFIRMATIONS, 10);
export async function start() {
initScan();
initNotifiers();
await requestBootstrap();
let { startingBlockHeight } = store.getState();
while (startingBlockHeight === undefined || startingBlockHeight === null) {
await requestBootstrap();
await sleep(10000);
startingBlockHeight = store.getState().startingBlockHeight;
}
}
export function exit() {

View File

@ -2,62 +2,63 @@
# yarn lockfile v1
"@sentry/core@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.4.2.tgz#562526bc634c087f04bbca68b09cedc4b41cc64d"
integrity sha512-hJyAodTCf4sZfVdf41Rtuzj4EsyzYq5rdMZ+zc2Vinwdf8D0/brHe91fHeO0CKXEb2P0wJsrjwMidG/ccq/M8A==
"@sentry/core@4.6.4":
version "4.6.4"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.6.4.tgz#7236e08115423b81b96a13c2c37f29bcc1477745"
integrity sha512-NGl2nkAaQ8dGqJAMS1Hb+7RyVjW4tmCbK6d7H/zKnOpBuU+qSW4XCm2NoGLLa8qb4SZUPIBRv6U0ByvEQlGtqw==
dependencies:
"@sentry/hub" "4.4.2"
"@sentry/minimal" "4.4.2"
"@sentry/types" "4.4.2"
"@sentry/utils" "4.4.2"
"@sentry/hub" "4.6.4"
"@sentry/minimal" "4.6.4"
"@sentry/types" "4.5.3"
"@sentry/utils" "4.6.4"
tslib "^1.9.3"
"@sentry/hub@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.4.2.tgz#1399556fda06fb83c4f186c4aa842725f520159c"
integrity sha512-oe9ytXkTWyD+QmOpVzHAqTbRV4Hc0ee2Nt6HvrDtRmlXzQxfvTWG2F8KYT6w8kzqg5klnuRpnsmgTTV3KuNBVQ==
"@sentry/hub@4.6.4":
version "4.6.4"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.6.4.tgz#2bd5d67ccd43d4f5afc45005a330a11b14d46cea"
integrity sha512-R3ACxUZbrAMP6vyIvt1k4bE3OIyg1CzbEhzknKljPrk1abVmJVP7W/X1vBysdRtI3m/9RjOSO7Lxx3XXqoHoQg==
dependencies:
"@sentry/types" "4.4.2"
"@sentry/utils" "4.4.2"
"@sentry/types" "4.5.3"
"@sentry/utils" "4.6.4"
tslib "^1.9.3"
"@sentry/minimal@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.4.2.tgz#13fffc6b17a2401b6a79947838a637626ab80b10"
integrity sha512-GEZZiNvVgqFAESZhAe3vjwTInn13lI2bSI3ItQN4RUWKL/W4n/fwVoDJbkb1U8aWxanuMnRDEpKwyQv6zYTZfw==
"@sentry/minimal@4.6.4":
version "4.6.4"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.6.4.tgz#dc4bb47df90dad6025d832852ac11fe29ed50147"
integrity sha512-jZa9mfzDzJI98tg6uxFG3gdVLyz0nOHpLP9H8Kn/BelZ7WEG/ogB8PDi1hI9JvCTXAr8kV81mEecldADa9L9Yg==
dependencies:
"@sentry/hub" "4.4.2"
"@sentry/types" "4.4.2"
"@sentry/hub" "4.6.4"
"@sentry/types" "4.5.3"
tslib "^1.9.3"
"@sentry/node@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.4.2.tgz#549921d2df3cbf58ebcfb525c3005c3fec4739a3"
integrity sha512-8/KlSdfVhledZ6PS6muxZY5r2pqhw8MNSXP7AODR2qRrHwsbnirVgV21WIAYAjKXEfYQGbm69lyoaTJGazlQ3Q==
"@sentry/node@4.6.4":
version "4.6.4"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.6.4.tgz#933c2e3ce93bc7861de6d4310ed1fe66f85da301"
integrity sha512-nfaLB+cE0dddjWD0yI0nB/UqXkPw/6FKDRpB1NZ61amAM4QRXa4hRTdHvqjUovzV/5/pVMQYsOyCk0pNWMtMUQ==
dependencies:
"@sentry/core" "4.4.2"
"@sentry/hub" "4.4.2"
"@sentry/types" "4.4.2"
"@sentry/utils" "4.4.2"
"@sentry/core" "4.6.4"
"@sentry/hub" "4.6.4"
"@sentry/types" "4.5.3"
"@sentry/utils" "4.6.4"
"@types/stack-trace" "0.0.29"
cookie "0.3.1"
https-proxy-agent "^2.2.1"
https-proxy-agent "2.2.1"
lru_map "0.3.3"
lsmod "1.0.0"
stack-trace "0.0.10"
tslib "^1.9.3"
"@sentry/types@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.4.2.tgz#f38dd3bc671cd2f5983a85553aebeac9c2286b17"
integrity sha512-QyQd6PKKIyjJgaq/RQjsxPJEWbXcuiWZ9RvSnhBjS5jj53HEzkM1qkbAFqlYHJ1DTJJ1EuOM4+aTmGzHe93zuA==
"@sentry/types@4.5.3":
version "4.5.3"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.5.3.tgz#3350dce2b7f9b936a8c327891c12e3aef7bd8852"
integrity sha512-7ll1PAFNjrBNX9rzy3P2qAQrpQwHaDO3uKj735qsnGw34OtAS8Xr8WYrjI14f9fMPa/XIeWvMPb4GMic28V/ag==
"@sentry/utils@4.4.2":
version "4.4.2"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.4.2.tgz#e05a47e135ecef29e63a996f59aee8c8f792c222"
integrity sha512-j/Ad8G1abHlJdD2q7aWWbSOSeWB5M5v1R1VKL8YPlwEbSvvmEQWePhBKFI0qlnKd2ObdUQsj86pHEXJRSFNfCw==
"@sentry/utils@4.6.4":
version "4.6.4"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.6.4.tgz#ca254c142b519b4f20d63c2f9edf1a89966be36f"
integrity sha512-Tc5R46z7ve9Z+uU34ceDoEUR7skfQgXVIZqjbrTQphgm6EcMSNdRfkK3SJYZL5MNKiKhb7Tt/O3aPBy5bTZy6w==
dependencies:
"@sentry/types" "4.4.2"
"@sentry/types" "4.5.3"
tslib "^1.9.3"
"@types/body-parser@*", "@types/body-parser@1.17.0":
@ -1005,7 +1006,7 @@ http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3:
setprototypeof "1.1.0"
statuses ">= 1.4.0 < 2"
https-proxy-agent@^2.2.1:
https-proxy-agent@2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0"
integrity sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==
@ -1320,6 +1321,11 @@ lru-cache@^4.0.1:
pseudomap "^1.0.2"
yallist "^2.1.2"
lru_map@0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
integrity sha1-tcg1G5Rky9dQM1p5ZQoOwOVhGN0=
lsmod@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lsmod/-/lsmod-1.0.0.tgz#9a00f76dca36eb23fa05350afe1b585d4299e64b"

View File

@ -21,4 +21,4 @@ Tests can be found in `cypress/integration`. Cypress will hot-reload open tests
### CI
TODO
Coming soon.

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

Some files were not shown because too many files have changed in this diff Show More