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

1396 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 youre 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 youre 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 teams background and experience. Demonstrate that you have the skills and expertise necessary for the project that youre 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'])