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

1071 lines
35 KiB
Python

from datetime import datetime
from decimal import Decimal, ROUND_HALF_DOWN
from functools import reduce
from flask import Blueprint, request
from marshmallow import fields, validate
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
from grant.milestone.models import Milestone
from grant.parser import body, query, paginated_fields
from grant.proposal.models import (
Proposal,
ProposalArbiter,
ProposalContribution,
proposals_schema,
proposal_schema,
user_proposal_contributions_schema,
admin_proposal_contribution_schema,
admin_proposal_contributions_schema,
)
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
from grant.user.models import User, admin_users_schema, admin_user_schema
from grant.utils import pagination
from grant.utils.enums import (
ProposalStatus,
ProposalStage,
ContributionStatus,
ProposalArbiterStatus,
MilestoneStage,
RFPStatus,
CCRStatus
)
from grant.utils.misc import make_url, make_explore_url
from .example_emails import example_email_args
blueprint = Blueprint('admin', __name__, url_prefix='/api/v1/admin')
def make_2fa_state():
return {
"isLoginFresh": admin.is_auth_fresh(),
"has2fa": admin.has_2fa_setup(),
"is2faAuthed": admin.admin_is_2fa_authed(),
"backupCodeCount": admin.backup_code_count(),
"isEmailVerified": auth.is_email_verified(),
}
def make_login_state():
return {
"isLoggedIn": admin.admin_is_authed(),
"is2faAuthed": admin.admin_is_2fa_authed()
}
@blueprint.route("/checklogin", methods=["GET"])
def loggedin():
return make_login_state()
@blueprint.route("/login", methods=["POST"])
@body({
"username": fields.Str(required=False, missing=None),
"password": fields.Str(required=False, missing=None)
})
def login(username, password):
if auth.auth_user(username, password):
if admin.admin_is_authed():
return make_login_state()
return {"message": "Username or password incorrect."}, 401
@blueprint.route("/refresh", methods=["POST"])
@body({
"password": fields.Str(required=True)
})
def refresh(password):
if auth.refresh_auth(password):
return make_login_state()
else:
return {"message": "Username or password incorrect."}, 401
@blueprint.route("/2fa", methods=["GET"])
def get_2fa():
if not admin.admin_is_authed():
return {"message": "Must be authenticated"}, 403
return make_2fa_state()
@blueprint.route("/2fa/init", methods=["GET"])
def get_2fa_init():
admin.throw_on_2fa_not_allowed()
return admin.make_2fa_setup()
@blueprint.route("/2fa/enable", methods=["POST"])
@body({
"backupCodes": fields.List(fields.Str(), required=True),
"totpSecret": fields.Str(required=True),
"verifyCode": fields.Str(required=True)
})
def post_2fa_enable(backup_codes, totp_secret, verify_code):
admin.throw_on_2fa_not_allowed()
admin.check_and_set_2fa_setup(backup_codes, totp_secret, verify_code)
db.session.commit()
return make_2fa_state()
@blueprint.route("/2fa/verify", methods=["POST"])
@body({
"verifyCode": fields.Str(required=True)
})
def post_2fa_verify(verify_code):
admin.throw_on_2fa_not_allowed(allow_stale=True)
admin.admin_auth_2fa(verify_code)
db.session.commit()
return make_2fa_state()
@blueprint.route("/logout", methods=["GET"])
def logout():
admin.logout()
return {
"isLoggedIn": False,
"is2faAuthed": False
}
@blueprint.route("/stats", methods=["GET"])
@admin.admin_auth_required
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()
proposal_no_arbiter_count = db.session.query(func.count(Proposal.id)) \
.join(Proposal.arbiter) \
.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) \
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(Proposal.stage != ProposalStage.CANCELED) \
.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()
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": 0,
}
# USERS
@blueprint.route('/users/<user_id>', methods=['DELETE'])
@admin.admin_auth_required
def delete_user(user_id):
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "No user matching that id"}, 404
db.session.delete(user)
db.session.commit()
return {"message": "ok"}, 200
@blueprint.route("/users", methods=["GET"])
@query(paginated_fields)
@admin.admin_auth_required
def get_users(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.user(
schema=admin_users_schema,
query=User.query,
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/users/<id>', methods=['GET'])
@admin.admin_auth_required
def get_user(id):
user_db = User.query.filter(User.id == id).first()
if user_db:
user = admin_user_schema.dump(user_db)
user_proposals = Proposal.query.filter(Proposal.team.any(id=user['userid'])).all()
user['proposals'] = proposals_schema.dump(user_proposals)
user_comments = Comment.get_by_user(user_db)
user['comments'] = user_comments_schema.dump(user_comments)
contributions = ProposalContribution.get_by_userid(user_db.id)
contributions_dump = user_proposal_contributions_schema.dump(contributions)
user["contributions"] = contributions_dump
return user
return {"message": f"Could not find user with id {id}"}, 404
@blueprint.route('/users/<user_id>', methods=['PUT'])
@body({
"silenced": fields.Bool(required=False, missing=None),
"banned": fields.Bool(required=False, missing=None),
"bannedReason": fields.Str(required=False, missing=None),
"isAdmin": fields.Bool(required=False, missing=None),
})
@admin.admin_auth_required
def edit_user(user_id, silenced, banned, banned_reason, is_admin):
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": f"Could not find user with id {id}"}, 404
if silenced is not None:
user.set_silenced(silenced)
if banned is not None:
if banned and not banned_reason: # if banned true, provide reason
return {"message": "Please include reason for banning"}, 417
user.set_banned(banned, banned_reason)
if is_admin is not None:
user.set_admin(is_admin)
db.session.commit()
return admin_user_schema.dump(user)
# ARBITERS
@blueprint.route("/arbiters", methods=["GET"])
@query({
"search": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def get_arbiters(search):
results = []
error = None
if len(search) < 3:
error = 'search query must be at least 3 characters long'
else:
users = User.query.filter(
User.email_address.ilike(f'%{search}%') | User.display_name.ilike(f'%{search}%')
).order_by(User.display_name).all()
results = admin_users_schema.dump(users)
return {
'results': results,
'search': search,
'error': error
}
@blueprint.route('/arbiters', methods=['PUT'])
@body({
"proposalId": fields.Int(required=True),
"userId": fields.Int(required=True),
})
@admin.admin_auth_required
def set_arbiter(proposal_id, user_id):
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
return {"message": "Proposal not found"}, 404
for member in proposal.team:
if member.id == user_id:
return {"message": "Cannot set proposal team member as arbiter"}, 400
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
# send email
code = user.email_verification.code
send_email(user.email_address, 'proposal_arbiter', {
'proposal': proposal,
'proposal_url': make_url(f'/proposals/{proposal.id}'),
'accept_url': make_url(f'/email/arbiter?code={code}&proposalId={proposal.id}'),
})
proposal.arbiter.user = user
proposal.arbiter.status = ProposalArbiterStatus.NOMINATED
db.session.add(proposal.arbiter)
db.session.commit()
return {
'proposal': proposal_schema.dump(proposal),
'user': admin_user_schema.dump(user)
}, 200
# PROPOSALS
@blueprint.route("/proposals", methods=["GET"])
@query(paginated_fields)
@admin.admin_auth_required
def get_proposals(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.proposal(
schema=proposals_schema,
query=Proposal.query.filter(Proposal.status.notin_([ProposalStatus.ARCHIVED])),
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/proposals/<id>', methods=['GET'])
@admin.admin_auth_required
def get_proposal(id):
proposal = Proposal.query.filter(Proposal.id == id).first()
if proposal:
return proposal_schema.dump(proposal)
return {"message": f"Could not find proposal with id {id}"}, 404
@blueprint.route('/proposals/<id>', methods=['DELETE'])
@admin.admin_auth_required
def delete_proposal(id):
return {"message": "Not implemented."}, 400
@blueprint.route('/proposals/<proposal_id>/discussion', methods=['PUT'])
@body({
"isOpenForDiscussion": fields.Bool(required=True),
"rejectReason": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
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": "No Proposal found."}, 404
proposal.approve_discussion(is_open_for_discussion, reject_reason)
db.session.commit()
return proposal_schema.dump(proposal)
@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/<proposal_id>/resolve', methods=['PUT'])
@admin.admin_auth_required
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()
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'])
@admin.admin_auth_required
def cancel_proposal(id):
proposal = Proposal.query.filter_by(id=id).first()
if not proposal:
return {"message": "No proposal found."}, 404
proposal.cancel()
db.session.add(proposal)
db.session.commit()
return proposal_schema.dump(proposal)
@blueprint.route("/proposals/<id>/milestone/<mid>/paid", methods=["PUT"])
@body({
"txId": fields.Str(required=True),
})
@admin.admin_auth_required
def paid_milestone_payout_request(id, mid, tx_id):
proposal = Proposal.query.filter_by(id=id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
if not proposal.is_funded:
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()
db.session.commit()
# email TEAM that payout request was PAID
amount = Decimal(ms.payout_percent) * Decimal(proposal.target) / 100
for member in proposal.team:
send_email(member.email_address, 'milestone_paid', {
'proposal': proposal,
'milestone': ms,
'amount': amount,
'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
# EMAIL
@blueprint.route('/email/example/<type>', methods=['GET'])
@admin.admin_auth_required
def get_email_example(type):
email = generate_email(type, example_email_args.get(type))
if email['info'].get('subscription'):
# Unserializable, so remove
email['info'].pop('subscription', None)
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
@blueprint.route('/rfps', methods=['GET'])
@admin.admin_auth_required
def get_rfps():
rfps = RFP.query.all()
return admin_rfps_schema.dump(rfps)
@blueprint.route('/rfps', methods=['POST'])
@body({
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"content": fields.Str(required=True),
"bounty": fields.Str(required=False, missing=0),
"matching": fields.Bool(required=False, missing=False),
"dateCloses": fields.Int(required=False, missing=None)
})
@admin.admin_auth_required
def create_rfp(date_closes, **kwargs):
rfp = RFP(
**kwargs,
date_closes=datetime.fromtimestamp(date_closes) if date_closes else None,
)
db.session.add(rfp)
db.session.commit()
return admin_rfp_schema.dump(rfp), 200
@blueprint.route('/rfps/<rfp_id>', methods=['GET'])
@admin.admin_auth_required
def get_rfp(rfp_id):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
return admin_rfp_schema.dump(rfp)
@blueprint.route('/rfps/<rfp_id>', methods=['PUT'])
@body({
"title": fields.Str(required=True),
"brief": fields.Str(required=True),
"status": fields.Str(required=True, validate=validate.OneOf(choices=RFPStatus.list())),
"content": fields.Str(required=True),
"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, 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
# Update fields
rfp.title = title
rfp.brief = brief
rfp.content = content
rfp.matching = matching
rfp.bounty = bounty
rfp.date_closes = datetime.fromtimestamp(date_closes) if date_closes else None
# Update timestamps if status changed
if rfp.status != status:
if status == RFPStatus.LIVE and not rfp.date_opened:
rfp.date_opened = datetime.now()
if status == RFPStatus.CLOSED:
rfp.date_closed = datetime.now()
rfp.status = status
db.session.add(rfp)
db.session.commit()
return admin_rfp_schema.dump(rfp)
@blueprint.route('/rfps/<rfp_id>', methods=['DELETE'])
@admin.admin_auth_required
def delete_rfp(rfp_id):
rfp = RFP.query.filter(RFP.id == rfp_id).first()
if not rfp:
return {"message": "No RFP matching that id"}, 404
db.session.delete(rfp)
db.session.commit()
return {"message": "ok"}, 200
# Contributions
@blueprint.route('/contributions', methods=['GET'])
@query(paginated_fields)
@admin.admin_auth_required
def get_contributions(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.contribution(
page=page,
schema=admin_proposal_contributions_schema,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/contributions', methods=['POST'])
@body({
"proposalId": fields.Int(required=True),
"userId": fields.Int(required=True),
"status": fields.Str(required=True, validate=validate.OneOf(choices=ContributionStatus.list())),
"amount": fields.Str(required=True),
"txId": fields.Str(required=False, missing=None)
})
@admin.admin_auth_required
def create_contribution(proposal_id, user_id, status, amount, tx_id):
# Some fields set manually since we're admin, and normally don't do this
contribution = ProposalContribution(
proposal_id=proposal_id,
user_id=user_id,
amount=amount,
)
contribution.status = status
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200
@blueprint.route('/contributions/<contribution_id>', methods=['GET'])
@admin.admin_auth_required
def get_contribution(contribution_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
if not contribution:
return {"message": "No contribution matching that id"}, 404
return admin_proposal_contribution_schema.dump(contribution), 200
@blueprint.route('/contributions/<contribution_id>', methods=['PUT'])
@body({
"proposalId": fields.Int(required=False, missing=None),
"userId": fields.Int(required=False, missing=None),
"status": fields.Str(required=False, missing=None, validate=validate.OneOf(choices=ContributionStatus.list())),
"amount": fields.Str(required=False, missing=None),
"txId": fields.Str(required=False, missing=None),
"refundTxId": fields.Str(required=False, allow_none=True, missing=None),
})
@admin.admin_auth_required
def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id, refund_tx_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
if not contribution:
return {"message": "No contribution matching that id"}, 404
had_refund = contribution.refund_tx_id
# do not allow editing certain fields on contributions once a proposal has become funded
if (proposal_id or user_id or status or amount or tx_id) and contribution.proposal.is_funded:
return {"message": "Cannot edit contributions to fully-funded proposals"}, 400
# Proposal ID (must belong to an existing proposal)
if proposal_id:
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
return {"message": "No proposal matching that id"}, 400
contribution.proposal_id = proposal_id
# User ID (must belong to an existing user or 0 to unset)
if user_id is not None:
if user_id == 0:
contribution.user_id = None
else:
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "No user matching that id"}, 400
contribution.user_id = user_id
# Status (must be in list of statuses)
if status:
if not ContributionStatus.includes(status):
return {"message": "Invalid status"}, 400
contribution.status = status
# Amount (must be a Decimal parseable)
if amount:
try:
contribution.amount = str(Decimal(amount))
except:
return {"message": "Amount could not be parsed as number"}, 400
# Transaction ID (no validation)
if tx_id is not None:
contribution.tx_id = tx_id
# Refund TX ID (no validation)
if refund_tx_id is not None:
contribution.refund_tx_id = refund_tx_id
db.session.add(contribution)
db.session.flush()
# TODO: should this stay?
contribution.proposal.set_pending_when_ready()
db.session.commit()
return admin_proposal_contribution_schema.dump(contribution), 200
# Comments
@blueprint.route('/comments', methods=['GET'])
@body(paginated_fields)
@admin.admin_auth_required
def get_comments(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.comment(
page=page,
filters=filters_workaround,
search=search,
sort=sort,
schema=admin_comments_schema
)
return page
@blueprint.route('/comments/<comment_id>', methods=['PUT'])
@body({
"hidden": fields.Bool(required=False, missing=None),
"reported": fields.Bool(required=False, missing=None),
})
@admin.admin_auth_required
def edit_comment(comment_id, hidden, reported):
comment = Comment.query.filter(Comment.id == comment_id).first()
if not comment:
return {"message": "No comment matching that id"}, 404
if hidden is not None:
comment.hide(hidden)
if reported is not None:
comment.report(reported)
db.session.commit()
return admin_comment_schema.dump(comment)
# Financials
@blueprint.route("/financials", methods=["GET"])
@admin.admin_auth_required
def financials():
nfmt = '999999.99999999' # smallest unit of ZEC
def sql_pc(where: str):
return f"SELECT SUM(TO_NUMBER(amount, '{nfmt}')) FROM proposal_contribution WHERE {where}"
def sql_pc_p(where: str):
return f'''
SELECT SUM(TO_NUMBER(amount, '{nfmt}'))
FROM proposal_contribution as pc
INNER JOIN proposal as p ON pc.proposal_id = p.id
LEFT OUTER JOIN "user" as u ON pc.user_id = u.id
LEFT OUTER JOIN user_settings as us ON u.id = us.user_id
WHERE {where}
'''
def sql_ms(where: str):
return f'''
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
FROM milestone as ms
INNER JOIN proposal as p ON ms.proposal_id = p.id
WHERE 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()
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
# expected payments
po_future = ex(sql_ms("ms.stage IN ('IDLE', 'REJECTED', 'REQUESTED') AND p.stage IN ('WIP', 'COMPLETED')"))
po_total = po_due + po_paid + po_future
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),
'paid': str(po_paid),
'future': str(po_future),
}
grants = {
'total': '0',
'matching': '0',
'bounty': '0',
}
def add_str_dec(a: str, b: str):
return str((Decimal(a) + Decimal(b)).quantize(Decimal('0.001'), rounding=ROUND_HALF_DOWN))
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
if p.stage in [ProposalStage.WIP, ProposalStage.COMPLETED]:
# matching
matching = Decimal(p.contributed) * Decimal(p.contribution_matching)
remaining = max(Decimal(p.target) - Decimal(p.contributed), Decimal('0.0'))
if matching > remaining:
matching = remaining
# bounty
bounty = Decimal(p.contribution_bounty)
remaining = max(Decimal(p.target) - (matching + Decimal(p.contributed)), Decimal('0.0'))
if bounty > remaining:
bounty = remaining
grants['matching'] = add_str_dec(grants['matching'], matching)
grants['bounty'] = add_str_dec(grants['bounty'], bounty)
grants['total'] = add_str_dec(grants['total'], matching + bounty)
return {
'grants': grants,
'payouts': payouts,
'payouts_by_quarter': payouts_by_quarter
}