zcash-grant-system/backend/grant/proposal/views.py

542 lines
18 KiB
Python
Raw Normal View History

from dateutil.parser import parse
2018-11-07 11:19:12 -08:00
from flask import Blueprint, g
2018-10-22 15:31:33 -07:00
from flask_yoloapi import endpoint, parameter
from grant.comment.models import Comment, comment_schema, comments_schema
2018-11-16 08:16:52 -08:00
from grant.email.send import send_email
2019-01-22 21:35:22 -08:00
from grant.milestone.models import Milestone
from grant.settings import EXPLORER_URL, PROPOSAL_STAKING_AMOUNT
2019-01-22 21:35:22 -08:00
from grant.user.models import User
from grant.rfp.models import RFP
2019-01-09 13:57:15 -08:00
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
2018-11-13 08:07:09 -08:00
from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url, from_zat, make_preview
from grant.utils.enums import ProposalStatus, ContributionStatus
2019-01-22 21:35:22 -08:00
from sqlalchemy import or_
2019-01-09 13:57:15 -08:00
from .models import (
Proposal,
proposals_schema,
proposal_schema,
ProposalUpdate,
proposal_update_schema,
ProposalContribution,
proposal_contribution_schema,
2018-11-26 15:47:24 -08:00
proposal_team,
2018-11-26 17:14:00 -08:00
ProposalTeamInvite,
proposal_team_invite_schema,
proposal_proposal_contributions_schema,
db,
)
2018-09-10 09:55:26 -07:00
2018-09-18 15:20:17 -07:00
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
2018-09-10 09:55:26 -07:00
@blueprint.route("/<proposal_id>", methods=["GET"])
@endpoint.api()
2018-09-10 09:55:26 -07:00
def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
2018-09-10 09:55:26 -07:00
if proposal:
if proposal.status != ProposalStatus.LIVE:
if proposal.status == ProposalStatus.DELETED:
return {"message": "Proposal was deleted"}, 404
authed_user = get_authed_user()
team_ids = list(x.id for x in proposal.team)
if not authed_user or authed_user.id not in team_ids:
return {"message": "User cannot view this proposal"}, 404
return proposal_schema.dump(proposal)
2018-09-10 09:55:26 -07:00
else:
return {"message": "No proposal matching id"}, 404
2018-09-10 09:55:26 -07:00
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
@endpoint.api()
2018-09-10 09:55:26 -07:00
def get_proposal_comments(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
2018-12-14 11:36:22 -08:00
# Only pull top comments, replies will be attached to them
comments = Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None)
num_comments = Comment.query.filter_by(proposal_id=proposal_id).count()
return {
"proposalId": proposal_id,
"totalComments": num_comments,
"comments": comments_schema.dump(comments)
}
2018-09-10 09:55:26 -07:00
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
2018-12-14 11:36:22 -08:00
@requires_auth
2018-10-22 15:31:33 -07:00
@endpoint.api(
parameter('comment', type=str, required=True),
2018-12-14 11:36:22 -08:00
parameter('parentCommentId', type=int, required=False)
2018-10-22 15:31:33 -07:00
)
2018-12-17 10:33:33 -08:00
def post_proposal_comments(proposal_id, comment, parent_comment_id):
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
2018-10-22 15:31:33 -07:00
return {"message": "No proposal matching id"}, 404
# Make sure the parent comment exists
parent = None
if parent_comment_id:
parent = Comment.query.filter_by(id=parent_comment_id).first()
if not parent:
return {"message": "Parent comment doesnt exist"}, 400
2019-01-27 18:51:05 -08:00
# Make sure user has verified their email
if not g.current_user.email_verification.has_verified:
message = "Please confirm your email before commenting."
return {"message": message}, 401
# Make the comment
comment = Comment(
proposal_id=proposal_id,
user_id=g.current_user.id,
parent_comment_id=parent_comment_id,
content=comment
)
db.session.add(comment)
db.session.commit()
dumped_comment = comment_schema.dump(comment)
# TODO: Email proposal team if top-level comment
preview = make_preview(comment.content, 60)
if not parent:
for member in proposal.team:
send_email(member.email_address, 'proposal_comment', {
'author': g.current_user,
'proposal': proposal,
'preview': preview,
'comment_url': make_url(f'/proposal/{proposal.id}?tab=discussions&comment={comment.id}'),
'author_url': make_url(f'/profile/{comment.author.id}'),
})
# Email parent comment creator, if it's not themselves
if parent and parent.author.id != comment.author.id:
send_email(parent.author.email_address, 'comment_reply', {
'author': g.current_user,
'proposal': proposal,
'preview': preview,
'comment_url': make_url(f'/proposal/{proposal.id}?tab=discussions&comment={comment.id}'),
'author_url': make_url(f'/profile/{comment.author.id}'),
})
return dumped_comment, 201
2018-09-10 09:55:26 -07:00
@blueprint.route("/", methods=["GET"])
2018-10-22 15:31:33 -07:00
@endpoint.api(
parameter('stage', type=str, required=False)
)
def get_proposals(stage):
2018-09-10 09:55:26 -07:00
if stage:
proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage)
.order_by(Proposal.date_created.desc())
.all()
2018-09-10 09:55:26 -07:00
)
else:
proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE)
.order_by(Proposal.date_created.desc())
.all()
)
dumped_proposals = proposals_schema.dump(proposals)
2018-12-14 11:36:22 -08:00
return dumped_proposals
2018-09-10 09:55:26 -07:00
2018-11-13 08:07:09 -08:00
@blueprint.route("/drafts", methods=["POST"])
2018-12-14 11:36:22 -08:00
@requires_auth
@endpoint.api(
parameter('rfpId', type=int),
)
def make_proposal_draft(rfp_id):
proposal = Proposal.create(status=ProposalStatus.DRAFT)
2018-11-13 08:07:09 -08:00
proposal.team.append(g.current_user)
if 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.title = rfp.title
proposal.brief = rfp.brief
proposal.category = rfp.category
rfp.proposals.append(proposal)
db.session.add(rfp)
2018-11-13 08:07:09 -08:00
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal), 201
2018-09-10 09:55:26 -07:00
2018-11-26 15:47:24 -08:00
2018-11-13 08:07:09 -08:00
@blueprint.route("/drafts", methods=["GET"])
2018-12-14 11:36:22 -08:00
@requires_auth
2018-11-13 08:07:09 -08:00
@endpoint.api()
def get_proposal_drafts():
proposals = (
Proposal.query
.filter(or_(
Proposal.status == ProposalStatus.DRAFT,
Proposal.status == ProposalStatus.REJECTED,
))
.join(proposal_team)
.filter(proposal_team.c.user_id == g.current_user.id)
.order_by(Proposal.date_created.desc())
.all()
2018-09-10 09:55:26 -07:00
)
2018-11-13 08:07:09 -08:00
return proposals_schema.dump(proposals), 200
2018-09-10 09:55:26 -07:00
2018-12-14 11:36:22 -08:00
2018-11-13 08:07:09 -08:00
@blueprint.route("/<proposal_id>", methods=["PUT"])
@requires_team_member_auth
2018-10-22 15:31:33 -07:00
@endpoint.api(
2018-11-13 08:07:09 -08:00
parameter('title', type=str),
parameter('brief', type=str),
parameter('category', type=str),
2018-11-14 09:27:40 -08:00
parameter('content', type=str),
2018-11-13 08:07:09 -08:00
parameter('target', type=str),
parameter('payoutAddress', type=str),
parameter('deadlineDuration', type=int),
parameter('milestones', type=list)
2018-10-22 15:31:33 -07:00
)
def update_proposal(milestones, proposal_id, **kwargs):
2018-11-13 08:07:09 -08:00
# Update the base proposal fields
try:
g.current_proposal.update(**kwargs)
2018-11-13 08:07:09 -08:00
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
2018-11-13 08:07:09 -08:00
db.session.add(g.current_proposal)
# Delete & re-add milestones
[db.session.delete(x) for x in g.current_proposal.milestones]
2018-11-13 08:07:09 -08:00
if milestones:
for mdata in milestones:
m = Milestone(
title=mdata["title"],
content=mdata["content"],
date_estimated=parse(mdata["dateEstimated"]),
2018-11-13 08:07:09 -08:00
payout_percent=str(mdata["payoutPercent"]),
immediate_payout=mdata["immediatePayout"],
proposal_id=g.current_proposal.id
)
2018-11-13 08:07:09 -08:00
db.session.add(m)
2018-12-14 11:36:22 -08:00
2018-11-13 08:07:09 -08:00
# Commit
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
2018-09-10 09:55:26 -07:00
2018-09-26 01:46:30 -07:00
@blueprint.route("/<proposal_id>", methods=["DELETE"])
2018-11-13 08:07:09 -08:00
@requires_team_member_auth
@endpoint.api()
def delete_proposal(proposal_id):
deleteable_statuses = [
ProposalStatus.DRAFT,
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,
]
status = g.current_proposal.status
if status not in deleteable_statuses:
return {"message": "Cannot delete proposals with %s status" % status}, 400
2018-11-13 08:07:09 -08:00
db.session.delete(g.current_proposal)
db.session.commit()
return None, 202
2018-09-10 09:55:26 -07:00
2018-09-26 01:46:30 -07:00
@blueprint.route("/<proposal_id>/submit_for_approval", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def submit_for_approval_proposal(proposal_id):
try:
g.current_proposal.submit_for_approval()
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
db.session.add(g.current_proposal)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
@requires_team_member_auth
@endpoint.api()
def get_proposal_stake(proposal_id):
if g.current_proposal.status != ProposalStatus.STAKING:
return None, 400
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
if contribution:
return proposal_contribution_schema.dump(contribution)
return None, 404
2018-11-13 08:07:09 -08:00
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
def publish_proposal(proposal_id):
2018-09-10 09:55:26 -07:00
try:
2018-11-13 08:07:09 -08:00
g.current_proposal.publish()
except ValidationException as e:
return {"message": "{}".format(str(e))}, 400
2018-11-13 08:07:09 -08:00
db.session.add(g.current_proposal)
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/updates", methods=["GET"])
@endpoint.api()
def get_proposal_updates(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return dumped_proposal["updates"]
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/updates/<update_id>", methods=["GET"])
@endpoint.api()
def get_proposal_update(proposal_id, update_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
update = ProposalUpdate.query.filter_by(proposal_id=proposal.id, id=update_id).first()
if update:
return proposal_update_schema.dump(update)
else:
return {"message": "No update matching id"}
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
2018-11-07 11:19:12 -08:00
@requires_team_member_auth
@endpoint.api(
parameter('title', type=str, required=True),
parameter('content', type=str, required=True)
)
def post_proposal_update(proposal_id, title, content):
2018-11-07 11:19:12 -08:00
update = ProposalUpdate(
proposal_id=g.current_proposal.id,
title=title,
content=content
)
db.session.add(update)
db.session.commit()
# Send email to all contributors (even if contribution failed)
preview = make_preview(update.content, 200)
contributions = ProposalContribution.query.filter_by(proposal_id=proposal_id).all()
for c in contributions:
send_email(c.user.email_address, 'contribution_update', {
'proposal': g.current_proposal,
'proposal_update': update,
'preview': preview,
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
2018-11-07 11:19:12 -08:00
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
2018-12-14 11:36:22 -08:00
2018-11-16 08:16:52 -08:00
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
@requires_team_member_auth
@endpoint.api(
parameter('address', type=str, required=True)
)
def post_proposal_team_invite(proposal_id, address):
invite = ProposalTeamInvite(
proposal_id=proposal_id,
address=address
)
db.session.add(invite)
db.session.commit()
# Send email
# TODO: Move this to some background task / after request action
email = address
2018-12-14 11:36:22 -08:00
user = User.get_by_email(email_address=address)
2018-11-16 08:16:52 -08:00
if user:
email = user.email_address
if is_email(email):
send_email(email, 'team_invite', {
'user': user,
'inviter': g.current_user,
2018-12-03 18:45:18 -08:00
'proposal': g.current_proposal,
'invite_url': make_url(
f'/profile/{user.id}?tab=invites' if user else '/auth')
2018-11-16 08:16:52 -08:00
})
return proposal_team_invite_schema.dump(invite), 201
2018-11-26 17:14:00 -08:00
2018-11-16 08:16:52 -08:00
@blueprint.route("/<proposal_id>/invite/<id_or_address>", methods=["DELETE"])
@requires_team_member_auth
@endpoint.api()
def delete_proposal_team_invite(proposal_id, id_or_address):
invite = ProposalTeamInvite.query.filter(
(ProposalTeamInvite.id == id_or_address) |
(ProposalTeamInvite.address == id_or_address)
).first()
if not invite:
return {"message": "No invite found given {}".format(id_or_address)}, 404
if invite.accepted:
return {"message": "Cannot delete an invite that has been accepted"}, 403
db.session.delete(invite)
db.session.commit()
2018-11-26 17:14:00 -08:00
return None, 202
@blueprint.route("/<proposal_id>/contributions", methods=["GET"])
@endpoint.api()
def get_proposal_contributions(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
2019-01-09 12:48:41 -08:00
if not proposal:
return {"message": "No proposal matching id"}, 404
2019-01-22 21:35:22 -08:00
2019-01-09 12:48:41 -08:00
top_contributions = ProposalContribution.query \
.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
) \
2019-01-09 12:48:41 -08:00
.order_by(ProposalContribution.amount.desc()) \
.limit(5) \
.all()
latest_contributions = ProposalContribution.query \
.filter_by(
proposal_id=proposal_id,
status=ContributionStatus.CONFIRMED,
) \
2019-01-09 12:48:41 -08:00
.order_by(ProposalContribution.date_created.desc()) \
.limit(5) \
.all()
2019-01-22 21:35:22 -08:00
2019-01-09 12:48:41 -08:00
return {
'top': proposal_proposal_contributions_schema.dump(top_contributions),
'latest': proposal_proposal_contributions_schema.dump(latest_contributions),
}
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
@endpoint.api()
def get_proposal_contribution(proposal_id, contribution_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
2019-01-08 09:44:54 -08:00
contribution = ProposalContribution.query.filter_by(id=contribution_id).first()
if contribution:
return proposal_contribution_schema.dump(contribution)
else:
return {"message": "No contribution matching id"}
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
2018-12-14 11:36:22 -08:00
@requires_auth
@endpoint.api(
parameter('amount', type=str, required=True)
)
def post_proposal_contribution(proposal_id, amount):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
code = 200
contribution = ProposalContribution \
.get_existing_contribution(g.current_user.id, proposal_id, amount)
if not contribution:
code = 201
contribution = proposal.create_contribution(g.current_user.id, amount)
dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, code
2019-01-09 13:32:51 -08:00
# Can't use <proposal_id> since webhook doesn't know proposal id
@blueprint.route("/contribution/<contribution_id>/confirm", methods=["POST"])
@internal_webhook
@endpoint.api(
parameter('to', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txid', type=str, required=True),
)
def post_contribution_confirmation(contribution_id, to, amount, txid):
contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
# TODO: Log in sentry
print(f'Unknown contribution {contribution_id} confirmed with txid {txid}')
return {"message": "No contribution matching id"}, 404
if contribution.status == ContributionStatus.CONFIRMED:
# Duplicates can happen, just return ok
return None, 200
# Convert to whole zcash coins from zats
zec_amount = str(from_zat(int(amount)))
contribution.confirm(tx_id=txid, amount=zec_amount)
db.session.add(contribution)
db.session.commit()
if contribution.proposal.status == ProposalStatus.STAKING:
# fully staked, set status PENDING & notify user
if contribution.proposal.is_staked: # float(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
contribution.proposal.status = ProposalStatus.PENDING
db.session.add(contribution.proposal)
db.session.commit()
# email progress of staking, partial or complete
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'fully_staked': contribution.proposal.is_staked,
'stake_target': PROPOSAL_STAKING_AMOUNT
})
else:
# Send to the user
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
})
# Send to the full proposal gang
for member in contribution.proposal.team:
send_email(member.email_address, 'proposal_contribution', {
'proposal': contribution.proposal,
'contribution': contribution,
'contributor': contribution.user,
'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
})
# TODO: Once we have a task queuer in place, queue emails to everyone
# on funding target reached.
return None, 200
2019-01-09 13:32:51 -08:00
2019-01-22 21:35:22 -08:00
2019-01-09 13:32:51 -08:00
@blueprint.route("/contribution/<contribution_id>", methods=["DELETE"])
@requires_auth
@endpoint.api()
def delete_proposal_contribution(contribution_id):
contribution = contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
return {"message": "No contribution matching id"}, 404
if contribution.status == ContributionStatus.CONFIRMED:
2019-01-09 13:32:51 -08:00
return {"message": "Cannot delete confirmed contributions"}, 400
if contribution.user_id != g.current_user.id:
return {"message": "Must be the user of the contribution to delete it"}, 403
contribution.status = ContributionStatus.DELETED
2019-01-09 13:32:51 -08:00
db.session.add(contribution)
db.session.commit()
return None, 202