Compare commits

...

94 Commits

Author SHA1 Message Date
Jack Gavigan 38c268f540
Update README.md 2023-10-31 13:46:12 +00:00
Daniel Ternyak 2f87d96171
Merge pull request #505 from ZcashFoundation/master
Master
2021-07-12 18:20:19 -07:00
Daniel Ternyak 206ed2e63a
adjust default proposal content 2021-04-13 23:55:08 -07:00
Daniel Ternyak 819c15ba9c
funded by zomg true by default 2021-03-19 01:04:26 -05:00
Daniel Ternyak c0e05a86e6
fix 2fa 2021-02-02 01:55:18 -06:00
Daniel Ternyak b92a89d8ea
tsc 2021-02-02 01:49:06 -06:00
Daniel Ternyak 424ca4d283
fix story 2021-02-02 01:42:07 -06:00
Daniel Ternyak e12b4e1162
fix ci 2021-02-02 01:40:44 -06:00
Daniel Ternyak 7f065b4163
setup 'FUNDING BY ZOMG' 2021-02-01 19:32:12 -06:00
Daniel Ternyak 1a38eea631
fix nav on mobile 2021-01-10 23:08:42 -06:00
Daniel Ternyak e8e7004f17
fix tsl 2020-12-31 16:41:00 -06:00
Daniel Ternyak 475abec08b
move kyc acceptance to post approval 2020-12-31 03:11:13 -06:00
Daniel Ternyak ed7a3343c9
KYC acceptance property and admin UI 2020-12-28 15:07:37 -06:00
Daniel Ternyak 97b0cbc4b3
setup KYC page and update funding approved email with link to KYC page 2020-12-25 02:33:05 -06:00
Daniel Ternyak a36861d063
ensure proposals can only be submitted when KYC is accepted. Setup KYC info modal 2020-12-20 16:06:47 -06:00
Daniel Ternyak d6c7119dd0
increase scale 2020-12-07 18:22:54 -06:00
Daniel Ternyak a6dd059442
update logos 2020-12-07 18:03:57 -06:00
Daniel Ternyak cfd38e91b7
Merge pull request #502 from ZcashFoundation/zomg-changes
Preparation for ZOMG.
2020-12-07 13:18:58 -06:00
Daniel Ternyak ccdd6e4550
Merge branch 'develop' 2020-12-07 11:27:19 -06:00
Daniel Ternyak f89e089a00
merge develop 2020-12-07 11:13:18 -06:00
Daniel Ternyak a61cdf5b7e
ZOMG related updates 2020-11-30 18:19:33 -06:00
Daniel Ternyak e2a57e1ced
merge 2020-11-24 18:07:43 -06:00
Daniel Ternyak b192f00709
Merge pull request #501 from ZcashFoundation/develop
Provide notice for the temporary pausing in acceptance of new grants …
2020-07-24 14:43:27 -05:00
Daniel Ternyak 665c12bffa
Provide notice for the temporary pausing in acceptance of new grants (#500)
* add banner and removal create a proposal buttons

* fix travis
2020-07-24 14:43:07 -05:00
Daniel Ternyak 15fbdc17b8
add banner and removal create a proposal buttons 2020-07-24 14:17:07 -05:00
Daniel Ternyak e612e4f403
bump https-proxy-agent 2020-04-16 22:51:14 -05:00
Daniel Ternyak 08c6d6aaae
Update yarn.lock 2020-04-07 23:16:07 -05:00
Daniel Ternyak ad2743933f
Merge pull request #499 from ZcashFoundation/develop
Bump minimist from 0.0.8 to 1.2.3 in /frontend
2020-04-07 22:38:39 -05:00
Daniel Ternyak 42be278348
Merge pull request #498 from ZcashFoundation/dependabot/npm_and_yarn/frontend/minimist-1.2.3
Bump minimist from 0.0.8 to 1.2.3 in /frontend
2020-04-07 22:19:12 -05:00
dependabot[bot] 0c55a776c8
Bump minimist from 0.0.8 to 1.2.3 in /frontend
Bumps [minimist](https://github.com/substack/minimist) from 0.0.8 to 1.2.3.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/0.0.8...1.2.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-04-08 02:58:19 +00:00
Daniel Ternyak f50b516ade
Merge pull request #497 from ZcashFoundation/develop
ZF Grants 2.1
2020-04-07 21:57:24 -05:00
Daniel Ternyak 5a15022987
ZF Grants 2.1 (#496)
* fix ccr pagination defaults

* add ccr admin tests

* add ccr user tests

* checkpoint

* fix tslint

* request changes discussion flow mvp

* admin - add discussion status

* backend - add live drafts

* admin - add live drafts

* frontend - add live drafts

* frontend - add edit discussion proposal

* fix tsc

* include DISCUSSION status in propsal listview

* do not make live draft on admin request changes

* hide live drafts from user proposal draft list

* fix backend tests

* add admin tests

* add user tests

* fix: liking, viewing discussion proposals, admin menu

* admin - update hints for live drafts

* fe - add better messaging when updating a proposal

* be - fix like test

* remove TODO comments

* add new email types

* fix storybook

* add revision tab story

* backend - implement proposal revisions

* frontend - implement proposal revisions

* update revision tab story

* fix lint

* remove set detection

* email proposal followers on revision

* restrict banner to team members only

* misc bug fixes

* update, add backend tests

* add milestone title change to revision history story

* fix milestones display in preview

* allow archived proposals to be queried

* implement archived proposal page

* fix tsc

* implement archived proposal get route

* move styling into less

* remove proposal archive parent id

* handle archived proposal status

* cleanup

* remove contributions, switch to USD, implement quarters

* use Qs to preserve formatting

* handle edit only kyc

* prevent ARCHIVED proposals from being sent to admin

* display latest revision first

* admin - proposal & ccr reject permanently

* backend - proposal & ccr reject permanently

* frontend - proposal & ccr reject permanently

* fix tsc

* use $ in milestone payout email

* introduce custom filters to proposal listview

* hide archive link on first revision

* upgrade packages

* add bech32 implementation

* add z address validation with tests

* fix tslint

* use local address validation

* fix tests, remove blockchain mock gets

* add additional bad addresses

* update briefs to include page break message

* remove contributions routes, menu entry

* disable countribution count admin stats

* remove matching and pretty print in finance

* fix tslint

* separate out rejected permanently proposals

* make removing proposals generic

* allow linked tabs to be ignored

* remove rejected permanently, bugfix

* update preview link to point to rejected tab

* implement rejected permanently tab, add tab message

* refactor variable

* fix tslint

* fix tslint

* send ccr reject permanently email on rejection

* fix preview message

* wire up proposal arbiter and rejected emails

* disable tip jar in proposal and profile

* sync ccr/proposal drafts on create form init

* check invites on submit modal open

* update team invite language

* update team text when edit

* fix ccr rejected permanently tag

* text changes, email preview fix

* display changes requested tag when in discussion with changes requested

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

* place sort below filter

* derive filter from query string

* use better filter names in query params

* fix tslint

* create snapshot of original proposal on first revision

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

* update tests

* fix test

* remove print

* SameSite Fixes (#150)

* QA Fixes 2 (#151)

* set filters as query strings on change

* remove rejected permanently tags

* add dollar sign in financials legend

* fix tsc

* Copy Touchups (#152)

* Email Fixes (#155)

* fix ZEC in milestone payout emails

* fix links in rejected permanently CCR/proposal emails

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

* poll for team and invite changes in create flow

* fix tslint

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

* pretty print payouts by quarter (#156)

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

* Remove Blockchain Module (#154)

* remove blockchain route from backend, remove calls to node

* revert blockchain_get removal

* Add Tags to Proposal Cards (#157)

* add tag to proposals and dynamically set v1 card height

* listen on window resize

* make card height props optional

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

* cleanup

* cleanup

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

* Improve Frontend Address Validation (#158)

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

* Remove blockchain module (#162)

* remove blockchain route from backend, remove calls to node

* revert blockchain_get removal

* Remove Blockchain App (#160)

* remove blockchain app

* remove blockchain app from travis

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

* Proposal Edit Fixes (#161)

* fe - display error if edit creation fails

* be - restrict live draft publish

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

* Restrict Arbiter Assignment (#159)

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

* Email Copy updates

* Remove Admin Financials Card

* Hookup 'proposal_approved_without_funding' to admin email example

* bump various package versions

* Update yarn.lock files

* Attach 'proposal_approved_without_funding' to backend example email

* bump package versions

Co-authored-by: Danny Skubak <skubakdj@gmail.com>
2020-04-07 21:56:32 -05:00
Daniel Ternyak 452637cc28
Merge pull request #488 from ZcashFoundation/develop
ZF Grants 2.0
2019-12-10 23:55:44 -06:00
Daniel Ternyak 7301d2a4e0
Hookup Proposal Tutorial (#487) 2019-12-10 23:54:40 -06:00
Sonya Mann b52d26b9cf Create PROPOSAL_TUTORIAL.md (#486) 2019-12-10 23:19:52 -06:00
Sonya Mann 560f76aaf2 Add Guide Content (#485)
* add guide content

* add link to walk-through tutorial

* Update GUIDE.md
2019-12-10 22:47:36 -06:00
Daniel Ternyak 044deea218
Merge pull request #484 from grant-project/develop
ZF Grants 2.0
2019-12-10 15:50:55 -06:00
Daniel Ternyak 797c042629
Fix typos / copy (#109)
Update default proposal content
Create Guide section
2019-12-10 15:32:12 -06:00
Danny Skubak 33411f105d Misc Fixes (#108)
* disallow t addresses

* restrict ccr drafts to current user
2019-12-10 14:22:40 -06:00
Danny Skubak 59ebf8e971 reject -> request changes (#107) 2019-12-10 12:15:28 -06:00
Danny Skubak 96d0b9e30e Fix Misc Bugs (#106)
* consider all proposals to be staked

* allow proposal titles to wrap

* rejected -> changes requested

* allow empty strings as tip addresses
2019-12-09 16:00:01 -06:00
Daniel Ternyak 7e7650eeae
Bugfixes (#105)
* Make KYC field mandatory

* Fix tip jar names

* adjust tip jar language
2019-12-09 15:57:32 -06:00
Danny Skubak b02e14a42f Proposal Migration Script - Delete Stale Drafts (#104)
* fix admin tsc check

* delete stale drafts
2019-12-06 10:17:36 -06:00
Daniel Ternyak dc09690ea3
Feedback (#103) 2019-12-05 19:12:46 -06:00
Danny Skubak 64d832d585 match prod css bundle (#102) 2019-12-05 19:01:47 -06:00
Daniel Ternyak 3311be8e98
CCRs (#86)
* CCRs API / Models boilerplate

* start on frontend

* backendy things

* Create CCR redux module, integrate API endpoints, create types

* Fix/Cleanup API

* Wire up CreateRequestDraftList

* bounty->target

* Add 'Create Request Flow' MVP

* cleanup

* Tweak filenames

* Simplify migrations

* fix migrations

* CCR Staking MVP

* tslint

* Get Pending Requests into Profile

* Remove staking requirement

* more staking related removals

* MVP Admin integration

* Make RFP when CCR is accepted

* Add pagination to CCRs in Admin
Improve styles for Proposals

* Hookup notifications
Adjust copy

* Simplify ccr->rfp relationship
Add admin approval email
Fixup copy

* Show Message on RFP Detail
Make Header CTAs change based on draft status
Adjust proposal card style

* Bugfix: Show header for non signed in users

* Add 'create a request' to intro

* Profile Created CCRs
RFP CCR attribution

* ignore

* CCR Price in USD  (#85)

* init profile tipjar backend

* init profile tipjar frontend

* fix lint

* implement tip jar block

* fix wrapping, hide tip block on self

* init backend proposal tipjar

* init frontend proposal tipjar

* add hide title, fix bug

* uncomment rate limit

* rename vars, use null check

* allow address and view key to be unset

* add api tests

* fix tsc errors

* fix lint

* fix CopyInput styling

* fix migrations

* hide tipping in proposal if address not set

* add tip address to create flow

* redesign campaign block

* fix typo

* init backend changes

* init admin changes

* init frontend changes

* fix backend tests

* update campaign block

* be - init rfp usd changes

* admin - init rfp usd changes

* fe - fully adapt api util functions to usd

* fe - init rfp usd changes

* adapt profile created to usd

* misc usd changes

* add tip jar to dedicated card

* fix tipjar bug

* use zf light logo

* switch to zf grants logo

* hide profile tip jar if address not set

* add comment, run prettier

* conditionally add info icon and tooltip to funding line

* admin - disallow decimals in RFPs

* fe - cover usd string edge case

* add Usd as rfp bounty type

* fix migration order

* fix email bug

* adapt CCRs to USD

* implement CCR preview

* fix tsc

* Copy Updates and UX Tweaks (#87)

* Add default structure to proposal content

* Landing page copy

* Hide contributors tab for v2 proposals

* Minor UX tweaks for Liking/Following/Tipping

* Copy for Tipping Tooltip, proposal explainer for review, and milestone day estimate notice.

* Fix header styles bug and remove commented out styles.

* Revert "like" / "unfollow" hyphenication

* Comment out unused tests related to staking
Increase PROPOSAL_TARGET_MAX in .env.example

* Comment out ccr approval email send until ready

* Adjust styles, copy.

* fix proposal prune test (#88)

* fix USD display in preview, fix non-unique key (#90)

* Pre-stepper explainer for CCRs.

* Tweak styles

* Default content for CCRs

* fix tsc

* CCR approval and rejection emails

* add back admin_approval_ccr email templates

* Link ccr author name to profile in RFPs

* copy tweaks

* copy tweak

* hookup mangle user command

* Fix/add endif in jinja

* fix tests

* review

* fix review
2019-12-05 19:01:02 -06:00
Danny Skubak 95102842a7 Misc fixes (#101)
* fix env var bug

* fix #99

* fix #97

* fix #96

* fix #95, #94

* restrict commenting to live proposals
2019-12-05 18:06:03 -06:00
Daniel Ternyak 98dce6c5ea
Create user command for mangling users (#98)
* Create user command for mangling users

* remove 'identity' arguement to CLI command

* fix typo
2019-12-04 15:44:08 -06:00
Danny Skubak aa15b13782 Update Proposal Migration Command (#81)
* address additional proposals, first pass

* convert staking proposals to draft

* print found staking proposal count
2019-12-04 15:07:45 -06:00
Danny Skubak 597472b5c6 remove funded proposals from arbiter count (#92) 2019-12-03 18:02:56 -06:00
Danny Skubak 4a0e23e9c7 Price in Usd (#91)
* init profile tipjar backend

* init profile tipjar frontend

* fix lint

* implement tip jar block

* fix wrapping, hide tip block on self

* init backend proposal tipjar

* init frontend proposal tipjar

* add hide title, fix bug

* uncomment rate limit

* rename vars, use null check

* allow address and view key to be unset

* add api tests

* fix tsc errors

* fix lint

* fix CopyInput styling

* fix migrations

* hide tipping in proposal if address not set

* add tip address to create flow

* redesign campaign block

* fix typo

* init backend changes

* init admin changes

* init frontend changes

* fix backend tests

* update campaign block

* be - init rfp usd changes

* admin - init rfp usd changes

* fe - fully adapt api util functions to usd

* fe - init rfp usd changes

* adapt profile created to usd

* misc usd changes

* add tip jar to dedicated card

* fix tipjar bug

* use zf light logo

* switch to zf grants logo

* hide profile tip jar if address not set

* add comment, run prettier

* conditionally add info icon and tooltip to funding line

* admin - disallow decimals in RFPs

* fe - cover usd string edge case

* add Usd as rfp bounty type
2019-12-03 18:02:39 -06:00
Danny Skubak 6f4e1b779b keep original proposal card design for v1 proposals (#89) 2019-12-03 16:08:24 -06:00
Danny Skubak 94dc22b879 move tipping into payment create flow tab (#84) 2019-12-02 10:40:24 -06:00
Danny Skubak 7936b418f4 Workflow Improvements (#78)
* refresh proposal creation view when user has unverified email

* add info icon & tooltip to payout immediatly

* replace “Title” with “About You” on signup

* add review to stepper, use "Review" on final stage of proposal

* update buttons in email verify success view

* add an optional team member
2019-11-24 09:07:03 -06:00
Danny Skubak 213595cfba Redesign Campaign Block (#74)
* init profile tipjar backend

* init profile tipjar frontend

* fix lint

* implement tip jar block

* fix wrapping, hide tip block on self

* init backend proposal tipjar

* init frontend proposal tipjar

* add hide title, fix bug

* uncomment rate limit

* rename vars, use null check

* allow address and view key to be unset

* add api tests

* fix tsc errors

* fix lint

* fix CopyInput styling

* fix migrations

* hide tipping in proposal if address not set

* add tip address to create flow

* redesign campaign block

* fix typo

* update campaign block

* add tip jar to dedicated card

* fix tipjar bug

* use zf light logo

* switch to zf grants logo

* hide profile tip jar if address not set

* add comment, run prettier
2019-11-24 09:05:08 -06:00
Danny Skubak 8f187ad775 add explainer to proposals (#76) 2019-11-20 15:44:45 -06:00
Danny Skubak 4702f1a752 Redesign Proposal Card (#73) 2019-11-20 15:39:37 -06:00
Danny Skubak 13d762b011 Change 'reject' to 'request changes' (#72) 2019-11-20 15:37:58 -06:00
Danny Skubak db49fbc7e1 Tip Jar Proposal (#65)
* init profile tipjar backend

* init profile tipjar frontend

* fix lint

* implement tip jar block

* fix wrapping, hide tip block on self

* init backend proposal tipjar

* init frontend proposal tipjar

* add hide title, fix bug

* uncomment rate limit

* rename vars, use null check

* allow address and view key to be unset

* add api tests

* fix tsc errors

* fix lint

* fix CopyInput styling

* fix migrations

* hide tipping in proposal if address not set

* add tip address to create flow
2019-11-20 15:37:26 -06:00
Danny Skubak 216b37f6a3 Stylesheet Fix (#69)
* move bundle.css before custom styling

* roll back previous fix

* style like and proposal buttons with class
2019-11-15 14:30:40 -06:00
Danny Skubak ceb9f8cbdf always run milestone date estimation (#71) 2019-11-15 13:48:41 -06:00
Danny Skubak b824f462f0 Fix Create Proposals Command (#70) 2019-11-15 13:29:03 -06:00
Daniel Ternyak d64cfb6de7
fix tsc 2019-11-13 22:27:07 -06:00
Daniel Ternyak 506a00a2fa
Design changes for Profile tips. 2019-11-13 22:26:12 -06:00
Danny Skubak ec3350e45f Proposal Migration Script (#47)
* init script

* only modify object in not dry runs
2019-11-13 17:59:35 -06:00
Danny Skubak 08fe3efca5 upgrade mini-css-extract-plugin, use in dev (#68) 2019-11-13 17:45:01 -06:00
Danny Skubak d98b255378 Tip Jar Profile (#64)
* init profile tipjar backend

* init profile tipjar frontend

* fix lint

* implement tip jar block

* fix wrapping, hide tip block on self

* add hide title, fix bug

* rename vars, use null check

* allow address and view key to be unset

* add api tests

* fix migrations
2019-11-13 17:44:35 -06:00
Daniel Ternyak 4b7d85872a
fix multiple migration heads 2019-11-13 17:26:53 -06:00
Danny Skubak 8cfec5de5d Remove Categories (#63)
* remove category from admin

* remove category from frontend, add likes to proposal card view

* make category nullable in backend, remove from views

* add db migration

* remove category from frontend rfp

* update tests

* remove category from admin proposal

* remove category from rfp put

* remove moment

* remove moment
2019-11-13 17:23:36 -06:00
Danny Skubak ed6d98ceec Milestone Estimate in Days (#59)
* init admin milestone estimate in days

* init frontend milestone estimate in days

* init backend milestone estimate in days

* fix bugs

* fix bugs

* fix tests

* add tests

* add milestone_deadline email to examples

* fix type errors

* fix tests

* remove comment

* temp prep for merge

* restore changes, update tests

* add db migration

* add tests and comments for set_v2_date_estimates
2019-11-13 16:38:17 -06:00
Daniel Ternyak 8ced452411
Add Explainer to Proposal Creation Flow. (#60)
* Add Explainer step to Proposal Create Flow

* Add Explainer step to Proposal Create Flow

* remove unneeded localstorage file
2019-11-13 15:37:12 -06:00
Danny Skubak 67fbbae9bf Port Landing Hooks (#30)
* port & adapt landing hooks from grant-base

* fix type guard

* CSS Adjustments for Illustration and Content layout.
2019-11-07 22:58:55 -05:00
Danny Skubak 8255f0174c remove funding required filter (#62) 2019-11-05 13:41:08 -06:00
Danny Skubak c66be86c54 Prune Empty Drafts (#54)
* prune empty drafts after 72 hours

* add additional noops, update tests
2019-11-05 13:38:34 -06:00
Danny Skubak 494303883a apply margin directly to like & follow (#53) 2019-10-30 21:01:38 -05:00
Daniel Ternyak dd9bcb8865
Batch migrations from model updates (#42) 2019-10-24 12:50:57 -05:00
Danny Skubak 5f049d899b Add Signalling of Support (#41)
* init proposal subscribe be and fe

* add subscription email templates

* wire up subscription emails

* email subscribers on proposal milestone, update, cancel

* disallow subscriptions if email not verified

* update spelling, titles

* disallow proposal subscribe if user is team member

* hide subscribe if not signed in, is team member, canceled

* port follow from grant-base

* remove subscribed

* convert subscribed to follower

* backend - update tests

* frontend - fix typings

* finish follower port

* update comment

* fix email button display issues

* init liking backend

* init liking frontend

* fix lint

* add liking backend tests

* refactor like component
2019-10-24 12:32:00 -05:00
Danny Skubak 39f9cea42e remove progress bar for v2 proposals (#37) 2019-10-24 12:14:03 -05:00
Danny Skubak 9f485fabc4 disable milestones for proposals accepted without funding (#40) 2019-10-23 16:46:25 -05:00
Danny Skubak 25e43a34ff Update Accepted Without Funding (#35)
* backend - init endpoints and model changes

* backend - add tests

* admin - add change to accepted with funding functionality

* backend - fix tests
2019-10-23 16:44:19 -05:00
Danny Skubak 85c21d4cbf apply style directly to buttons (#39) 2019-10-23 16:34:31 -05:00
Danny Skubak 5799ffab19 Proposal Subscription (#31)
* init proposal subscribe be and fe

* add subscription email templates

* wire up subscription emails

* email subscribers on proposal milestone, update, cancel

* disallow subscriptions if email not verified

* update spelling, titles

* disallow proposal subscribe if user is team member

* hide subscribe if not signed in, is team member, canceled

* port follow from grant-base

* remove subscribed

* convert subscribed to follower

* backend - update tests

* frontend - fix typings

* finish follower port

* update comment

* fix email button display issues

* remove loading on AuthButton to prevent two spinners
2019-10-23 16:34:10 -05:00
Danny Skubak 58eb8f2455 Make Accepted Proposals Live (#34)
* make accepted proposals live

* update tests
2019-10-17 17:25:12 -05:00
Danny Skubak fb6b9b5af7 Proposal Lifecycle & Crowdfunding (#23)
* add proposal versioning

* remove deadlines

* update proposal lifecycle for admin

* update proposal lifecycle for backend

* update proposal lifecycle for frontend

* fix tests

* remove acceptedWithFunding

* fix lint, remove commented code

* remove commented code

* refactor backend to provide isVersionTwo

* refactor backend to provide isVersionTwo

* Revert "refactor backend to provide isVersionTwo"

This reverts commit e3b9bc661081e482326f83fa6aa517cf6bdebe6c.

* use isVersionTwo in admin

* add acceptedWithFunding

* trigger ci

* remove "version"

* remove "version"

* remove rejected from campaign block
2019-10-16 22:43:20 -05:00
Daniel Ternyak 701a2f95a9
Proposal deadlines (#28)
* add proposal versioning

* remove deadlines

* remove acceptedWithFunding

* fix lint, remove commented code

* refactor backend to provide isVersionTwo

* refactor backend to provide isVersionTwo

* Revert "refactor backend to provide isVersionTwo"

This reverts commit e3b9bc661081e482326f83fa6aa517cf6bdebe6c.

* trigger ci

* remove "version"
2019-10-11 14:52:52 -05:00
Danny Skubak 746398c59b Proposal Versioning (#21)
* add proposal versioning

* refactor backend to provide isVersionTwo

* trigger ci

* remove "version"
2019-10-11 14:51:10 -05:00
Danny Skubak 54b0d58ffa Disallow Proposal Submissions to Expired RFPs (#25)
* disallow rfp proposal submissions after close

* Add closed tag to closed RFPs
2019-10-10 19:12:38 -05:00
Daniel Ternyak c1a014a4b5
Merge pull request #24 from grant-project/mobile-nav-fix
Add Requests to Mobile Nav
2019-10-10 11:04:34 -05:00
Danny Skubak 7ef5dea343
add requests to drawer 2019-10-10 11:11:18 -04:00
Daniel Ternyak 11966b5060
Merge pull request #481 from ZcashFoundation/develop
Release 1.6.2
2019-08-22 10:44:36 -05:00
Daniel Ternyak 3c3bdc5600
Merge pull request #480 from ZcashFoundation/working-dep-updates
Partially revert dep updates
2019-08-22 10:29:39 -05:00
Daniel Ternyak e38ffe90b9
partially revert frontend changes 2019-08-22 10:19:31 -05:00
Daniel Ternyak 1280349931
revert blockchain dep changes 2019-08-22 10:19:16 -05:00
Daniel Ternyak 7a85a89c78
Merge pull request #468 from ZcashFoundation/develop
Release 1.6.0
2019-08-05 23:33:57 -05:00
341 changed files with 20617 additions and 9308 deletions

32
.github/workflows/node.js.yml vendored Normal file
View File

@ -0,0 +1,32 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches:
- develop
- master
pull_request:
branches:
- develop
- master
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: cd frontend && yarn && && yarn run lint && yarn run tsc

32
.github/workflows/python-app.yml vendored Normal file
View File

@ -0,0 +1,32 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python application
on:
push:
branches:
- develop
- master
pull_request:
branches:
- develop
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Install dependencies
run: |
python -m pip install --upgrade pip
cd backend && pip install -r requirements/dev.txt
- name: Test with flask test
run: |
cd backend && cp .env.example .env && flask test

View File

@ -18,12 +18,3 @@ matrix:
install: pip install -r requirements/dev.txt
script:
- flask test
# Blockchain
- language: node_js
node_js: 8.13.0
before_install:
- cd blockchain
install: yarn
script:
- yarn run test
- yarn run build

View File

@ -1,6 +1,6 @@
# Zcash Grant System
This is a collection of the various services and components that make up the Zcash Grant System.
This is a collection of the various services and components that make up the old Zcash Grant System, which has been deprecated.
### Setup

View File

@ -103,11 +103,14 @@
"tslint-react": "^3.6.0",
"typescript": "3.0.3",
"url-loader": "^1.1.1",
"webpack": "^4.19.0",
"webpack": "^4.42.0",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "3.2.1",
"webpack-hot-middleware": "^2.24.0",
"xss": "^1.0.3"
"xss": "^1.0.3",
"acorn": "^6.4.1",
"minimist": "^1.2.3",
"kind-of": "^6.0.3"
},
"devDependencies": {
"@types/bn.js": "4.11.1",

View File

@ -13,12 +13,11 @@ import UserDetail from 'components/UserDetail';
import Emails from 'components/Emails';
import Proposals from 'components/Proposals';
import ProposalDetail from 'components/ProposalDetail';
import CCRs from 'components/CCRs';
import CCRDetail from 'components/CCRDetail';
import RFPs from 'components/RFPs';
import RFPForm from 'components/RFPForm';
import RFPDetail from 'components/RFPDetail';
import Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail';
import Financials from 'components/Financials';
import Moderation from 'components/Moderation';
import Settings from 'components/Settings';
@ -47,14 +46,12 @@ class Routes extends React.Component<Props> {
<Route path="/users" component={Users} />
<Route path="/proposals/:id" component={ProposalDetail} />
<Route path="/proposals" component={Proposals} />
<Route path="/ccrs/:id" component={CCRDetail} />
<Route path="/ccrs" component={CCRs} />
<Route path="/rfps/new" component={RFPForm} />
<Route path="/rfps/:id/edit" component={RFPForm} />
<Route path="/rfps/:id" component={RFPDetail} />
<Route path="/rfps" component={RFPs} />
<Route path="/contributions/new" component={ContributionForm} />
<Route path="/contributions/:id/edit" component={ContributionForm} />
<Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} />
<Route path="/financials" component={Financials} />
<Route path="/emails/:type?" component={Emails} />
<Route path="/moderation" component={Moderation} />

View File

@ -30,10 +30,11 @@ class ArbiterControlNaked extends React.Component<Props, State> {
}, 1000);
render() {
const { arbiter } = this.props;
const { arbiter, isVersionTwo, acceptedWithFunding } = this.props;
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching;
const buttonDisabled = isVersionTwo && !acceptedWithFunding;
const disp = {
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
@ -51,6 +52,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
type="primary"
onClick={this.handleShowSearch}
{...this.props.buttonProps}
disabled={buttonDisabled}
>
{disp[arbiter.status]}
</Button>

View File

@ -0,0 +1,50 @@
.CCRDetail {
h1 {
font-size: 1.5rem;
}
&-controls {
&-control + &-control {
margin-left: 0 !important;
margin-top: 0.8rem;
}
}
&-deet {
position: relative;
margin-bottom: 1rem;
& > span {
font-size: 0.7rem;
position: absolute;
opacity: 0.8;
bottom: -0.7rem;
}
}
& .ant-card,
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
}
&-popover {
&-overlay {
max-width: 400px;
}
}
&-alert {
& pre {
margin: 1rem 0;
overflow: hidden;
word-break: break-all;
white-space: inherit;
}
}
&-review {
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
}

View File

@ -0,0 +1,241 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Alert, Button, Card, Col, Collapse, message, Row } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { CCR_STATUS } from 'src/types';
import Back from 'components/Back';
import Markdown from 'components/Markdown';
import FeedbackModal from '../FeedbackModal';
import './index.less';
import { Link } from 'react-router-dom';
type Props = RouteComponentProps<any>;
const STATE = {
paidTxId: '',
showCancelAndRefundPopover: false,
showChangeToAcceptedWithFundingPopover: false,
};
type State = typeof STATE;
class CCRDetailNaked extends React.Component<Props, State> {
state = STATE;
rejectInput: null | TextArea = null;
componentDidMount() {
this.loadDetail();
}
render() {
const id = this.getIdFromQuery();
const { ccrDetail: c, ccrDetailFetching } = store;
if (!c || (c && c.ccrId !== id) || ccrDetailFetching) {
return 'loading ccr...';
}
const renderApproved = () =>
c.status === CCR_STATUS.APPROVED && (
<Alert
showIcon
type="success"
message={`Approved on ${formatDateSeconds(c.dateApproved)}`}
description={`
This ccr has been approved.
`}
/>
);
const renderReview = () =>
c.status === CCR_STATUS.PENDING && (
<Alert
showIcon
type="warning"
message="Review Pending"
description={
<div>
<p>
Please review this Community Created Request and render your judgment.
</p>
<Button
className="CCRDetail-review"
loading={store.ccrDetailApproving}
icon="check"
type="primary"
onClick={() => this.handleApprove()}
>
Generate RFP from CCR
</Button>
<Button
className="CCRDetail-review"
loading={store.ccrDetailApproving}
icon="warning"
type="default"
onClick={() => {
FeedbackModal.open({
title: 'Request changes for this Request?',
label: 'Please provide a reason:',
okText: 'Request changes',
onOk: this.handleReject,
});
}}
>
Request changes
</Button>
<Button
className="CCRDetail-review"
loading={store.ccrDetailRejectingPermanently}
icon="close"
type="danger"
onClick={() => {
FeedbackModal.open({
title: 'Reject this CCR permanently?',
label: 'Please provide a reason:',
okText: 'Reject Permanently',
onOk: this.handleRejectPermanently,
});
}}
>
Reject Permanently
</Button>
</div>
}
/>
);
const renderRejected = () =>
c.status === CCR_STATUS.REJECTED && (
<Alert
showIcon
type="error"
message="Changes requested"
description={
<div>
<p>
This CCR has changes requested. The team will be able to re-submit it for
approval should they desire to do so.
</p>
<b>Reason:</b>
<br />
<i>{c.rejectReason}</i>
</div>
}
/>
);
const renderDeetItem = (name: string, val: any) => (
<div className="CCRDetail-deet">
<span>{name}</span>
{val} &nbsp;
</div>
);
return (
<div className="CCRDetail">
<Back to="/ccrs" text="CCRs" />
<h1>{c.title}</h1>
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
{renderApproved()}
{renderReview()}
{renderRejected()}
<Collapse defaultActiveKey={['brief', 'content', 'target']}>
<Collapse.Panel key="brief" header="brief">
{c.brief}
</Collapse.Panel>
<Collapse.Panel key="content" header="content">
<Markdown source={c.content} />
</Collapse.Panel>
<Collapse.Panel key="target" header="target">
<Markdown source={c.target} />
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(c, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
</Col>
{/* RIGHT SIDE */}
<Col span={6}>
{c.rfp && (
<Alert
message="Linked to RFP"
description={
<React.Fragment>
This CCR has been accepted and is instantiated as an RFP{' '}
<Link to={`/rfps/${c.rfp.id}`}>here</Link>.
</React.Fragment>
}
type="info"
showIcon
/>
)}
{/* DETAILS */}
<Card title="Details" size="small">
{renderDeetItem('id', c.ccrId)}
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
{renderDeetItem(
'published',
c.datePublished ? formatDateSeconds(c.datePublished) : 'n/a',
)}
{renderDeetItem(
'status',
c.status === CCR_STATUS.LIVE ? 'Accepted/Generated RFP' : c.status,
)}
{renderDeetItem('target', c.target)}
</Card>
<Card title="Author" size="small">
<div key={c.author.userid}>
<Link to={`/users/${c.author.userid}`}>{c.author.displayName}</Link>
</div>
</Card>
</Col>
</Row>
</div>
);
}
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
private loadDetail = () => {
store.fetchCCRDetail(this.getIdFromQuery());
};
private handleApprove = async () => {
await store.approveCCR(true);
if (store.ccrCreatedRFPId) {
message.success('Successfully created RFP from CCR!', 1);
setTimeout(
() => this.props.history.replace(`/rfps/${store.ccrCreatedRFPId}/edit`),
1500,
);
}
};
private handleReject = async (reason: string) => {
await store.approveCCR(false, reason);
message.info('CCR changes requested');
};
private handleRejectPermanently = async (rejectReason: string) => {
await store.rejectPermanentlyCcr(rejectReason);
message.info('CCR rejected permanently');
};
}
const CCRDetail = withRouter(view(CCRDetailNaked));
export default CCRDetail;

View File

@ -0,0 +1,16 @@
.CCRItem {
& h2 {
font-size: 1.4rem;
margin-bottom: 0;
& .ant-tag {
vertical-align: text-top;
margin: 0.2rem 0 0 0.5rem;
}
}
& p {
color: rgba(#000, 0.5);
margin: 0;
}
}

View File

@ -0,0 +1,37 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Tag, Tooltip, List } from 'antd';
import { Link } from 'react-router-dom';
import { CCR } from 'src/types';
import { CCR_STATUSES, getStatusById } from 'util/statuses';
import { formatDateSeconds } from 'util/time';
import './CCRItem.less';
class CCRItemNaked extends React.Component<CCR> {
render() {
const props = this.props;
const status = getStatusById(CCR_STATUSES, props.status);
return (
<List.Item key={props.ccrId} className="CCRItem">
<Link to={`/ccrs/${props.ccrId}`}>
<h2>
{props.title || '(no title)'}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>
{status.tagDisplay === 'Live'
? 'Accepted/Generated RFP'
: status.tagDisplay}
</Tag>
</Tooltip>
</h2>
<p>Created: {formatDateSeconds(props.dateCreated)}</p>
<p>{props.brief}</p>
</Link>
</List.Item>
);
}
}
const CCRItem = view(CCRItemNaked);
export default CCRItem;

View File

@ -0,0 +1,29 @@
import React from 'react';
import { view } from 'react-easy-state';
import store from 'src/store';
import CCRItem from './CCRItem';
import Pageable from 'components/Pageable';
import { CCR } from 'src/types';
import { ccrFilters } from 'util/filters';
class CCRs extends React.Component<{}> {
render() {
const { page } = store.ccrs;
// NOTE: sync with /backend ... pagination.py CCRPagination.SORT_MAP
const sorts = ['CREATED:DESC', 'CREATED:ASC'];
return (
<Pageable
page={page}
filters={ccrFilters}
sorts={sorts}
searchPlaceholder="Search CCR titles"
renderItem={(c: CCR) => <CCRItem key={c.ccrId} {...c} />}
handleSearch={store.fetchCCRs}
handleChangeQuery={store.setCCRPageQuery}
handleResetQuery={store.resetCCRPageQuery}
/>
);
}
}
export default view(CCRs);

View File

@ -41,10 +41,51 @@ export default [
title: 'Proposal approved',
description: 'Sent when an admin approves your submitted proposal',
},
{
id: 'proposal_approved_without_funding',
title: 'Proposal approved without funding',
description: 'Sent when an admin approves your submitted proposal',
},
{
id: 'proposal_approved_discussion',
title: 'Proposal approved for public discussion',
description: 'Sent when an admin approves a proposal for public discussion',
},
{
id: 'proposal_rejected',
title: 'Proposal rejected',
description: 'Sent when an admin rejects your submitted proposal',
title: 'Proposal changes requested',
description: 'Sent when an admin requests changes for your submitted proposal',
},
{
id: 'proposal_rejected_permanently',
title: 'Proposal rejected permanently',
description: 'Sent when an admin rejects a proposal permanently',
},
{
id: 'proposal_arbiter_assigned',
title: 'Proposal arbiter assigned',
description: 'Sent when a nominated arbiter accepts',
},
{
id: 'ccr_approved',
title: 'Request has been approved',
description: 'Sent when an admin approves a submitted CCR',
},
{
id: 'ccr_rejected',
title: 'Request has changes requested',
description: 'Sent when an admin requests changes for a CCR',
},
{
id: 'ccr_rejected_permanently',
title: 'Request rejected permanently',
description: 'Sent when an admin rejects a CCR permanently',
},
{
id: 'proposal_rejected_discussion',
title: 'Proposal changes requested',
description:
'Sent when an admin requests changes for a proposal open for public discussion',
},
{
id: 'proposal_contribution',
@ -130,11 +171,21 @@ export default [
title: 'Milestone paid',
description: 'Sent when milestone is paid',
},
{
id: 'milestone_deadline',
title: 'Milestone deadline',
description: 'Sent when the estimated deadline for milestone has been reached',
},
{
id: 'admin_approval',
title: 'Admin Approval',
description: 'Sent when proposal is ready for review',
},
{
id: 'admin_changes_resolved',
title: 'Admin Requested Changes Resolved',
description: 'Sent when proposal team has marked requested changes as resolved',
},
{
id: 'admin_arbiter',
title: 'Admin Arbiter',
@ -145,4 +196,20 @@ export default [
title: 'Admin Payout',
description: 'Sent when milestone payout has been approved',
},
{
id: 'followed_proposal_milestone',
title: 'Followed Proposal Milestone',
description:
'Sent to followers of a proposal when one of its milestones has been approved',
},
{
id: 'followed_proposal_update',
title: 'Followed Proposal Update',
description: 'Sent to followers of a proposal when it has a new update',
},
{
id: 'followed_proposal_revised',
title: 'Followed Proposal Revised',
description: 'Sent to followers of a proposal when a revision has been made',
},
] as Email[];

View File

@ -1,95 +1,53 @@
import React from 'react';
import { Spin, Card, Row, Col } from 'antd';
import { Spin, Card, Row, Col, Dropdown, Button, Icon, Menu } from 'antd';
import { Charts } from 'ant-design-pro';
import { view } from 'react-easy-state';
import store from '../../store';
import Info from 'components/Info';
import { formatUsd } from '../../util/formatters';
import './index.less';
class Financials extends React.Component {
componentDidMount() {
store.fetchFinancials();
interface State {
selectedYear: string;
}
class Financials extends React.Component<{}, State> {
state: State = {
selectedYear: '',
};
async componentDidMount() {
await store.fetchFinancials();
const years = Object.keys(store.financials.payoutsByQuarter);
const selectedYear = years[years.length - 1];
this.setState({
selectedYear,
});
}
render() {
const { contributions, grants, payouts } = store.financials;
if (!store.financialsFetched) {
const { selectedYear } = this.state;
const { grants, payouts, payoutsByQuarter } = store.financials;
if (!store.financialsFetched || !selectedYear) {
return <Spin tip="Loading financials..." />;
}
const years = Object.keys(store.financials.payoutsByQuarter);
const quarterData = payoutsByQuarter[this.state.selectedYear];
const payoutsByQuarterMenu = (
<Menu onClick={e => this.setState({ selectedYear: e.key })}>
{years.map(year => (
<Menu.Item key={year}>{year}</Menu.Item>
))}
</Menu>
);
return (
<div className="Financials">
<Row gutter={16}>
<Col lg={8} md={12} sm={24}>
<Card size="small" title="Contributions">
<Charts.Pie
hasLegend
title="Contributions"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + contributions.total,
}}
/>
)}
data={[
{ x: 'funded', y: parseFloat(contributions.funded) },
{ x: 'funding', y: parseFloat(contributions.funding) },
{ x: 'refunding', y: parseFloat(contributions.refunding) },
{ x: 'refunded', y: parseFloat(contributions.refunded) },
{ x: 'donation', y: parseFloat(contributions.donations) },
{ x: 'staking', y: parseFloat(contributions.staking) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
title={
<Info
content={
<>
<p>
Matching and bounty obligations for active and completed
proposals.
</p>
<b>matching</b> - total matching amount pleged
<br />
<b>bounties</b> - total bounty amount pledged
<br />
</>
}
>
Grants
</Info>
}
>
<Charts.Pie
hasLegend
title="Grants"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + grants.total,
}}
/>
)}
data={[
{ x: 'bounties', y: parseFloat(grants.bounty) },
{ x: 'matching', y: parseFloat(grants.matching) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
@ -120,7 +78,7 @@ class Financials extends React.Component {
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + payouts.total,
__html: '$ ' + formatUsd(grants.total, false),
}}
/>
)}
@ -129,7 +87,70 @@ class Financials extends React.Component {
{ x: 'future', y: parseFloat(payouts.future) },
{ x: 'paid', y: parseFloat(payouts.paid) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
valueFormat={val => (
<span
dangerouslySetInnerHTML={{ __html: `${formatUsd(val, true, 2)}` }}
/>
)}
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
title={
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Info
content={
<>
<p>
Milestone payouts broken down by quarter. Use the dropdown to
select a different year.
</p>
</>
}
>
Payouts by Quarter
</Info>
<Dropdown overlay={payoutsByQuarterMenu} trigger={['click']}>
<Button>
{this.state.selectedYear} <Icon type="down" />
</Button>
</Dropdown>
</div>
}
>
<Charts.Pie
hasLegend
title="Contributions"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: '$ ' + formatUsd(quarterData.yearTotal, false, 2),
}}
/>
)}
data={[
{ x: 'Q1', y: parseFloat(quarterData.q1) },
{ x: 'Q2', y: parseFloat(quarterData.q2) },
{ x: 'Q3', y: parseFloat(quarterData.q3) },
{ x: 'Q4', y: parseFloat(quarterData.q3) },
]}
valueFormat={val => (
<span
dangerouslySetInnerHTML={{ __html: `${formatUsd(val, true, 2)}` }}
/>
)}
height={180}
/>
</Card>

View File

@ -14,6 +14,7 @@ class Home extends React.Component {
const {
userCount,
proposalCount,
ccrPendingCount,
proposalPendingCount,
proposalNoArbiterCount,
proposalMilestonePayoutsCount,
@ -21,6 +22,13 @@ class Home extends React.Component {
} = store.stats;
const actionItems = [
!!ccrPendingCount && (
<div>
<Icon type="exclamation-circle" /> There are <b>{ccrPendingCount}</b> community
created requests <b>waiting for review</b>.{' '}
<Link to="/ccrs?filters[]=STATUS_PENDING">Click here</Link> to view them.
</div>
),
!!proposalPendingCount && (
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
@ -32,7 +40,7 @@ class Home extends React.Component {
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
live proposals <b>without an arbiter</b>.{' '}
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING&filters[]=STAGE_NOT_CANCELED">
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING&filters[]=STAGE_NOT_CANCELED&filters[]=ACCEPTED_WITH_FUNDING">
Click here
</Link>{' '}
to view them.

View File

@ -26,10 +26,6 @@
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
&-popover {
@ -46,4 +42,9 @@
white-space: inherit;
}
}
&-review {
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
}

View File

@ -2,35 +2,19 @@ import React from 'react';
import BN from 'bn.js';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import {
Row,
Col,
Card,
Alert,
Button,
Collapse,
Popconfirm,
Input,
Switch,
Tag,
message,
} from 'antd';
import { Alert, Button, Card, Col, Collapse, Input, message, Popconfirm, Row, Switch, Tag } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
import { formatDateSeconds, formatDurationSeconds } from 'util/time';
import {
PROPOSAL_STATUS,
PROPOSAL_ARBITER_STATUS,
MILESTONE_STAGE,
PROPOSAL_STAGE,
} from 'src/types';
import { MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS, PROPOSAL_STAGE, PROPOSAL_STATUS } from 'src/types';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl';
import { toZat, fromZat } from 'src/util/units';
import { fromZat, toZat } from 'src/util/units';
import FeedbackModal from '../FeedbackModal';
import { formatUsd } from 'util/formatters';
import './index.less';
type Props = RouteComponentProps<any>;
@ -38,6 +22,7 @@ type Props = RouteComponentProps<any>;
const STATE = {
paidTxId: '',
showCancelAndRefundPopover: false,
showChangeToAcceptedWithFundingPopover: false,
};
type State = typeof STATE;
@ -45,9 +30,11 @@ type State = typeof STATE;
class ProposalDetailNaked extends React.Component<Props, State> {
state = STATE;
rejectInput: null | TextArea = null;
componentDidMount() {
this.loadDetail();
}
render() {
const id = this.getIdFromQuery();
const { proposalDetail: p, proposalDetailFetching } = store;
@ -56,6 +43,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return 'loading proposal...';
}
console.log(p.fundedByZomg);
const needsArbiter =
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
p.status === PROPOSAL_STATUS.LIVE &&
@ -65,21 +54,36 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100);
const { isVersionTwo } = p;
const shouldShowArbiter =
!isVersionTwo || (isVersionTwo && p.acceptedWithFunding === true);
const cancelButtonText = isVersionTwo ? 'Cancel' : 'Cancel & refund';
const shouldShowChangeToAcceptedWithFunding =
isVersionTwo && p.acceptedWithFunding === false;
const renderCancelControl = () => {
const 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>
isVersionTwo ? (
<p>
Are you sure you want to cancel proposal?
<br />
This cannot be undone.
</p>
) : (
<p>
Are you sure you want to cancel proposal and begin
<br />
the refund process? This cannot be undone.
</p>
)
}
placement="left"
cancelText="cancel"
okText="confirm"
placement='left'
cancelText='cancel'
okText='confirm'
visible={this.state.showCancelAndRefundPopover}
okButtonProps={{
loading: store.proposalDetailCanceling,
@ -88,14 +92,47 @@ class ProposalDetailNaked extends React.Component<Props, State> {
onConfirm={this.handleConfirmCancel}
>
<Button
icon="close-circle"
className="ProposalDetail-controls-control"
icon='close-circle'
className='ProposalDetail-controls-control'
loading={store.proposalDetailCanceling}
onClick={this.handleCancelAndRefundClick}
disabled={disabled}
block
>
Cancel & refund
{cancelButtonText}
</Button>
</Popconfirm>
);
};
const renderChangeToAcceptedWithFundingControl = () => {
return (
<Popconfirm
title={
<p>
Are you sure you want to accept the proposal
<br />
with funding? This cannot be undone.
</p>
}
placement='left'
cancelText='cancel'
okText='confirm'
visible={this.state.showChangeToAcceptedWithFundingPopover}
okButtonProps={{
loading: store.proposalDetailCanceling,
}}
onCancel={this.handleChangeToAcceptWithFundingCancel}
onConfirm={this.handleChangeToAcceptWithFundingConfirm}
>
<Button
icon='close-circle'
className='ProposalDetail-controls-control'
loading={store.proposalDetailChangingToAcceptedWithFunding}
onClick={this.handleChangeToAcceptedWithFunding}
block
>
Accept With Funding
</Button>
</Popconfirm>
);
@ -116,74 +153,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderMatchingControl = () => (
<div className="ProposalDetail-controls-control">
<Popconfirm
overlayClassName="ProposalDetail-popover-overlay"
onConfirm={this.handleToggleMatching}
title={
<>
<div>
Turn {p.contributionMatching ? 'off' : 'on'} contribution matching?
</div>
{p.status === PROPOSAL_STATUS.LIVE && (
<div>
This is a LIVE proposal, this will alter the funding state of the
proposal!
</div>
)}
</>
}
okText="ok"
cancelText="cancel"
>
<Switch
checked={p.contributionMatching === 1}
loading={store.proposalDetailUpdating}
disabled={
p.isFailed ||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
}
/>{' '}
</Popconfirm>
<span>
matching{' '}
<Info
placement="right"
content={
<span>
<b>Contribution matching</b>
<br /> Funded amount will be multiplied by 2.
<br /> <i>Disabled after proposal is fully-funded.</i>
</span>
}
/>
</span>
</div>
);
const renderBountyControl = () => (
<div className="ProposalDetail-controls-control">
<Button
icon="dollar"
className="ProposalDetail-controls-control"
loading={store.proposalDetailUpdating}
onClick={this.handleSetBounty}
disabled={
p.isFailed || [PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
}
block
>
Set bounty
</Button>
</div>
);
const renderApproved = () =>
p.status === PROPOSAL_STATUS.APPROVED && (
<Alert
showIcon
type="success"
type='success'
message={`Approved on ${formatDateSeconds(p.dateApproved)}`}
description={`
This proposal has been approved and will become live when a team-member
@ -192,54 +166,159 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderReview = () =>
const renderKycColumn = () =>
p.isVersionTwo && (
<Col span={8}>
<Alert
showIcon
type={p.rfpOptIn ? 'success' : 'error'}
message={p.rfpOptIn ? 'KYC Accepted by user' : 'KYC rejected'}
description={
<div>
{p.rfpOptIn ? (
<p>KYC has been accepted by the proposer.</p>
) : (
<p>KYC has been rejected. Recommend against approving with funding.</p>
)}
</div>
}
/>
</Col>
);
const renderReviewDiscussion = () =>
p.status === PROPOSAL_STATUS.PENDING && (
<Alert
showIcon
type="warning"
message="Review Pending"
description={
<div>
<p>Please review this proposal and render your judgment.</p>
<Button
loading={store.proposalDetailApproving}
icon="check"
type="primary"
onClick={this.handleApprove}
>
Approve
</Button>
<Button
loading={store.proposalDetailApproving}
icon="close"
type="danger"
onClick={() => {
FeedbackModal.open({
title: 'Reject this proposal?',
label: 'Please provide a reason:',
okText: 'Reject',
onOk: this.handleReject,
});
}}
>
Reject
</Button>
</div>
}
/>
<>
<Row gutter={16}>
<Col span={isVersionTwo ? 16 : 24}>
<Alert
showIcon
type='warning'
message='Review Discussion'
description={
<div>
<p>Please review this proposal and render your judgment.</p>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailApprovingDiscussion}
icon='check'
type='primary'
onClick={() => this.handleApproveDiscussion()}
>
Open for Public Review
</Button>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailApprovingDiscussion}
icon='warning'
type='default'
onClick={() => {
FeedbackModal.open({
title: 'Request changes to this proposal?',
label: 'Please provide a reason:',
okText: 'Request changes',
onOk: this.handleRejectDiscussion,
});
}}
>
Request Changes
</Button>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailRejectingPermanently}
icon='close'
type='danger'
onClick={() => {
FeedbackModal.open({
title: 'Reject this proposal permanently?',
label: 'Please provide a reason:',
okText: 'Reject Permanently',
onOk: this.handleRejectPermanently,
});
}}
>
Reject Permanently
</Button>
</div>
}
/>
</Col>
{renderKycColumn()}
</Row>
</>
);
const renderReviewProposal = () =>
p.status === PROPOSAL_STATUS.DISCUSSION &&
!p.changesRequestedDiscussion && (
<>
<Row gutter={16}>
<Col span={isVersionTwo ? 16 : 24}>
<Alert
showIcon
type='warning'
message='Review Pending'
description={
<div>
<p>Please review this proposal and render your judgment.</p>
<>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailAcceptingProposal}
icon='check'
type='primary'
onClick={() => this.handleAcceptProposal(true, true)}
>
Approve With Funding
</Button>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailAcceptingProposal}
icon='check'
type='default'
onClick={() => this.handleAcceptProposal(true, false)}
>
Approve Without Funding
</Button>
</>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailMarkingChangesAsResolved}
icon='close'
type='danger'
onClick={() => {
FeedbackModal.open({
title: 'Request changes to this proposal?',
label: 'Please provide a reason:',
okText: 'Request changes',
onOk: this.handleRejectProposal,
});
}}
>
Request Changes
</Button>
</div>
}
/>
</Col>
{renderKycColumn()}
</Row>
</>
);
const renderRejected = () =>
p.status === PROPOSAL_STATUS.REJECTED && (
<Alert
showIcon
type="error"
message="Rejected"
type='error'
message='Changes requested'
description={
<div>
<p>
This proposal has been rejected. The team will be able to re-submit it for
approval should they desire to do so.
This proposal has changes requested. The team will be able to re-submit it
for approval should they desire to do so.
</p>
<b>Reason:</b>
<br />
@ -249,28 +328,89 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderNominateArbiter = () =>
needsArbiter && (
const renderChangesRequestedDiscussion = () =>
p.status === PROPOSAL_STATUS.DISCUSSION &&
p.changesRequestedDiscussion && (
<Alert
showIcon
type="warning"
message="No arbiter on live proposal"
type='error'
message='Changes requested'
description={
<div>
<p>An arbiter is required to review milestone payout requests.</p>
<ArbiterControl {...p} />
<p>
This proposal has changes requested. The team will be able to update their
proposal and mark the changes as resolved should they desire to do so.
</p>
<b>Reason:</b>
<br />
<i>{p.changesRequestedDiscussionReason}</i>
<br />
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
className='ProposalDetail-review'
loading={false}
icon='check'
type='danger'
onClick={this.handleMarkChangesAsResolved}
>
Mark Request as Resolved
</Button>
</div>
</div>
}
/>
);
const renderNominateArbiter = () =>
needsArbiter &&
shouldShowArbiter && (
<>
{!p.kycApproved ? (
<Alert
showIcon
type='error'
message='KYC approval required'
description={
<div>
<p>
Please wait until an Admin has marked KYC approved before proceeding
with payouts.
</p>
<Button
className='ProposalDetail-review'
loading={store.proposalDetailApprovingKyc}
icon='check'
type='primary'
onClick={() => this.handleApproveKYC()}
>
KYC Approved
</Button>
</div>
}
/>
) : (
<Alert
showIcon
type='warning'
message='No arbiter on live proposal'
description={
<div>
<p>An arbiter is required to review milestone payout requests.</p>
<ArbiterControl {...p} />
</div>
}
/>
)}
</>
);
const renderNominatedArbiter = () =>
PROPOSAL_ARBITER_STATUS.NOMINATED === p.arbiter.status &&
p.status === PROPOSAL_STATUS.LIVE && (
<Alert
showIcon
type="info"
message="Arbiter has been nominated"
type='info'
message='Arbiter has been nominated'
description={
<div>
<p>
@ -297,16 +437,28 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return;
}
const ms = p.currentMilestone;
const amount = fromZat(
toZat(p.target)
.mul(new BN(ms.payoutPercent))
.divn(100),
);
let paymentMsg;
if (p.isVersionTwo) {
const target = parseFloat(p.target.toString());
const payoutPercent = parseFloat(ms.payoutPercent);
const amountNum = (target * payoutPercent) / 100;
const amount = formatUsd(amountNum, true, 2);
paymentMsg = `${amount} in ZEC`;
} else {
const amount = fromZat(
toZat(p.target)
.mul(new BN(ms.payoutPercent))
.divn(100),
);
paymentMsg = `${amount} ZEC`;
}
return (
<Alert
className="ProposalDetail-alert"
className='ProposalDetail-alert'
showIcon
type="warning"
type='warning'
message={null}
description={
<div>
@ -318,13 +470,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</p>
<p>
{' '}
Please make a payment of <b>{amount.toString()} ZEC</b> to:
Please make a payment of <b>{paymentMsg}</b> to:
</p>{' '}
<pre>{p.payoutAddress}</pre>
<Input.Search
placeholder="please enter payment txid"
placeholder='please enter payment txid'
value={this.state.paidTxId}
enterButton="Mark Paid"
enterButton='Mark Paid'
onChange={e => this.setState({ paidTxId: e.target.value })}
onSearch={this.handlePaidMilestone}
/>
@ -338,7 +490,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
p.isFailed && (
<Alert
showIcon
type="error"
type='error'
message={
p.stage === PROPOSAL_STAGE.FAILED ? 'Proposal failed' : 'Proposal canceled'
}
@ -360,58 +512,71 @@ class ProposalDetailNaked extends React.Component<Props, State> {
);
const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet">
<div className='ProposalDetail-deet'>
<span>{name}</span>
{val} &nbsp;
</div>
);
// @ts-ignore
return (
<div className="ProposalDetail">
<Back to="/proposals" text="Proposals" />
<div className='ProposalDetail'>
<Back to='/proposals' text='Proposals' />
<h1>{p.title}</h1>
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
{renderApproved()}
{renderReview()}
{renderReviewDiscussion()}
{renderReviewProposal()}
{renderRejected()}
{renderChangesRequestedDiscussion()}
{renderNominateArbiter()}
{renderNominatedArbiter()}
{renderMilestoneAccepted()}
{renderFailed()}
<Collapse defaultActiveKey={['brief', 'content', 'milestones']}>
<Collapse.Panel key="brief" header="brief">
<Collapse.Panel key='brief' header='brief'>
{p.brief}
</Collapse.Panel>
<Collapse.Panel key="content" header="content">
<Collapse.Panel key='content' header='content'>
<Markdown source={p.content} />
</Collapse.Panel>
<Collapse.Panel key="milestones" header="milestones">
{
p.milestones.map((milestone, i) =>
<Collapse.Panel key='milestones' header='milestones'>
{p.milestones.map((milestone, i) => (
<Card
title={
<>
{milestone.title + ' '}
{milestone.immediatePayout && (
<Tag color='magenta'>Immediate Payout</Tag>
)}
</>
}
extra={`${milestone.payoutPercent}% Payout`}
key={i}
>
{p.isVersionTwo && (
<p>
<b>Estimated Days to Complete:</b>{' '}
{milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '}
</p>
)}
<p>
<b>Estimated Date:</b>{' '}
{milestone.dateEstimated
? formatDateSeconds(milestone.dateEstimated)
: 'N/A'}{' '}
</p>
<Card title={
<>
{milestone.title + ' '}
{milestone.immediatePayout && <Tag color="magenta">Immediate Payout</Tag>}
</>
}
extra={`${milestone.payoutPercent}% Payout`}
key={i}
>
<p><b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated )} </p>
<p>{milestone.content}</p>
</Card>
)
}
<p>{milestone.content}</p>
</Card>
))}
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
<Collapse.Panel key='json' header='json'>
<pre>{JSON.stringify(p, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
@ -419,16 +584,39 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{/* RIGHT SIDE */}
<Col span={6}>
{p.isVersionTwo &&
!p.acceptedWithFunding &&
p.stage === PROPOSAL_STAGE.WIP && (
<Alert
message='Accepted without funding'
description="This proposal has been posted publicly, but isn't being funded by the Zcash Foundation."
type='info'
showIcon
/>
)}
{/* ACTIONS */}
<Card size="small" className="ProposalDetail-controls">
<Card size='small' className='ProposalDetail-controls'>
{renderCancelControl()}
{renderArbiterControl()}
{renderBountyControl()}
{renderMatchingControl()}
{
p.acceptedWithFunding &&
<div style={{ marginTop: '10px' }}>
<Switch checkedChildren='Funded by ZOMG'
unCheckedChildren='Funded by ZF'
onChange={this.handleSwitchFunder}
loading={store.proposalDetailSwitchingFunder}
checked={p.fundedByZomg} />
</div>
}
{shouldShowChangeToAcceptedWithFunding &&
renderChangeToAcceptedWithFundingControl()}
</Card>
{/* DETAILS */}
<Card title="Details" size="small">
<Card title='Details' size='small'>
{renderDeetItem('id', p.proposalId)}
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
{renderDeetItem(
@ -440,20 +628,26 @@ class ProposalDetailNaked extends React.Component<Props, State> {
formatDurationSeconds(p.deadlineDuration),
)}
{p.datePublished &&
renderDeetItem(
'(deadline)',
formatDateSeconds(p.datePublished + p.deadlineDuration),
)}
renderDeetItem(
'(deadline)',
formatDateSeconds(p.datePublished + p.deadlineDuration),
)}
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
{renderDeetItem('status', p.status)}
{renderDeetItem('stage', p.stage)}
{renderDeetItem('category', p.category)}
{renderDeetItem('target', p.target)}
{renderDeetItem('target', p.isVersionTwo ? formatUsd(p.target) : p.target)}
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem(
'funded (inc. matching)',
p.isVersionTwo ? formatUsd(p.funded) : p.funded,
)}
{renderDeetItem('matching', p.contributionMatching)}
{renderDeetItem('bounty', p.contributionBounty)}
{renderDeetItem('rfpOptIn', JSON.stringify(p.rfpOptIn))}
{renderDeetItem(
'acceptedWithFunding',
JSON.stringify(p.acceptedWithFunding),
)}
{renderDeetItem(
'arbiter',
<>
@ -466,14 +660,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</>,
)}
{p.rfp &&
renderDeetItem(
'rfp',
<Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>,
)}
renderDeetItem(
'rfp',
<Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>,
)}
</Card>
{/* TEAM */}
<Card title="Team" size="small">
<Card title='Team' size='small'>
{p.team.map(t => (
<div key={t.userid}>
<Link to={`/users/${t.userid}`}>{t.displayName}</Link>
@ -508,6 +702,20 @@ class ProposalDetailNaked extends React.Component<Props, State> {
}
};
private handleChangeToAcceptedWithFunding = () => {
this.setState({ showChangeToAcceptedWithFundingPopover: true });
};
private handleChangeToAcceptWithFundingCancel = () => {
this.setState({ showChangeToAcceptedWithFundingPopover: false });
};
private handleChangeToAcceptWithFundingConfirm = () => {
if (!store.proposalDetail) return;
store.changeProposalToAcceptedWithFunding(store.proposalDetail.proposalId);
this.setState({ showChangeToAcceptedWithFundingPopover: false });
};
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
@ -526,43 +734,44 @@ class ProposalDetailNaked extends React.Component<Props, State> {
this.setState({ showCancelAndRefundPopover: false });
};
private handleApprove = () => {
store.approveProposal(true);
private handleApproveDiscussion = async () => {
await store.approveDiscussion(true);
message.info('Proposal now open for discussion');
};
private handleReject = async (reason: string) => {
await store.approveProposal(false, reason);
message.info('Proposal rejected');
private handleRejectDiscussion = async (rejectReason: string) => {
await store.approveDiscussion(false, rejectReason);
message.info('Proposal changes requested');
};
private handleToggleMatching = async () => {
if (store.proposalDetail) {
// 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;
await store.updateProposalDetail({ contributionMatching });
message.success('Updated matching');
}
private handleRejectPermanently = async (rejectReason: string) => {
await store.rejectPermanentlyProposal(rejectReason);
message.info('Proposal rejected permanently');
};
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');
},
});
private handleApproveKYC = async () => {
await store.approveProposalKYC();
message.info(`Proposal KYC approved`);
};
private handleAcceptProposal = async (
isAccepted: boolean,
withFunding: boolean,
changesRequestedReason?: string,
) => {
await store.acceptProposal(isAccepted, withFunding, changesRequestedReason);
message.info(`Proposal accepted ${withFunding ? 'with' : 'without'} funding`);
};
private handleRejectProposal = async (changesRequestedReason: string) => {
await store.acceptProposal(false, false, changesRequestedReason);
message.info(`Proposal changes requested`);
};
private handleMarkChangesAsResolved = async () => {
const success = await store.markProposalChangesAsResolved();
if (success) {
message.info(`Requested changes marked as resolved`);
}
};
@ -572,6 +781,10 @@ class ProposalDetailNaked extends React.Component<Props, State> {
await store.markMilestonePaid(pid, mid, this.state.paidTxId);
message.success('Marked milestone paid.');
};
private handleSwitchFunder = async (checkValue: boolean) => {
store.switchProposalFunder(checkValue);
};
}
const ProposalDetail = withRouter(view(ProposalDetailNaked));

View File

@ -2,13 +2,14 @@ import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin } from 'antd';
import { Row, Col, Collapse, Card, Button, Popconfirm, Spin, Alert } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import Back from 'components/Back';
import Markdown from 'components/Markdown';
import { formatDateSeconds } from 'util/time';
import store from 'src/store';
import { PROPOSAL_STATUS } from 'src/types';
import { formatUsd } from 'src/util/formatters';
import './index.less';
type Props = RouteComponentProps<{ id?: string }>;
@ -37,9 +38,11 @@ class RFPDetail extends React.Component<Props> {
</div>
);
const pendingProposals = rfp.proposals.filter(p => p.status === PROPOSAL_STATUS.PENDING);
const acceptedProposals = rfp.proposals.filter(p =>
p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED
const pendingProposals = rfp.proposals.filter(
p => p.status === PROPOSAL_STATUS.PENDING,
);
const acceptedProposals = rfp.proposals.filter(
p => p.status === PROPOSAL_STATUS.LIVE || p.status === PROPOSAL_STATUS.APPROVED,
);
return (
@ -66,6 +69,20 @@ class RFPDetail extends React.Component<Props> {
{/* RIGHT SIDE */}
<Col span={6}>
{rfp.ccr && (
<Alert
message="Linked CCR"
description={
<React.Fragment>
This RFP has been generated from a CCR{' '}
<Link to={`/ccrs/${rfp.ccr.ccrId}`}>here</Link>.
</React.Fragment>
}
type="info"
showIcon
/>
)}
{/* ACTIONS */}
<Card className="RFPDetail-actions" size="small">
<Link to={`/rfps/${rfp.id}/edit`}>
@ -90,10 +107,15 @@ class RFPDetail extends React.Component<Props> {
{renderDeetItem('id', rfp.id)}
{renderDeetItem('created', formatDateSeconds(rfp.dateCreated))}
{renderDeetItem('status', rfp.status)}
{renderDeetItem('category', rfp.category)}
{renderDeetItem('matching', String(rfp.matching))}
{renderDeetItem('bounty', `${rfp.bounty} ZEC`)}
{renderDeetItem('dateCloses', rfp.dateCloses && formatDateSeconds(rfp.dateCloses))}
{renderDeetItem(
'bounty',
rfp.isVersionTwo ? formatUsd(rfp.bounty) : `${rfp.bounty} ZEC`,
)}
{renderDeetItem(
'dateCloses',
rfp.dateCloses && formatDateSeconds(rfp.dateCloses),
)}
</Card>
{/* PROPOSALS */}

View File

@ -3,23 +3,10 @@ import moment from 'moment';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import {
Form,
Input,
Select,
Icon,
Button,
message,
Spin,
Checkbox,
Row,
Col,
DatePicker,
} from 'antd';
import { Form, Input, Select, Button, message, Spin, Row, Col, DatePicker } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
import { CATEGORY_UI } from 'util/ui';
import { RFP_STATUS, RFPArgs } from 'src/types';
import { typedKeys } from 'util/ts';
import { RFP_STATUSES, getStatusById } from 'util/statuses';
import Markdown from 'components/Markdown';
@ -54,13 +41,14 @@ class RFPForm extends React.Component<Props, State> {
title: '',
brief: '',
content: '',
category: '',
status: '',
matching: false,
bounty: undefined,
dateCloses: undefined,
};
const rfpId = this.getRFPId();
let isVersionTwo = true;
if (rfpId) {
if (!store.rfpsFetched) {
return <Spin />;
@ -72,12 +60,12 @@ class RFPForm extends React.Component<Props, State> {
title: rfp.title,
brief: rfp.brief,
content: rfp.content,
category: rfp.category,
status: rfp.status,
matching: rfp.matching,
bounty: rfp.bounty,
dateCloses: rfp.dateCloses || undefined,
};
isVersionTwo = rfp.isVersionTwo;
} else {
return <Exception type="404" desc="This RFP does not exist" />;
}
@ -88,6 +76,10 @@ class RFPForm extends React.Component<Props, State> {
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
const forceClosed = dateCloses && dateCloses.isBefore(moment.now());
const bountyMatchRule = isVersionTwo
? { pattern: /^[^.]*$/, message: 'Cannot contain a decimal' }
: {};
return (
<Form className="RFPForm" layout="vertical" onSubmit={this.handleSubmit}>
<Back to="/rfps" text="RFPs" />
@ -131,28 +123,6 @@ class RFPForm extends React.Component<Props, State> {
</Form.Item>
)}
<Form.Item label="Category">
{getFieldDecorator('category', {
initialValue: defaults.category,
rules: [
{ required: true, message: 'Category is required' },
{ max: 60, message: 'Max 60 chars' },
],
})(
<Select size="large" placeholder="Select a category">
{typedKeys(PROPOSAL_CATEGORY).map(c => (
<Select.Option value={c} key={c}>
<Icon
type={CATEGORY_UI[c].icon}
style={{ color: CATEGORY_UI[c].color }}
/>{' '}
{CATEGORY_UI[c].label}
</Select.Option>
))}
</Select>,
)}
</Form.Item>
<Form.Item label="Brief description">
{getFieldDecorator('brief', {
initialValue: defaults.brief,
@ -199,26 +169,20 @@ class RFPForm extends React.Component<Props, State> {
<Form.Item className="RFPForm-bounty" label="Bounty">
{getFieldDecorator('bounty', {
initialValue: defaults.bounty,
rules: [
{ required: true, message: 'Bounty is required' },
bountyMatchRule,
],
})(
<Input
autoComplete="off"
name="bounty"
placeholder="100"
addonAfter="ZEC"
placeholder="1000"
addonBefore={isVersionTwo ? '$' : undefined}
addonAfter={isVersionTwo ? undefined : 'ZEC'}
size="large"
/>,
)}
{getFieldDecorator('matching', {
initialValue: defaults.matching,
})(
<Checkbox
className="RFPForm-bounty-matching"
name="matching"
defaultChecked={defaults.matching}
>
Match community contributions for approved proposals
</Checkbox>,
)}
</Form.Item>
</Col>
<Col sm={12} xs={24}>

View File

@ -51,18 +51,18 @@ class Template extends React.Component<Props> {
<span className="nav-text">Proposals</span>
</Link>
</Menu.Item>
<Menu.Item key="ccrs">
<Link to="/ccrs">
<Icon type="solution" />
<span className="nav-text">CCRs</span>
</Link>
</Menu.Item>
<Menu.Item key="rfps">
<Link to="/rfps">
<Icon type="notification" />
<span className="nav-text">RFPs</span>
</Link>
</Menu.Item>
<Menu.Item key="contributions">
<Link to="/contributions">
<Icon type="dollar" />
<span className="nav-text">Contributions</span>
</Link>
</Menu.Item>
<Menu.Item key="financials">
<Link to="/financials">
<Icon type="audit" />

View File

@ -2,16 +2,17 @@ import { pick } from 'lodash';
import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios';
import {
User,
Proposal,
CCR,
CommentArgs,
Contribution,
ContributionArgs,
EmailExample,
PageData,
PageQuery,
Proposal,
RFP,
RFPArgs,
EmailExample,
PageQuery,
PageData,
CommentArgs,
User,
} from './types';
// API
@ -129,19 +130,64 @@ async function deleteProposal(id: number) {
return data;
}
async function approveProposal(id: number, isApprove: boolean, rejectReason?: string) {
const { data } = await api.put(`/admin/proposals/${id}/approve`, {
isApprove,
async function approveDiscussion(
id: number,
isOpenForDiscussion: boolean,
rejectReason?: string,
) {
const { data } = await api.put(`/admin/proposals/${id}/discussion`, {
isOpenForDiscussion,
rejectReason,
});
return data;
}
async function switchProposalFunder(id: number, fundedByZomg: boolean) {
const { data } = await api.put(`/admin/proposals/${id}/adjust-funder`, {fundedByZomg});
return data;
}
async function approveProposalKYC(id: number) {
const { data } = await api.put(`/admin/proposals/${id}/approve-kyc`);
return data;
}
async function acceptProposal(
id: number,
isAccepted: boolean,
withFunding: boolean,
changesRequestedReason?: string,
) {
const { data } = await api.put(`/admin/proposals/${id}/accept`, {
isAccepted,
withFunding,
changesRequestedReason,
});
return data;
}
async function rejectPermanentlyProposal(id: number, rejectReason: string) {
const { data } = await api.put(`/admin/proposals/${id}/reject_permanently`, {
rejectReason,
});
return data;
}
async function markProposalChangesAsResolved(id: number) {
const { data } = await api.put(`/admin/proposals/${id}/resolve`);
return data;
}
async function cancelProposal(id: number) {
const { data } = await api.put(`/admin/proposals/${id}/cancel`);
return data;
}
async function changeProposalToAcceptedWithFunding(id: number) {
const { data } = await api.put(`/admin/proposals/${id}/accept/fund`);
return data;
}
async function fetchComments(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/comments', { params });
return data;
@ -165,6 +211,35 @@ async function getEmailExample(type: string) {
return data;
}
async function fetchCCRDetail(id: number) {
const { data } = await api.get(`/admin/ccrs/${id}`);
return data;
}
async function approveCCR(id: number, isAccepted: boolean, rejectReason?: string) {
const { data } = await api.put(`/admin/ccrs/${id}/accept`, {
isAccepted,
rejectReason,
});
return data;
}
async function rejectPermanentlyCcr(id: number, rejectReason: string) {
const { data } = await api.put(`/admin/ccrs/${id}/reject_permanently`, {
rejectReason,
});
return data;
}
async function fetchCCRs(params: Partial<PageQuery>) {
const { data } = await api.get(`/admin/ccrs`, { params });
return data;
}
export async function deleteCCR(id: number) {
await api.delete(`/admin/ccrs/${id}`);
}
async function getRFPs() {
const { data } = await api.get(`/admin/rfps`);
return data;
@ -204,6 +279,14 @@ async function editContribution(id: number, args: ContributionArgs) {
return data;
}
interface QuarterData {
q1: string;
q2: string;
q3: string;
q4: string;
yearTotal: string;
}
// STORE
const app = store({
/*** DATA ***/
@ -218,6 +301,7 @@ const app = store({
stats: {
userCount: 0,
proposalCount: 0,
ccrPendingCount: 0,
proposalPendingCount: 0,
proposalNoArbiterCount: 0,
proposalMilestonePayoutsCount: 0,
@ -232,22 +316,13 @@ const app = store({
matching: '0',
bounty: '0',
},
contributions: {
total: '0',
gross: '0',
staking: '0',
funding: '0',
funded: '0',
refunding: '0',
refunded: '0',
donations: '0',
},
payouts: {
total: '0',
due: '0',
paid: '0',
future: '0',
},
payoutsByQuarter: {} as { [type: string]: QuarterData },
},
users: {
@ -277,11 +352,36 @@ const app = store({
proposalDetail: null as null | Proposal,
proposalDetailFetching: false,
proposalDetailApproving: false,
proposalDetailApprovingDiscussion: false,
proposalDetailMarkingChangesAsResolved: false,
proposalDetailAcceptingProposal: false,
proposalDetailApprovingKyc: false,
proposalDetailSwitchingFunder: false,
proposalDetailMarkingMilestonePaid: false,
proposalDetailCanceling: false,
proposalDetailUpdating: false,
proposalDetailUpdated: false,
proposalDetailChangingToAcceptedWithFunding: false,
proposalDetailRejectingPermanently: false,
ccrs: {
page: createDefaultPageData<CCR>('CREATED:DESC'),
},
ccrSaving: false,
ccrSaved: false,
ccrDeleting: false,
ccrDeleted: false,
ccrDetail: null as null | CCR,
ccrDetailFetching: false,
ccrDetailApproving: false,
ccrDetailMarkingMilestonePaid: false,
ccrDetailCanceling: false,
ccrDetailUpdating: false,
ccrDetailUpdated: false,
ccrDetailChangingToAcceptedWithFunding: false,
ccrDetailRejectingPermanently: false,
ccrCreatedRFPId: null,
comments: {
page: createDefaultPageData<Comment>('CREATED:DESC'),
@ -482,6 +582,71 @@ const app = store({
app.arbiterSaving = false;
},
// CCRS
async fetchCCRs() {
return await pageFetch(app.ccrs, fetchCCRs);
},
setCCRPageQuery(params: Partial<PageQuery>) {
setPageParams(app.ccrs, params);
},
resetCCRPageQuery() {
resetPageParams(app.ccrs);
},
async fetchCCRDetail(id: number) {
app.ccrDetailFetching = true;
try {
app.ccrDetail = await fetchCCRDetail(id);
} catch (e) {
handleApiError(e);
}
app.ccrDetailFetching = false;
},
async approveCCR(isAccepted: boolean, rejectReason?: string) {
if (!app.ccrDetail) {
const m = 'store.approveCCR(): Expected ccrDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.ccrCreatedRFPId = null;
app.ccrDetailApproving = true;
try {
const { ccrId } = app.ccrDetail;
const res = await approveCCR(ccrId, isAccepted, rejectReason);
await app.fetchCCRs();
await app.fetchRFPs();
if (isAccepted) {
app.ccrCreatedRFPId = res.rfpId;
}
} catch (e) {
handleApiError(e);
}
app.ccrDetailApproving = false;
},
async rejectPermanentlyCcr(rejectReason: string) {
if (!app.ccrDetail) {
const m = 'store.rejectPermanentlyCcr(): Expected ccrDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.ccrDetailRejectingPermanently = true;
try {
const { ccrId } = app.ccrDetail;
await rejectPermanentlyCcr(ccrId, rejectReason);
await app.fetchCCRDetail(ccrId);
} catch (e) {
handleApiError(e);
}
app.ccrDetailRejectingPermanently = false;
},
// Proposals
async fetchProposals() {
@ -536,22 +701,125 @@ const app = store({
}
},
async approveProposal(isApprove: boolean, rejectReason?: string) {
async switchProposalFunder(fundedByZomg: boolean) {
if (!app.proposalDetail) {
const m = 'store.approveProposal(): Expected proposalDetail to be populated!';
const m = 'store.acceptProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.proposalDetailApproving = true;
app.proposalDetailSwitchingFunder = true;
try {
const { proposalId } = app.proposalDetail;
const res = await approveProposal(proposalId, isApprove, rejectReason);
const res = await switchProposalFunder(proposalId, fundedByZomg);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailApproving = false;
app.proposalDetailSwitchingFunder = false;
},
async approveProposalKYC() {
if (!app.proposalDetail) {
const m = 'store.acceptProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.proposalDetailApprovingKyc = true;
try {
const { proposalId } = app.proposalDetail;
const res = await approveProposalKYC(proposalId);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailApprovingKyc = false;
},
async acceptProposal(
isAccepted: boolean,
withFunding: boolean,
changesRequestedReason?: string,
) {
if (!app.proposalDetail) {
const m = 'store.acceptProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.proposalDetailAcceptingProposal = true;
try {
const { proposalId } = app.proposalDetail;
const res = await acceptProposal(
proposalId,
isAccepted,
withFunding,
changesRequestedReason,
);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailAcceptingProposal = false;
},
async approveDiscussion(isOpenForDiscussion: boolean, rejectReason?: string) {
if (!app.proposalDetail) {
const m = 'store.approveDiscussion(): Expected proposalDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.proposalDetailApprovingDiscussion = true;
try {
const { proposalId } = app.proposalDetail;
const res = await approveDiscussion(proposalId, isOpenForDiscussion, rejectReason);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailApprovingDiscussion = false;
},
async rejectPermanentlyProposal(rejectReason: string) {
if (!app.proposalDetail) {
const m =
'store.rejectPermanentlyProposal(): Expected proposalDetail to be populated!';
app.generalError.push(m);
console.error(m);
return;
}
app.proposalDetailRejectingPermanently = true;
try {
const { proposalId } = app.proposalDetail;
const res = await rejectPermanentlyProposal(proposalId, rejectReason);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailRejectingPermanently = false;
},
async markProposalChangesAsResolved() {
if (!app.proposalDetail) {
const m = 'store.approveDiscussion(): Expected proposalDetail to be populated!';
app.generalError.push(m);
return;
}
let success = false;
app.proposalDetailMarkingChangesAsResolved = true;
try {
const { proposalId } = app.proposalDetail;
const res = await markProposalChangesAsResolved(proposalId);
app.updateProposalInStore(res);
success = true;
} catch (e) {
handleApiError(e);
success = false;
}
app.proposalDetailMarkingChangesAsResolved = false;
return success;
},
async cancelProposal(id: number) {
@ -565,6 +833,19 @@ const app = store({
app.proposalDetailCanceling = false;
},
async changeProposalToAcceptedWithFunding(id: number) {
app.proposalDetailChangingToAcceptedWithFunding = true;
try {
const res = await changeProposalToAcceptedWithFunding(id);
app.updateProposalInStore(res);
} catch (e) {
handleApiError(e);
}
app.proposalDetailChangingToAcceptedWithFunding = false;
},
async markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
app.proposalDetailMarkingMilestonePaid = true;
try {
@ -743,6 +1024,7 @@ function createDefaultPageData<T>(sort: string): PageData<T> {
}
type FNFetchPage = (params: PageQuery) => Promise<any>;
interface PageParent<T> {
page: PageData<T>;
}

View File

@ -17,7 +17,8 @@ export interface Milestone {
index: number;
content: string;
dateCreated: number;
dateEstimated: number;
dateEstimated?: number;
daysEstimated?: string;
dateRequested: number;
dateAccepted: number;
dateRejected: number;
@ -41,18 +42,18 @@ export interface RFP {
title: string;
brief: string;
content: string;
category: string;
status: string;
proposals: Proposal[];
matching: boolean;
bounty: string | null;
dateCloses: number | null;
isVersionTwo: boolean;
ccr?: CCR;
}
export interface RFPArgs {
title: string;
brief: string;
content: string;
category: string;
matching: boolean;
dateCloses: number | null | undefined;
bounty: string | null | undefined;
@ -72,9 +73,12 @@ export interface ProposalArbiter {
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
export enum PROPOSAL_STATUS {
DRAFT = 'DRAFT',
LIVE_DRAFT = 'LIVE_DRAFT',
PENDING = 'PENDING',
DISCUSSION = 'DISCUSSION',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY',
LIVE = 'LIVE',
DELETED = 'DELETED',
STAKING = 'STAKING',
@ -102,7 +106,6 @@ export interface Proposal {
title: string;
content: string;
stage: PROPOSAL_STAGE;
category: string;
milestones: Milestone[];
currentMilestone?: Milestone;
team: User[];
@ -116,6 +119,12 @@ export interface Proposal {
rfpOptIn: null | boolean;
rfp?: RFP;
arbiter: ProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean;
changesRequestedDiscussion: boolean | null;
changesRequestedDiscussionReason: string | null;
kycApproved: null | boolean;
fundedByZomg: boolean;
}
export interface Comment {
id: number;
@ -199,6 +208,31 @@ export enum PROPOSAL_CATEGORY {
ACCESSIBILITY = 'ACCESSIBILITY',
}
export enum CCR_STATUS {
DRAFT = 'DRAFT',
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY',
LIVE = 'LIVE',
DELETED = 'DELETED',
}
export interface CCR {
ccrId: number;
brief: string;
status: CCR_STATUS;
dateCreated: number;
dateApproved: number;
datePublished: number;
title: string;
content: string;
target: string;
rejectReason: string;
rfp?: RFP;
author: User;
}
export interface PageQuery {
page: number;
filters: string[];

View File

@ -5,6 +5,7 @@ import {
PROPOSAL_ARBITER_STATUSES,
MILESTONE_STAGES,
PROPOSAL_STAGES,
CCR_STATUSES,
} from './statuses';
export interface Filter {
@ -59,7 +60,21 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
color: s.tagColor,
group: 'Milestone',
})),
);
)
.concat([
{
id: 'ACCEPTED_WITH_FUNDING',
display: 'Accepted With Funding',
color: '#2D2A26',
group: 'Funding',
},
{
id: 'ACCEPTED_WITHOUT_FUNDING',
display: 'Accepted Without Funding',
color: '#108ee9',
group: 'Funding',
},
]);
export const proposalFilters: Filters = {
list: PROPOSAL_FILTERS,
@ -80,6 +95,20 @@ export const rfpFilters: Filters = {
getById: getFilterById(RFP_FILTERS),
};
// CCR
const CCR_FILTERS = CCR_STATUSES.map(c => ({
id: `STATUS_${c.id}`,
display: `Status: ${c.tagDisplay}`,
color: c.tagColor,
group: 'Status',
}));
export const ccrFilters: Filters = {
list: CCR_FILTERS,
getById: getFilterById(CCR_FILTERS),
};
// Contribution
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
@ -87,17 +116,20 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
display: `Status: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Status',
})).concat([{
id: 'REFUNDABLE',
display: 'Refundable',
color: '#afd500',
group: 'Refundable',
}, {
id: 'DONATION',
display: 'Donations',
color: '#afd500',
group: 'Donations',
}]);
})).concat([
{
id: 'REFUNDABLE',
display: 'Refundable',
color: '#afd500',
group: 'Refundable',
},
{
id: 'DONATION',
display: 'Donations',
color: '#afd500',
group: 'Donations',
},
]);
export const contributionFilters: Filters = {
list: CONTRIBUTION_FILTERS,

View File

@ -0,0 +1,72 @@
const toFixed = (num: string, digits: number = 3) => {
const [integerPart, fractionPart = ''] = num.split('.');
if (fractionPart.length === digits) {
return num;
}
if (fractionPart.length < digits) {
return `${integerPart}.${fractionPart.padEnd(digits, '0')}`;
}
let decimalPoint = integerPart.length;
const formattedFraction = fractionPart.slice(0, digits);
const integerArr = `${integerPart}${formattedFraction}`.split('').map(str => +str);
let carryOver = Math.floor((+fractionPart[digits] + 5) / 10);
// grade school addition / rounding
for (let i = integerArr.length - 1; i >= 0; i--) {
const currVal = integerArr[i] + carryOver;
const newVal = currVal % 10;
carryOver = Math.floor(currVal / 10);
integerArr[i] = newVal;
if (i === 0 && carryOver > 0) {
integerArr.unshift(0);
decimalPoint++;
i++;
}
}
const strArr = integerArr.map(n => n.toString());
strArr.splice(decimalPoint, 0, '.');
if (strArr[strArr.length - 1] === '.') {
strArr.pop();
}
return strArr.join('');
};
export function formatNumber(num: string, digits?: number): string {
const parts = toFixed(num, digits).split('.');
// Remove trailing zeroes on decimal (If there is a decimal)
if (parts[1]) {
parts[1] = parts[1].replace(/0+$/, '');
// If there's nothing left, remove decimal altogether
if (!parts[1]) {
parts.pop();
}
}
// Commafy the whole numbers
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
export function formatUsd(
amount: number | string | undefined | null,
includeDollarSign: boolean = true,
digits: number = 0,
) {
if (!amount) return includeDollarSign ? '$0' : '0';
const a = typeof amount === 'number' ? amount.toString() : amount;
const str = formatNumber(a, digits);
return includeDollarSign ? `$${str}` : str;
}

View File

@ -1,5 +1,6 @@
import {
PROPOSAL_STATUS,
CCR_STATUS,
RFP_STATUS,
CONTRIBUTION_STATUS,
PROPOSAL_ARBITER_STATUS,
@ -48,6 +49,53 @@ export const MILESTONE_STAGES: Array<StatusSoT<MILESTONE_STAGE>> = [
},
];
export const CCR_STATUSES: Array<StatusSoT<CCR_STATUS>> = [
{
id: CCR_STATUS.APPROVED,
tagDisplay: 'Approved',
tagColor: '#afd500',
hint: 'Request has been approved and is awaiting being published by user.',
},
{
id: CCR_STATUS.DELETED,
tagDisplay: 'Deleted',
tagColor: '#bebebe',
hint: 'Request has been deleted and is not visible on the platform.',
},
{
id: CCR_STATUS.DRAFT,
tagDisplay: 'Draft',
tagColor: '#8d8d8d',
hint: 'Request is being created by the user.',
},
{
id: CCR_STATUS.LIVE,
tagDisplay: 'Live',
tagColor: '#108ee9',
hint: 'Request is live on the platform.',
},
{
id: CCR_STATUS.PENDING,
tagDisplay: 'Awaiting Approval',
tagColor: '#ffaa00',
hint: 'User is waiting for admin to approve or request changes to this Request.',
},
{
id: CCR_STATUS.REJECTED,
tagDisplay: 'Changes Requested',
tagColor: '#eb4118',
hint:
'Admin has requested changes for this Request. User may adjust it and resubmit for approval.',
},
{
id: CCR_STATUS.REJECTED_PERMANENTLY,
tagDisplay: 'Rejected Permanently',
tagColor: '#eb4118',
hint:
'Admin has rejected this CCR permanently. It cannot be resubmitted for approval.',
},
];
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
{
id: PROPOSAL_STATUS.APPROVED,
@ -55,6 +103,18 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
tagColor: '#afd500',
hint: 'Proposal has been approved and is awaiting being published by user.',
},
{
id: PROPOSAL_STATUS.DISCUSSION,
tagDisplay: 'Open for Public Review',
tagColor: '#afd500',
hint: 'Proposal has been opened for public discussion.',
},
{
id: PROPOSAL_STATUS.LIVE_DRAFT,
tagDisplay: 'Live Draft',
tagColor: '#8d8d8d',
hint: 'Proposal is an edit that will to be published to another proposal.',
},
{
id: PROPOSAL_STATUS.DELETED,
tagDisplay: 'Deleted',
@ -77,14 +137,21 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
id: PROPOSAL_STATUS.PENDING,
tagDisplay: 'Awaiting Approval',
tagColor: '#ffaa00',
hint: 'User is waiting for admin to approve or reject this Proposal.',
hint: 'User is waiting for admin to approve or request changes to this Proposal.',
},
{
id: PROPOSAL_STATUS.REJECTED,
tagDisplay: 'Approval Rejected',
tagDisplay: 'Changes Requested',
tagColor: '#eb4118',
hint:
'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
'Admin has requested changes for this proposal. User may adjust it and resubmit for approval.',
},
{
id: PROPOSAL_STATUS.REJECTED_PERMANENTLY,
tagDisplay: 'Rejected Permanently',
tagColor: '#eb4118',
hint:
'Admin has rejected this proposal permanently. It cannot be resubmitted for approval.',
},
{
id: PROPOSAL_STATUS.STAKING,

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ DATABASE_URL="sqlite:////tmp/dev.db"
REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"
SENDGRID_API_KEY="optional, but emails won't send without it"
SESSION_COOKIE_SAMESITE=Lax
# set this so third-party cookie blocking doesn't kill backend sessions (production)
# SESSION_COOKIE_DOMAIN="zfnd.org"
@ -39,4 +40,4 @@ EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
PROPOSAL_STAKING_AMOUNT=0.025
# Maximum amount for a proposal target, keep in sync with frontend .env
PROPOSAL_TARGET_MAX=10000
PROPOSAL_TARGET_MAX=999999

View File

@ -69,6 +69,10 @@ To run all tests, run
flask test
To run only select test, Flask allows you to match against the test filename with ``-t` like so:
flask test -t proposal
## Migrations
Whenever a database migration needs to be made. Run the following commands

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
"""Create an application instance."""
from grant.patches import patch_werkzeug_set_samesite
patch_werkzeug_set_samesite()
from grant.app import create_app
app = create_app()

View File

@ -35,8 +35,17 @@ class FakeUpdate(object):
proposal_id = 123
class FakeCCR(object):
id = 123
title = 'Example CCR'
brief = 'This is an example CCR'
content = 'Example example example example'
target = "100"
user = FakeUser()
proposal = FakeProposal()
ccr = FakeCCR()
milestone = FakeMilestone()
contribution = FakeContribution()
update = FakeUpdate()
@ -67,16 +76,55 @@ example_email_args = {
'recover_url': 'http://somerecoverurl.com',
'contact_url': 'http://somecontacturl.com',
},
'proposal_approved_without_funding': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'admin_note': "We've opened up your proposal for community donations.",
},
'proposal_approved': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'admin_note': 'This proposal was the hottest stuff our team has seen yet. We look forward to throwing the fat stacks at you.',
},
'proposal_approved_discussion': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
},
'proposal_rejected': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'admin_note': 'We think that youve asked for too much money for the project youve proposed, and for such an inexperienced team. Feel free to change your target amount, or elaborate on why you need so much money, and try applying again.',
},
'proposal_rejected_discussion': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
},
'proposal_rejected_permanently': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'profile_rejected_url': 'http://someproposal.com/profile?tab=rejected',
'admin_note': 'We don\'t really think this is needed right now by the ecosystem. Feel free to elaborate and submit again',
},
'proposal_arbiter_assigned': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com'
},
'ccr_approved': {
'ccr': ccr,
'ccr_url': 'http://someproposal.com',
'admin_note': 'This proposal was the hottest stuff our team has seen yet. Great work.',
},
'ccr_rejected': {
'ccr': ccr,
'ccr_url': 'http://someproposal.com',
'admin_note': 'We don\'t really think this is needed right now by the ecosystem. Feel free to elaborate and submit again',
},
'ccr_rejected_permanently': {
'ccr': ccr,
'ccr_url': 'http://someproposal.com',
'profile_rejected_url': 'http://someproposal.com/profile?tab=rejected',
'admin_note': 'We don\'t really think this will ever be needed by the ecosystem.',
},
'proposal_contribution': {
'proposal': proposal,
'contribution': contribution,
@ -149,6 +197,10 @@ example_email_args = {
'proposal': proposal,
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
},
'milestone_deadline': {
'proposal': proposal,
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
},
'milestone_reject': {
'proposal': proposal,
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
@ -170,6 +222,10 @@ example_email_args = {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
'admin_changes_resolved': {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
'admin_arbiter': {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
@ -178,4 +234,17 @@ example_email_args = {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
'followed_proposal_milestone': {
"proposal": proposal,
"milestone": milestone,
"proposal_url": "http://someproposal.com",
},
'followed_proposal_update': {
"proposal": proposal,
"proposal_url": "http://someproposal.com",
},
'followed_proposal_revised': {
"proposal": proposal,
"proposal_url": "http://someproposal.com",
},
}

View File

@ -4,10 +4,11 @@ from functools import reduce
from flask import Blueprint, request
from marshmallow import fields, validate
from sqlalchemy import func, or_, text
from sqlalchemy import func, text
import grant.utils.admin as admin
import grant.utils.auth as auth
from grant.ccr.models import CCR, ccrs_schema, ccr_schema
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
from grant.email.send import generate_email, send_email
from grant.extensions import db
@ -24,9 +25,8 @@ from grant.proposal.models import (
admin_proposal_contributions_schema,
)
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema
from grant.user.models import User, admin_users_schema, admin_user_schema
from grant.utils import pagination
from grant.utils.enums import Category
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
@ -34,6 +34,7 @@ from grant.utils.enums import (
ProposalArbiterStatus,
MilestoneStage,
RFPStatus,
CCRStatus
)
from grant.utils.misc import make_url, make_explore_url
from .example_emails import example_email_args
@ -137,6 +138,9 @@ def logout():
def stats():
user_count = db.session.query(func.count(User.id)).scalar()
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
ccr_pending_count = db.session.query(func.count(CCR.id)) \
.filter(CCR.status == CCRStatus.PENDING) \
.scalar()
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
.filter(Proposal.status == ProposalStatus.PENDING) \
.scalar()
@ -145,6 +149,7 @@ def stats():
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
.filter(Proposal.stage != ProposalStage.CANCELED) \
.filter(Proposal.accepted_with_funding == True) \
.scalar()
proposal_milestone_payouts_count = db.session.query(func.count(Proposal.id)) \
.join(Proposal.milestones) \
@ -153,26 +158,27 @@ def stats():
.filter(Milestone.stage == MilestoneStage.ACCEPTED) \
.scalar()
# Count contributions on proposals that didn't get funded for users who have specified a refund address
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
.join(ProposalContribution.user) \
.join(UserSettings) \
.filter(UserSettings.refund_address != None) \
.scalar()
# contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
# .filter(ProposalContribution.refund_tx_id == None) \
# .filter(ProposalContribution.staking == False) \
# .filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
# .join(Proposal) \
# .filter(or_(
# Proposal.stage == ProposalStage.FAILED,
# Proposal.stage == ProposalStage.CANCELED,
# )) \
# .join(ProposalContribution.user) \
# .join(UserSettings) \
# .filter(UserSettings.refund_address != None) \
# .scalar()
return {
"userCount": user_count,
"ccrPendingCount": ccr_pending_count,
"proposalCount": proposal_count,
"proposalPendingCount": proposal_pending_count,
"proposalNoArbiterCount": proposal_no_arbiter_count,
"proposalMilestonePayoutsCount": proposal_milestone_payouts_count,
"contributionRefundableCount": contribution_refundable_count,
"contributionRefundableCount": 0,
}
@ -296,6 +302,9 @@ def set_arbiter(proposal_id, user_id):
if proposal.is_failed:
return {"message": "Cannot set arbiter on failed proposal"}, 400
if proposal.version == '2' and not proposal.accepted_with_funding:
return {"message": "Cannot set arbiter, proposal has not been accepted with funding"}, 400
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "User not found"}, 404
@ -313,9 +322,9 @@ def set_arbiter(proposal_id, user_id):
db.session.commit()
return {
'proposal': proposal_schema.dump(proposal),
'user': admin_user_schema.dump(user)
}, 200
'proposal': proposal_schema.dump(proposal),
'user': admin_user_schema.dump(user)
}, 200
# PROPOSALS
@ -328,7 +337,7 @@ def get_proposals(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.proposal(
schema=proposals_schema,
query=Proposal.query,
query=Proposal.query.filter(Proposal.status.notin_([ProposalStatus.ARCHIVED])),
page=page,
filters=filters_workaround,
search=search,
@ -352,43 +361,146 @@ def delete_proposal(id):
return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<id>', methods=['PUT'])
@blueprint.route('/proposals/<proposal_id>/discussion', methods=['PUT'])
@body({
"contributionMatching": fields.Int(required=False, missing=None),
"contributionBounty": fields.Str(required=False, missing=None)
"isOpenForDiscussion": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def update_proposal(id, contribution_matching, contribution_bounty):
proposal = Proposal.query.filter(Proposal.id == id).first()
def open_proposal_for_discussion(proposal_id, is_open_for_discussion, reject_reason=None):
proposal = Proposal.query.get(proposal_id)
if not proposal:
return {"message": f"Could not find proposal with id {id}"}, 404
return {"message": "No Proposal found."}, 404
if contribution_matching is not None:
proposal.set_contribution_matching(contribution_matching)
proposal.approve_discussion(is_open_for_discussion, reject_reason)
db.session.commit()
return proposal_schema.dump(proposal)
if contribution_bounty is not None:
proposal.set_contribution_bounty(contribution_bounty)
@blueprint.route('/proposals/<id>/approve-kyc', methods=['PUT'])
@admin.admin_auth_required
def approve_proposal_kyc(id):
proposal = Proposal.query.get(id)
if not proposal:
return {"message": "No proposal found."}, 404
proposal.kyc_approved = True
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<id>/adjust-funder', methods=['PUT'])
@body({
"fundedByZomg": fields.Bool(required=True),
})
@admin.admin_auth_required
def adjust_funder(id, funded_by_zomg):
proposal = Proposal.query.get(id)
if not proposal:
return {"message": "No proposal found."}, 404
proposal.funded_by_zomg = funded_by_zomg
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<id>/accept', methods=['PUT'])
@body({
"isAccepted": fields.Bool(required=True),
"withFunding": fields.Bool(required=False, missing=None),
"changesRequestedReason": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def accept_proposal(id, is_accepted, with_funding, changes_requested_reason):
proposal = Proposal.query.get(id)
if not proposal:
return {"message": "No proposal found."}, 404
if is_accepted:
proposal.accept_proposal(with_funding)
if with_funding:
Milestone.set_v2_date_estimates(proposal)
else:
proposal.request_changes_discussion(changes_requested_reason)
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<proposal_id>/reject_permanently', methods=['PUT'])
@body({
"rejectReason": fields.Str(required=True, missing=None)
})
@admin.admin_auth_required
def reject_permanently_proposal(proposal_id, reject_reason):
proposal = Proposal.query.get(proposal_id)
if not proposal:
return {"message": "No proposal found."}, 404
reject_permanently_statuses = [
ProposalStatus.REJECTED,
ProposalStatus.PENDING
]
if proposal.status not in reject_permanently_statuses:
return {"message": "Proposal status is not REJECTED or PENDING."}, 401
proposal.status = ProposalStatus.REJECTED_PERMANENTLY
proposal.reject_reason = reject_reason
db.session.add(proposal)
db.session.commit()
for user in proposal.team:
send_email(user.email_address, 'proposal_rejected_permanently', {
'user': user,
'proposal': proposal,
'proposal_url': make_url(f'/proposals/{proposal.id}'),
'admin_note': reject_reason,
'profile_rejected_url': make_url('/profile?tab=rejected'),
})
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<id>/approve', methods=['PUT'])
@body({
"isApprove": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@blueprint.route('/proposals/<proposal_id>/resolve', methods=['PUT'])
@admin.admin_auth_required
def approve_proposal(id, is_approve, reject_reason=None):
proposal = Proposal.query.filter_by(id=id).first()
if proposal:
proposal.approve_pending(is_approve, reject_reason)
db.session.commit()
return proposal_schema.dump(proposal)
def resolve_changes_discussion(proposal_id):
proposal = Proposal.query.get(proposal_id)
if not proposal:
return {"message": "No proposal found"}, 404
return {"message": "No proposal found."}, 404
proposal.resolve_changes_discussion()
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<id>/accept/fund', methods=['PUT'])
@admin.admin_auth_required
def change_proposal_to_accepted_with_funding(id):
proposal = Proposal.query.filter_by(id=id).first()
if not proposal:
return {"message": "No proposal found."}, 404
if proposal.accepted_with_funding:
return {"message": "Proposal already accepted with funding."}, 404
if proposal.version != '2':
return {"message": "Only version two proposals can be accepted with funding"}, 404
if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.APPROVED:
return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404
proposal.update_proposal_with_funding()
Milestone.set_v2_date_estimates(proposal)
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route('/proposals/<id>/cancel', methods=['PUT'])
@ -417,12 +529,14 @@ def paid_milestone_payout_request(id, mid, tx_id):
return {"message": "Proposal is not fully funded"}, 400
for ms in proposal.milestones:
if ms.id == int(mid):
is_final_milestone = False
ms.mark_paid(tx_id)
db.session.add(ms)
db.session.flush()
# check if this is the final ms, and update proposal.stage
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
if num_paid == len(proposal.milestones):
is_final_milestone = True
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
db.session.add(proposal)
db.session.flush()
@ -437,6 +551,18 @@ def paid_milestone_payout_request(id, mid, tx_id):
'tx_explorer_url': make_explore_url(tx_id),
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
})
# email FOLLOWERS that milestone was accepted
proposal.send_follower_email(
"followed_proposal_milestone",
email_args={"milestone": ms},
url_suffix="?tab=milestones",
)
if not is_final_milestone:
Milestone.set_v2_date_estimates(proposal)
db.session.commit()
return proposal_schema.dump(proposal), 200
return {"message": "No milestone matching id"}, 404
@ -455,6 +581,99 @@ def get_email_example(type):
return email
# CCRs
@blueprint.route("/ccrs", methods=["GET"])
@query(paginated_fields)
@admin.admin_auth_required
def get_ccrs(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.ccr(
schema=ccrs_schema,
query=CCR.query,
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/ccrs/<ccr_id>', methods=['DELETE'])
@admin.admin_auth_required
def delete_ccr(ccr_id):
ccr = CCR.query.filter(CCR.id == ccr_id).first()
if not ccr:
return {"message": "No CCR matching that id"}, 404
db.session.delete(ccr)
db.session.commit()
return {"message": "ok"}, 200
@blueprint.route('/ccrs/<id>', methods=['GET'])
@admin.admin_auth_required
def get_ccr(id):
ccr = CCR.query.filter(CCR.id == id).first()
if ccr:
return ccr_schema.dump(ccr)
return {"message": f"Could not find ccr with id {id}"}, 404
@blueprint.route('/ccrs/<ccr_id>/accept', methods=['PUT'])
@body({
"isAccepted": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def approve_ccr(ccr_id, is_accepted, reject_reason=None):
ccr = CCR.query.filter_by(id=ccr_id).first()
if ccr:
rfp_id = ccr.approve_pending(is_accepted, reject_reason)
if is_accepted:
return {"rfpId": rfp_id}, 201
else:
return ccr_schema.dump(ccr)
return {"message": "No CCR found."}, 404
@blueprint.route('/ccrs/<ccr_id>/reject_permanently', methods=['PUT'])
@body({
"rejectReason": fields.Str(required=True, missing=None)
})
@admin.admin_auth_required
def reject_permanently_ccr(ccr_id, reject_reason):
ccr = CCR.query.get(ccr_id)
if not ccr:
return {"message": "No CCR found."}, 404
reject_permanently_statuses = [
CCRStatus.REJECTED,
CCRStatus.PENDING
]
if ccr.status not in reject_permanently_statuses:
return {"message": "CCR status is not REJECTED or PENDING."}, 401
ccr.status = CCRStatus.REJECTED_PERMANENTLY
ccr.reject_reason = reject_reason
db.session.add(ccr)
db.session.commit()
send_email(ccr.author.email_address, 'ccr_rejected_permanently', {
'user': ccr.author,
'ccr': ccr,
'admin_note': reject_reason,
'profile_rejected_url': make_url('/profile?tab=rejected')
})
return ccr_schema.dump(ccr)
# Requests for Proposal
@ -470,7 +689,6 @@ def get_rfps():
"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=False, missing=None)
@ -502,13 +720,12 @@ def get_rfp(rfp_id):
"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):
def update_rfp(rfp_id, title, brief, content, bounty, matching, date_closes, status):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
@ -517,7 +734,6 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c
rfp.title = title
rfp.brief = brief
rfp.content = content
rfp.category = category
rfp.matching = matching
rfp.bounty = bounty
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
@ -587,8 +803,8 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
db.session.add(contribution)
db.session.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200
@ -660,8 +876,8 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
db.session.add(contribution)
db.session.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200
@ -711,7 +927,6 @@ def edit_comment(comment_id, hidden, reported):
@blueprint.route("/financials", methods=["GET"])
@admin.admin_auth_required
def financials():
nfmt = '999999.99999999' # smallest unit of ZEC
def sql_pc(where: str):
@ -732,49 +947,60 @@ def financials():
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
FROM milestone as ms
INNER JOIN proposal as p ON ms.proposal_id = p.id
WHERE {where}
WHERE p.version = '2' AND {where}
'''
def ex(sql: str):
res = db.engine.execute(text(sql))
return [row[0] if row[0] else Decimal(0) for row in res][0].normalize()
contributions = {
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
# should have a refund_address
'refunding': str(ex(sql_pc_p(
'''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NULL AND
p.stage IN ('CANCELED', 'FAILED') AND
us.refund_address IS NOT NULL
'''
))),
# here we don't care about current refund_address of user, just that there has been a refund_tx_id
'refunded': str(ex(sql_pc_p(
'''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NOT NULL AND
p.stage IN ('CANCELED', 'FAILED')
'''
))),
# if there is no user, or the user hasn't any refund_address
'donations': str(ex(sql_pc_p(
'''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NULL AND
(pc.user_id IS NULL OR us.refund_address IS NULL) AND
p.stage IN ('CANCELED', 'FAILED')
'''
))),
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
}
def gen_quarter_date_range(year, quarter):
if quarter == 1:
return f"{year}-1-1", f"{year}-3-31"
if quarter == 2:
return f"{year}-4-1", f"{year}-6-30"
if quarter == 3:
return f"{year}-7-1", f"{year}-9-30"
if quarter == 4:
return f"{year}-10-1", f"{year}-12-31"
# contributions = {
# 'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
# 'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
# 'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
# 'funded': str(
# ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
# # should have a refund_address
# 'refunding': str(ex(sql_pc_p(
# '''
# pc.status = 'CONFIRMED' AND
# pc.staking = FALSE AND
# pc.refund_tx_id IS NULL AND
# p.stage IN ('CANCELED', 'FAILED') AND
# us.refund_address IS NOT NULL
# '''
# ))),
# # here we don't care about current refund_address of user, just that there has been a refund_tx_id
# 'refunded': str(ex(sql_pc_p(
# '''
# pc.status = 'CONFIRMED' AND
# pc.staking = FALSE AND
# pc.refund_tx_id IS NOT NULL AND
# p.stage IN ('CANCELED', 'FAILED')
# '''
# ))),
# # if there is no user, or the user hasn't any refund_address
# 'donations': str(ex(sql_pc_p(
# '''
# pc.status = 'CONFIRMED' AND
# pc.staking = FALSE AND
# pc.refund_tx_id IS NULL AND
# (pc.user_id IS NULL OR us.refund_address IS NULL) AND
# p.stage IN ('CANCELED', 'FAILED')
# '''
# ))),
# 'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
# }
po_due = ex(sql_ms("ms.stage = 'ACCEPTED'")) # payments accepted but not yet marked as paid
po_paid = ex(sql_ms("ms.stage = 'PAID'")) # will catch paid ms from all proposals regardless of status/stage
@ -782,6 +1008,24 @@ def financials():
po_future = ex(sql_ms("ms.stage IN ('IDLE', 'REJECTED', 'REQUESTED') AND p.stage IN ('WIP', 'COMPLETED')"))
po_total = po_due + po_paid + po_future
now = datetime.now()
start_year = 2019
end_year = now.year
payouts_by_quarter = {}
for year in range(start_year, end_year + 1):
payouts_by_quarter[f"{year}"] = {}
year_total = 0
for quarter in range(1, 5):
begin, end = gen_quarter_date_range(year, quarter)
payouts = ex(sql_ms(f"ms.stage = 'PAID' AND (ms.date_paid BETWEEN '{begin}' AND '{end}')"))
payouts_by_quarter[f"{year}"][f"q{quarter}"] = str(payouts)
year_total += payouts
payouts_by_quarter[f"{year}"]["year_total"] = str(year_total)
payouts = {
'total': str(po_total),
'due': str(po_due),
@ -798,7 +1042,7 @@ def financials():
def add_str_dec(a: str, b: str):
return str((Decimal(a) + Decimal(b)).quantize(Decimal('0.001'), rounding=ROUND_HALF_DOWN))
proposals = Proposal.query.all()
proposals = Proposal.query.filter_by(version='2')
for p in proposals:
# CANCELED proposals excluded, though they could have had milestones paid out with grant funds
@ -821,7 +1065,6 @@ def financials():
return {
'grants': grants,
'contributions': contributions,
'payouts': payouts,
'net': str(Decimal(contributions['gross']) - Decimal(payouts['paid']))
'payouts_by_quarter': payouts_by_quarter
}

View File

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""The app module, containing the app factory function."""
import sentry_sdk
import logging
import traceback
import sentry_sdk
from animal_case import animalify
from flask import Flask, Response, jsonify, request, current_app, g
from flask_cors import CORS
@ -10,7 +11,21 @@ from flask_security import SQLAlchemyUserDatastore
from flask_sslify import SSLify
from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp, e2e
from grant import (
commands,
proposal,
user,
ccr,
comment,
milestone,
admin,
email,
task,
rfp,
e2e,
home
)
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
from grant.settings import SENTRY_RELEASE, ENV, E2E_TESTING, DEBUG, CORS_DOMAINS
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
@ -29,6 +44,8 @@ class JSONResponse(Response):
def create_app(config_objects=["grant.settings"]):
from grant.patches import patch_werkzeug_set_samesite
patch_werkzeug_set_samesite()
app = Flask(__name__.split(".")[0])
app.response_class = JSONResponse
@ -129,15 +146,16 @@ def register_extensions(app):
def register_blueprints(app):
"""Register Flask blueprints."""
app.register_blueprint(ccr.views.blueprint)
app.register_blueprint(comment.views.blueprint)
app.register_blueprint(proposal.views.blueprint)
app.register_blueprint(user.views.blueprint)
app.register_blueprint(milestone.views.blueprint)
app.register_blueprint(admin.views.blueprint)
app.register_blueprint(email.views.blueprint)
app.register_blueprint(blockchain.views.blueprint)
app.register_blueprint(task.views.blueprint)
app.register_blueprint(rfp.views.blueprint)
app.register_blueprint(home.views.blueprint)
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)
@ -162,5 +180,7 @@ def register_commands(app):
app.cli.add_command(commands.reset_db_chain_data)
app.cli.add_command(proposal.commands.create_proposal)
app.cli.add_command(proposal.commands.create_proposals)
app.cli.add_command(proposal.commands.retire_v1_proposals)
app.cli.add_command(user.commands.set_admin)
app.cli.add_command(user.commands.mangle_users)
app.cli.add_command(task.commands.create_task)

View File

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

View File

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

230
backend/grant/ccr/models.py Normal file
View File

@ -0,0 +1,230 @@
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import or_
from sqlalchemy.ext.hybrid import hybrid_property
from grant.email.send import send_email
from grant.extensions import ma, db
from grant.utils.enums import CCRStatus
from grant.utils.exceptions import ValidationException
from grant.utils.misc import make_admin_url, gen_random_id, dt_to_unix
def default_content():
return """# Overview
What you think should be accomplished
# Approach
How you expect a proposing team to accomplish your request
# Deliverable
The end result of a proposal the fulfills this request
"""
class CCR(db.Model):
__tablename__ = "ccr"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
title = db.Column(db.String(255), nullable=True)
brief = db.Column(db.String(255), nullable=True)
content = db.Column(db.Text, nullable=True)
status = db.Column(db.String(255), nullable=False)
_target = db.Column("target", db.String(255), nullable=True)
reject_reason = db.Column(db.String())
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="ccrs")
rfp_id = db.Column(db.Integer, db.ForeignKey("rfp.id"), nullable=True)
rfp = db.relationship("RFP", back_populates="ccr")
@staticmethod
def get_by_user(user, statuses=[CCRStatus.LIVE]):
status_filter = or_(CCR.status == v for v in statuses)
return CCR.query \
.filter(CCR.user_id == user.id) \
.filter(status_filter) \
.all()
@staticmethod
def create(**kwargs):
ccr = CCR(
**kwargs
)
db.session.add(ccr)
db.session.flush()
return ccr
@hybrid_property
def target(self):
return self._target
@target.setter
def target(self, target: str):
if target and Decimal(target) > 0:
self._target = target
else:
self._target = None
def __init__(
self,
user_id: int,
title: str = '',
brief: str = '',
content: str = default_content(),
target: str = '0',
status: str = CCRStatus.DRAFT,
):
assert CCRStatus.includes(status)
self.id = gen_random_id(CCR)
self.date_created = datetime.now()
self.title = title[:255]
self.brief = brief[:255]
self.content = content
self.target = target
self.status = status
self.user_id = user_id
def update(
self,
title: str = '',
brief: str = '',
content: str = '',
target: str = '0',
):
self.title = title[:255]
self.brief = brief[:255]
self.content = content[:300000]
self._target = target[:255] if target != '' and target else '0'
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
def submit_for_approval(self):
self.validate_publishable()
allowed_statuses = [CCRStatus.DRAFT, CCRStatus.REJECTED]
# specific validation
if self.status not in allowed_statuses:
raise ValidationException(f"CCR status must be draft or rejected to submit for approval")
self.set_pending()
def send_admin_email(self, type: str):
from grant.user.models import User
admins = User.get_admins()
for a in admins:
send_email(a.email_address, type, {
'user': a,
'ccr': self,
'ccr_url': make_admin_url(f'/ccrs/{self.id}'),
})
# state: status DRAFT -> PENDING
def set_pending(self):
self.send_admin_email('admin_approval_ccr')
self.status = CCRStatus.PENDING
db.session.add(self)
db.session.flush()
def validate_publishable(self):
# Require certain fields
required_fields = ['title', 'content', 'brief', 'target']
for field in required_fields:
if not hasattr(self, field):
raise ValidationException("Proposal must have a {}".format(field))
# Stricter limits on certain fields
if len(self.title) > 60:
raise ValidationException("Proposal title cannot be longer than 60 characters")
if len(self.brief) > 140:
raise ValidationException("Brief cannot be longer than 140 characters")
if len(self.content) > 250000:
raise ValidationException("Content cannot be longer than 250,000 characters")
# state: status PENDING -> (LIVE || REJECTED)
def approve_pending(self, is_approve, reject_reason=None):
from grant.rfp.models import RFP
self.validate_publishable()
# specific validation
if not self.status == CCRStatus.PENDING:
raise ValidationException(f"CCR must be pending to approve or reject")
if is_approve:
self.status = CCRStatus.LIVE
rfp = RFP(
title=self.title,
brief=self.brief,
content=self.content,
bounty=self._target,
date_closes=datetime.now() + timedelta(days=90),
)
db.session.add(self)
db.session.add(rfp)
db.session.flush()
self.rfp_id = rfp.id
db.session.add(rfp)
db.session.flush()
# for emails
db.session.commit()
send_email(self.author.email_address, 'ccr_approved', {
'user': self.author,
'ccr': self,
'admin_note': f'Congratulations! Your Request has been accepted. There may be a delay between acceptance and final posting as required by the Zcash Foundation.'
})
return rfp.id
else:
if not reject_reason:
raise ValidationException("Please provide a reason for rejecting the ccr")
self.status = CCRStatus.REJECTED
self.reject_reason = reject_reason
# for emails
db.session.add(self)
db.session.commit()
send_email(self.author.email_address, 'ccr_rejected', {
'user': self.author,
'ccr': self,
'admin_note': reject_reason
})
return None
class CCRSchema(ma.Schema):
class Meta:
model = CCR
# Fields to expose
fields = (
"author",
"id",
"title",
"brief",
"ccr_id",
"content",
"status",
"target",
"date_created",
"reject_reason",
"rfp"
)
rfp = ma.Nested("RFPSchema")
date_created = ma.Method("get_date_created")
author = ma.Nested("UserSchema")
ccr_id = ma.Method("get_ccr_id")
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_ccr_id(self, obj):
return obj.id
ccr_schema = CCRSchema()
ccrs_schema = CCRSchema(many=True)

114
backend/grant/ccr/views.py Normal file
View File

@ -0,0 +1,114 @@
from flask import Blueprint, g
from marshmallow import fields
from sqlalchemy import or_
from grant.extensions import limiter
from grant.parser import body
from grant.utils.auth import (
requires_auth,
requires_email_verified_auth,
get_authed_user
)
from grant.utils.auth import requires_ccr_owner_auth
from grant.utils.enums import CCRStatus
from grant.utils.exceptions import ValidationException
from .models import CCR, ccr_schema, ccrs_schema, db
blueprint = Blueprint("ccr", __name__, url_prefix="/api/v1/ccrs")
@blueprint.route("/<ccr_id>", methods=["GET"])
def get_ccr(ccr_id):
ccr = CCR.query.filter_by(id=ccr_id).first()
if ccr:
if ccr.status != CCRStatus.LIVE:
if CCR.status == CCRStatus.DELETED:
return {"message": "CCR was deleted"}, 404
authed_user = get_authed_user()
if authed_user.id != ccr.author.id:
return {"message": "User cannot view this CCR"}, 404
return ccr_schema.dump(ccr)
else:
return {"message": "No CCR matching id"}, 404
@blueprint.route("/drafts", methods=["POST"])
@limiter.limit("10/hour;3/minute")
@requires_email_verified_auth
def make_ccr_draft():
user = g.current_user
ccr = CCR.create(status=CCRStatus.DRAFT, user_id=user.id)
db.session.commit()
return ccr_schema.dump(ccr), 201
@blueprint.route("/drafts", methods=["GET"])
@requires_auth
def get_ccr_drafts():
ccrs = (
CCR.query
.filter_by(user_id=g.current_user.id)
.filter(or_(
CCR.status == CCRStatus.DRAFT,
CCR.status == CCRStatus.REJECTED,
))
.order_by(CCR.date_created.desc())
.all()
)
return ccrs_schema.dump(ccrs), 200
@blueprint.route("/<ccr_id>", methods=["DELETE"])
@requires_ccr_owner_auth
def delete_ccr(ccr_id):
deleteable_statuses = [
CCRStatus.DRAFT,
CCRStatus.PENDING,
CCRStatus.APPROVED,
CCRStatus.REJECTED,
CCRStatus.REJECTED_PERMANENTLY
]
status = g.current_ccr.status
if status not in deleteable_statuses:
return {"message": "Cannot delete CCRs with %s status" % status}, 400
db.session.delete(g.current_ccr)
db.session.commit()
return {"message": "ok"}, 202
@blueprint.route("/<ccr_id>", methods=["PUT"])
@requires_ccr_owner_auth
@body({
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"content": fields.Str(required=True),
"target": fields.Str(required=True, allow_none=True),
})
def update_ccr(ccr_id, **kwargs):
try:
if g.current_ccr.status not in [CCRStatus.DRAFT,
CCRStatus.REJECTED]:
raise ValidationException(
f"CCR with status: {g.current_ccr.status} are not authorized for updates"
)
g.current_ccr.update(**kwargs)
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
db.session.add(g.current_ccr)
# Commit
db.session.commit()
return ccr_schema.dump(g.current_ccr), 200
@blueprint.route("/<ccr_id>/submit_for_approval", methods=["PUT"])
@requires_ccr_owner_auth
def submit_for_approval_ccr(ccr_id):
try:
g.current_ccr.submit_for_approval()
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
db.session.add(g.current_ccr)
db.session.commit()
return ccr_schema.dump(g.current_ccr), 200

View File

@ -4,10 +4,19 @@ 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
from sqlalchemy.orm import raiseload, column_property
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import func, select
HIDDEN_CONTENT = '~~comment removed by admin~~'
comment_liker = db.Table(
"comment_liker",
db.Model.metadata,
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
db.Column("comment_id", db.Integer, db.ForeignKey("comment.id")),
)
class Comment(db.Model):
__tablename__ = "comment"
@ -25,6 +34,15 @@ class Comment(db.Model):
author = db.relationship("User", back_populates="comments")
replies = db.relationship("Comment")
likes = db.relationship(
"User", secondary=comment_liker, back_populates="liked_comments"
)
likes_count = column_property(
select([func.count(comment_liker.c.comment_id)])
.where(comment_liker.c.comment_id == id)
.correlate_except(comment_liker)
)
def __init__(self, proposal_id, user_id, parent_comment_id, content):
self.id = gen_random_id(Comment)
self.proposal_id = proposal_id
@ -49,6 +67,28 @@ class Comment(db.Model):
self.hidden = hidden
db.session.add(self)
@hybrid_property
def authed_liked(self):
from grant.utils.auth import get_authed_user
authed = get_authed_user()
if not authed:
return False
res = (
db.session.query(comment_liker)
.filter_by(user_id=authed.id, comment_id=self.id)
.count()
)
if res:
return True
return False
def like(self, user, is_liked):
if is_liked:
self.likes.append(user)
else:
self.likes.remove(user)
db.session.flush()
# are all of the replies hidden?
def all_hidden(replies):
@ -74,6 +114,8 @@ class CommentSchema(ma.Schema):
"replies",
"reported",
"hidden",
"authed_liked",
"likes_count"
)
content = ma.Method("get_content")

View File

@ -1,4 +1,26 @@
from flask import Blueprint
from flask import Blueprint, g
from grant.utils.auth import requires_auth
from grant.parser import body
from marshmallow import fields
from .models import Comment, db, comment_schema
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
@blueprint.route("/<comment_id>/like", methods=["PUT"])
@requires_auth
@body({"isLiked": fields.Bool(required=True)})
def like_comment(comment_id, is_liked):
user = g.current_user
# Make sure comment exists
comment = Comment.query.filter_by(id=comment_id).first()
if not comment:
return {"message": "No comment matching id"}, 404
comment.like(user, is_liked)
db.session.commit()
return comment_schema.dump(comment), 201

View File

@ -1,18 +1,20 @@
from .subscription_settings import EmailSubscription, is_subscribed
from sendgrid.helpers.mail import Email, Mail, Content
from python_http_client import HTTPError
from grant.utils.misc import make_url
from sentry_sdk import capture_exception
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
import sendgrid
from threading import Thread
from flask import render_template, Markup, current_app, g
import sendgrid
from flask import render_template, Markup, current_app, g
from python_http_client import HTTPError
from sendgrid.helpers.mail import Email, Mail, Content
from sentry_sdk import capture_exception
from grant.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, UI, E2E_TESTING
from grant.settings import SENDGRID_DEFAULT_FROMNAME
from grant.utils.misc import make_url
from .subscription_settings import EmailSubscription, is_subscribed
default_template_args = {
'home_url': make_url('/'),
'account_url': make_url('/profile'),
'profile_rejected_url': make_url('/profile?tab=rejected'),
'email_settings_url': make_url('/profile/settings?tab=emails'),
'unsubscribe_url': make_url('/profile/settings?tab=emails'),
}
@ -68,22 +70,94 @@ def change_password_info(email_args):
def proposal_approved(email_args):
return {
'subject': 'Your proposal has been approved!',
'title': 'Your proposal has been approved',
'preview': 'Start raising funds for {} now'.format(email_args['proposal'].title),
'subject': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
'title': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
'preview': "Your proposal '{}' has been funded".format(email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def proposal_approved_without_funding(email_args):
return {
'subject': "Your proposal '{}' has been listed on ZF Grants for community donations".format(
email_args['proposal'].title),
'title': "Your proposal '{}' has been listed on ZF Grants for community donations".format(
email_args['proposal'].title),
'preview': "Your proposal '{}' has been listed on ZF Grants for community donations".format(
email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def proposal_approved_discussion(email_args):
return {
'subject': "Your proposal '{}' has been approved for public discussion".format(email_args['proposal'].title),
'title': "Your proposal '{}' has been approved for public discussion".format(email_args['proposal'].title),
'preview': '{} is now open for public discussion on ZF Grants.'.format(email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def ccr_approved(email_args):
return {
'subject': "Your request '{}' has been approved!".format(email_args['ccr'].title),
'title': "Your request '{}' has been approved!".format(email_args['ccr'].title),
'preview': '{} will soon be live on ZF Grants!'.format(email_args['ccr'].title),
}
def ccr_rejected(email_args):
return {
'subject': "Your request '{}' has changes requested".format(email_args['ccr'].title),
'title': "Your request '{}' has changes requested".format(email_args['ccr'].title),
'preview': '{} has changes requested'.format(email_args['ccr'].title),
}
def ccr_rejected_permanently(email_args):
return {
'subject': "Your request '{}' has been rejected".format(email_args['ccr'].title),
'title': "Your request '{}' has been rejected".format(email_args['ccr'].title),
'preview': f'{email_args["ccr"].title} won\'t be accepted',
}
def proposal_rejected(email_args):
return {
'subject': 'Your proposal has been rejected',
'title': 'Your proposal has been rejected',
'subject': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
'title': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
'preview': '{} has changes requested'.format(email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def proposal_rejected_discussion(email_args):
return {
'subject': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
'title': "Your proposal '{}' has changes requested".format(email_args['proposal'].title),
'preview': '{} has changes requested'.format(email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def proposal_rejected_permanently(email_args):
return {
'subject': "Your proposal '{}' has been rejected".format(email_args['proposal'].title),
'title': "Your proposal '{}' has been rejected".format(email_args['proposal'].title),
'preview': '{} has been rejected'.format(email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def proposal_arbiter_assigned(email_args):
return {
'subject': "Your proposal '{}' is ready for payout requests".format(email_args['proposal'].title),
'title': "Your proposal '{}' is ready for payout requests".format(email_args['proposal'].title),
'preview': '{} is ready for payout '.format(email_args['proposal'].title),
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL
}
def proposal_contribution(email_args):
if email_args['contribution'].private:
email_args['contributor'] = None
@ -101,7 +175,8 @@ def proposal_contribution(email_args):
def proposal_comment(email_args):
return {
'subject': 'New comment from {}'.format(email_args['author'].display_name),
'subject': "New comment from {} to your proposal '{}'".format(email_args['author'].display_name,
email_args['proposal'].title),
'title': 'You got a comment',
'preview': '{} has added a comment to your proposal {}'.format(
email_args['author'].display_name,
@ -113,8 +188,8 @@ def proposal_comment(email_args):
def proposal_failed(email_args):
return {
'subject': 'Your proposal failed to get funding',
'title': 'Proposal failed',
'subject': "Your proposal '{}' failed to get funding".format(email_args['proposal'].title),
'title': "Your proposal '{}' failed to get funding".format(email_args['proposal'].title),
'preview': 'Your proposal entitled {} failed to get enough funding by the deadline'.format(
email_args['proposal'].title,
),
@ -126,7 +201,7 @@ def proposal_canceled(email_args):
return {
'subject': 'Your proposal has been canceled',
'title': 'Proposal canceled',
'preview': 'Your proposal entitled {} has been canceled, and your contributors will be refunded'.format(
'preview': 'Your proposal entitled {} has been canceled'.format(
email_args['proposal'].title,
),
}
@ -245,6 +320,17 @@ def milestone_request(email_args):
}
def milestone_deadline(email_args):
p = email_args['proposal']
ms = p.current_milestone
return {
'subject': f'Milestone deadline reached for {p.title} - {ms.title}',
'title': f'Milestone deadline reached',
'preview': f'The estimated deadline for milestone {ms.title} has been reached.',
'subscription': EmailSubscription.ARBITER,
}
def milestone_reject(email_args):
p = email_args['proposal']
ms = p.current_milestone
@ -263,7 +349,7 @@ def milestone_accept(email_args):
return {
'subject': f'Payout approved for {p.title} - {ms.title}!',
'title': f'Milestone payout approved',
'preview': f'The payout of {a} ZEC for milestone {ms.title} has been approved.',
'preview': f'The payout of ${a} in ZEC for milestone {ms.title} has been approved.',
'subscription': EmailSubscription.MY_PROPOSAL_APPROVAL,
}
@ -275,7 +361,7 @@ def milestone_paid(email_args):
return {
'subject': f'{p.title} - {ms.title} has been paid!',
'title': f'Milestone paid',
'preview': f'The milestone {ms.title} payout of {a} ZEC has been paid!',
'preview': f'The milestone {ms.title} payout of ${a} in ZEC has been paid!',
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
}
@ -289,6 +375,24 @@ def admin_approval(email_args):
}
def admin_approval_ccr(email_args):
return {
'subject': f'Review needed for {email_args["ccr"].title}',
'title': f'CCR Review',
'preview': f'{email_args["ccr"].title} needs review, as an admin you can help.',
'subscription': EmailSubscription.ADMIN_APPROVAL_CCR,
}
def admin_changes_resolved(email_args):
return {
'subject': f'Changes marked as resolved for {email_args["proposal"].title}',
'title': f'Changes Resolved',
'preview': f'Team members of proposal {email_args["proposal"].title} have marked requested changes as resolved.',
'subscription': EmailSubscription.ADMIN_APPROVAL,
}
def admin_arbiter(email_args):
return {
'subject': f'Arbiter needed for {email_args["proposal"].title}',
@ -307,6 +411,37 @@ def admin_payout(email_args):
}
def followed_proposal_milestone(email_args):
p = email_args["proposal"]
ms = email_args["milestone"]
return {
"subject": f"Milestone accepted for {p.title}",
"title": f"Milestone Accepted",
"preview": f"Followed proposal {p.title} has passed a milestone",
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
}
def followed_proposal_update(email_args):
p = email_args["proposal"]
return {
"subject": f"Proposal update for {p.title}",
"title": f"Proposal Update",
"preview": f"Followed proposal {p.title} has an update",
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
}
def followed_proposal_revised(email_args):
p = email_args["proposal"]
return {
"subject": f"Proposal '{p.title}' has been revised.",
"title": f"Proposal Revised",
"preview": f"Followed proposal {p.title} has been revised",
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info,
@ -314,8 +449,16 @@ get_info_lookup = {
'change_email': change_email_info,
'change_email_old': change_email_old_info,
'change_password': change_password_info,
'ccr_approved': ccr_approved,
'ccr_rejected': ccr_rejected,
'ccr_rejected_permanently': ccr_rejected_permanently,
'proposal_approved_without_funding': proposal_approved_without_funding,
'proposal_approved': proposal_approved,
'proposal_approved_discussion': proposal_approved_discussion,
'proposal_rejected': proposal_rejected,
'proposal_rejected_discussion': proposal_rejected_discussion,
'proposal_rejected_permanently': proposal_rejected_permanently,
'proposal_arbiter_assigned': proposal_arbiter_assigned,
'proposal_contribution': proposal_contribution,
'proposal_comment': proposal_comment,
'proposal_failed': proposal_failed,
@ -330,12 +473,18 @@ get_info_lookup = {
'comment_reply': comment_reply,
'proposal_arbiter': proposal_arbiter,
'milestone_request': milestone_request,
'milestone_deadline': milestone_deadline,
'milestone_reject': milestone_reject,
'milestone_accept': milestone_accept,
'milestone_paid': milestone_paid,
'admin_approval': admin_approval,
'admin_approval_ccr': admin_approval_ccr,
'admin_changes_resolved': admin_changes_resolved,
'admin_arbiter': admin_arbiter,
'admin_payout': admin_payout
'admin_payout': admin_payout,
'followed_proposal_milestone': followed_proposal_milestone,
'followed_proposal_update': followed_proposal_update,
'followed_proposal_revised': followed_proposal_revised
}

View File

@ -65,6 +65,14 @@ class EmailSubscription(Enum):
'bit': 14,
'key': 'admin_payout'
}
FOLLOWED_PROPOSAL = {
'bit': 15,
'key': 'followed_proposal'
}
ADMIN_APPROVAL_CCR = {
'bit': 16,
'key': 'admin_approval_ccr'
}
def is_email_sub_key(k: str):

View File

@ -0,0 +1,34 @@
from datetime import datetime
from flask import Blueprint
from sqlalchemy import or_
from grant.proposal.models import Proposal, proposals_schema
from grant.rfp.models import RFP, rfps_schema
from grant.utils.enums import ProposalStatus, ProposalStage, RFPStatus
blueprint = Blueprint("home", __name__, url_prefix="/api/v1/home")
@blueprint.route("/latest", methods=["GET"])
def get_home_content():
latest_proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE)
.filter(Proposal.stage != ProposalStage.CANCELED)
.filter(Proposal.stage != ProposalStage.FAILED)
.order_by(Proposal.date_created.desc())
.limit(3)
.all()
)
latest_rfps = (
RFP.query.filter_by(status=RFPStatus.LIVE)
.filter(or_(RFP.date_closes == None, RFP.date_closes > datetime.now()))
.order_by(RFP.date_opened)
.limit(3)
.all()
)
return {
"latest_proposals": proposals_schema.dump(latest_proposals),
"latest_rfps": rfps_schema.dump(latest_rfps),
}

View File

@ -5,6 +5,7 @@ from grant.utils.enums import MilestoneStage
from grant.utils.exceptions import ValidationException
from grant.utils.ma_fields import UnixDate
from grant.utils.misc import gen_random_id
from grant.task.jobs import MilestoneDeadline
class MilestoneException(Exception):
@ -22,7 +23,8 @@ 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)
date_estimated = db.Column(db.DateTime, nullable=False)
date_estimated = db.Column(db.DateTime, nullable=True)
days_estimated = db.Column(db.String(255), nullable=True)
stage = db.Column(db.String(255), nullable=False)
@ -46,7 +48,7 @@ class Milestone(db.Model):
index: int,
title: str,
content: str,
date_estimated: datetime,
days_estimated: str,
payout_percent: str,
immediate_payout: bool,
stage: str = MilestoneStage.IDLE,
@ -56,13 +58,14 @@ class Milestone(db.Model):
self.title = title[:255]
self.content = content[:255]
self.stage = stage
self.date_estimated = date_estimated
self.days_estimated = days_estimated[:255]
self.payout_percent = payout_percent[:255]
self.immediate_payout = immediate_payout
self.proposal_id = proposal_id
self.date_created = datetime.datetime.now()
self.index = index
@staticmethod
def make(milestones_data, proposal):
if milestones_data:
@ -72,7 +75,7 @@ class Milestone(db.Model):
m = Milestone(
title=milestone_data["title"][:255],
content=milestone_data["content"][:255],
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
days_estimated=str(milestone_data["days_estimated"])[:255],
payout_percent=str(milestone_data["payout_percent"])[:255],
immediate_payout=milestone_data["immediate_payout"],
proposal_id=proposal.id,
@ -80,6 +83,75 @@ class Milestone(db.Model):
)
db.session.add(m)
# clone milestones from one proposal to another
@staticmethod
def clone(source_proposal, destination_proposal):
# delete any milestones on destination proposal
[db.session.delete(ms) for ms in destination_proposal.milestones]
# copy milestones from source proposal to destination proposal
for i, ms in enumerate(source_proposal.milestones):
new_ms = Milestone(
proposal_id=destination_proposal.id,
title=ms.title,
content=ms.content,
days_estimated=ms.days_estimated,
payout_percent=ms.payout_percent,
immediate_payout=ms.immediate_payout,
index=i
)
db.session.add(new_ms)
# The purpose of this method is to set the `date_estimated` property on all milestones in a proposal. This works
# by figuring out a starting point for each milestone (the `base_date` below) and adding `days_estimated`.
#
# As proposal creators now estimate their milestones in days (instead of picking months), this method allows us to
# keep `date_estimated` in sync throughout the lifecycle of a proposal. For example, if a user misses their
# first milestone deadline by a week, this method would take the actual completion date of that milestone and
# adjust the `date_estimated` of the remaining milestones accordingly.
#
@staticmethod
def set_v2_date_estimates(proposal):
if not proposal.date_approved:
raise MilestoneException(f'Cannot estimate milestone dates because proposal has no date_approved set')
# The milestone being actively worked on
current_milestone = proposal.current_milestone
if current_milestone.stage == MilestoneStage.PAID:
raise MilestoneException(f'Cannot estimate milestone dates because they are all completed')
# The starting point for `date_estimated` calculation for each uncompleted milestone
# We add `days_estimated` to `base_date` to calculate `date_estimated`
base_date = None
for index, milestone in enumerate(proposal.milestones):
if index == 0:
# If it's the first milestone, use the proposal approval date as a `base_date`
base_date = proposal.date_approved
if milestone.date_paid:
# If milestone has been paid, set `base_date` for the next milestone and noop out
base_date = milestone.date_paid
continue
days_estimated = milestone.days_estimated if not milestone.immediate_payout else "0"
date_estimated = base_date + datetime.timedelta(days=int(days_estimated))
milestone.date_estimated = date_estimated
# Set the `base_date` for the next milestone using the estimate completion date of the current milestone
base_date = date_estimated
db.session.add(milestone)
# Skip task creation if current milestone has an immediate payout
if current_milestone.immediate_payout:
return
# Create MilestoneDeadline task for the current milestone so arbiters will be alerted if the deadline is missed
task = MilestoneDeadline(proposal, current_milestone)
task.make_task()
def request_payout(self, user_id: int):
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
@ -140,6 +212,7 @@ class MilestoneSchema(ma.Schema):
"date_rejected",
"date_accepted",
"date_paid",
"days_estimated"
)
date_created = UnixDate(attribute='date_created')

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

@ -0,0 +1,8 @@
from werkzeug import http, wrappers
from grant.werkzeug_http_fork import dump_cookie
def patch_werkzeug_set_samesite():
http.dump_cookie = dump_cookie
wrappers.base_response.dump_cookie = dump_cookie

View File

@ -7,7 +7,7 @@ from flask.cli import with_appcontext
from .models import Proposal, db
from grant.milestone.models import Milestone
from grant.comment.models import Comment
from grant.utils.enums import ProposalStatus, Category, ProposalStageEnum
from grant.utils.enums import ProposalStatus, Category, ProposalStage
from grant.user.models import User
@ -35,9 +35,9 @@ def create_proposals(count):
user = User.query.filter_by().first()
for i in range(count):
if i < 5:
stage = ProposalStageEnum.FUNDING_REQUIRED
stage = ProposalStage.WIP
else:
stage = ProposalStageEnum.COMPLETED
stage = ProposalStage.COMPLETED
p = Proposal.create(
stage=stage,
status=ProposalStatus.LIVE,
@ -51,6 +51,10 @@ def create_proposals(count):
)
p.date_published = datetime.datetime.now()
p.team.append(user)
p.date_approved = datetime.datetime.now()
p.accepted_with_funding = True
p.version = '2'
p.fully_fund_contibution_bounty()
db.session.add(p)
db.session.flush()
num_ms = randint(1, 9)
@ -58,7 +62,7 @@ def create_proposals(count):
m = Milestone(
title=f'Fake MS {j}',
content=f'Fake milestone #{j} on fake proposal #{i}!',
date_estimated=datetime.datetime.now(),
days_estimated='10',
payout_percent=str(floor(1 / num_ms * 100)),
immediate_payout=j == 0,
proposal_id=p.id,
@ -74,5 +78,119 @@ def create_proposals(count):
)
db.session.add(c)
Milestone.set_v2_date_estimates(p)
db.session.add(p)
db.session.commit()
print(f'Added {count} LIVE fake proposals')
@click.command()
@click.argument('dry', required=False)
@with_appcontext
def retire_v1_proposals(dry):
now = datetime.datetime.now()
proposals_funding_required = Proposal.query.filter_by(stage="FUNDING_REQUIRED").all()
proposals_draft = Proposal.query.filter_by(status=ProposalStatus.DRAFT).all()
proposals_pending = Proposal.query.filter_by(status=ProposalStatus.PENDING).all()
proposals_staking = Proposal.query.filter_by(status=ProposalStatus.STAKING).all()
modified_funding_required_count = 0
modified_draft_count = 0
modified_pending_count = 0
modified_staking_count = 0
deleted_draft_count = 0
if not proposals_funding_required and not proposals_draft and not proposals_pending and not proposals_staking:
print("No proposals found. Exiting...")
return
print(f"Found {len(proposals_funding_required)} 'FUNDING_REQUIRED' proposals to modify")
print(f"Found {len(proposals_draft)} 'DRAFT' proposals to modify")
print(f"Found {len(proposals_pending)} 'PENDING' proposals to modify")
print(f"Found {len(proposals_staking)} 'STAKING' proposals to modify")
if dry:
print(f"This is a dry run. Changes will not be committed to the database")
confirm = input("Continue? (y/n) ")
if confirm != "y":
print("Exiting...")
return
# move 'FUNDING_REQUIRED' proposals to a failed state
for p in proposals_funding_required:
if not dry:
new_deadline = (now - p.date_published).total_seconds()
p.stage = ProposalStage.FAILED
p.deadline_duration = int(new_deadline)
db.session.add(p)
modified_funding_required_count += 1
print(f"Modified 'FUNDING_REQUIRED' proposal {p.id} - {p.title}")
# reset proposal to draft state
def convert_proposal_to_v2_draft(proposal):
milestones = Milestone.query.filter_by(proposal_id=proposal.id).all()
if not dry:
# reset target because v2 estimates are in USD
proposal.target = '0'
proposal.version = '2'
proposal.stage = ProposalStage.PREVIEW
proposal.status = ProposalStatus.DRAFT
db.session.add(proposal)
for m in milestones:
# clear date estimated because v2 proposals use days_estimated (date_estimated is dynamically set)
m.date_estimated = None
db.session.add(m)
print(f"Modified {len(milestones)} milestones on proposal {p.id}")
# delete drafts that have no content
def delete_stale_draft(proposal):
if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0":
return False
if proposal.payout_address or proposal.milestones:
return False
if not dry:
db.session.delete(proposal)
return True
for p in proposals_draft:
is_stale = delete_stale_draft(p)
if is_stale:
deleted_draft_count += 1
print(f"Deleted stale 'DRAFT' proposal {p.id} - {p.title}")
continue
convert_proposal_to_v2_draft(p)
modified_draft_count += 1
print(f"Modified 'DRAFT' proposal {p.id} - {p.title}")
for p in proposals_pending:
convert_proposal_to_v2_draft(p)
modified_pending_count += 1
print(f"Modified 'PENDING' proposal {p.id} - {p.title}")
for p in proposals_staking:
convert_proposal_to_v2_draft(p)
modified_staking_count += 1
print(f"Modified 'STAKING' proposal {p.id} - {p.title}")
if not dry:
print(f"Committing changes to database")
db.session.commit()
print("")
print(f"Modified {modified_funding_required_count} 'FUNDING_REQUIRED' proposals")
print(f"Modified {modified_draft_count} 'DRAFT' proposals")
print(f"Modified {modified_pending_count} 'PENDING' proposals")
print(f"Modified {modified_staking_count} 'STAKING' proposals")
print(f"Deleted {deleted_draft_count} stale 'DRAFT' proposals")

View File

@ -1,30 +1,33 @@
import datetime
import json
from decimal import Decimal, ROUND_DOWN
from functools import reduce
from typing import Optional
from flask import current_app
from marshmallow import post_dump
from sqlalchemy import func, or_
from sqlalchemy import func, or_, select, ForeignKey
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import column_property
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.milestone.models import Milestone
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
from grant.task.jobs import ContributionExpired
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
Category,
ContributionStatus,
ProposalArbiterStatus,
MilestoneStage
MilestoneStage,
ProposalChange
)
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url, make_admin_url, gen_random_id
from grant.utils.requests import blockchain_get
from grant.utils.stubs import anonymous_user
from grant.utils.validate import is_z_address_valid
proposal_team = db.Table(
'proposal_team', db.Model.metadata,
@ -32,6 +35,20 @@ proposal_team = db.Table(
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
)
proposal_follower = db.Table(
"proposal_follower",
db.Model.metadata,
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
)
proposal_liker = db.Table(
"proposal_liker",
db.Model.metadata,
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
)
class ProposalTeamInvite(db.Model):
__tablename__ = "proposal_team_invite"
@ -145,6 +162,8 @@ class ProposalContribution(db.Model):
raise ValidationException('Proposal ID is required')
# User ID (must belong to an existing user)
if user_id:
from grant.user.models import User
user = User.query.filter(User.id == user_id).first()
if not user:
raise ValidationException('No user matching that ID')
@ -212,32 +231,188 @@ class ProposalArbiter(db.Model):
raise ValidationException('User is not arbiter')
class ProposalRevision(db.Model):
__tablename__ = "proposal_revision"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
# user who submitted the changes
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", uselist=False, lazy=True)
# the proposal these changes are associated with
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
proposal = db.relationship("Proposal", foreign_keys=[proposal_id], back_populates="revisions")
# the archived proposal id associated with these changes
proposal_archive_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
# the detected changes as a JSON string
changes = db.Column(db.Text, nullable=False)
# the placement of this revision in the total revisions
revision_index = db.Column(db.Integer)
def __init__(self, author, proposal_id: int, proposal_archive_id: int, changes: str, revision_index: int):
self.id = gen_random_id(ProposalRevision)
self.date_created = datetime.datetime.now()
self.author = author
self.proposal_id = proposal_id
self.proposal_archive_id = proposal_archive_id
self.changes = changes
self.revision_index = revision_index
@staticmethod
def calculate_milestone_changes(old_milestones, new_milestones):
changes = []
old_length = len(old_milestones)
new_length = len(new_milestones)
# determine the longer milestone collection so we can enumerate it
long_ms = None
short_ms = None
if old_length >= new_length:
long_ms = old_milestones
short_ms = new_milestones
else:
long_ms = new_milestones
short_ms = old_milestones
# detect whether we're adding or removing milestones
is_adding = False
is_removing = False
if old_length > new_length:
is_removing = True
if new_length > old_length:
is_adding = True
for i, ms in enumerate(long_ms):
compare_ms = short_ms[i] if len(short_ms) - 1 >= i else None
# when compare milestone doesn't exist, the current milestone is either being added or removed
if not compare_ms:
if is_adding:
changes.append({"type": ProposalChange.MILESTONE_ADD, "milestone_index": i})
if is_removing:
changes.append({"type": ProposalChange.MILESTONE_REMOVE, "milestone_index": i})
continue
if ms.days_estimated != compare_ms.days_estimated:
changes.append({"type": ProposalChange.MILESTONE_EDIT_DAYS, "milestone_index": i})
if ms.immediate_payout != compare_ms.immediate_payout:
changes.append({"type": ProposalChange.MILESTONE_EDIT_IMMEDIATE_PAYOUT, "milestone_index": i})
if ms.payout_percent != compare_ms.payout_percent:
changes.append({"type": ProposalChange.MILESTONE_EDIT_PERCENT, "milestone_index": i})
if ms.content != compare_ms.content:
changes.append({"type": ProposalChange.MILESTONE_EDIT_CONTENT, "milestone_index": i})
if ms.title != compare_ms.title:
changes.append({"type": ProposalChange.MILESTONE_EDIT_TITLE, "milestone_index": i})
return changes
@staticmethod
def calculate_proposal_changes(old_proposal, new_proposal):
proposal_changes = []
if old_proposal.brief != new_proposal.brief:
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_BRIEF})
if old_proposal.content != new_proposal.content:
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_CONTENT})
if old_proposal.target != new_proposal.target:
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TARGET})
if old_proposal.title != new_proposal.title:
proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TITLE})
milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones,
new_proposal.milestones)
return proposal_changes + milestone_changes
def default_proposal_content():
return """### If you have any doubts about the questions below, please reach out to anyone on the ZOMG on the [Zcash forums](https://forum.zcashcommunity.com/).
# Description of Problem or Opportunity
In addition to describing the problem/opportunity, please give a sense of how serious or urgent of a need you believe this to be. What evidence do you have? What validation have you already done, or how do you think you could validate this?
# Proposed Solution
Describe the solution at a high level. Please be specific about who the users and stakeholders are and how they would interact with your solution. E.g. retail ZEC holders, Zcash core devs, wallet devs, DeFi users, potential Zcash community participants.
# Solution Format
What is the exact form of the deliverable youre creating? E.g. code shipped within the zcashd and zebra code bases, a website, a feature within a wallet, a text/markdown file, user manuals, etc.
# Technical approach
Dive into the _how_ of your project. Describe your approaches, components, workflows, methodology, etc. Bullet points and diagrams are appreciated!
# How big of a problem would it be to not solve this problem?
# Execution risks
What obstacles do you expect? What is most likely to go wrong? Which unknown factors or dependencies could jeopardize success? Who would have to incorporate your work in order for it to be usable?
# Unintended Consequences Downsides
What are the negative ramifications if your project is successful? Consider usability, stability, privacy, integrity, availability, decentralization, interoperability, maintainability, technical debt, requisite education, etc.
# Evaluation plan
What metrics for success can you share with the community once youre done? In addition to quantitative metrics, what qualitative metrics do you think you could report?
# Schedule and Milestones
What is your timeline for the project? Include concrete milestones and the major tasks required to complete each milestone.
# Budget and Payout Timeline
How much funding do you need, and how will it be allocated (e.g., compensation for your effort, specific equipment, specific external services)? Please tie your payout timelines to the milestones presented in the previous step. Convention has been for applicants to base their budget on hours of work and an hourly rate, but we are open to proposals based on the value of outcomes instead.
# Applicant background
Summarize you and/or your teams background and experience. Demonstrate that you have the skills and expertise necessary for the project that youre proposing. Institutional bona fides are not required, but we want to hear about your track record.
"""
class Proposal(db.Model):
__tablename__ = "proposal"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
version = db.Column(db.String(255), nullable=True)
# Content info
status = db.Column(db.String(255), nullable=False)
title = db.Column(db.String(255), nullable=False)
brief = db.Column(db.String(255), nullable=False)
stage = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False, default=default_proposal_content())
category = db.Column(db.String(255), nullable=True)
date_approved = db.Column(db.DateTime)
date_published = db.Column(db.DateTime)
reject_reason = db.Column(db.String())
kyc_approved = db.Column(db.Boolean(), nullable=True, default=False)
funded_by_zomg = db.Column(db.Boolean(), nullable=True, default=False)
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
changes_requested_discussion = db.Column(db.Boolean(), nullable=True)
changes_requested_discussion_reason = db.Column(db.String(255), nullable=True)
# Payment info
target = db.Column(db.String(255), nullable=False)
payout_address = db.Column(db.String(255), nullable=False)
deadline_duration = db.Column(db.Integer(), nullable=False)
deadline_duration = db.Column(db.Integer(), nullable=True)
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
contribution_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()
tip_jar_address = db.Column(db.String(255), nullable=True)
tip_jar_view_key = db.Column(db.String(255), nullable=True)
# Relations
team = db.relationship("User", secondary=proposal_team)
@ -248,13 +423,35 @@ class Proposal(db.Model):
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
followers = db.relationship(
"User", secondary=proposal_follower, back_populates="followed_proposals"
)
followers_count = column_property(
select([func.count(proposal_follower.c.proposal_id)])
.where(proposal_follower.c.proposal_id == id)
.correlate_except(proposal_follower)
)
likes = db.relationship(
"User", secondary=proposal_liker, back_populates="liked_proposals"
)
likes_count = column_property(
select([func.count(proposal_liker.c.proposal_id)])
.where(proposal_liker.c.proposal_id == id)
.correlate_except(proposal_liker)
)
live_draft_parent_id = db.Column(db.Integer, ForeignKey('proposal.id'))
live_draft = db.relationship("Proposal", uselist=False,
backref=db.backref('live_draft_parent', remote_side=[id], uselist=False))
revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True,
cascade="all, delete-orphan")
def __init__(
self,
status: str = ProposalStatus.DRAFT,
title: str = '',
brief: str = '',
content: str = '',
content: str = default_proposal_content(),
stage: str = ProposalStage.PREVIEW,
target: str = '0',
payout_address: str = '',
@ -272,18 +469,17 @@ class Proposal(db.Model):
self.payout_address = payout_address
self.deadline_duration = deadline_duration
self.stage = stage
self.version = '2'
self.funded_by_zomg = True
@staticmethod
def simple_validate(proposal):
# Validate fields to be database save-able.
# Stricter validation is done in validate_publishable.
stage = proposal.get('stage')
category = proposal.get('category')
if stage and not ProposalStage.includes(stage):
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
if category and not Category.includes(category):
raise ValidationException("Category {} not a valid category".format(category))
def validate_publishable_milestones(self):
payout_total = 0.0
@ -316,7 +512,7 @@ class Proposal(db.Model):
self.validate_publishable_milestones()
# Require certain fields
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
required_fields = ['title', 'content', 'brief', 'target', 'payout_address']
for field in required_fields:
if not hasattr(self, field):
raise ValidationException("Proposal must have a {}".format(field))
@ -329,31 +525,42 @@ class Proposal(db.Model):
if len(self.content) > 250000:
raise ValidationException("Content cannot be longer than 250,000 characters")
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
if Decimal(self.target) < 0.0001:
raise ValidationException("Target cannot be less than 0.0001")
raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX))
if Decimal(self.target) < 0:
raise ValidationException("Target cannot be less than 0")
if not self.target.isdigit():
raise ValidationException("Target must be a whole number")
if self.deadline_duration > 7776000:
raise ValidationException("Deadline duration cannot be more than 90 days")
# Check with node that the address is kosher
try:
res = blockchain_get('/validate/address', {'address': self.payout_address})
except:
raise ValidationException(
"Could not validate your payout address due to an internal server error, please try again later")
if not res['valid']:
raise ValidationException("Payout address is not a valid Zcash address")
# Validate payout address
if not is_z_address_valid(self.payout_address):
raise ValidationException("Payout address is not a valid z address")
# Validate tip jar address
if self.tip_jar_address and not is_z_address_valid(self.tip_jar_address):
raise ValidationException("Tip address is not a valid z address")
# Then run through regular validation
Proposal.simple_validate(vars(self))
# only do this when user submits for approval, there is a chance the dates will
# be passed by the time admin approval / user publishing occurs
def validate_milestone_dates(self):
present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def validate_milestone_days(self):
for milestone in self.milestones:
if present > milestone.date_estimated:
raise ValidationException("Milestone date estimate must be in the future ")
if milestone.immediate_payout:
continue
try:
p = float(milestone.days_estimated)
if not p.is_integer():
raise ValidationException("Milestone days estimated must be whole numbers, no decimals")
if p <= 0:
raise ValidationException("Milestone days estimated must be greater than zero")
if p > 365:
raise ValidationException("Milestone days estimated must be less than 365")
except ValueError:
raise ValidationException("Milestone days estimated must be a number")
return
@staticmethod
def create(**kwargs):
@ -372,7 +579,7 @@ class Proposal(db.Model):
return proposal
@staticmethod
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
def get_by_user(user, statuses=[ProposalStatus.LIVE, ProposalStatus.DISCUSSION]):
status_filter = or_(Proposal.status == v for v in statuses)
return Proposal.query \
.join(proposal_team) \
@ -396,6 +603,7 @@ class Proposal(db.Model):
content: str = '',
target: str = '0',
payout_address: str = '',
tip_jar_address: Optional[str] = None,
deadline_duration: int = 5184000 # 60 days
):
self.title = title[:255]
@ -404,25 +612,19 @@ class Proposal(db.Model):
self.content = content[:300000]
self.target = target[:255] if target != '' else '0'
self.payout_address = payout_address[:255]
self.tip_jar_address = tip_jar_address[:255] if tip_jar_address is not None else None
self.deadline_duration = deadline_duration
Proposal.simple_validate(vars(self))
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,
private: bool = True,
self,
amount,
user_id: int = None,
staking: bool = False,
private: bool = True,
):
contribution = ProposalContribution(
proposal_id=self.id,
@ -469,19 +671,15 @@ class Proposal(db.Model):
'proposal_url': make_admin_url(f'/proposals/{self.id}'),
})
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
# state: status (DRAFT || REJECTED) -> (PENDING)
def submit_for_approval(self):
self.validate_publishable()
self.validate_milestone_dates()
self.validate_milestone_days()
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
# specific validation
if self.status not in allowed_statuses:
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
# set to PENDING if staked, else STAKING
if self.is_staked:
self.status = ProposalStatus.PENDING
else:
self.status = ProposalStatus.STAKING
self.set_pending()
def set_pending_when_ready(self):
if self.status == ProposalStatus.STAKING and self.is_staked:
@ -489,31 +687,24 @@ class Proposal(db.Model):
# state: status STAKING -> PENDING
def set_pending(self):
if self.status != ProposalStatus.STAKING:
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
if not self.is_staked:
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
self.send_admin_email('admin_approval')
self.status = ProposalStatus.PENDING
db.session.add(self)
db.session.flush()
# state: status PENDING -> (APPROVED || REJECTED)
def approve_pending(self, is_approve, reject_reason=None):
self.validate_publishable()
# specific validation
# approve a proposal moving from PENDING to DISCUSSION status
# state: status PENDING -> (DISCUSSION || REJECTED)
def approve_discussion(self, is_open_for_discussion, reject_reason=None):
if not self.status == ProposalStatus.PENDING:
raise ValidationException(f"Proposal must be pending to approve or reject")
raise ValidationException("Proposal must be pending to open for public discussion")
if is_approve:
self.status = ProposalStatus.APPROVED
self.date_approved = datetime.datetime.now()
if is_open_for_discussion:
self.status = ProposalStatus.DISCUSSION
for t in self.team:
send_email(t.email_address, 'proposal_approved', {
send_email(t.email_address, 'proposal_approved_discussion', {
'user': t,
'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'),
'admin_note': 'Congratulations! Your proposal has been approved.'
'proposal_url': make_url(f'/proposals/{self.id}')
})
else:
if not reject_reason:
@ -528,6 +719,77 @@ class Proposal(db.Model):
'admin_note': reject_reason
})
# request changes for a proposal with a DISCUSSION status
def request_changes_discussion(self, reason):
if self.status != ProposalStatus.DISCUSSION:
raise ValidationException("Proposal does not have a DISCUSSION status")
if not reason:
raise ValidationException("Please provide a reason for requesting changes")
self.changes_requested_discussion = True
self.changes_requested_discussion_reason = reason
for t in self.team:
send_email(t.email_address, 'proposal_rejected_discussion', {
'user': t,
'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'),
'admin_note': reason
})
# mark a request changes as resolve for a proposal with a DISCUSSION status
def resolve_changes_discussion(self):
if self.status != ProposalStatus.DISCUSSION:
raise ValidationException("Proposal does not have a DISCUSSION status")
if not self.changes_requested_discussion:
raise ValidationException("Proposal does not have changes requested")
self.changes_requested_discussion = False
self.changes_requested_discussion_reason = None
# state: status DISCUSSION -> (LIVE)
def accept_proposal(self, with_funding):
self.validate_publishable()
# specific validation
if not self.status == ProposalStatus.DISCUSSION:
raise ValidationException(f"Proposal must have a DISCUSSION status to approve or reject")
self.status = ProposalStatus.LIVE
self.date_approved = datetime.datetime.now()
self.accepted_with_funding = with_funding
# also update date_published and stage since publish() is no longer called by user
self.date_published = datetime.datetime.now()
self.stage = ProposalStage.WIP
if with_funding:
self.fully_fund_contibution_bounty()
for t in self.team:
if with_funding:
admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.'
send_email(t.email_address, 'proposal_approved', {
'user': t,
'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'),
'admin_note': admin_note
})
else:
admin_note = '''
We've chosen to list your proposal on ZF Grants, but we won't be funding your proposal at this time.
Your proposal can still receive funding from the community in the form of tips if you have set a tip address for your proposal.
If you have not yet done so, you can do this from the actions dropdown at your proposal.
'''
send_email(t.email_address, 'proposal_approved_without_funding', {
'user': t,
'proposal': self,
'proposal_url': make_url(f'/proposals/{self.id}'),
'admin_note': admin_note
})
def update_proposal_with_funding(self):
self.accepted_with_funding = True
self.fully_fund_contibution_bounty()
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
def publish(self):
self.validate_publishable()
@ -536,28 +798,7 @@ class Proposal(db.Model):
raise ValidationException(f"Proposal status must be approved")
self.date_published = datetime.datetime.now()
self.status = ProposalStatus.LIVE
self.stage = ProposalStage.FUNDING_REQUIRED
# If we had a bounty that pushed us into funding, skip straight into WIP
self.set_funded_when_ready()
def set_funded_when_ready(self):
if self.status == ProposalStatus.LIVE and self.stage == ProposalStage.FUNDING_REQUIRED and self.is_funded:
self.set_funded()
# state: stage FUNDING_REQUIRED -> WIP
def set_funded(self):
if self.status != ProposalStatus.LIVE:
raise ValidationException(f"Proposal status must be live in order transition to funded state")
if self.stage != ProposalStage.FUNDING_REQUIRED:
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
if not self.is_funded:
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
self.send_admin_email('admin_arbiter')
self.stage = ProposalStage.WIP
db.session.add(self)
db.session.flush()
# check the first step, if immediate payout bump it to accepted
self.current_milestone.accept_immediate()
def set_contribution_bounty(self, bounty: str):
# do not allow changes on funded/WIP proposals
@ -567,20 +808,9 @@ class Proposal(db.Model):
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:
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
# enforce 1 or 0 for now
if matching == 0.0 or matching == 1.0:
self.contribution_matching = matching
db.session.add(self)
db.session.flush()
self.set_funded_when_ready()
else:
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
def fully_fund_contibution_bounty(self):
self.set_contribution_bounty(self.target)
def cancel(self):
if self.status != ProposalStatus.LIVE:
@ -603,6 +833,33 @@ class Proposal(db.Model):
'account_settings_url': make_url('/profile/settings?tab=account')
})
def follow(self, user, is_follow):
if is_follow:
self.followers.append(user)
else:
self.followers.remove(user)
db.session.flush()
def like(self, user, is_liked):
if is_liked:
self.likes.append(user)
else:
self.likes.remove(user)
db.session.flush()
def send_follower_email(self, type: str, email_args={}, url_suffix=""):
for u in self.followers:
send_email(
u.email_address,
type,
{
"user": u,
"proposal": self,
"proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"),
**email_args,
},
)
@hybrid_property
def contributed(self):
contributions = ProposalContribution.query \
@ -635,12 +892,7 @@ class Proposal(db.Model):
@hybrid_property
def is_staked(self):
# Don't use self.contributed since that ignores stake contributions
contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
.all()
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
return Decimal(funded) >= PROPOSAL_STAKING_AMOUNT
return True
@hybrid_property
def is_funded(self):
@ -670,6 +922,147 @@ class Proposal(db.Model):
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
return d.values()
@hybrid_property
def authed_follows(self):
from grant.utils.auth import get_authed_user
authed = get_authed_user()
if not authed:
return False
res = (
db.session.query(proposal_follower)
.filter_by(user_id=authed.id, proposal_id=self.id)
.count()
)
if res:
return True
return False
@hybrid_property
def authed_liked(self):
from grant.utils.auth import get_authed_user
authed = get_authed_user()
if not authed:
return False
res = (
db.session.query(proposal_liker)
.filter_by(user_id=authed.id, proposal_id=self.id)
.count()
)
if res:
return True
return False
@hybrid_property
def get_tip_jar_view_key(self):
from grant.utils.auth import get_authed_user
authed = get_authed_user()
if authed not in self.team:
return None
else:
return self.tip_jar_view_key
# make a LIVE_DRAFT proposal by copying the relevant fields from an existing proposal
@staticmethod
def make_live_draft(proposal):
live_draft_proposal = Proposal.create(
title=proposal.title,
brief=proposal.brief,
content=proposal.content,
target=proposal.target,
payout_address=proposal.payout_address,
status=ProposalStatus.LIVE_DRAFT
)
live_draft_proposal.tip_jar_address = proposal.tip_jar_address
live_draft_proposal.changes_requested_discussion_reason = proposal.changes_requested_discussion_reason
live_draft_proposal.rfp_opt_in = proposal.rfp_opt_in
live_draft_proposal.team = proposal.team
db.session.add(live_draft_proposal)
Milestone.clone(proposal, live_draft_proposal)
return live_draft_proposal
# port changes made in LIVE_DRAFT proposal to self and delete the draft
def consume_live_draft(self, author):
if self.status != ProposalStatus.DISCUSSION:
raise ValidationException("Proposal is not open for public review")
live_draft = self.live_draft
revision_changes = ProposalRevision.calculate_proposal_changes(self, live_draft)
if len(revision_changes) == 0:
if live_draft.rfp_opt_in == self.rfp_opt_in \
and live_draft.payout_address == self.payout_address \
and live_draft.tip_jar_address == self.tip_jar_address \
and live_draft.team == self.team:
raise ValidationException("Live draft does not appear to have any changes")
else:
# cover special cases where properties not tracked in revisions have changed:
self.rfp_opt_in = live_draft.rfp_opt_in
self.payout_address = live_draft.payout_address
self.tip_jar_address = live_draft.tip_jar_address
self.team = live_draft.team
self.live_draft = None
db.session.add(self)
db.session.delete(live_draft)
return False
# if this is the first revision, create a base revision that's a snapshot of the original proposal
if len(self.revisions) == 0:
base_draft = self.make_live_draft(self)
base_draft.status = ProposalStatus.ARCHIVED
base_draft.invites = []
db.session.add(base_draft)
base_revision = ProposalRevision(
author=author,
proposal_id=self.id,
proposal_archive_id=base_draft.id,
changes=json.dumps([]),
revision_index=0
)
self.revisions.append(base_revision)
revision_index = len(self.revisions)
revision = ProposalRevision(
author=author,
proposal_id=self.id,
proposal_archive_id=live_draft.id,
changes=json.dumps(revision_changes),
revision_index=revision_index
)
self.title = live_draft.title
self.brief = live_draft.brief
self.content = live_draft.content
self.target = live_draft.target
self.payout_address = live_draft.payout_address
self.tip_jar_address = live_draft.tip_jar_address
self.rfp_opt_in = live_draft.rfp_opt_in
self.team = live_draft.team
self.invites = []
self.live_draft = None
self.revisions.append(revision)
db.session.add(self)
# copy milestones
Milestone.clone(live_draft, self)
# archive live draft
live_draft.status = ProposalStatus.ARCHIVED
live_draft.invites = []
db.session.add(live_draft)
return True
class ProposalSchema(ma.Schema):
class Meta:
@ -694,7 +1087,6 @@ class ProposalSchema(ma.Schema):
"updates",
"milestones",
"current_milestone",
"category",
"team",
"payout_address",
"deadline_duration",
@ -703,13 +1095,30 @@ class ProposalSchema(ma.Schema):
"invites",
"rfp",
"rfp_opt_in",
"arbiter"
"arbiter",
"accepted_with_funding",
"is_version_two",
"authed_follows",
"followers_count",
"authed_liked",
"likes_count",
"tip_jar_address",
"tip_jar_view_key",
"changes_requested_discussion",
"changes_requested_discussion_reason",
"live_draft_id",
"kyc_approved",
"funded_by_zomg"
)
date_created = ma.Method("get_date_created")
date_approved = ma.Method("get_date_approved")
date_published = ma.Method("get_date_published")
proposal_id = ma.Method("get_proposal_id")
is_version_two = ma.Method("get_is_version_two")
tip_jar_view_key = ma.Method("get_tip_jar_view_key")
live_draft_id = ma.Method("get_live_draft_id")
funded_by_zomg = ma.Method("get_funded_by_zomg")
updates = ma.Nested("ProposalUpdateSchema", many=True)
team = ma.Nested("UserSchema", many=True)
@ -719,6 +1128,14 @@ class ProposalSchema(ma.Schema):
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
def get_funded_by_zomg(self, obj):
if obj.funded_by_zomg is None:
return False
elif obj.funded_by_zomg is False:
return False
else:
return True
def get_proposal_id(self, obj):
return obj.id
@ -731,6 +1148,15 @@ class ProposalSchema(ma.Schema):
def get_date_published(self, obj):
return dt_to_unix(obj.date_published) if obj.date_published else None
def get_is_version_two(self, obj):
return True if obj.version == '2' else False
def get_tip_jar_view_key(self, obj):
return obj.get_tip_jar_view_key
def get_live_draft_id(self, obj):
return obj.live_draft.id if obj.live_draft else None
proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True)
@ -747,7 +1173,12 @@ user_fields = [
"date_approved",
"date_published",
"reject_reason",
"changes_requested_discussion_reason",
"team",
"accepted_with_funding",
"is_version_two",
"authed_follows",
"authed_liked"
]
user_proposal_schema = ProposalSchema(only=user_fields)
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
@ -783,6 +1214,40 @@ proposal_update_schema = ProposalUpdateSchema()
proposals_update_schema = ProposalUpdateSchema(many=True)
class ProposalRevisionSchema(ma.Schema):
class Meta:
model = ProposalRevision
# Fields to expose
fields = (
"revision_id",
"date_created",
"author",
"proposal_id",
"proposal_archive_id",
"changes",
"revision_index"
)
revision_id = ma.Method("get_revision_id")
date_created = ma.Method("get_date_created")
changes = ma.Method("get_changes")
author = ma.Nested("UserSchema")
def get_revision_id(self, obj):
return obj.id
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_changes(self, obj):
return json.loads(obj.changes)
proposal_revision_schema = ProposalRevisionSchema()
proposals_revisions_schema = ProposalRevisionSchema(many=True)
class ProposalTeamInviteSchema(ma.Schema):
class Meta:
model = ProposalTeamInvite

View File

@ -1,4 +1,5 @@
from decimal import Decimal
from datetime import datetime
from flask import Blueprint, g, request, current_app
from marshmallow import fields, validate
@ -13,7 +14,7 @@ from grant.milestone.models import Milestone
from grant.parser import body, query, paginated_fields
from grant.rfp.models import RFP
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.task.jobs import ProposalDeadline
from grant.task.jobs import ProposalDeadline, PruneDraft
from grant.user.models import User
from grant.utils import pagination
from grant.utils.auth import (
@ -24,8 +25,9 @@ from grant.utils.auth import (
get_authed_user,
internal_webhook
)
from grant.utils.validate import is_z_address_valid
from grant.utils.enums import Category
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus
from grant.utils.enums import ProposalStatus, ProposalStage, ContributionStatus, RFPStatus
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url, from_zat, make_explore_url
from .models import (
@ -34,11 +36,13 @@ from .models import (
proposal_schema,
ProposalUpdate,
proposal_update_schema,
proposals_revisions_schema,
ProposalContribution,
proposal_contribution_schema,
proposal_team,
ProposalTeamInvite,
proposal_team_invite_schema,
proposal_team_invites_schema,
proposal_proposal_contributions_schema,
db,
)
@ -50,7 +54,9 @@ blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
if proposal.status != ProposalStatus.LIVE:
if proposal.status == ProposalStatus.ARCHIVED:
return {"message": "Proposal has been archived"}, 401
if proposal.status not in [ProposalStatus.LIVE, ProposalStatus.DISCUSSION]:
if proposal.status == ProposalStatus.DELETED:
return {"message": "Proposal was deleted"}, 404
authed_user = get_authed_user()
@ -62,6 +68,19 @@ def get_proposal(proposal_id):
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/archive", methods=["GET"])
def get_archived_proposal(proposal_id):
proposal = Proposal.query.get(proposal_id)
if not proposal:
return {"message": "No proposal matching id"}, 404
if proposal.status != ProposalStatus.ARCHIVED:
return {"message": "Proposal is not archived"}, 401
return proposal_schema.dump(proposal)
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
@query(paginated_fields)
def get_proposal_comments(proposal_id, page, filters, search, sort):
@ -108,6 +127,9 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
if not proposal:
return {"message": "No proposal matching id"}, 404
if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.DISCUSSION:
return {"message": "Proposal must be live or open for public review to comment"}, 400
# Make sure the parent comment exists
parent = None
if parent_comment_id:
@ -159,7 +181,10 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
@query(paginated_fields)
def get_proposals(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
query = Proposal.query.filter_by(status=ProposalStatus.LIVE) \
query = Proposal.query.filter(or_(
Proposal.status == ProposalStatus.LIVE,
Proposal.status == ProposalStatus.DISCUSSION
)) \
.filter(Proposal.stage != ProposalStage.CANCELED) \
.filter(Proposal.stage != ProposalStage.FAILED)
page = pagination.proposal(
@ -187,15 +212,37 @@ def make_proposal_draft(rfp_id):
rfp = RFP.query.filter_by(id=rfp_id).first()
if not rfp:
return {"message": "The request this proposal was made for doesnt exist"}, 400
proposal.category = rfp.category
if datetime.now() > rfp.date_closes:
return {"message": "The request this proposal was made for has expired"}, 400
if rfp.status == RFPStatus.CLOSED:
return {"message": "The request this proposal was made for has been closed"}, 400
rfp.proposals.append(proposal)
db.session.add(rfp)
task = PruneDraft(proposal)
task.make_task()
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal), 201
@blueprint.route("/<proposal_id>/draft", methods=["POST"])
@requires_team_member_auth
def make_proposal_live_draft(proposal_id):
proposal = g.current_proposal
if proposal.status != ProposalStatus.DISCUSSION:
return {"message": "Proposal does not have a DISCUSSION status"}, 404
if not proposal.live_draft:
proposal.live_draft = Proposal.make_live_draft(proposal)
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal.live_draft), 201
@blueprint.route("/drafts", methods=["GET"])
@requires_auth
def get_proposal_drafts():
@ -204,6 +251,7 @@ def get_proposal_drafts():
.filter(or_(
Proposal.status == ProposalStatus.DRAFT,
Proposal.status == ProposalStatus.REJECTED,
Proposal.status == ProposalStatus.LIVE_DRAFT
))
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
@ -219,11 +267,10 @@ def get_proposal_drafts():
# Length checks are to prevent database errors, not actual user limits imposed
"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),
"tipJarAddress": fields.Str(required=False, missing=None),
"milestones": fields.List(fields.Dict(), required=True),
"rfpOptIn": fields.Bool(required=False, missing=None),
})
@ -231,6 +278,7 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
# Update the base proposal fields
try:
if g.current_proposal.status not in [ProposalStatus.DRAFT,
ProposalStatus.LIVE_DRAFT,
ProposalStatus.REJECTED]:
raise ValidationException(
f"Proposal with status: {g.current_proposal.status} are not authorized for updates"
@ -251,6 +299,41 @@ def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/resolve", methods=["PUT"])
@requires_team_member_auth
def resolve_changes_discussion(proposal_id):
proposal = Proposal.query.get(proposal_id)
if not proposal:
return {"message": "No proposal found"}, 404
proposal.resolve_changes_discussion()
db.session.add(proposal)
db.session.commit()
proposal.send_admin_email('admin_changes_resolved')
return proposal_schema.dump(proposal)
@blueprint.route("/<proposal_id>/tips", methods=["PUT"])
@requires_team_member_auth
@body({
"address": fields.Str(required=False, missing=None),
"viewKey": fields.Str(required=False, missing=None)
})
def update_proposal_tip_jar(proposal_id, address, view_key):
if address is not None and address is not '' and not is_z_address_valid(address):
return {"message": "Tip address is not a valid z address"}, 400
if address is not None:
g.current_proposal.tip_jar_address = address
if view_key is not None:
g.current_proposal.tip_jar_view_key = view_key
db.session.add(g.current_proposal)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/rfp", methods=["DELETE"])
@requires_team_member_auth
def unlink_proposal_from_rfp(proposal_id):
@ -269,6 +352,7 @@ def delete_proposal(proposal_id):
deleteable_statuses = [
ProposalStatus.DRAFT,
ProposalStatus.PENDING,
ProposalStatus.REJECTED_PERMANENTLY,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
ProposalStatus.STAKING,
@ -293,17 +377,6 @@ def submit_for_approval_proposal(proposal_id):
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
@requires_team_member_auth
def get_proposal_stake(proposal_id):
if g.current_proposal.status != ProposalStatus.STAKING:
return {"message": "ok"}, 400
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
if contribution:
return proposal_contribution_schema.dump(contribution)
return {"message": "ok"}, 404
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth
def publish_proposal(proposal_id):
@ -320,6 +393,42 @@ def publish_proposal(proposal_id):
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/publish/live", methods=["PUT"])
@requires_team_member_auth
def publish_live_draft(proposal_id):
if g.current_proposal.status != ProposalStatus.LIVE_DRAFT:
return {"message": "Proposal is not a live draft"}, 403
if not g.current_proposal.live_draft_parent_id:
return {"message": "No parent proposal found"}, 404
parent_proposal = Proposal.query.get(g.current_proposal.live_draft_parent_id)
if not parent_proposal:
return {"message": "No proposal matching id"}, 404
# TODO: double check this isn't needed:
#
# if g.current_user not in proposal.team:
# return {"message": "You are not a team member of this proposal"}
try:
parent_proposal.live_draft.validate_publishable()
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
had_revisions = parent_proposal.consume_live_draft(g.current_user)
db.session.commit()
# Send email to all followers if revisions were detected
if had_revisions:
parent_proposal.send_follower_email(
"followed_proposal_revised", url_suffix="?tab=revisions"
)
return proposal_schema.dump(parent_proposal), 200
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
def get_proposal_updates(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
@ -343,6 +452,26 @@ def get_proposal_update(proposal_id, update_id):
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/revisions", methods=["GET"])
def get_proposal_revisions(proposal_id):
proposal = Proposal.query.get(proposal_id)
if not proposal:
return {"message": "No proposal matching id"}, 404
if proposal.status in [ProposalStatus.DRAFT, ProposalStatus.REJECTED]:
return {"message": "Proposal is not live"}, 400
def sort_by_revision_index(r):
return r.revision_index
revisions = proposal.revisions
revisions.sort(key=sort_by_revision_index)
dumped_revisions = proposals_revisions_schema.dump(revisions)
return dumped_revisions
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
@limiter.limit("5/day;1/minute")
@requires_team_member_auth
@ -367,10 +496,25 @@ def post_proposal_update(proposal_id, title, content):
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
# Send email to all followers
g.current_proposal.send_follower_email(
"followed_proposal_update", url_suffix="?tab=updates"
)
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@blueprint.route("/<proposal_id>/invites", methods=["GET"])
@requires_team_member_auth
def get_proposal_team_invites(proposal_id):
proposal_dump = proposal_schema.dump(g.current_proposal)
return {
"team": proposal_dump["team"],
"invites": proposal_dump["invites"]
}
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
@limiter.limit("30/day;10/minute")
@requires_team_member_auth
@ -566,9 +710,6 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '',
})
# on funding target reached.
contribution.proposal.set_funded_when_ready()
db.session.commit()
return {"message": "ok"}, 200
@ -662,3 +803,37 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason):
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
@blueprint.route("/<proposal_id>/follow", methods=["PUT"])
@requires_auth
@body({"isFollow": fields.Bool(required=True)})
def follow_proposal(proposal_id, is_follow):
user = g.current_user
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
proposal.follow(user, is_follow)
db.session.commit()
return {"message": "ok"}, 200
@blueprint.route("/<proposal_id>/like", methods=["PUT"])
@requires_auth
@body({"isLiked": fields.Bool(required=True)})
def like_proposal(proposal_id, is_liked):
user = g.current_user
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
if proposal.status not in [ProposalStatus.LIVE, ProposalStatus.DISCUSSION]:
return {"message": "Cannot like a proposal that's not live or in discussion"}, 404
proposal.like(user, is_liked)
db.session.commit()
return {"message": "ok"}, 200

View File

@ -2,10 +2,19 @@ from datetime import datetime
from decimal import Decimal
from grant.extensions import ma, db
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import func, select
from sqlalchemy.orm import column_property
from grant.utils.enums import RFPStatus
from grant.utils.misc import dt_to_unix, gen_random_id
from grant.utils.enums import Category
rfp_liker = db.Table(
"rfp_liker",
db.Model.metadata,
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
db.Column("rfp_id", db.Integer, db.ForeignKey("rfp.id")),
)
class RFP(db.Model):
__tablename__ = "rfp"
@ -16,13 +25,16 @@ class RFP(db.Model):
title = db.Column(db.String(255), nullable=False)
brief = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
category = db.Column(db.String(255), nullable=True)
status = db.Column(db.String(255), nullable=False)
matching = db.Column(db.Boolean, default=False, nullable=False)
_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)
version = db.Column(db.String(255), nullable=True)
ccr = db.relationship("CCR", uselist=False, back_populates="rfp")
# Relationships
proposals = db.relationship(
@ -38,6 +50,15 @@ class RFP(db.Model):
cascade="all, delete-orphan",
)
likes = db.relationship(
"User", secondary=rfp_liker, back_populates="liked_rfps"
)
likes_count = column_property(
select([func.count(rfp_liker.c.rfp_id)])
.where(rfp_liker.c.rfp_id == id)
.correlate_except(rfp_liker)
)
@hybrid_property
def bounty(self):
return self._bounty
@ -49,29 +70,50 @@ class RFP(db.Model):
else:
self._bounty = None
@hybrid_property
def authed_liked(self):
from grant.utils.auth import get_authed_user
authed = get_authed_user()
if not authed:
return False
res = (
db.session.query(rfp_liker)
.filter_by(user_id=authed.id, rfp_id=self.id)
.count()
)
if res:
return True
return False
def like(self, user, is_liked):
if is_liked:
self.likes.append(user)
else:
self.likes.remove(user)
db.session.flush()
def __init__(
self,
title: str,
brief: str,
content: str,
category: str,
bounty: str,
date_closes: datetime,
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[:255]
self.brief = brief[:255]
self.content = content
self.category = category
self.bounty = bounty
self.date_closes = date_closes
self.matching = matching
self.status = status
self.version = '2'
class RFPSchema(ma.Schema):
@ -83,7 +125,6 @@ class RFPSchema(ma.Schema):
"title",
"brief",
"content",
"category",
"status",
"matching",
"bounty",
@ -92,13 +133,19 @@ class RFPSchema(ma.Schema):
"date_opened",
"date_closed",
"accepted_proposals",
"authed_liked",
"likes_count",
"is_version_two",
"ccr"
)
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
status = ma.Method("get_status")
date_closes = ma.Method("get_date_closes")
date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed")
accepted_proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
is_version_two = ma.Method("get_is_version_two")
def get_status(self, obj):
# Force it into closed state if date_closes is in the past
@ -115,6 +162,9 @@ class RFPSchema(ma.Schema):
def get_date_closed(self, obj):
return dt_to_unix(obj.date_closed) if obj.date_closed else None
def get_is_version_two(self, obj):
return True if obj.version == '2' else False
rfp_schema = RFPSchema()
rfps_schema = RFPSchema(many=True)
@ -129,7 +179,6 @@ class AdminRFPSchema(ma.Schema):
"title",
"brief",
"content",
"category",
"status",
"matching",
"bounty",
@ -138,14 +187,18 @@ class AdminRFPSchema(ma.Schema):
"date_opened",
"date_closed",
"proposals",
"is_version_two",
"ccr"
)
ccr = ma.Nested("CCRSchema", exclude=["rfp"])
status = ma.Method("get_status")
date_created = ma.Method("get_date_created")
date_closes = ma.Method("get_date_closes")
date_opened = ma.Method("get_date_opened")
date_closed = ma.Method("get_date_closed")
proposals = ma.Nested("ProposalSchema", many=True, exclude=["rfp"])
is_version_two = ma.Method("get_is_version_two")
def get_status(self, obj):
# Force it into closed state if date_closes is in the past
@ -165,6 +218,9 @@ class AdminRFPSchema(ma.Schema):
def get_date_closed(self, obj):
return dt_to_unix(obj.date_closes) if obj.date_closes else None
def get_is_version_two(self, obj):
return True if obj.version == '2' else False
admin_rfp_schema = AdminRFPSchema()
admin_rfps_schema = AdminRFPSchema(many=True)

View File

@ -1,8 +1,11 @@
from flask import Blueprint
from flask import Blueprint, g
from sqlalchemy import or_
from grant.utils.enums import RFPStatus
from .models import RFP, rfp_schema, rfps_schema
from grant.utils.auth import requires_auth
from grant.parser import body
from .models import RFP, rfp_schema, rfps_schema, db
from marshmallow import fields
blueprint = Blueprint("rfp", __name__, url_prefix="/api/v1/rfps")
@ -25,3 +28,20 @@ def get_rfp(rfp_id):
if not rfp or rfp.status == RFPStatus.DRAFT:
return {"message": "No RFP with that ID"}, 404
return rfp_schema.dump(rfp)
@blueprint.route("/<rfp_id>/like", methods=["PUT"])
@requires_auth
@body({"isLiked": fields.Bool(required=True)})
def like_rfp(rfp_id, is_liked):
user = g.current_user
# Make sure rfp exists
rfp = RFP.query.filter_by(id=rfp_id).first()
if not rfp:
return {"message": "No RFP matching id"}, 404
if not rfp.status == RFPStatus.LIVE:
return {"message": "RFP is not live"}, 404
rfp.like(user, is_liked)
db.session.commit()
return {"message": "ok"}, 200

View File

@ -31,6 +31,8 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
# so backend session cookies are first-party
SESSION_COOKIE_DOMAIN = env.str('SESSION_COOKIE_DOMAIN', default=None)
CORS_DOMAINS = env.str('CORS_DOMAINS', default='*')
SESSION_COOKIE_SAMESITE = env.str('SESSION_COOKIE_SAMESITE', default='None')
SESSION_COOKIE_SECURE = True if SESSION_COOKIE_SAMESITE == 'None' else False
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
SENDGRID_DEFAULT_FROM = "noreply@grants.zfnd.org"
@ -60,11 +62,14 @@ 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")
STAGING_PASSWORD = env.str("STAGING_PASSWORD", default=None)
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
UI = {
'NAME': 'ZF Grants',
'PRIMARY': '#CF8A00',

View File

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from grant.extensions import db
from grant.email.send import send_email
from grant.utils.enums import ProposalStage, ContributionStatus
from grant.utils.enums import ProposalStage, ContributionStatus, ProposalStatus
from grant.utils.misc import make_url
from flask import current_app
@ -126,8 +126,117 @@ class ContributionExpired:
})
class PruneDraft:
JOB_TYPE = 4
PRUNE_TIME = 259200 # 72 hours in seconds
def __init__(self, proposal):
self.proposal = proposal
def blobify(self):
return {
"proposal_id": self.proposal.id,
}
def make_task(self):
from .models import Task
task = Task(
job_type=self.JOB_TYPE,
blob=self.blobify(),
execute_after=self.proposal.date_created + timedelta(seconds=self.PRUNE_TIME),
)
db.session.add(task)
db.session.commit()
@staticmethod
def process_task(task):
from grant.proposal.models import Proposal, default_proposal_content
proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first()
# If it was deleted or moved out of a draft, noop out
if not proposal or proposal.status != ProposalStatus.DRAFT:
return
# If proposal content deviates from the default, noop out
if proposal.content != default_proposal_content():
return
# If any of the remaining proposal fields are filled, noop out
if proposal.title or proposal.brief or proposal.category or proposal.target != "0":
return
if proposal.payout_address or proposal.milestones:
return
# Otherwise, delete the empty proposal
db.session.delete(proposal)
db.session.commit()
class MilestoneDeadline:
JOB_TYPE = 5
def __init__(self, proposal, milestone):
self.proposal = proposal
self.milestone = milestone
def blobify(self):
from grant.proposal.models import ProposalUpdate
update_count = len(ProposalUpdate.query.filter_by(proposal_id=self.proposal.id).all())
return {
"proposal_id": self.proposal.id,
"milestone_id": self.milestone.id,
"update_count": update_count
}
def make_task(self):
from .models import Task
task = Task(
job_type=self.JOB_TYPE,
blob=self.blobify(),
execute_after=self.milestone.date_estimated,
)
db.session.add(task)
db.session.commit()
@staticmethod
def process_task(task):
from grant.proposal.models import Proposal, ProposalUpdate
from grant.milestone.models import Milestone
proposal_id = task.blob["proposal_id"]
milestone_id = task.blob["milestone_id"]
update_count = task.blob["update_count"]
proposal = Proposal.query.filter_by(id=proposal_id).first()
milestone = Milestone.query.filter_by(id=milestone_id).first()
current_update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all())
# if proposal was deleted or cancelled, noop out
if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED:
return
# if milestone was deleted, noop out
if not milestone:
return
# if milestone payout has been requested or an update has been posted, noop out
if current_update_count > update_count or milestone.date_requested:
return
# send email to arbiter notifying milestone deadline has been missed
send_email(proposal.arbiter.user.email_address, 'milestone_deadline', {
'proposal': proposal,
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
})
JOBS = {
1: ProposalReminder.process_task,
2: ProposalDeadline.process_task,
3: ContributionExpired.process_task,
4: PruneDraft.process_task,
5: MilestoneDeadline.process_task
}

View File

@ -0,0 +1,32 @@
<p style="margin: 0 0 20px;">
<a href="{{ args.ccr_url }}" target="_blank">
{{ args.ccr.title }}</a
>
is awaiting approval. As an admin you can help out by reviewing it.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.ccr_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
Review Request
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
{{ args.ccr.title }} is awaiting approval. As an admin you can help out by reviewing it.
Visit the request and review: {{ args.ccr_url }}

View File

@ -0,0 +1,33 @@
<p style="margin: 0 0 20px;">
Team members of proposal
<a href="{{ args.proposal_url }}" target="_blank">
{{ args.proposal.title }}</a
>
have marked requested changes as resolved. As an admin you can help out by reviewing it.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.proposal_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
Review Proposal
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,5 @@
Team members of proposal {{ args.proposal.title }} have marked requested changes as resolved.
As an admin you can help out by reviewing it.
Visit the proposal and review: {{ args.proposal_url }}

View File

@ -0,0 +1,12 @@
<p style="margin: 0;">
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}

View File

@ -0,0 +1,9 @@
Congratulations on your approval! We look forward to seeing proposals that are generated as a result of your request.
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}
{{ args.proposal_url }}

View File

@ -0,0 +1,19 @@
<p style="margin: 0;">
Your request has changes requested. You're free to modify it
and try submitting again.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
Please note that repeated submissions without significant changes or with
content that doesn't match the platform guidelines may result in a removal
of your submission privileges.
</p>

View File

@ -0,0 +1,12 @@
Your request has changes requested. You're free to modify it
and try submitting again.
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}
Please note that repeated submissions without significant changes or with
content that doesn't match the platform guidelines may result in a removal
of your submission privileges.

View File

@ -0,0 +1,13 @@
<p style="margin: 0;">
Your request has been rejected. Your request won't be publicly visible on ZF Grants.
<a href="{{ args.profile_rejected_url }}" >Visit your profile</a> to delete this request.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}

View File

@ -0,0 +1,9 @@
Your request has been rejected. Your request won't be publicly visible on ZF Grants. Visit your profile to delete this request:
{{ args.profile_rejected_url }}
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}

View File

@ -0,0 +1,31 @@
<p style="margin: 0;">
Your followed proposal {{ args.proposal.title }} has had its
{{ args.milestone.title }}
milestone accepted!
</p>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td
align="center"
bgcolor="{{ UI.PRIMARY }}"
style="border-radius: 3px;"
>
<a
href="{{ args.proposal_url }}"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
target="_blank"
>
Check it out
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted!
Check it out: {{ args.proposal_url }}

View File

@ -0,0 +1,29 @@
<p style="margin: 0;">
Your followed proposal {{ args.proposal.title }} has been revised!
</p>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td
align="center"
bgcolor="{{ UI.PRIMARY }}"
style="border-radius: 3px;"
>
<a
href="{{ args.proposal_url }}"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
target="_blank"
>
Check it out
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

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

View File

@ -0,0 +1,29 @@
<p style="margin: 0;">
Your followed proposal {{ args.proposal.title }} has an update!
</p>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td
align="center"
bgcolor="{{ UI.PRIMARY }}"
style="border-radius: 3px;"
>
<a
href="{{ args.proposal_url }}"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
target="_blank"
>
Check it out
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

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

View File

@ -3,7 +3,7 @@
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
>
payout of <b>{{ args.amount }} ZEC</b> has been approved.
payout of <b>${{ args.amount }}</b> in ZEC has been approved.
</p>
<p style="margin: 0;">

View File

@ -1,5 +1,5 @@
The proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}"
payout of {{args.amount}} ZEC has been approved!
payout of ${{args.amount}} in ZEC has been approved!
You will receive payment shortly!

View File

@ -0,0 +1,32 @@
<p style="margin: 0 0 20px;">
The estimated deadline has been reached for proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
>.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.proposal_milestones_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
View the milestone
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
The estimated deadline has been reached for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}".
View the milestone: {{ args.proposal_milestones_url }}

View File

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

View File

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

View File

@ -1,34 +1,13 @@
<p style="margin: 0;">
Congratulations on your approval! We look forward to seeing the support your
proposal receives. To get your campaign started, click below and follow the
instructions to publish your proposal.
Congratulations, your proposal has been funded by the Zcash Foundation! Once an arbiter is selected by the Foundation, you'll be able to request payouts according to your grant's milestone schedule. <a href='https://grants.zfnd.org/kyc'>Click here</a> for instructions on documentation you need to submit before the Zcash Foundation can transfer funds.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the admin team was attached to your approval:
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="{{ UI.PRIMARY }}">
<a
href="{{ args.proposal_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{ UI.PRIMARY }}; display: inline-block;"
>
Publish your proposal
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -1,9 +1,8 @@
Congratulations on your approval! We look forward to seeing the support your
proposal receives. To start the fundraising (and the clock) go to the URL
below and publish your proposal.
Congratulations, your proposal has been funded by the Zcash Foundation! Once an arbiter is selected by the Foundation, you'll be able to request payouts according to your grant's milestone schedule.
{% if args.admin_note %}
A note from the admin team was attached to your approval:
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}

View File

@ -0,0 +1,13 @@
<p style="margin: 0;">
Your proposal has been approved for public discussion and community feedback on ZF Grants. The Zcash Foundation reviews open grant applications on an ongoing basis, and may request additional revisions based on open feedback before making a final funding determination. Please <a href="https://forum.zcashcommunity.com/c/Grants/Applications/">post your proposal to the Zcash Forums</a> for community feedback!
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}

View File

@ -0,0 +1,10 @@
Your proposal has been approved for public discussion and community feedback on ZF Grants. The Zcash Foundation reviews open grant applications on an ongoing basis, and may request additional revisions based on open feedback before making a final funding determination. Please post your proposal to the Zcash Forums (https://forum.zcashcommunity.com/c/Grants/Applications) for community feedback!
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}
{{ args.proposal_url }}

View File

@ -0,0 +1,13 @@
<p style="margin: 0;">
Your proposal has been reviewed by the Zcash Foundation and has been listed on ZF Grants for community donations. Although the Zcash Foundation won't be providing funding to your proposal directly, the community will have an opportunity to provide funding to your 'tip address'.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}

View File

@ -0,0 +1,10 @@
Your proposal has been reviewed by the Zcash Foundation and is now listed on ZF Grants!
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}
{{ args.proposal_url }}

View File

@ -0,0 +1,6 @@
<p style="margin: 0 0 20px;">
Your proposal {{ args.proposal.title }} is ready for payout requests.
<a href="{{ args.proposal_url }}" target="_blank">
Visit your proposal</a
> to see more.
</p>

View File

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

View File

@ -1,7 +1,6 @@
<p style="margin: 0 0 20px;">
This notice is to inform you that your proposal <strong>{{ args.proposal.title }}</strong>
has been canceled. We've let your contributors know, and they should be expecting refunds
shortly.
has been canceled.
</p>
<p style="margin: 0;">

View File

@ -1,6 +1,5 @@
This notice is to inform you that your proposal "{{ args.proposal.title }}"
has been canceled. We've let your contributors know, and they should be expecting refunds
shortly.
has been canceled.
If you have any further questions, please contact support for more information:
{{ args.support_url }}

View File

@ -1,11 +1,11 @@
<p style="margin: 0;">
Your proposal has unfortunately been rejected. You're free to modify it
Your proposal has changes requested. You're free to modify it
and try submitting again.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the admin team was attached to your rejection:
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”

View File

@ -1,8 +1,8 @@
Your proposal has unfortunately been rejected. You're free to modify it
Your proposal has changes requested. You're free to modify it
and try submitting again.
{% if args.admin_note %}
A note from the admin team was attached to your rejection:
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}

View File

@ -0,0 +1,19 @@
<p style="margin: 0;">
Your proposal is still open for public discussion, but the ZF team has requested changes.
Please make the necessary edits and mark the changes as resolved.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}
<p style="margin: 20px 0 0; font-size: 12px; line-height: 18px; color: #999; text-align: center;">
Please note that repeated submissions without significant changes or with
content that doesn't match the platform guidelines may result in a removal
of your submission privileges.
</p>

View File

@ -0,0 +1,12 @@
Your proposal is still open for public discussion, but the ZF team has requested changes.
Please make the necessary edits and mark the changes as resolved.
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}
Please note that repeated submissions without significant changes or with
content that doesn't match the platform guidelines may result in a removal
of your submission privileges.

View File

@ -0,0 +1,13 @@
<p style="margin: 0;">
Your proposal has been rejected.
<a href="{{ args.profile_rejected_url }}" >Visit your profile</a> to delete this proposal.
</p>
{% if args.admin_note %}
<p style="margin: 20px 0 0;">
A note from the Zcash Foundation:
</p>
<p style="margin: 10px 0; padding: 20px; background: #F8F8F8;">
“{{ args.admin_note }}”
</p>
{% endif %}

View File

@ -0,0 +1,9 @@
Your proposal has been rejected. Visit your profile to delete this proposal:
{{ args.profile_rejected_url }}
{% if args.admin_note %}
A note from the Zcash Foundation:
> {{ args.admin_note }}
{% endif %}

View File

@ -91,7 +91,7 @@
<tr>
<td align="center" style="padding: 40px 10px 40px 10px;" valign="top">
<a href="{{ args.home_url }}" target="_blank">
<img alt="ZF Grants logo" border="0" height="44" src="https://s3.us-east-2.amazonaws.com/zf-grants-prod/email-logo.png"
<img alt="ZF Grants logo" border="0" height="44" src="https://i.imgur.com/tYx0apf.png"
style="display: block; width: 220px; max-width: 220px; min-width: 220px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
width="220">
</a>

View File

@ -1,7 +1,9 @@
import click
from flask.cli import with_appcontext
from .models import User, db
from .models import User, db, SocialMedia
from grant.task.models import Task
from grant.settings import STAGING_PASSWORD
# @click.command()
@ -23,7 +25,6 @@ from .models import User, db
# 'account address, or email address of an ' \
# 'existing user.')
@click.command()
@click.argument('identity')
@with_appcontext
@ -36,6 +37,7 @@ def set_admin(identity):
if user:
user.set_admin(True)
user.email_verification.has_verified = True
db.session.add(user)
db.session.commit()
click.echo(f'Successfully set {user.display_name} (uid {user.id}) to admin')
@ -43,3 +45,28 @@ def set_admin(identity):
raise click.BadParameter('''Invalid user identity. Must be a userid,
'account address, or email address of an
'existing user.''')
@click.command()
@with_appcontext
def mangle_users():
if STAGING_PASSWORD:
print("Mangling all users")
for i, user in enumerate(User.query.all()):
user.email_address = "random" + str(i) + "@grant.io"
user.password = STAGING_PASSWORD
# DELETE TOTP SECRET
user.totp_secret = None
# DELETE BACKUP CODES
user.backup_codes = None
db.session.add(user)
# DELETE ALL TASKS
for task in Task.query.all():
db.session.delete(task)
# REMOVE ALL SOCIAL MEDIA
for social in SocialMedia.query.all():
db.session.delete(social)
db.session.commit()

View File

@ -3,6 +3,7 @@ from flask_security.core import current_user
from flask_security.utils import hash_password, verify_and_update_password, login_user
from sqlalchemy.ext.hybrid import hybrid_property
from grant.comment.models import Comment
from grant.ccr.models import CCR
from grant.email.models import EmailVerification, EmailRecovery
from grant.email.send import send_email
from grant.email.subscription_settings import (
@ -58,6 +59,8 @@ class UserSettings(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
_email_subscriptions = db.Column("email_subscriptions", db.Integer, default=0) # bitmask
refund_address = db.Column(db.String(255), unique=False, nullable=True)
tip_jar_address = db.Column(db.String(255), unique=False, nullable=True)
tip_jar_view_key = db.Column(db.String(255), unique=False, nullable=True)
user = db.relationship("User", back_populates="settings")
@ -123,6 +126,7 @@ class User(db.Model, UserMixin):
# relations
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
comments = db.relationship(Comment, backref="user", lazy=True)
ccrs = db.relationship(CCR, back_populates="author", lazy=True, cascade="all, delete-orphan")
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
settings = db.relationship(UserSettings, uselist=False, back_populates="user",
lazy=True, cascade="all, delete-orphan")
@ -133,6 +137,18 @@ class User(db.Model, UserMixin):
roles = db.relationship('Role', secondary='roles_users',
backref=db.backref('users', lazy='dynamic'))
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
followed_proposals = db.relationship(
"Proposal", secondary="proposal_follower", back_populates="followers"
)
liked_proposals = db.relationship(
"Proposal", secondary="proposal_liker", back_populates="likes"
)
liked_comments = db.relationship(
"Comment", secondary="comment_liker", back_populates="likes"
)
liked_rfps = db.relationship(
"RFP", secondary="rfp_liker", back_populates="likes"
)
def __init__(
self,
@ -343,13 +359,15 @@ class UserSchema(ma.Schema):
"avatar",
"display_name",
"userid",
"email_verified"
"email_verified",
"tip_jar_address"
)
social_medias = ma.Nested("SocialMediaSchema", many=True)
avatar = ma.Nested("AvatarSchema")
userid = ma.Method("get_userid")
email_verified = ma.Method("get_email_verified")
tip_jar_address = ma.Method("get_tip_jar_address")
def get_userid(self, obj):
return obj.id
@ -357,6 +375,9 @@ class UserSchema(ma.Schema):
def get_email_verified(self, obj):
return obj.email_verification.has_verified
def get_tip_jar_address(self, obj):
return obj.settings.tip_jar_address
user_schema = UserSchema()
users_schema = UserSchema(many=True)
@ -399,6 +420,8 @@ class UserSettingsSchema(ma.Schema):
fields = (
"email_subscriptions",
"refund_address",
"tip_jar_address",
"tip_jar_view_key"
)

View File

@ -4,24 +4,26 @@ from flask import Blueprint, g, current_app
from marshmallow import fields
from validate_email import validate_email
from webargs import validate
from grant.email.send import send_email
from grant.utils.misc import make_url
import grant.utils.auth as auth
from grant.comment.models import Comment, user_comments_schema
from grant.email.models import EmailRecovery
from grant.ccr.models import CCR, ccrs_schema
from grant.extensions import limiter
from grant.parser import query, body
from grant.proposal.models import (
Proposal,
ProposalTeamInvite,
invites_with_proposal_schema,
ProposalContribution,
user_proposal_contributions_schema,
user_proposals_schema,
user_proposal_arbiters_schema
)
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.proposal.models import ProposalContribution
from grant.utils.enums import ProposalStatus, ContributionStatus, CCRStatus
from grant.utils.exceptions import ValidationException
from grant.utils.requests import validate_blockchain_get
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
from .models import (
@ -33,6 +35,7 @@ from .models import (
user_settings_schema,
db
)
from grant.utils.validate import is_z_address_valid
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@ -50,14 +53,21 @@ def get_me():
"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)
"withArbitrated": fields.Bool(required=False, missing=None),
"withRequests": fields.Bool(required=False, missing=None),
"withRejectedPermanently": fields.Bool(required=False, missing=None)
})
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated):
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated, with_requests, with_rejected_permanently):
user = User.get_by_id(user_id)
if user:
result = user_schema.dump(user)
authed_user = auth.get_authed_user()
is_self = authed_user and authed_user.id == user.id
if with_requests:
requests = CCR.get_by_user(user)
requests_dump = ccrs_schema.dump(requests)
result["requests"] = requests_dump
if with_proposals:
proposals = Proposal.get_by_user(user)
proposals_dump = user_proposals_schema.dump(proposals)
@ -75,16 +85,33 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump
if with_pending and is_self:
pending = Proposal.get_by_user(user, [
pending_proposals = Proposal.get_by_user(user, [
ProposalStatus.STAKING,
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
])
pending_dump = user_proposals_schema.dump(pending)
result["pendingProposals"] = pending_dump
pending_proposals_dump = user_proposals_schema.dump(pending_proposals)
result["pendingProposals"] = pending_proposals_dump
pending_ccrs = CCR.get_by_user(user, [
CCRStatus.PENDING,
CCRStatus.APPROVED,
CCRStatus.REJECTED,
])
pending_ccrs_dump = ccrs_schema.dump(pending_ccrs)
result["pendingRequests"] = pending_ccrs_dump
if with_arbitrated and is_self:
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
if with_rejected_permanently and is_self:
rejected_proposals = Proposal.get_by_user(user, [
ProposalStatus.REJECTED_PERMANENTLY
])
result["rejectedPermanentlyProposals"] = user_proposals_schema.dump(rejected_proposals)
rejected_ccrs = CCR.get_by_user(user, [
CCRStatus.REJECTED_PERMANENTLY,
])
result["rejectedPermanentlyRequests"] = ccrs_schema.dump(rejected_ccrs)
return result
else:
@ -348,10 +375,11 @@ def get_user_settings(user_id):
@auth.requires_same_user_auth
@body({
"emailSubscriptions": fields.Dict(required=False, missing=None),
"refundAddress": fields.Str(required=False, missing=None,
validate=lambda r: validate_blockchain_get('/validate/address', {'address': r}))
"refundAddress": fields.Str(required=False, missing=None),
"tipJarAddress": fields.Str(required=False, missing=None),
"tipJarViewKey": fields.Str(required=False, missing=None) # TODO: add viewkey validation here
})
def set_user_settings(user_id, email_subscriptions, refund_address):
def set_user_settings(user_id, email_subscriptions, refund_address, tip_jar_address, tip_jar_view_key):
if email_subscriptions:
try:
email_subscriptions = keys_to_snake_case(email_subscriptions)
@ -359,11 +387,21 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
except ValidationException as e:
return {"message": str(e)}, 400
if refund_address is not None and refund_address != '' and not is_z_address_valid(refund_address):
return {"message": "Refund address is not a valid z address"}, 400
if refund_address == '' and g.current_user.settings.refund_address:
return {"message": "Refund address cannot be unset, only changed"}, 400
if refund_address:
g.current_user.settings.refund_address = refund_address
if tip_jar_address is not None and tip_jar_address is not '' and not is_z_address_valid(tip_jar_address):
return {"message": "Tip address is not a valid z address"}, 400
if tip_jar_address is not None:
g.current_user.settings.tip_jar_address = tip_jar_address
if tip_jar_view_key is not None:
g.current_user.settings.tip_jar_view_key = tip_jar_view_key
db.session.commit()
return user_settings_schema.dump(g.current_user.settings)
@ -381,6 +419,14 @@ def set_user_arbiter(user_id, proposal_id, is_accept):
if is_accept:
proposal.arbiter.accept_nomination(g.current_user.id)
for user in proposal.team:
send_email(user.email_address, 'proposal_arbiter_assigned', {
'user': user,
'proposal': proposal,
'proposal_url': make_url(f'/proposals/{proposal.id}')
})
return {"message": "Accepted nomination"}, 200
else:
proposal.arbiter.reject_nomination(g.current_user.id)

View File

@ -1,13 +1,12 @@
from functools import wraps
from datetime import datetime, timedelta
from functools import wraps
import sentry_sdk
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
from grant.settings import BLOCKCHAIN_API_SECRET
from grant.user.models import User
class AuthException(Exception):
@ -28,7 +27,7 @@ def throw_on_banned(user):
raise AuthException("You are banned")
def is_auth_fresh(minutes: int=20):
def is_auth_fresh(minutes: int = 20):
if 'last_login_time' in session:
last = session['last_login_time']
now = datetime.now()
@ -41,6 +40,8 @@ def is_email_verified():
def auth_user(email, password):
from grant.user.models import User
existing_user = User.get_by_email(email)
if not existing_user:
raise AuthException("No user exists with that email")
@ -85,6 +86,8 @@ def requires_auth(f):
def requires_same_user_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
from grant.user.models import User
user_id = kwargs["user_id"]
if not user_id:
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500
@ -114,6 +117,8 @@ def requires_email_verified_auth(f):
def requires_team_member_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
from grant.proposal.models import Proposal
proposal_id = kwargs["proposal_id"]
if not proposal_id:
return jsonify(message="Decorator requires_team_member_auth requires path variable <proposal_id>"), 500
@ -131,9 +136,33 @@ def requires_team_member_auth(f):
return requires_email_verified_auth(decorated)
def requires_ccr_owner_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
from grant.ccr.models import CCR
ccr_id = kwargs["ccr_id"]
if not ccr_id:
return jsonify(message="Decorator requires_ccr_owner_auth requires path variable <ccr_id>"), 500
ccr = CCR.query.filter_by(id=ccr_id).first()
if not ccr:
return jsonify(message="No CCR exists with id {}".format(ccr_id)), 404
if g.current_user.id != ccr.author.id:
return jsonify(message="You are not authorized to modify this CCR"), 403
g.current_ccr = ccr
return f(*args, **kwargs)
return requires_email_verified_auth(decorated)
def requires_arbiter_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
from grant.proposal.models import Proposal
proposal_id = kwargs["proposal_id"]
if not proposal_id:
return jsonify(message="Decorator requires_arbiter_auth requires path variable <proposal_id>"), 500

View File

@ -0,0 +1,123 @@
# Copyright (c) 2017 Pieter Wuille
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""Reference implementation for Bech32 and segwit addresses."""
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
"""Internal function that computes the Bech32 checksum."""
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ value
for i in range(5):
chk ^= generator[i] if ((top >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
"""Expand the HRP into values for checksum computation."""
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def bech32_verify_checksum(hrp, data):
"""Verify a checksum given HRP and converted data characters."""
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
def bech32_create_checksum(hrp, data):
"""Compute the checksum values given HRP and data."""
values = bech32_hrp_expand(hrp) + data
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def bech32_encode(hrp, data):
"""Compute a Bech32 string given HRP and data values."""
combined = data + bech32_create_checksum(hrp, data)
return hrp + '1' + ''.join([CHARSET[d] for d in combined])
def bech32_decode(bech):
"""Validate a Bech32 string, and determine HRP and data."""
if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
(bech.lower() != bech and bech.upper() != bech)):
return (None, None)
bech = bech.lower()
pos = bech.rfind('1')
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
return (None, None)
if not all(x in CHARSET for x in bech[pos+1:]):
return (None, None)
hrp = bech[:pos]
data = [CHARSET.find(x) for x in bech[pos+1:]]
if not bech32_verify_checksum(hrp, data):
return (None, None)
return (hrp, data[:-6])
def convertbits(data, frombits, tobits, pad=True):
"""General power-of-2 base conversion."""
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
max_acc = (1 << (frombits + tobits - 1)) - 1
for value in data:
if value < 0 or (value >> frombits):
return None
acc = ((acc << frombits) | value) & max_acc
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad:
if bits:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
return None
return ret
def decode(hrp, addr):
"""Decode a segwit address."""
hrpgot, data = bech32_decode(addr)
if hrpgot != hrp:
return (None, None)
decoded = convertbits(data[1:], 5, 8, False)
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
return (None, None)
if data[0] > 16:
return (None, None)
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
return (None, None)
return (data[0], decoded)
def encode(hrp, witver, witprog):
"""Encode a segwit address."""
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5))
if decode(hrp, ret) == (None, None):
return None
return ret

View File

@ -11,12 +11,29 @@ class CustomEnum():
not attr.startswith('__')]
class ProposalStatusEnum(CustomEnum):
class CCRStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
PENDING = 'PENDING'
STAKING = 'STAKING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY'
LIVE = 'LIVE'
DELETED = 'DELETED'
CCRStatus = CCRStatusEnum()
class ProposalStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
LIVE_DRAFT = 'LIVE_DRAFT'
ARCHIVED = 'ARCHIVED'
STAKING = 'STAKING'
DISCUSSION = 'DISCUSSION'
PENDING = 'PENDING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
REJECTED_PERMANENTLY = 'REJECTED_PERMANENTLY'
LIVE = 'LIVE'
DELETED = 'DELETED'
@ -34,7 +51,6 @@ ProposalSort = ProposalSortEnum()
class ProposalStageEnum(CustomEnum):
PREVIEW = 'PREVIEW'
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
WIP = 'WIP'
COMPLETED = 'COMPLETED'
FAILED = 'FAILED'
@ -91,3 +107,20 @@ class ProposalArbiterStatusEnum(CustomEnum):
ProposalArbiterStatus = ProposalArbiterStatusEnum()
class ProposalChangeEnum(CustomEnum):
PROPOSAL_EDIT_BRIEF = 'PROPOSAL_EDIT_BRIEF'
PROPOSAL_EDIT_CONTENT = 'PROPOSAL_EDIT_CONTENT'
PROPOSAL_EDIT_TARGET = 'PROPOSAL_EDIT_TARGET'
PROPOSAL_EDIT_TITLE = 'PROPOSAL_EDIT_TITLE'
MILESTONE_ADD = 'MILESTONE_ADD'
MILESTONE_REMOVE = 'MILESTONE_REMOVE'
MILESTONE_EDIT_DAYS = 'MILESTONE_EDIT_DAYS'
MILESTONE_EDIT_IMMEDIATE_PAYOUT = 'MILESTONE_EDIT_IMMEDIATE_PAYOUT'
MILESTONE_EDIT_PERCENT = 'MILESTONE_EDIT_PERCENT'
MILESTONE_EDIT_CONTENT = 'MILESTONE_EDIT_CONTENT'
MILESTONE_EDIT_TITLE = 'MILESTONE_EDIT_TITLE'
ProposalChange = ProposalChangeEnum()

View File

@ -1,12 +1,14 @@
import abc
from sqlalchemy import or_, and_
from sqlalchemy import or_
from grant.ccr.models import CCR
from grant.comment.models import Comment, comments_schema
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.comment.models import Comment, comments_schema
from grant.user.models import User, UserSettings, users_schema
from grant.milestone.models import Milestone
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.user.models import User, UserSettings, users_schema
from .enums import CCRStatus, ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, \
MilestoneStage
def extract_filters(sw, strings):
@ -39,13 +41,13 @@ class Pagination(abc.ABC):
# consider moving these args into __init__ and attaching to self
@abc.abstractmethod
def paginate(
self,
schema: ma.Schema,
query: db.Query,
page: int,
filters: list,
search: str,
sort: str,
self,
schema: ma.Schema,
query: db.Query,
page: int,
filters: list,
search: str,
sort: str,
):
pass
@ -58,6 +60,7 @@ class ProposalPagination(Pagination):
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
self.FILTERS.extend([f'MILESTONE_{c}' for c in MilestoneStage.list()])
self.FILTERS.extend(['ACCEPTED_WITH_FUNDING', 'ACCEPTED_WITHOUT_FUNDING'])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': Proposal.date_created.desc(),
@ -67,13 +70,13 @@ class ProposalPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='PUBLISHED:DESC',
self,
schema: ma.Schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'PUBLISHED:DESC',
):
query = query or Proposal.query
sort = sort or 'PUBLISHED:DESC'
@ -102,6 +105,10 @@ class ProposalPagination(Pagination):
if milestone_filters:
query = query.join(Proposal.milestones) \
.filter(Milestone.stage.in_(milestone_filters))
if 'ACCEPTED_WITH_FUNDING' in filters:
query = query.filter(Proposal.accepted_with_funding == True)
if 'ACCEPTED_WITHOUT_FUNDING' in filters:
query = query.filter(Proposal.accepted_with_funding == False)
# SORT (see self.SORT_MAP)
if sort:
@ -137,13 +144,13 @@ class ContributionPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema=proposal_contributions_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='PUBLISHED:DESC',
self,
schema: ma.Schema = proposal_contributions_schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'PUBLISHED:DESC',
):
query = query or ProposalContribution.query
sort = sort or 'CREATED:DESC'
@ -162,9 +169,9 @@ class ContributionPagination(Pagination):
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
.join(ProposalContribution.user) \
.join(UserSettings) \
.filter(UserSettings.refund_address != None)
@ -174,9 +181,9 @@ class ContributionPagination(Pagination):
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
)) \
.join(ProposalContribution.user, isouter=True) \
.join(UserSettings, isouter=True) \
.filter(UserSettings.refund_address == None)
@ -217,13 +224,13 @@ class UserPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema=users_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='EMAIL:DESC',
self,
schema: ma.Schema = users_schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'EMAIL:DESC',
):
query = query or Proposal.query
sort = sort or 'EMAIL:DESC'
@ -273,13 +280,13 @@ class CommentPagination(Pagination):
}
def paginate(
self,
schema: ma.Schema=comments_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='CREATED:DESC',
self,
schema: ma.Schema = comments_schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'CREATED:DESC',
):
query = query or Comment.query
sort = sort or 'CREATED:DESC'
@ -315,7 +322,58 @@ class CommentPagination(Pagination):
}
class CCRPagination(Pagination):
def __init__(self):
self.FILTERS = [f'STATUS_{s}' for s in CCRStatus.list()]
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': CCR.date_created.desc(),
'CREATED:ASC': CCR.date_created
}
def paginate(
self,
schema: ma.Schema,
query: db.Query = None,
page: int = 1,
filters: list = None,
search: str = None,
sort: str = 'CREATED:DESC',
):
query = query or CCR.query
sort = sort or 'CREATED:DESC'
# FILTER
if filters:
self.validate_filters(filters)
status_filters = extract_filters('STATUS_', filters)
if status_filters:
query = query.filter(CCR.status.in_(status_filters))
# SORT (see self.SORT_MAP)
if sort:
self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort])
# SEARCH
if search:
query = query.filter(CCR.title.ilike(f'%{search}%'))
res = query.paginate(page, self.PAGE_SIZE, False)
return {
'page': res.page,
'total': res.total,
'page_size': self.PAGE_SIZE,
'items': schema.dump(res.items),
'filters': filters,
'search': search,
'sort': sort
}
# expose pagination methods here
ccr = CCRPagination().paginate
proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate
comment = CommentPagination().paginate

View File

@ -29,17 +29,6 @@ def blockchain_get(path, params=None):
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):
if E2E_TESTING:
return blockchain_rest_e2e(path, data)

View File

@ -0,0 +1,19 @@
from grant.utils.bech32 import bech32_decode
def is_z_address_valid(addr: str):
if type(addr) != str:
return False
if addr[:3] != 'zs1':
return False
hrp, data = bech32_decode(addr)
if hrp is None:
return False
if data is None:
return False
return True

View File

@ -0,0 +1,192 @@
import warnings
from datetime import timedelta, datetime
from time import time, gmtime
from werkzeug._compat import to_bytes, string_types, text_type, PY2, integer_types
from werkzeug._internal import _make_cookie_domain, _cookie_quote
from werkzeug.urls import iri_to_uri
def dump_cookie(
key,
value="",
max_age=None,
expires=None,
path="/",
domain=None,
secure=False,
httponly=False,
charset="utf-8",
sync_expires=True,
max_size=4093,
samesite=None,
):
"""Creates a new Set-Cookie header without the ``Set-Cookie`` prefix
The parameters are the same as in the cookie Morsel object in the
Python standard library but it accepts unicode data, too.
On Python 3 the return value of this function will be a unicode
string, on Python 2 it will be a native string. In both cases the
return value is usually restricted to ascii as the vast majority of
values are properly escaped, but that is no guarantee. If a unicode
string is returned it's tunneled through latin1 as required by
PEP 3333.
The return value is not ASCII safe if the key contains unicode
characters. This is technically against the specification but
happens in the wild. It's strongly recommended to not use
non-ASCII values for the keys.
:param max_age: should be a number of seconds, or `None` (default) if
the cookie should last only as long as the client's
browser session. Additionally `timedelta` objects
are accepted, too.
:param expires: should be a `datetime` object or unix timestamp.
:param path: limits the cookie to a given path, per default it will
span the whole domain.
:param domain: Use this if you want to set a cross-domain cookie. For
example, ``domain=".example.com"`` will set a cookie
that is readable by the domain ``www.example.com``,
``foo.example.com`` etc. Otherwise, a cookie will only
be readable by the domain that set it.
:param secure: The cookie will only be available via HTTPS
:param httponly: disallow JavaScript to access the cookie. This is an
extension to the cookie standard and probably not
supported by all browsers.
:param charset: the encoding for unicode values.
:param sync_expires: automatically set expires if max_age is defined
but expires not.
:param max_size: Warn if the final header value exceeds this size. The
default, 4093, should be safely `supported by most browsers
<cookie_>`_. Set to 0 to disable this check.
:param samesite: Limits the scope of the cookie such that it will only
be attached to requests if those requests are "same-site".
.. _`cookie`: http://browsercookielimits.squawky.net/
"""
key = to_bytes(key, charset)
value = to_bytes(value, charset)
if path is not None:
path = iri_to_uri(path, charset)
domain = _make_cookie_domain(domain)
if isinstance(max_age, timedelta):
max_age = (max_age.days * 60 * 60 * 24) + max_age.seconds
if expires is not None:
if not isinstance(expires, string_types):
expires = cookie_date(expires)
elif max_age is not None and sync_expires:
expires = to_bytes(cookie_date(time() + max_age))
samesite = samesite.title() if samesite else None
if samesite not in ("Strict", "Lax", 'None', None):
raise ValueError("invalid SameSite value; must be 'Strict', 'Lax', 'None', or None")
buf = [key + b"=" + _cookie_quote(value)]
# XXX: In theory all of these parameters that are not marked with `None`
# should be quoted. Because stdlib did not quote it before I did not
# want to introduce quoting there now.
for k, v, q in (
(b"Domain", domain, True),
(b"Expires", expires, False),
(b"Max-Age", max_age, False),
(b"Secure", secure, None),
(b"HttpOnly", httponly, None),
(b"Path", path, False),
(b"SameSite", samesite, False),
):
if q is None:
if v:
buf.append(k)
continue
if v is None:
continue
tmp = bytearray(k)
if not isinstance(v, (bytes, bytearray)):
v = to_bytes(text_type(v), charset)
if q:
v = _cookie_quote(v)
tmp += b"=" + v
buf.append(bytes(tmp))
# The return value will be an incorrectly encoded latin1 header on
# Python 3 for consistency with the headers object and a bytestring
# on Python 2 because that's how the API makes more sense.
rv = b"; ".join(buf)
if not PY2:
rv = rv.decode("latin1")
# Warn if the final value of the cookie is less than the limit. If the
# cookie is too large, then it may be silently ignored, which can be quite
# hard to debug.
cookie_size = len(rv)
if max_size and cookie_size > max_size:
value_size = len(value)
warnings.warn(
'The "{key}" cookie is too large: the value was {value_size} bytes'
" but the header required {extra_size} extra bytes. The final size"
" was {cookie_size} bytes but the limit is {max_size} bytes."
" Browsers may silently ignore cookies larger than this.".format(
key=key,
value_size=value_size,
extra_size=cookie_size - value_size,
cookie_size=cookie_size,
max_size=max_size,
),
stacklevel=2,
)
return rv
def cookie_date(expires=None):
"""Formats the time to ensure compatibility with Netscape's cookie
standard.
Accepts a floating point number expressed in seconds since the epoch in, a
datetime object or a timetuple. All times in UTC. The :func:`parse_date`
function can be used to parse such a date.
Outputs a string in the format ``Wdy, DD-Mon-YYYY HH:MM:SS GMT``.
:param expires: If provided that date is used, otherwise the current.
"""
return _dump_date(expires, "-")
def _dump_date(d, delim):
"""Used for `http_date` and `cookie_date`."""
if d is None:
d = gmtime()
elif isinstance(d, datetime):
d = d.utctimetuple()
elif isinstance(d, (integer_types, float)):
d = gmtime(d)
return "%s, %02d%s%s%s%s %02d:%02d:%02d GMT" % (
("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")[d.tm_wday],
d.tm_mday,
delim,
(
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
)[d.tm_mon - 1],
delim,
str(d.tm_year),
d.tm_hour,
d.tm_min,
d.tm_sec,
)

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