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/', 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/', 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/', 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/', 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/', methods=['DELETE']) @admin.admin_auth_required def delete_proposal(id): return {"message": "Not implemented."}, 400 @blueprint.route('/proposals//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//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//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//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//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//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//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//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//milestone//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/', 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/', 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/', 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//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//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/', 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/', 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/', 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/', 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/', 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/', 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 }