528 lines
18 KiB
Python
528 lines
18 KiB
Python
from dateutil.parser import parse
|
||
from flask import Blueprint, g
|
||
from flask_yoloapi import endpoint, parameter
|
||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||
from grant.email.send import send_email
|
||
from grant.milestone.models import Milestone
|
||
from grant.settings import EXPLORER_URL, PROPOSAL_STAKING_AMOUNT
|
||
from grant.user.models import User
|
||
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
|
||
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
|
||
from sqlalchemy import or_
|
||
|
||
from .models import (
|
||
Proposal,
|
||
proposals_schema,
|
||
proposal_schema,
|
||
ProposalUpdate,
|
||
proposal_update_schema,
|
||
ProposalContribution,
|
||
proposal_contribution_schema,
|
||
proposal_team,
|
||
ProposalTeamInvite,
|
||
proposal_team_invite_schema,
|
||
proposal_proposal_contributions_schema,
|
||
db,
|
||
)
|
||
|
||
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
|
||
|
||
|
||
@blueprint.route("/<proposal_id>", methods=["GET"])
|
||
@endpoint.api()
|
||
def get_proposal(proposal_id):
|
||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||
if proposal:
|
||
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)
|
||
else:
|
||
return {"message": "No proposal matching id"}, 404
|
||
|
||
|
||
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
|
||
@endpoint.api()
|
||
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
|
||
|
||
# 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)
|
||
}
|
||
|
||
|
||
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
||
@requires_auth
|
||
@endpoint.api(
|
||
parameter('comment', type=str, required=True),
|
||
parameter('parentCommentId', type=int, required=False)
|
||
)
|
||
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:
|
||
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 doesn’t exist"}, 400
|
||
|
||
# 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
|
||
|
||
|
||
@blueprint.route("/", methods=["GET"])
|
||
@endpoint.api(
|
||
parameter('stage', type=str, required=False)
|
||
)
|
||
def get_proposals(stage):
|
||
if stage:
|
||
proposals = (
|
||
Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage)
|
||
.order_by(Proposal.date_created.desc())
|
||
.all()
|
||
)
|
||
else:
|
||
proposals = (
|
||
Proposal.query.filter_by(status=ProposalStatus.LIVE)
|
||
.order_by(Proposal.date_created.desc())
|
||
.all()
|
||
)
|
||
dumped_proposals = proposals_schema.dump(proposals)
|
||
return dumped_proposals
|
||
|
||
|
||
@blueprint.route("/drafts", methods=["POST"])
|
||
@requires_auth
|
||
@endpoint.api()
|
||
def make_proposal_draft():
|
||
proposal = Proposal.create(status=ProposalStatus.DRAFT)
|
||
proposal.team.append(g.current_user)
|
||
db.session.add(proposal)
|
||
db.session.commit()
|
||
return proposal_schema.dump(proposal), 201
|
||
|
||
|
||
@blueprint.route("/drafts", methods=["GET"])
|
||
@requires_auth
|
||
@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()
|
||
)
|
||
return proposals_schema.dump(proposals), 200
|
||
|
||
|
||
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
||
@requires_team_member_auth
|
||
@endpoint.api(
|
||
parameter('title', type=str),
|
||
parameter('brief', type=str),
|
||
parameter('category', type=str),
|
||
parameter('content', type=str),
|
||
parameter('target', type=str),
|
||
parameter('payoutAddress', type=str),
|
||
parameter('deadlineDuration', type=int),
|
||
parameter('milestones', type=list)
|
||
)
|
||
def update_proposal(milestones, proposal_id, **kwargs):
|
||
# Update the base proposal fields
|
||
try:
|
||
g.current_proposal.update(**kwargs)
|
||
except ValidationException as e:
|
||
return {"message": "{}".format(str(e))}, 400
|
||
db.session.add(g.current_proposal)
|
||
# Delete & re-add milestones
|
||
[db.session.delete(x) for x in g.current_proposal.milestones]
|
||
if milestones:
|
||
for mdata in milestones:
|
||
m = Milestone(
|
||
title=mdata["title"],
|
||
content=mdata["content"],
|
||
date_estimated=parse(mdata["dateEstimated"]),
|
||
payout_percent=str(mdata["payoutPercent"]),
|
||
immediate_payout=mdata["immediatePayout"],
|
||
proposal_id=g.current_proposal.id
|
||
)
|
||
db.session.add(m)
|
||
|
||
# Commit
|
||
db.session.commit()
|
||
return proposal_schema.dump(g.current_proposal), 200
|
||
|
||
|
||
@blueprint.route("/<proposal_id>", methods=["DELETE"])
|
||
@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
|
||
db.session.delete(g.current_proposal)
|
||
db.session.commit()
|
||
return None, 202
|
||
|
||
|
||
@blueprint.route("/<proposal_id>/submit_for_approval", methods=["PUT"])
|
||
@requires_team_member_auth
|
||
@endpoint.api()
|
||
def submit_for_approval_proposal(proposal_id):
|
||
try:
|
||
g.current_proposal.submit_for_approval()
|
||
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
|
||
|
||
|
||
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
|
||
@requires_team_member_auth
|
||
@endpoint.api()
|
||
def publish_proposal(proposal_id):
|
||
try:
|
||
g.current_proposal.publish()
|
||
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>/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"])
|
||
@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):
|
||
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}'),
|
||
})
|
||
|
||
dumped_update = proposal_update_schema.dump(update)
|
||
return dumped_update, 201
|
||
|
||
|
||
@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
|
||
user = User.get_by_email(email_address=address)
|
||
if user:
|
||
email = user.email_address
|
||
if is_email(email):
|
||
send_email(email, 'team_invite', {
|
||
'user': user,
|
||
'inviter': g.current_user,
|
||
'proposal': g.current_proposal,
|
||
'invite_url': make_url(
|
||
f'/profile/{user.id}?tab=invites' if user else '/auth')
|
||
})
|
||
|
||
return proposal_team_invite_schema.dump(invite), 201
|
||
|
||
|
||
@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()
|
||
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()
|
||
if not proposal:
|
||
return {"message": "No proposal matching id"}, 404
|
||
|
||
top_contributions = ProposalContribution.query \
|
||
.filter_by(
|
||
proposal_id=proposal_id,
|
||
status=ContributionStatus.CONFIRMED,
|
||
) \
|
||
.order_by(ProposalContribution.amount.desc()) \
|
||
.limit(5) \
|
||
.all()
|
||
latest_contributions = ProposalContribution.query \
|
||
.filter_by(
|
||
proposal_id=proposal_id,
|
||
status=ContributionStatus.CONFIRMED,
|
||
) \
|
||
.order_by(ProposalContribution.date_created.desc()) \
|
||
.limit(5) \
|
||
.all()
|
||
|
||
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:
|
||
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"])
|
||
@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
|
||
|
||
|
||
# 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
|
||
|
||
|
||
@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:
|
||
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
|
||
db.session.add(contribution)
|
||
db.session.commit()
|
||
return None, 202
|