import datetime import json from decimal import Decimal, ROUND_DOWN from functools import reduce from typing import Optional from marshmallow import post_dump from sqlalchemy import func, or_, select, ForeignKey from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import column_property from grant.comment.models import Comment from grant.email.send import send_email from grant.extensions import ma, db from grant.milestone.models import Milestone from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX from grant.task.jobs import ContributionExpired from grant.utils.enums import ( ProposalStatus, ProposalStage, ContributionStatus, ProposalArbiterStatus, MilestoneStage, ProposalChange ) from grant.utils.exceptions import ValidationException from grant.utils.misc import dt_to_unix, make_url, make_admin_url, gen_random_id from grant.utils.requests import blockchain_get from grant.utils.stubs import anonymous_user from grant.utils.validate import is_z_address_valid proposal_team = db.Table( 'proposal_team', db.Model.metadata, db.Column('user_id', db.Integer, db.ForeignKey('user.id')), db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id')) ) proposal_follower = db.Table( "proposal_follower", db.Model.metadata, db.Column("user_id", db.Integer, db.ForeignKey("user.id")), db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), ) proposal_liker = db.Table( "proposal_liker", db.Model.metadata, db.Column("user_id", db.Integer, db.ForeignKey("user.id")), db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")), ) class ProposalTeamInvite(db.Model): __tablename__ = "proposal_team_invite" id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime) proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) address = db.Column(db.String(255), nullable=False) accepted = db.Column(db.Boolean) def __init__(self, proposal_id: int, address: str, accepted: bool = None): self.proposal_id = proposal_id self.address = address[:255] self.accepted = accepted self.date_created = datetime.datetime.now() @staticmethod def get_pending_for_user(user): return ProposalTeamInvite.query.filter( ProposalTeamInvite.accepted == None, (func.lower(user.email_address) == func.lower(ProposalTeamInvite.address)) ).all() class ProposalUpdate(db.Model): __tablename__ = "proposal_update" id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime) proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) title = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False) def __init__(self, proposal_id: int, title: str, content: str): self.id = gen_random_id(ProposalUpdate) self.proposal_id = proposal_id self.title = title[:255] self.content = content self.date_created = datetime.datetime.now() class ProposalContribution(db.Model): __tablename__ = "proposal_contribution" id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime, nullable=False) proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) status = db.Column(db.String(255), nullable=False) amount = db.Column(db.String(255), nullable=False) tx_id = db.Column(db.String(255), nullable=True) refund_tx_id = db.Column(db.String(255), nullable=True) staking = db.Column(db.Boolean, nullable=False) private = db.Column(db.Boolean, nullable=False, default=False, server_default='true') user = db.relationship("User") def __init__( self, proposal_id: int, amount: str, user_id: int = None, staking: bool = False, private: bool = True, ): self.proposal_id = proposal_id self.amount = amount self.user_id = user_id self.staking = staking self.private = private self.date_created = datetime.datetime.now() self.status = ContributionStatus.PENDING @staticmethod def get_existing_contribution(user_id: int, proposal_id: int, amount: str, private: bool = False): return ProposalContribution.query.filter_by( user_id=user_id, proposal_id=proposal_id, amount=amount, private=private, status=ContributionStatus.PENDING, ).first() @staticmethod def get_by_userid(user_id): return ProposalContribution.query \ .filter(ProposalContribution.user_id == user_id) \ .filter(ProposalContribution.status != ContributionStatus.DELETED) \ .filter(ProposalContribution.staking == False) \ .order_by(ProposalContribution.date_created.desc()) \ .all() @staticmethod def validate(contribution): proposal_id = contribution.get('proposal_id') user_id = contribution.get('user_id') status = contribution.get('status') amount = contribution.get('amount') tx_id = contribution.get('tx_id') # Proposal ID (must belong to an existing proposal) if proposal_id: proposal = Proposal.query.filter(Proposal.id == proposal_id).first() if not proposal: raise ValidationException('No proposal matching that ID') contribution.proposal_id = proposal_id else: raise ValidationException('Proposal ID is required') # User ID (must belong to an existing user) if user_id: from grant.user.models import User user = User.query.filter(User.id == user_id).first() if not user: raise ValidationException('No user matching that ID') contribution.user_id = user_id else: raise ValidationException('User ID is required') # Status (must be in list of statuses) if status: if not ContributionStatus.includes(status): raise ValidationException('Invalid status') contribution.status = status else: raise ValidationException('Status is required') # Amount (must be a Decimal parseable) if amount: try: contribution.amount = str(Decimal(amount)) except: raise ValidationException('Amount must be a number') else: raise ValidationException('Amount is required') def confirm(self, tx_id: str, amount: str): self.status = ContributionStatus.CONFIRMED self.tx_id = tx_id self.amount = amount @hybrid_property def refund_address(self): return self.user.settings.refund_address if self.user else None class ProposalArbiter(db.Model): __tablename__ = "proposal_arbiter" id = db.Column(db.Integer(), primary_key=True) proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) status = db.Column(db.String(255), nullable=False) proposal = db.relationship("Proposal", lazy=True, back_populates="arbiter") user = db.relationship("User", uselist=False, lazy=True, back_populates="arbiter_proposals") def __init__(self, proposal_id: int, user_id: int = None, status: str = ProposalArbiterStatus.MISSING): self.id = gen_random_id(ProposalArbiter) self.proposal_id = proposal_id self.user_id = user_id self.status = status def accept_nomination(self, user_id: int): if self.user_id == user_id: self.status = ProposalArbiterStatus.ACCEPTED db.session.add(self) db.session.commit() return raise ValidationException('User not nominated for arbiter') def reject_nomination(self, user_id: int): if self.user_id == user_id: self.status = ProposalArbiterStatus.MISSING self.user = None db.session.add(self) db.session.commit() return raise ValidationException('User is not arbiter') class ProposalRevision(db.Model): __tablename__ = "proposal_revision" id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime) # user who submitted the changes author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) author = db.relationship("User", uselist=False, lazy=True) # the proposal these changes are associated with proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) proposal = db.relationship("Proposal", foreign_keys=[proposal_id], back_populates="revisions") # the archived proposal id associated with these changes proposal_archive_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False) # the detected changes as a JSON string changes = db.Column(db.Text, nullable=False) # the placement of this revision in the total revisions revision_index = db.Column(db.Integer) def __init__(self, author, proposal_id: int, proposal_archive_id: int, changes: str, revision_index: int): self.id = gen_random_id(ProposalRevision) self.date_created = datetime.datetime.now() self.author = author self.proposal_id = proposal_id self.proposal_archive_id = proposal_archive_id self.changes = changes self.revision_index = revision_index @staticmethod def calculate_milestone_changes(old_milestones, new_milestones): changes = [] old_length = len(old_milestones) new_length = len(new_milestones) # determine the longer milestone collection so we can enumerate it long_ms = None short_ms = None if old_length >= new_length: long_ms = old_milestones short_ms = new_milestones else: long_ms = new_milestones short_ms = old_milestones # detect whether we're adding or removing milestones is_adding = False is_removing = False if old_length > new_length: is_removing = True if new_length > old_length: is_adding = True for i, ms in enumerate(long_ms): compare_ms = short_ms[i] if len(short_ms) - 1 >= i else None # when compare milestone doesn't exist, the current milestone is either being added or removed if not compare_ms: if is_adding: changes.append({"type": ProposalChange.MILESTONE_ADD, "milestone_index": i}) if is_removing: changes.append({"type": ProposalChange.MILESTONE_REMOVE, "milestone_index": i}) continue if ms.days_estimated != compare_ms.days_estimated: changes.append({"type": ProposalChange.MILESTONE_EDIT_DAYS, "milestone_index": i}) if ms.immediate_payout != compare_ms.immediate_payout: changes.append({"type": ProposalChange.MILESTONE_EDIT_IMMEDIATE_PAYOUT, "milestone_index": i}) if ms.payout_percent != compare_ms.payout_percent: changes.append({"type": ProposalChange.MILESTONE_EDIT_PERCENT, "milestone_index": i}) if ms.content != compare_ms.content: changes.append({"type": ProposalChange.MILESTONE_EDIT_CONTENT, "milestone_index": i}) if ms.title != compare_ms.title: changes.append({"type": ProposalChange.MILESTONE_EDIT_TITLE, "milestone_index": i}) return changes @staticmethod def calculate_proposal_changes(old_proposal, new_proposal): proposal_changes = [] if old_proposal.brief != new_proposal.brief: proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_BRIEF}) if old_proposal.content != new_proposal.content: proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_CONTENT}) if old_proposal.target != new_proposal.target: proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TARGET}) if old_proposal.title != new_proposal.title: proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TITLE}) milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones) return proposal_changes + milestone_changes def default_proposal_content(): return """### If you have any doubts about the questions below, please reach out to anyone on the ZOMG on the [Zcash forums](https://forum.zcashcommunity.com/). # Description of Problem or Opportunity In addition to describing the problem/opportunity, please give a sense of how serious or urgent of a need you believe this to be. What evidence do you have? What validation have you already done, or how do you think you could validate this? # Proposed Solution Describe the solution at a high level. Please be specific about who the users and stakeholders are and how they would interact with your solution. E.g. retail ZEC holders, Zcash core devs, wallet devs, DeFi users, potential Zcash community participants. # Solution Format What is the exact form of the deliverable you’re creating? E.g. code shipped within the zcashd and zebra code bases, a website, a feature within a wallet, a text/markdown file, user manuals, etc. # Technical approach Dive into the _how_ of your project. Describe your approaches, components, workflows, methodology, etc. Bullet points and diagrams are appreciated! # How big of a problem would it be to not solve this problem? # Execution risks What obstacles do you expect? What is most likely to go wrong? Which unknown factors or dependencies could jeopardize success? Who would have to incorporate your work in order for it to be usable? # Unintended Consequences Downsides What are the negative ramifications if your project is successful? Consider usability, stability, privacy, integrity, availability, decentralization, interoperability, maintainability, technical debt, requisite education, etc. # Evaluation plan What metrics for success can you share with the community once you’re done? In addition to quantitative metrics, what qualitative metrics do you think you could report? # Schedule and Milestones What is your timeline for the project? Include concrete milestones and the major tasks required to complete each milestone. # Budget and Payout Timeline How much funding do you need, and how will it be allocated (e.g., compensation for your effort, specific equipment, specific external services)? Please tie your payout timelines to the milestones presented in the previous step. Convention has been for applicants to base their budget on hours of work and an hourly rate, but we are open to proposals based on the value of outcomes instead. # Applicant background Summarize you and/or your team’s background and experience. Demonstrate that you have the skills and expertise necessary for the project that you’re proposing. Institutional bona fides are not required, but we want to hear about your track record. """ class Proposal(db.Model): __tablename__ = "proposal" id = db.Column(db.Integer(), primary_key=True) date_created = db.Column(db.DateTime) rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True) version = db.Column(db.String(255), nullable=True) # Content info status = db.Column(db.String(255), nullable=False) title = db.Column(db.String(255), nullable=False) brief = db.Column(db.String(255), nullable=False) stage = db.Column(db.String(255), nullable=False) content = db.Column(db.Text, nullable=False, default=default_proposal_content()) category = db.Column(db.String(255), nullable=True) date_approved = db.Column(db.DateTime) date_published = db.Column(db.DateTime) reject_reason = db.Column(db.String()) kyc_approved = db.Column(db.Boolean(), nullable=True, default=False) funded_by_zomg = db.Column(db.Boolean(), nullable=True, default=False) accepted_with_funding = db.Column(db.Boolean(), nullable=True) changes_requested_discussion = db.Column(db.Boolean(), nullable=True) changes_requested_discussion_reason = db.Column(db.String(255), nullable=True) # Payment info target = db.Column(db.String(255), nullable=False) payout_address = db.Column(db.String(255), nullable=False) deadline_duration = db.Column(db.Integer(), nullable=True) contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0")) contribution_bounty = db.Column(db.String(255), nullable=False, default='0', server_default=db.text("'0'")) rfp_opt_in = db.Column(db.Boolean(), nullable=True) contributed = db.column_property() tip_jar_address = db.Column(db.String(255), nullable=True) tip_jar_view_key = db.Column(db.String(255), nullable=True) # Relations team = db.relationship("User", secondary=proposal_team) comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan") updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan") contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan") milestones = db.relationship("Milestone", backref="proposal", order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan") invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan") arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan") followers = db.relationship( "User", secondary=proposal_follower, back_populates="followed_proposals" ) followers_count = column_property( select([func.count(proposal_follower.c.proposal_id)]) .where(proposal_follower.c.proposal_id == id) .correlate_except(proposal_follower) ) likes = db.relationship( "User", secondary=proposal_liker, back_populates="liked_proposals" ) likes_count = column_property( select([func.count(proposal_liker.c.proposal_id)]) .where(proposal_liker.c.proposal_id == id) .correlate_except(proposal_liker) ) live_draft_parent_id = db.Column(db.Integer, ForeignKey('proposal.id')) live_draft = db.relationship("Proposal", uselist=False, backref=db.backref('live_draft_parent', remote_side=[id], uselist=False)) revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True, cascade="all, delete-orphan") def __init__( self, status: str = ProposalStatus.DRAFT, title: str = '', brief: str = '', content: str = default_proposal_content(), stage: str = ProposalStage.PREVIEW, target: str = '0', payout_address: str = '', deadline_duration: int = 5184000, # 60 days category: str = '' ): self.id = gen_random_id(Proposal) self.date_created = datetime.datetime.now() self.status = status self.title = title self.brief = brief self.content = content self.category = category self.target = target self.payout_address = payout_address self.deadline_duration = deadline_duration self.stage = stage self.version = '2' self.funded_by_zomg = True @staticmethod def simple_validate(proposal): # Validate fields to be database save-able. # Stricter validation is done in validate_publishable. stage = proposal.get('stage') if stage and not ProposalStage.includes(stage): raise ValidationException("Proposal stage {} is not a valid stage".format(stage)) def validate_publishable_milestones(self): payout_total = 0.0 for i, milestone in enumerate(self.milestones): if milestone.immediate_payout and i != 0: raise ValidationException("Only the first milestone can have an immediate payout") if len(milestone.title) > 60: raise ValidationException("Milestone title cannot be longer than 60 chars") if len(milestone.content) > 200: raise ValidationException("Milestone content cannot be longer than 200 chars") try: p = float(milestone.payout_percent) if not p.is_integer(): raise ValidationException("Milestone payout percents must be whole numbers, no decimals") if p <= 0 or p > 100: raise ValidationException("Milestone payout percent must be greater than zero") except ValueError: raise ValidationException("Milestone payout percent must be a number") payout_total += p if payout_total != 100.0: raise ValidationException("Payout percentages of milestones must add up to exactly 100%") def validate_publishable(self): self.validate_publishable_milestones() # Require certain fields required_fields = ['title', 'content', 'brief', 'target', 'payout_address'] for field in required_fields: if not hasattr(self, field): raise ValidationException("Proposal must have a {}".format(field)) # Stricter limits on certain fields if len(self.title) > 60: raise ValidationException("Proposal title cannot be longer than 60 characters") if len(self.brief) > 140: raise ValidationException("Brief cannot be longer than 140 characters") if len(self.content) > 250000: raise ValidationException("Content cannot be longer than 250,000 characters") if Decimal(self.target) > PROPOSAL_TARGET_MAX: raise ValidationException("Target cannot be more than {} USD".format(PROPOSAL_TARGET_MAX)) if Decimal(self.target) < 0: raise ValidationException("Target cannot be less than 0") if not self.target.isdigit(): raise ValidationException("Target must be a whole number") if self.deadline_duration > 7776000: raise ValidationException("Deadline duration cannot be more than 90 days") # Validate payout address if not is_z_address_valid(self.payout_address): raise ValidationException("Payout address is not a valid z address") # Validate tip jar address if self.tip_jar_address and not is_z_address_valid(self.tip_jar_address): raise ValidationException("Tip address is not a valid z address") # Then run through regular validation Proposal.simple_validate(vars(self)) def validate_milestone_days(self): for milestone in self.milestones: if milestone.immediate_payout: continue try: p = float(milestone.days_estimated) if not p.is_integer(): raise ValidationException("Milestone days estimated must be whole numbers, no decimals") if p <= 0: raise ValidationException("Milestone days estimated must be greater than zero") if p > 365: raise ValidationException("Milestone days estimated must be less than 365") except ValueError: raise ValidationException("Milestone days estimated must be a number") return @staticmethod def create(**kwargs): Proposal.simple_validate(kwargs) proposal = Proposal( **kwargs ) # arbiter needs proposal.id db.session.add(proposal) db.session.flush() arbiter = ProposalArbiter(proposal_id=proposal.id) db.session.add(arbiter) return proposal @staticmethod def get_by_user(user, statuses=[ProposalStatus.LIVE, ProposalStatus.DISCUSSION]): status_filter = or_(Proposal.status == v for v in statuses) return Proposal.query \ .join(proposal_team) \ .filter(proposal_team.c.user_id == user.id) \ .filter(status_filter) \ .all() @staticmethod def get_by_user_contribution(user): return Proposal.query \ .join(ProposalContribution) \ .filter(ProposalContribution.user_id == user.id) \ .order_by(ProposalContribution.date_created.desc()) \ .all() def update( self, title: str = '', brief: str = '', category: str = '', content: str = '', target: str = '0', payout_address: str = '', tip_jar_address: Optional[str] = None, deadline_duration: int = 5184000 # 60 days ): self.title = title[:255] self.brief = brief[:255] self.category = category self.content = content[:300000] self.target = target[:255] if target != '' else '0' self.payout_address = payout_address[:255] self.tip_jar_address = tip_jar_address[:255] if tip_jar_address is not None else None self.deadline_duration = deadline_duration Proposal.simple_validate(vars(self)) def update_rfp_opt_in(self, opt_in: bool): self.rfp_opt_in = opt_in def create_contribution( self, amount, user_id: int = None, staking: bool = False, private: bool = True, ): contribution = ProposalContribution( proposal_id=self.id, amount=amount, user_id=user_id, staking=staking, private=private ) db.session.add(contribution) db.session.flush() if user_id: task = ContributionExpired(contribution) task.make_task() db.session.commit() return contribution def get_staking_contribution(self, user_id: int): contribution = None remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.amount_staked) # check funding if remaining > 0: # find pending contribution for any user of remaining amount contribution = ProposalContribution.query.filter_by( proposal_id=self.id, status=ProposalStatus.PENDING, staking=True, ).first() if not contribution: contribution = self.create_contribution( user_id=user_id, amount=str(remaining.normalize()), staking=True, ) return contribution def send_admin_email(self, type: str): from grant.user.models import User admins = User.get_admins() for a in admins: send_email(a.email_address, type, { 'user': a, 'proposal': self, 'proposal_url': make_admin_url(f'/proposals/{self.id}'), }) # state: status (DRAFT || REJECTED) -> (PENDING) def submit_for_approval(self): self.validate_publishable() self.validate_milestone_days() allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED] # specific validation if self.status not in allowed_statuses: raise ValidationException(f"Proposal status must be draft or rejected to submit for approval") self.set_pending() def set_pending_when_ready(self): if self.status == ProposalStatus.STAKING and self.is_staked: self.set_pending() # state: status STAKING -> PENDING def set_pending(self): self.send_admin_email('admin_approval') self.status = ProposalStatus.PENDING db.session.add(self) db.session.flush() # approve a proposal moving from PENDING to DISCUSSION status # state: status PENDING -> (DISCUSSION || REJECTED) def approve_discussion(self, is_open_for_discussion, reject_reason=None): if not self.status == ProposalStatus.PENDING: raise ValidationException("Proposal must be pending to open for public discussion") if is_open_for_discussion: self.status = ProposalStatus.DISCUSSION for t in self.team: send_email(t.email_address, 'proposal_approved_discussion', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}') }) else: if not reject_reason: raise ValidationException("Please provide a reason for rejecting the proposal") self.status = ProposalStatus.REJECTED self.reject_reason = reject_reason for t in self.team: send_email(t.email_address, 'proposal_rejected', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), 'admin_note': reject_reason }) # request changes for a proposal with a DISCUSSION status def request_changes_discussion(self, reason): if self.status != ProposalStatus.DISCUSSION: raise ValidationException("Proposal does not have a DISCUSSION status") if not reason: raise ValidationException("Please provide a reason for requesting changes") self.changes_requested_discussion = True self.changes_requested_discussion_reason = reason for t in self.team: send_email(t.email_address, 'proposal_rejected_discussion', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), 'admin_note': reason }) # mark a request changes as resolve for a proposal with a DISCUSSION status def resolve_changes_discussion(self): if self.status != ProposalStatus.DISCUSSION: raise ValidationException("Proposal does not have a DISCUSSION status") if not self.changes_requested_discussion: raise ValidationException("Proposal does not have changes requested") self.changes_requested_discussion = False self.changes_requested_discussion_reason = None # state: status DISCUSSION -> (LIVE) def accept_proposal(self, with_funding): self.validate_publishable() # specific validation if not self.status == ProposalStatus.DISCUSSION: raise ValidationException(f"Proposal must have a DISCUSSION status to approve or reject") self.status = ProposalStatus.LIVE self.date_approved = datetime.datetime.now() self.accepted_with_funding = with_funding # also update date_published and stage since publish() is no longer called by user self.date_published = datetime.datetime.now() self.stage = ProposalStage.WIP if with_funding: self.fully_fund_contibution_bounty() for t in self.team: if with_funding: admin_note = 'Congratulations! Your proposal has been accepted with funding from the Zcash Foundation.' send_email(t.email_address, 'proposal_approved', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), 'admin_note': admin_note }) else: admin_note = ''' We've chosen to list your proposal on ZF Grants, but we won't be funding your proposal at this time. Your proposal can still receive funding from the community in the form of tips if you have set a tip address for your proposal. If you have not yet done so, you can do this from the actions dropdown at your proposal. ''' send_email(t.email_address, 'proposal_approved_without_funding', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), 'admin_note': admin_note }) def update_proposal_with_funding(self): self.accepted_with_funding = True self.fully_fund_contibution_bounty() # state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED def publish(self): self.validate_publishable() # specific validation if not self.status == ProposalStatus.APPROVED: raise ValidationException(f"Proposal status must be approved") self.date_published = datetime.datetime.now() self.status = ProposalStatus.LIVE self.stage = ProposalStage.WIP def set_contribution_bounty(self, bounty: str): # do not allow changes on funded/WIP proposals if self.is_funded: raise ValidationException("Cannot change contribution bounty on fully-funded proposal") # wrap in Decimal so it throws for non-decimal strings self.contribution_bounty = str(Decimal(bounty)) db.session.add(self) db.session.flush() def fully_fund_contibution_bounty(self): self.set_contribution_bounty(self.target) def cancel(self): if self.status != ProposalStatus.LIVE: raise ValidationException("Cannot cancel a proposal until it's live") self.stage = ProposalStage.CANCELED db.session.add(self) db.session.flush() # Send emails to team & contributors for u in self.team: send_email(u.email_address, 'proposal_canceled', { 'proposal': self, 'support_url': make_url('/contact'), }) for u in self.contributors: send_email(u.email_address, 'contribution_proposal_canceled', { 'proposal': self, 'refund_address': u.settings.refund_address, 'account_settings_url': make_url('/profile/settings?tab=account') }) def follow(self, user, is_follow): if is_follow: self.followers.append(user) else: self.followers.remove(user) db.session.flush() def like(self, user, is_liked): if is_liked: self.likes.append(user) else: self.likes.remove(user) db.session.flush() def send_follower_email(self, type: str, email_args={}, url_suffix=""): for u in self.followers: send_email( u.email_address, type, { "user": u, "proposal": self, "proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"), **email_args, }, ) @hybrid_property def contributed(self): contributions = ProposalContribution.query \ .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED, staking=False) \ .all() funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) return str(funded) @hybrid_property def amount_staked(self): contributions = ProposalContribution.query \ .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED, staking=True) \ .all() amount = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) return str(amount) @hybrid_property def funded(self): target = Decimal(self.target) # apply matching multiplier funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching) # apply bounty if self.contribution_bounty: funded = funded + Decimal(self.contribution_bounty) # if funded > target, just set as target if funded > target: return str(target.quantize(Decimal('.001'), rounding=ROUND_DOWN)) return str(funded.quantize(Decimal('.001'), rounding=ROUND_DOWN)) @hybrid_property def is_staked(self): return True @hybrid_property def is_funded(self): return self.is_staked and Decimal(self.funded) >= Decimal(self.target) @hybrid_property def is_failed(self): if not self.status == ProposalStatus.LIVE or not self.date_published: return False if self.stage == ProposalStage.FAILED or self.stage == ProposalStage.CANCELED: return True deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration) passed = deadline < datetime.datetime.now() return passed and not self.is_funded @hybrid_property def current_milestone(self): if self.milestones: for ms in self.milestones: if ms.stage != MilestoneStage.PAID: return ms return self.milestones[-1] # return last one if all PAID return None @hybrid_property def contributors(self): d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED} return d.values() @hybrid_property def authed_follows(self): from grant.utils.auth import get_authed_user authed = get_authed_user() if not authed: return False res = ( db.session.query(proposal_follower) .filter_by(user_id=authed.id, proposal_id=self.id) .count() ) if res: return True return False @hybrid_property def authed_liked(self): from grant.utils.auth import get_authed_user authed = get_authed_user() if not authed: return False res = ( db.session.query(proposal_liker) .filter_by(user_id=authed.id, proposal_id=self.id) .count() ) if res: return True return False @hybrid_property def get_tip_jar_view_key(self): from grant.utils.auth import get_authed_user authed = get_authed_user() if authed not in self.team: return None else: return self.tip_jar_view_key # make a LIVE_DRAFT proposal by copying the relevant fields from an existing proposal @staticmethod def make_live_draft(proposal): live_draft_proposal = Proposal.create( title=proposal.title, brief=proposal.brief, content=proposal.content, target=proposal.target, payout_address=proposal.payout_address, status=ProposalStatus.LIVE_DRAFT ) live_draft_proposal.tip_jar_address = proposal.tip_jar_address live_draft_proposal.changes_requested_discussion_reason = proposal.changes_requested_discussion_reason live_draft_proposal.rfp_opt_in = proposal.rfp_opt_in live_draft_proposal.team = proposal.team db.session.add(live_draft_proposal) Milestone.clone(proposal, live_draft_proposal) return live_draft_proposal # port changes made in LIVE_DRAFT proposal to self and delete the draft def consume_live_draft(self, author): if self.status != ProposalStatus.DISCUSSION: raise ValidationException("Proposal is not open for public review") live_draft = self.live_draft revision_changes = ProposalRevision.calculate_proposal_changes(self, live_draft) if len(revision_changes) == 0: if live_draft.rfp_opt_in == self.rfp_opt_in \ and live_draft.payout_address == self.payout_address \ and live_draft.tip_jar_address == self.tip_jar_address \ and live_draft.team == self.team: raise ValidationException("Live draft does not appear to have any changes") else: # cover special cases where properties not tracked in revisions have changed: self.rfp_opt_in = live_draft.rfp_opt_in self.payout_address = live_draft.payout_address self.tip_jar_address = live_draft.tip_jar_address self.team = live_draft.team self.live_draft = None db.session.add(self) db.session.delete(live_draft) return False # if this is the first revision, create a base revision that's a snapshot of the original proposal if len(self.revisions) == 0: base_draft = self.make_live_draft(self) base_draft.status = ProposalStatus.ARCHIVED base_draft.invites = [] db.session.add(base_draft) base_revision = ProposalRevision( author=author, proposal_id=self.id, proposal_archive_id=base_draft.id, changes=json.dumps([]), revision_index=0 ) self.revisions.append(base_revision) revision_index = len(self.revisions) revision = ProposalRevision( author=author, proposal_id=self.id, proposal_archive_id=live_draft.id, changes=json.dumps(revision_changes), revision_index=revision_index ) self.title = live_draft.title self.brief = live_draft.brief self.content = live_draft.content self.target = live_draft.target self.payout_address = live_draft.payout_address self.tip_jar_address = live_draft.tip_jar_address self.rfp_opt_in = live_draft.rfp_opt_in self.team = live_draft.team self.invites = [] self.live_draft = None self.revisions.append(revision) db.session.add(self) # copy milestones Milestone.clone(live_draft, self) # archive live draft live_draft.status = ProposalStatus.ARCHIVED live_draft.invites = [] db.session.add(live_draft) return True class ProposalSchema(ma.Schema): class Meta: model = Proposal # Fields to expose fields = ( "stage", "status", "date_created", "date_approved", "date_published", "reject_reason", "title", "brief", "proposal_id", "target", "contributed", "is_staked", "is_failed", "funded", "content", "updates", "milestones", "current_milestone", "team", "payout_address", "deadline_duration", "contribution_matching", "contribution_bounty", "invites", "rfp", "rfp_opt_in", "arbiter", "accepted_with_funding", "is_version_two", "authed_follows", "followers_count", "authed_liked", "likes_count", "tip_jar_address", "tip_jar_view_key", "changes_requested_discussion", "changes_requested_discussion_reason", "live_draft_id", "kyc_approved", "funded_by_zomg" ) date_created = ma.Method("get_date_created") date_approved = ma.Method("get_date_approved") date_published = ma.Method("get_date_published") proposal_id = ma.Method("get_proposal_id") is_version_two = ma.Method("get_is_version_two") tip_jar_view_key = ma.Method("get_tip_jar_view_key") live_draft_id = ma.Method("get_live_draft_id") funded_by_zomg = ma.Method("get_funded_by_zomg") updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) milestones = ma.Nested("MilestoneSchema", many=True) current_milestone = ma.Nested("MilestoneSchema") invites = ma.Nested("ProposalTeamInviteSchema", many=True) rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"]) arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"]) def get_funded_by_zomg(self, obj): if obj.funded_by_zomg is None: return False elif obj.funded_by_zomg is False: return False else: return True def get_proposal_id(self, obj): return obj.id def get_date_created(self, obj): return dt_to_unix(obj.date_created) def get_date_approved(self, obj): return dt_to_unix(obj.date_approved) if obj.date_approved else None def get_date_published(self, obj): return dt_to_unix(obj.date_published) if obj.date_published else None def get_is_version_two(self, obj): return True if obj.version == '2' else False def get_tip_jar_view_key(self, obj): return obj.get_tip_jar_view_key def get_live_draft_id(self, obj): return obj.live_draft.id if obj.live_draft else None proposal_schema = ProposalSchema() proposals_schema = ProposalSchema(many=True) user_fields = [ "proposal_id", "status", "title", "brief", "target", "is_staked", "funded", "contribution_matching", "date_created", "date_approved", "date_published", "reject_reason", "changes_requested_discussion_reason", "team", "accepted_with_funding", "is_version_two", "authed_follows", "authed_liked" ] user_proposal_schema = ProposalSchema(only=user_fields) user_proposals_schema = ProposalSchema(many=True, only=user_fields) class ProposalUpdateSchema(ma.Schema): class Meta: model = ProposalUpdate # Fields to expose fields = ( "update_id", "date_created", "proposal_id", "title", "content" ) date_created = ma.Method("get_date_created") proposal_id = ma.Method("get_proposal_id") update_id = ma.Method("get_update_id") def get_update_id(self, obj): return obj.id def get_proposal_id(self, obj): return obj.proposal_id def get_date_created(self, obj): return dt_to_unix(obj.date_created) proposal_update_schema = ProposalUpdateSchema() proposals_update_schema = ProposalUpdateSchema(many=True) class ProposalRevisionSchema(ma.Schema): class Meta: model = ProposalRevision # Fields to expose fields = ( "revision_id", "date_created", "author", "proposal_id", "proposal_archive_id", "changes", "revision_index" ) revision_id = ma.Method("get_revision_id") date_created = ma.Method("get_date_created") changes = ma.Method("get_changes") author = ma.Nested("UserSchema") def get_revision_id(self, obj): return obj.id def get_date_created(self, obj): return dt_to_unix(obj.date_created) def get_changes(self, obj): return json.loads(obj.changes) proposal_revision_schema = ProposalRevisionSchema() proposals_revisions_schema = ProposalRevisionSchema(many=True) class ProposalTeamInviteSchema(ma.Schema): class Meta: model = ProposalTeamInvite fields = ( "id", "date_created", "address", "accepted" ) date_created = ma.Method("get_date_created") def get_date_created(self, obj): return dt_to_unix(obj.date_created) proposal_team_invite_schema = ProposalTeamInviteSchema() proposal_team_invites_schema = ProposalTeamInviteSchema(many=True) class InviteWithProposalSchema(ma.Schema): class Meta: model = ProposalTeamInvite fields = ( "id", "date_created", "address", "accepted", "proposal" ) date_created = ma.Method("get_date_created") proposal = ma.Nested("ProposalSchema") def get_date_created(self, obj): return dt_to_unix(obj.date_created) invite_with_proposal_schema = InviteWithProposalSchema() invites_with_proposal_schema = InviteWithProposalSchema(many=True) class ProposalContributionSchema(ma.Schema): class Meta: model = ProposalContribution # Fields to expose fields = ( "id", "proposal", "user", "status", "tx_id", "amount", "date_created", "addresses", "is_anonymous", "private" ) proposal = ma.Nested("ProposalSchema") user = ma.Nested("UserSchema", default=anonymous_user) date_created = ma.Method("get_date_created") addresses = ma.Method("get_addresses") is_anonymous = ma.Method("get_is_anonymous") def get_date_created(self, obj): return dt_to_unix(obj.date_created) def get_addresses(self, obj): # Omit 'memo' and 'sprout' for now # NOTE: Add back in 'sapling' when ready addresses = blockchain_get('/contribution/addresses', {'contributionId': obj.id}) return { 'transparent': addresses['transparent'], } def get_is_anonymous(self, obj): return not obj.user_id or obj.private @post_dump def stub_anonymous_user(self, data): if 'user' in data and data['user'] is None or data['private']: data['user'] = anonymous_user return data proposal_contribution_schema = ProposalContributionSchema() proposal_contributions_schema = ProposalContributionSchema(many=True) user_proposal_contribution_schema = ProposalContributionSchema(exclude=['user', 'addresses']) user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses']) proposal_proposal_contribution_schema = ProposalContributionSchema(exclude=['proposal', 'addresses']) proposal_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['proposal', 'addresses']) class AdminProposalContributionSchema(ma.Schema): class Meta: model = ProposalContribution # Fields to expose fields = ( "id", "proposal", "user", "status", "tx_id", "amount", "date_created", "addresses", "refund_address", "refund_tx_id", "staking", "private", ) proposal = ma.Nested("ProposalSchema") user = ma.Nested("UserSchema") date_created = ma.Method("get_date_created") addresses = ma.Method("get_addresses") def get_date_created(self, obj): return dt_to_unix(obj.date_created) def get_addresses(self, obj): return blockchain_get('/contribution/addresses', {'contributionId': obj.id}) admin_proposal_contribution_schema = AdminProposalContributionSchema() admin_proposal_contributions_schema = AdminProposalContributionSchema(many=True) class ProposalArbiterSchema(ma.Schema): class Meta: model = ProposalArbiter fields = ( "id", "user", "proposal", "status" ) user = ma.Nested("UserSchema") # , exclude=['arbiter_proposals'] (if UserSchema ever includes it) proposal = ma.Nested("ProposalSchema", exclude=['arbiter']) user_proposal_arbiter_schema = ProposalArbiterSchema(exclude=['user']) user_proposal_arbiters_schema = ProposalArbiterSchema(many=True, exclude=['user'])