commit
4abf840c0a
|
@ -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
|
|
@ -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.
|
20
README.md
20
README.md
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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} />} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -54,7 +54,7 @@ class ModerationItem extends React.Component<Comment> {
|
|||
}
|
||||
description={
|
||||
<ShowMore height={100}>
|
||||
<Markdown source={p.content} />
|
||||
<Markdown source={p.content} reduced />
|
||||
</ShowMore>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
opacity: 0.8;
|
||||
top: 1rem;
|
||||
bottom: -0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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' }]],
|
||||
},
|
||||
|
|
192
admin/yarn.lock
192
admin/yarn.lock
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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`
|
||||
|
@ -163,12 +163,3 @@ These instructions are for `development`, for `production` simply replace all ho
|
|||
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,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')
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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']))
|
||||
}
|
||||
|
|
|
@ -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": "You’ve 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)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from . import views
|
|
@ -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 {}
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,7 +822,7 @@ 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'],
|
||||
|
@ -768,7 +861,8 @@ class AdminProposalContributionSchema(ma.Schema):
|
|||
"addresses",
|
||||
"refund_address",
|
||||
"refund_tx_id",
|
||||
"staking"
|
||||
"staking",
|
||||
"no_refund",
|
||||
)
|
||||
|
||||
proposal = ma.Nested("ProposalSchema")
|
||||
|
|
|
@ -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 doesn’t 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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
@ -147,5 +165,6 @@ class AdminRFPSchema(ma.Schema):
|
|||
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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
@ -63,8 +63,8 @@ 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
|
||||
|
@ -72,22 +72,62 @@ class ProposalDeadline:
|
|||
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,
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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>
|
|
@ -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 }}
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}')
|
||||
|
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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'")
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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 ###
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -16,7 +16,7 @@ async function start() {
|
|||
log.info("============== Starting services ==============");
|
||||
await initNode();
|
||||
await RestServer.start();
|
||||
await Webhooks.start();
|
||||
Webhooks.start();
|
||||
log.info("===============================================");
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -21,4 +21,4 @@ Tests can be found in `cypress/integration`. Cypress will hot-reload open tests
|
|||
|
||||
### CI
|
||||
|
||||
TODO
|
||||
Coming soon.
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue