2018-09-10 09:55:26 -07:00
|
|
|
import datetime
|
2019-04-16 13:08:13 -07:00
|
|
|
from decimal import Decimal, ROUND_DOWN
|
2019-01-09 11:45:16 -08:00
|
|
|
from functools import reduce
|
2019-03-14 13:29:02 -07:00
|
|
|
|
|
|
|
from marshmallow import post_dump
|
2019-10-23 14:34:10 -07:00
|
|
|
from sqlalchemy import func, or_, select
|
2019-01-30 09:59:15 -08:00
|
|
|
from sqlalchemy.ext.hybrid import hybrid_property
|
2019-10-23 14:34:10 -07:00
|
|
|
from sqlalchemy.orm import column_property
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
from grant.comment.models import Comment
|
2019-03-14 09:46:09 -07:00
|
|
|
from grant.email.send import send_email
|
2018-09-10 09:55:26 -07:00
|
|
|
from grant.extensions import ma, db
|
2019-03-18 11:35:08 -07:00
|
|
|
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
|
2019-03-14 13:29:02 -07:00
|
|
|
from grant.task.jobs import ContributionExpired
|
2019-02-11 14:51:31 -08:00
|
|
|
from grant.utils.enums import (
|
|
|
|
ProposalStatus,
|
|
|
|
ProposalStage,
|
|
|
|
Category,
|
|
|
|
ContributionStatus,
|
|
|
|
ProposalArbiterStatus,
|
|
|
|
MilestoneStage
|
|
|
|
)
|
2019-03-14 13:29:02 -07:00
|
|
|
from grant.utils.exceptions import ValidationException
|
2019-04-16 10:38:14 -07:00
|
|
|
from grant.utils.misc import dt_to_unix, make_url, make_admin_url, gen_random_id
|
2019-03-14 13:29:02 -07:00
|
|
|
from grant.utils.requests import blockchain_get
|
2019-02-23 12:31:07 -08:00
|
|
|
from grant.utils.stubs import anonymous_user
|
2018-11-13 08:07:09 -08:00
|
|
|
|
2018-09-25 13:09:25 -07:00
|
|
|
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'))
|
|
|
|
)
|
|
|
|
|
2019-10-23 14:34:10 -07:00
|
|
|
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")),
|
|
|
|
)
|
2018-11-30 15:52:00 -08:00
|
|
|
|
2019-10-24 10:32:00 -07:00
|
|
|
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")),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2018-11-15 13:51:32 -08:00
|
|
|
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
|
2019-03-18 12:03:01 -07:00
|
|
|
self.address = address[:255]
|
2018-11-15 13:51:32 -08:00
|
|
|
self.accepted = accepted
|
|
|
|
self.date_created = datetime.datetime.now()
|
|
|
|
|
2018-11-16 10:50:47 -08:00
|
|
|
@staticmethod
|
|
|
|
def get_pending_for_user(user):
|
|
|
|
return ProposalTeamInvite.query.filter(
|
|
|
|
ProposalTeamInvite.accepted == None,
|
2018-11-16 11:17:09 -08:00
|
|
|
(func.lower(user.email_address) == func.lower(ProposalTeamInvite.address))
|
2018-11-16 10:50:47 -08:00
|
|
|
).all()
|
|
|
|
|
2018-11-07 09:33:19 -08:00
|
|
|
|
2018-11-02 09:24:28 -07:00
|
|
|
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):
|
2019-03-04 13:47:52 -08:00
|
|
|
self.id = gen_random_id(ProposalUpdate)
|
2018-11-02 09:24:28 -07:00
|
|
|
self.proposal_id = proposal_id
|
2019-03-18 12:03:01 -07:00
|
|
|
self.title = title[:255]
|
2018-11-02 09:24:28 -07:00
|
|
|
self.content = content
|
|
|
|
self.date_created = datetime.datetime.now()
|
|
|
|
|
2018-09-25 13:09:25 -07:00
|
|
|
|
2018-11-21 19:18:22 -08:00
|
|
|
class ProposalContribution(db.Model):
|
|
|
|
__tablename__ = "proposal_contribution"
|
|
|
|
|
2019-01-08 09:44:54 -08:00
|
|
|
id = db.Column(db.Integer(), primary_key=True)
|
2018-11-21 19:18:22 -08:00
|
|
|
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)
|
2019-01-06 14:48:07 -08:00
|
|
|
status = db.Column(db.String(255), nullable=False)
|
|
|
|
amount = db.Column(db.String(255), nullable=False)
|
2019-02-17 08:52:35 -08:00
|
|
|
tx_id = db.Column(db.String(255), nullable=True)
|
|
|
|
refund_tx_id = db.Column(db.String(255), nullable=True)
|
2019-02-21 10:02:29 -08:00
|
|
|
staking = db.Column(db.Boolean, nullable=False)
|
2019-06-11 19:49:14 -07:00
|
|
|
private = db.Column(db.Boolean, nullable=False, default=False, server_default='true')
|
2018-11-21 19:18:22 -08:00
|
|
|
|
2019-01-09 12:48:41 -08:00
|
|
|
user = db.relationship("User")
|
|
|
|
|
2018-11-21 19:18:22 -08:00
|
|
|
def __init__(
|
2019-01-22 21:35:22 -08:00
|
|
|
self,
|
|
|
|
proposal_id: int,
|
2019-02-21 10:02:29 -08:00
|
|
|
amount: str,
|
2019-02-23 12:31:07 -08:00
|
|
|
user_id: int = None,
|
2019-02-21 10:02:29 -08:00
|
|
|
staking: bool = False,
|
2019-06-11 19:49:14 -07:00
|
|
|
private: bool = True,
|
2018-11-21 19:18:22 -08:00
|
|
|
):
|
|
|
|
self.proposal_id = proposal_id
|
|
|
|
self.amount = amount
|
2019-02-23 12:31:07 -08:00
|
|
|
self.user_id = user_id
|
2019-02-21 10:02:29 -08:00
|
|
|
self.staking = staking
|
2019-06-11 19:49:14 -07:00
|
|
|
self.private = private
|
2018-11-21 19:18:22 -08:00
|
|
|
self.date_created = datetime.datetime.now()
|
2019-01-30 09:59:15 -08:00
|
|
|
self.status = ContributionStatus.PENDING
|
2019-01-06 14:48:07 -08:00
|
|
|
|
2019-01-06 22:58:33 -08:00
|
|
|
@staticmethod
|
2019-06-11 19:49:14 -07:00
|
|
|
def get_existing_contribution(user_id: int, proposal_id: int, amount: str, private: bool = False):
|
2019-01-09 11:35:37 -08:00
|
|
|
return ProposalContribution.query.filter_by(
|
|
|
|
user_id=user_id,
|
|
|
|
proposal_id=proposal_id,
|
|
|
|
amount=amount,
|
2019-06-11 19:49:14 -07:00
|
|
|
private=private,
|
2019-01-30 09:59:15 -08:00
|
|
|
status=ContributionStatus.PENDING,
|
2019-01-09 11:35:37 -08:00
|
|
|
).first()
|
2019-01-16 14:26:45 -08:00
|
|
|
|
2019-01-09 11:07:50 -08:00
|
|
|
@staticmethod
|
|
|
|
def get_by_userid(user_id):
|
2019-01-09 11:35:37 -08:00
|
|
|
return ProposalContribution.query \
|
2019-01-09 13:32:51 -08:00
|
|
|
.filter(ProposalContribution.user_id == user_id) \
|
2019-01-30 09:59:15 -08:00
|
|
|
.filter(ProposalContribution.status != ContributionStatus.DELETED) \
|
2019-02-21 10:07:11 -08:00
|
|
|
.filter(ProposalContribution.staking == False) \
|
2019-01-09 11:35:37 -08:00
|
|
|
.order_by(ProposalContribution.date_created.desc()) \
|
|
|
|
.all()
|
2019-01-06 22:58:33 -08:00
|
|
|
|
2019-02-06 11:01:46 -08:00
|
|
|
@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:
|
2019-10-24 10:32:00 -07:00
|
|
|
from grant.user.models import User
|
|
|
|
|
2019-02-06 11:01:46 -08:00
|
|
|
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:
|
2019-02-06 14:24:07 -08:00
|
|
|
contribution.amount = str(Decimal(amount))
|
2019-02-06 11:01:46 -08:00
|
|
|
except:
|
|
|
|
raise ValidationException('Amount must be a number')
|
|
|
|
else:
|
|
|
|
raise ValidationException('Amount is required')
|
|
|
|
|
2019-01-06 14:48:07 -08:00
|
|
|
def confirm(self, tx_id: str, amount: str):
|
2019-01-30 09:59:15 -08:00
|
|
|
self.status = ContributionStatus.CONFIRMED
|
2019-01-06 14:48:07 -08:00
|
|
|
self.tx_id = tx_id
|
|
|
|
self.amount = amount
|
2019-02-25 08:41:00 -08:00
|
|
|
|
2019-02-17 08:52:35 -08:00
|
|
|
@hybrid_property
|
|
|
|
def refund_address(self):
|
2019-02-23 12:31:07 -08:00
|
|
|
return self.user.settings.refund_address if self.user else None
|
2018-11-21 19:18:22 -08:00
|
|
|
|
|
|
|
|
2019-02-09 18:58:40 -08:00
|
|
|
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):
|
2019-03-04 13:47:52 -08:00
|
|
|
self.id = gen_random_id(ProposalArbiter)
|
2019-02-09 18:58:40 -08:00
|
|
|
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')
|
|
|
|
|
|
|
|
|
2018-09-10 09:55:26 -07:00
|
|
|
class Proposal(db.Model):
|
|
|
|
__tablename__ = "proposal"
|
|
|
|
|
|
|
|
id = db.Column(db.Integer(), primary_key=True)
|
|
|
|
date_created = db.Column(db.DateTime)
|
2019-02-01 11:13:30 -08:00
|
|
|
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
|
2019-10-11 12:51:10 -07:00
|
|
|
version = db.Column(db.String(255), nullable=True)
|
2018-09-10 09:55:26 -07:00
|
|
|
|
2018-12-19 13:27:58 -08:00
|
|
|
# Content info
|
2018-11-13 08:07:09 -08:00
|
|
|
status = db.Column(db.String(255), nullable=False)
|
2018-09-10 09:55:26 -07:00
|
|
|
title = db.Column(db.String(255), nullable=False)
|
2018-11-13 08:07:09 -08:00
|
|
|
brief = db.Column(db.String(255), nullable=False)
|
2018-09-10 09:55:26 -07:00
|
|
|
stage = db.Column(db.String(255), nullable=False)
|
|
|
|
content = db.Column(db.Text, nullable=False)
|
|
|
|
category = db.Column(db.String(255), nullable=False)
|
2019-01-09 10:23:08 -08:00
|
|
|
date_approved = db.Column(db.DateTime)
|
|
|
|
date_published = db.Column(db.DateTime)
|
2019-02-25 08:41:00 -08:00
|
|
|
reject_reason = db.Column(db.String())
|
2019-10-16 20:43:20 -07:00
|
|
|
accepted_with_funding = db.Column(db.Boolean(), nullable=True)
|
2018-09-10 09:55:26 -07:00
|
|
|
|
2018-12-19 13:27:58 -08:00
|
|
|
# Payment info
|
2018-11-14 09:27:40 -08:00
|
|
|
target = db.Column(db.String(255), nullable=False)
|
2018-11-13 08:07:09 -08:00
|
|
|
payout_address = db.Column(db.String(255), nullable=False)
|
2019-10-11 12:52:52 -07:00
|
|
|
deadline_duration = db.Column(db.Integer(), nullable=True)
|
2019-01-29 15:50:27 -08:00
|
|
|
contribution_matching = db.Column(db.Float(), nullable=False, default=0, server_default=db.text("0"))
|
2019-03-06 12:25:58 -08:00
|
|
|
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)
|
2019-01-29 15:50:27 -08:00
|
|
|
contributed = db.column_property()
|
2018-11-13 08:07:09 -08:00
|
|
|
|
|
|
|
# Relations
|
2018-09-25 13:09:25 -07:00
|
|
|
team = db.relationship("User", secondary=proposal_team)
|
2018-11-15 08:02:16 -08:00
|
|
|
comments = db.relationship(Comment, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
|
|
|
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
2018-11-26 15:47:24 -08:00
|
|
|
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
2019-02-13 08:54:46 -08:00
|
|
|
milestones = db.relationship("Milestone", backref="proposal",
|
|
|
|
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
|
2018-11-15 13:51:32 -08:00
|
|
|
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
2019-02-09 18:58:40 -08:00
|
|
|
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
2019-10-23 14:34:10 -07:00
|
|
|
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)
|
|
|
|
)
|
2019-10-24 10:32:00 -07:00
|
|
|
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)
|
|
|
|
)
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
2019-01-30 09:59:15 -08:00
|
|
|
status: str = ProposalStatus.DRAFT,
|
2018-11-13 08:07:09 -08:00
|
|
|
title: str = '',
|
|
|
|
brief: str = '',
|
|
|
|
content: str = '',
|
2019-02-13 08:54:46 -08:00
|
|
|
stage: str = ProposalStage.PREVIEW,
|
2018-11-13 08:07:09 -08:00
|
|
|
target: str = '0',
|
|
|
|
payout_address: str = '',
|
2018-11-30 15:52:00 -08:00
|
|
|
deadline_duration: int = 5184000, # 60 days
|
2018-11-13 08:07:09 -08:00
|
|
|
category: str = ''
|
2018-09-10 09:55:26 -07:00
|
|
|
):
|
2019-03-04 13:47:52 -08:00
|
|
|
self.id = gen_random_id(Proposal)
|
2018-11-13 08:07:09 -08:00
|
|
|
self.date_created = datetime.datetime.now()
|
|
|
|
self.status = status
|
2018-09-10 09:55:26 -07:00
|
|
|
self.title = title
|
2018-11-13 08:07:09 -08:00
|
|
|
self.brief = brief
|
2018-09-10 09:55:26 -07:00
|
|
|
self.content = content
|
|
|
|
self.category = category
|
2018-11-13 08:07:09 -08:00
|
|
|
self.target = target
|
|
|
|
self.payout_address = payout_address
|
|
|
|
self.deadline_duration = deadline_duration
|
|
|
|
self.stage = stage
|
2019-10-11 12:51:10 -07:00
|
|
|
self.version = '2'
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
@staticmethod
|
2019-03-14 13:29:02 -07:00
|
|
|
def simple_validate(proposal):
|
2019-03-18 11:35:08 -07:00
|
|
|
# Validate fields to be database save-able.
|
|
|
|
# Stricter validation is done in validate_publishable.
|
2018-11-13 08:07:09 -08:00
|
|
|
stage = proposal.get('stage')
|
|
|
|
category = proposal.get('category')
|
2019-03-14 13:29:02 -07:00
|
|
|
|
2019-01-30 09:59:15 -08:00
|
|
|
if stage and not ProposalStage.includes(stage):
|
|
|
|
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
|
|
|
if category and not Category.includes(category):
|
|
|
|
raise ValidationException("Category {} not a valid category".format(category))
|
2018-09-10 09:55:26 -07:00
|
|
|
|
2019-03-14 13:29:02 -07:00
|
|
|
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:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Milestone title cannot be longer than 60 chars")
|
2019-03-14 13:29:02 -07:00
|
|
|
|
|
|
|
if len(milestone.content) > 200:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Milestone content cannot be longer than 200 chars")
|
2019-03-14 13:29:02 -07:00
|
|
|
|
2019-03-15 16:17:55 -07:00
|
|
|
try:
|
|
|
|
p = float(milestone.payout_percent)
|
2019-03-18 11:53:15 -07:00
|
|
|
if not p.is_integer():
|
|
|
|
raise ValidationException("Milestone payout percents must be whole numbers, no decimals")
|
|
|
|
if p <= 0 or p > 100:
|
2019-03-15 16:17:55 -07:00
|
|
|
raise ValidationException("Milestone payout percent must be greater than zero")
|
|
|
|
except ValueError:
|
|
|
|
raise ValidationException("Milestone payout percent must be a number")
|
|
|
|
|
|
|
|
payout_total += p
|
2019-03-14 13:29:02 -07:00
|
|
|
|
|
|
|
if payout_total != 100.0:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Payout percentages of milestones must add up to exactly 100%")
|
2019-03-14 13:29:02 -07:00
|
|
|
|
2019-01-28 14:46:04 -08:00
|
|
|
def validate_publishable(self):
|
2019-03-14 13:29:02 -07:00
|
|
|
self.validate_publishable_milestones()
|
|
|
|
|
2019-01-09 10:23:08 -08:00
|
|
|
# Require certain fields
|
2019-01-27 18:04:11 -08:00
|
|
|
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
2019-01-27 18:51:05 -08:00
|
|
|
for field in required_fields:
|
|
|
|
if not hasattr(self, field):
|
|
|
|
raise ValidationException("Proposal must have a {}".format(field))
|
2019-01-27 18:04:11 -08:00
|
|
|
|
2019-03-18 11:35:08 -07:00
|
|
|
# Stricter limits on certain fields
|
2019-03-19 10:01:11 -07:00
|
|
|
if len(self.title) > 60:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
2019-03-19 10:01:11 -07:00
|
|
|
if len(self.brief) > 140:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Brief cannot be longer than 140 characters")
|
2019-03-19 10:01:11 -07:00
|
|
|
if len(self.content) > 250000:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Content cannot be longer than 250,000 characters")
|
2019-03-19 10:01:11 -07:00
|
|
|
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
|
2019-03-19 10:01:11 -07:00
|
|
|
if Decimal(self.target) < 0.0001:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Target cannot be less than 0.0001")
|
2019-03-19 10:01:11 -07:00
|
|
|
if self.deadline_duration > 7776000:
|
2019-03-18 11:35:08 -07:00
|
|
|
raise ValidationException("Deadline duration cannot be more than 90 days")
|
|
|
|
|
2019-02-05 12:26:37 -08:00
|
|
|
# Check with node that the address is kosher
|
2019-03-12 10:10:56 -07:00
|
|
|
try:
|
|
|
|
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
|
|
|
except:
|
2019-03-12 18:14:51 -07:00
|
|
|
raise ValidationException(
|
|
|
|
"Could not validate your payout address due to an internal server error, please try again later")
|
2019-02-05 12:26:37 -08:00
|
|
|
if not res['valid']:
|
|
|
|
raise ValidationException("Payout address is not a valid Zcash address")
|
|
|
|
|
2019-01-09 10:23:08 -08:00
|
|
|
# Then run through regular validation
|
2019-03-14 13:29:02 -07:00
|
|
|
Proposal.simple_validate(vars(self))
|
2019-01-09 10:23:08 -08:00
|
|
|
|
2019-07-24 11:29:11 -07:00
|
|
|
# only do this when user submits for approval, there is a chance the dates will
|
|
|
|
# be passed by the time admin approval / user publishing occurs
|
|
|
|
def validate_milestone_dates(self):
|
|
|
|
present = datetime.datetime.today().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
for milestone in self.milestones:
|
|
|
|
if present > milestone.date_estimated:
|
|
|
|
raise ValidationException("Milestone date estimate must be in the future ")
|
|
|
|
|
2018-09-10 09:55:26 -07:00
|
|
|
@staticmethod
|
|
|
|
def create(**kwargs):
|
2019-03-14 13:29:02 -07:00
|
|
|
Proposal.simple_validate(kwargs)
|
2019-02-09 18:58:40 -08:00
|
|
|
proposal = Proposal(
|
2018-09-10 09:55:26 -07:00
|
|
|
**kwargs
|
|
|
|
)
|
2018-11-30 15:52:00 -08:00
|
|
|
|
2019-02-09 18:58:40 -08:00
|
|
|
# arbiter needs proposal.id
|
|
|
|
db.session.add(proposal)
|
2019-02-11 13:59:29 -08:00
|
|
|
db.session.flush()
|
2019-02-09 18:58:40 -08:00
|
|
|
|
|
|
|
arbiter = ProposalArbiter(proposal_id=proposal.id)
|
|
|
|
db.session.add(arbiter)
|
|
|
|
|
|
|
|
return proposal
|
|
|
|
|
2018-11-30 15:52:00 -08:00
|
|
|
@staticmethod
|
2019-01-30 09:59:15 -08:00
|
|
|
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
|
2019-01-09 10:23:08 -08:00
|
|
|
status_filter = or_(Proposal.status == v for v in statuses)
|
2018-11-30 15:52:00 -08:00
|
|
|
return Proposal.query \
|
|
|
|
.join(proposal_team) \
|
|
|
|
.filter(proposal_team.c.user_id == user.id) \
|
2019-01-09 10:23:08 -08:00
|
|
|
.filter(status_filter) \
|
2018-11-30 15:52:00 -08:00
|
|
|
.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()
|
|
|
|
|
2018-11-14 08:43:00 -08:00
|
|
|
def update(
|
2019-01-22 21:35:22 -08:00
|
|
|
self,
|
|
|
|
title: str = '',
|
|
|
|
brief: str = '',
|
|
|
|
category: str = '',
|
|
|
|
content: str = '',
|
|
|
|
target: str = '0',
|
|
|
|
payout_address: str = '',
|
|
|
|
deadline_duration: int = 5184000 # 60 days
|
2018-11-14 08:43:00 -08:00
|
|
|
):
|
2019-03-18 11:35:08 -07:00
|
|
|
self.title = title[:255]
|
|
|
|
self.brief = brief[:255]
|
2018-11-14 08:43:00 -08:00
|
|
|
self.category = category
|
2019-03-18 11:35:08 -07:00
|
|
|
self.content = content[:300000]
|
2019-03-28 10:25:34 -07:00
|
|
|
self.target = target[:255] if target != '' else '0'
|
2019-03-18 11:35:08 -07:00
|
|
|
self.payout_address = payout_address[:255]
|
2018-11-14 08:43:00 -08:00
|
|
|
self.deadline_duration = deadline_duration
|
2019-03-14 13:29:02 -07:00
|
|
|
Proposal.simple_validate(vars(self))
|
2018-11-14 08:43:00 -08:00
|
|
|
|
2019-03-06 12:25:58 -08:00
|
|
|
def update_rfp_opt_in(self, opt_in: bool):
|
|
|
|
self.rfp_opt_in = opt_in
|
|
|
|
|
2019-03-13 16:36:24 -07:00
|
|
|
def create_contribution(
|
|
|
|
self,
|
|
|
|
amount,
|
|
|
|
user_id: int = None,
|
|
|
|
staking: bool = False,
|
2019-06-11 19:49:14 -07:00
|
|
|
private: bool = True,
|
2019-03-13 16:36:24 -07:00
|
|
|
):
|
2019-01-31 14:56:16 -08:00
|
|
|
contribution = ProposalContribution(
|
|
|
|
proposal_id=self.id,
|
2019-02-21 10:02:29 -08:00
|
|
|
amount=amount,
|
2019-02-23 12:31:07 -08:00
|
|
|
user_id=user_id,
|
2019-02-21 10:02:29 -08:00
|
|
|
staking=staking,
|
2019-06-11 19:49:14 -07:00
|
|
|
private=private
|
2019-01-31 14:56:16 -08:00
|
|
|
)
|
|
|
|
db.session.add(contribution)
|
2019-03-06 12:33:09 -08:00
|
|
|
db.session.flush()
|
2019-03-06 13:39:30 -08:00
|
|
|
if user_id:
|
|
|
|
task = ContributionExpired(contribution)
|
|
|
|
task.make_task()
|
|
|
|
db.session.commit()
|
2019-01-31 14:56:16 -08:00
|
|
|
return contribution
|
|
|
|
|
|
|
|
def get_staking_contribution(self, user_id: int):
|
|
|
|
contribution = None
|
2019-03-13 14:39:50 -07:00
|
|
|
remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.amount_staked)
|
2019-01-31 14:56:16 -08:00
|
|
|
# check funding
|
|
|
|
if remaining > 0:
|
2019-02-05 17:45:57 -08:00
|
|
|
# find pending contribution for any user of remaining amount
|
2019-01-31 14:56:16 -08:00
|
|
|
contribution = ProposalContribution.query.filter_by(
|
|
|
|
proposal_id=self.id,
|
2019-02-05 09:01:57 -08:00
|
|
|
status=ProposalStatus.PENDING,
|
2019-03-13 14:39:50 -07:00
|
|
|
staking=True,
|
2019-01-31 14:56:16 -08:00
|
|
|
).first()
|
|
|
|
if not contribution:
|
2019-02-21 10:02:29 -08:00
|
|
|
contribution = self.create_contribution(
|
|
|
|
user_id=user_id,
|
|
|
|
amount=str(remaining.normalize()),
|
|
|
|
staking=True,
|
|
|
|
)
|
2019-01-31 14:56:16 -08:00
|
|
|
|
|
|
|
return contribution
|
|
|
|
|
2019-04-16 10:38:14 -07:00
|
|
|
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}'),
|
|
|
|
})
|
|
|
|
|
2019-02-15 19:35:25 -08:00
|
|
|
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
2019-01-28 14:46:04 -08:00
|
|
|
def submit_for_approval(self):
|
|
|
|
self.validate_publishable()
|
2019-07-24 11:29:11 -07:00
|
|
|
self.validate_milestone_dates()
|
2019-01-30 09:59:15 -08:00
|
|
|
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
2019-01-09 10:23:08 -08:00
|
|
|
# specific validation
|
|
|
|
if self.status not in allowed_statuses:
|
2019-01-30 09:59:15 -08:00
|
|
|
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
|
2019-01-31 14:56:16 -08:00
|
|
|
# set to PENDING if staked, else STAKING
|
|
|
|
if self.is_staked:
|
|
|
|
self.status = ProposalStatus.PENDING
|
|
|
|
else:
|
|
|
|
self.status = ProposalStatus.STAKING
|
2019-01-09 10:23:08 -08:00
|
|
|
|
2019-02-15 19:35:25 -08:00
|
|
|
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):
|
|
|
|
if self.status != ProposalStatus.STAKING:
|
|
|
|
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
|
|
|
|
if not self.is_staked:
|
|
|
|
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
|
2019-04-16 10:38:14 -07:00
|
|
|
self.send_admin_email('admin_approval')
|
2019-02-15 19:35:25 -08:00
|
|
|
self.status = ProposalStatus.PENDING
|
|
|
|
db.session.add(self)
|
|
|
|
db.session.flush()
|
|
|
|
|
2019-10-17 15:25:12 -07:00
|
|
|
# state: status PENDING -> (LIVE || REJECTED)
|
2019-10-16 20:43:20 -07:00
|
|
|
def approve_pending(self, is_approve, with_funding, reject_reason=None):
|
2019-01-09 10:23:08 -08:00
|
|
|
self.validate_publishable()
|
|
|
|
# specific validation
|
2019-01-30 09:59:15 -08:00
|
|
|
if not self.status == ProposalStatus.PENDING:
|
|
|
|
raise ValidationException(f"Proposal must be pending to approve or reject")
|
2019-01-09 10:23:08 -08:00
|
|
|
|
|
|
|
if is_approve:
|
2019-10-17 15:25:12 -07:00
|
|
|
self.status = ProposalStatus.LIVE
|
2019-01-09 10:23:08 -08:00
|
|
|
self.date_approved = datetime.datetime.now()
|
2019-10-16 20:43:20 -07:00
|
|
|
self.accepted_with_funding = with_funding
|
|
|
|
with_or_out = 'without'
|
|
|
|
if with_funding:
|
|
|
|
self.fully_fund_contibution_bounty()
|
|
|
|
with_or_out = 'with'
|
2019-01-16 14:26:45 -08:00
|
|
|
for t in self.team:
|
|
|
|
send_email(t.email_address, 'proposal_approved', {
|
|
|
|
'user': t,
|
|
|
|
'proposal': self,
|
|
|
|
'proposal_url': make_url(f'/proposals/{self.id}'),
|
2019-10-16 20:43:20 -07:00
|
|
|
'admin_note': f'Congratulations! Your proposal has been accepted {with_or_out} funding.'
|
2019-01-16 14:26:45 -08:00
|
|
|
})
|
2019-01-09 10:23:08 -08:00
|
|
|
else:
|
|
|
|
if not reject_reason:
|
|
|
|
raise ValidationException("Please provide a reason for rejecting the proposal")
|
2019-01-30 09:59:15 -08:00
|
|
|
self.status = ProposalStatus.REJECTED
|
2019-01-09 10:23:08 -08:00
|
|
|
self.reject_reason = reject_reason
|
2019-01-16 14:26:45 -08:00
|
|
|
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
|
|
|
|
})
|
2019-01-09 10:23:08 -08:00
|
|
|
|
2019-10-23 14:44:19 -07:00
|
|
|
def update_proposal_with_funding(self):
|
|
|
|
self.accepted_with_funding = True
|
|
|
|
self.fully_fund_contibution_bounty()
|
|
|
|
|
2019-02-15 19:35:25 -08:00
|
|
|
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
2018-11-13 08:07:09 -08:00
|
|
|
def publish(self):
|
2019-01-09 10:23:08 -08:00
|
|
|
self.validate_publishable()
|
|
|
|
# specific validation
|
2019-01-30 09:59:15 -08:00
|
|
|
if not self.status == ProposalStatus.APPROVED:
|
|
|
|
raise ValidationException(f"Proposal status must be approved")
|
2019-01-09 10:23:08 -08:00
|
|
|
self.date_published = datetime.datetime.now()
|
2019-01-30 09:59:15 -08:00
|
|
|
self.status = ProposalStatus.LIVE
|
2019-02-15 19:35:25 -08:00
|
|
|
self.stage = ProposalStage.WIP
|
|
|
|
|
2019-03-06 12:25:58 -08:00
|
|
|
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()
|
|
|
|
|
2019-10-16 20:43:20 -07:00
|
|
|
def fully_fund_contibution_bounty(self):
|
|
|
|
self.set_contribution_bounty(self.target)
|
2019-02-15 19:35:25 -08:00
|
|
|
|
2019-02-23 13:38:06 -08:00
|
|
|
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()
|
2019-03-12 18:12:07 -07:00
|
|
|
|
2019-02-23 13:38:06 -08:00
|
|
|
# Send emails to team & contributors
|
|
|
|
for u in self.team:
|
2019-03-14 09:46:09 -07:00
|
|
|
send_email(u.email_address, 'proposal_canceled', {
|
2019-02-23 13:38:06 -08:00
|
|
|
'proposal': self,
|
|
|
|
'support_url': make_url('/contact'),
|
|
|
|
})
|
2019-03-14 09:46:09 -07:00
|
|
|
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')
|
|
|
|
})
|
2019-02-23 13:38:06 -08:00
|
|
|
|
2019-10-23 14:34:10 -07:00
|
|
|
def follow(self, user, is_follow):
|
|
|
|
if is_follow:
|
|
|
|
self.followers.append(user)
|
|
|
|
else:
|
|
|
|
self.followers.remove(user)
|
|
|
|
db.session.flush()
|
|
|
|
|
2019-10-24 10:32:00 -07:00
|
|
|
def like(self, user, is_liked):
|
|
|
|
if is_liked:
|
|
|
|
self.likes.append(user)
|
|
|
|
else:
|
|
|
|
self.likes.remove(user)
|
|
|
|
db.session.flush()
|
|
|
|
|
2019-10-23 14:34:10 -07:00
|
|
|
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,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2019-01-29 15:50:27 -08:00
|
|
|
@hybrid_property
|
|
|
|
def contributed(self):
|
2019-01-16 14:26:45 -08:00
|
|
|
contributions = ProposalContribution.query \
|
2019-02-21 10:07:11 -08:00
|
|
|
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED, staking=False) \
|
2019-01-16 14:26:45 -08:00
|
|
|
.all()
|
2019-02-05 17:45:57 -08:00
|
|
|
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
|
2019-01-16 14:26:45 -08:00
|
|
|
return str(funded)
|
|
|
|
|
2019-03-13 14:39:50 -07:00
|
|
|
@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)
|
|
|
|
|
2019-01-29 15:50:27 -08:00
|
|
|
@hybrid_property
|
|
|
|
def funded(self):
|
2019-02-05 17:45:57 -08:00
|
|
|
target = Decimal(self.target)
|
2019-01-29 15:50:27 -08:00
|
|
|
# apply matching multiplier
|
2019-02-05 17:45:57 -08:00
|
|
|
funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching)
|
2019-03-06 12:25:58 -08:00
|
|
|
# apply bounty
|
|
|
|
if self.contribution_bounty:
|
|
|
|
funded = funded + Decimal(self.contribution_bounty)
|
2019-01-29 15:50:27 -08:00
|
|
|
# if funded > target, just set as target
|
|
|
|
if funded > target:
|
2019-04-16 13:08:13 -07:00
|
|
|
return str(target.quantize(Decimal('.001'), rounding=ROUND_DOWN))
|
2019-01-29 15:50:27 -08:00
|
|
|
|
2019-04-16 13:08:13 -07:00
|
|
|
return str(funded.quantize(Decimal('.001'), rounding=ROUND_DOWN))
|
2019-01-29 15:50:27 -08:00
|
|
|
|
2019-01-31 14:56:16 -08:00
|
|
|
@hybrid_property
|
|
|
|
def is_staked(self):
|
2019-02-21 10:21:46 -08:00
|
|
|
# Don't use self.contributed since that ignores stake contributions
|
|
|
|
contributions = ProposalContribution.query \
|
|
|
|
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
|
|
|
|
.all()
|
|
|
|
funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0)
|
|
|
|
return Decimal(funded) >= PROPOSAL_STAKING_AMOUNT
|
2019-01-31 14:56:16 -08:00
|
|
|
|
2019-02-11 21:10:09 -08:00
|
|
|
@hybrid_property
|
|
|
|
def is_funded(self):
|
2019-02-21 10:21:46 -08:00
|
|
|
return self.is_staked and Decimal(self.funded) >= Decimal(self.target)
|
2019-02-15 19:35:25 -08:00
|
|
|
|
|
|
|
@hybrid_property
|
|
|
|
def is_failed(self):
|
|
|
|
if not self.status == ProposalStatus.LIVE or not self.date_published:
|
|
|
|
return False
|
2019-02-23 13:38:06 -08:00
|
|
|
if self.stage == ProposalStage.FAILED or self.stage == ProposalStage.CANCELED:
|
|
|
|
return True
|
2019-02-15 19:35:25 -08:00
|
|
|
deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration)
|
|
|
|
passed = deadline < datetime.datetime.now()
|
|
|
|
return passed and not self.is_funded
|
2019-02-11 21:10:09 -08:00
|
|
|
|
2019-02-11 13:08:51 -08:00
|
|
|
@hybrid_property
|
|
|
|
def current_milestone(self):
|
|
|
|
if self.milestones:
|
|
|
|
for ms in self.milestones:
|
|
|
|
if ms.stage != MilestoneStage.PAID:
|
|
|
|
return ms
|
2019-02-13 08:54:46 -08:00
|
|
|
return self.milestones[-1] # return last one if all PAID
|
2019-02-11 13:08:51 -08:00
|
|
|
return None
|
|
|
|
|
2019-03-14 09:46:09 -07:00
|
|
|
@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()
|
|
|
|
|
2019-10-23 14:34:10 -07:00
|
|
|
@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
|
|
|
|
|
2019-10-24 10:32:00 -07:00
|
|
|
@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
|
|
|
|
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
class ProposalSchema(ma.Schema):
|
|
|
|
class Meta:
|
|
|
|
model = Proposal
|
|
|
|
# Fields to expose
|
|
|
|
fields = (
|
|
|
|
"stage",
|
2019-01-09 10:23:08 -08:00
|
|
|
"status",
|
2018-09-10 09:55:26 -07:00
|
|
|
"date_created",
|
2019-01-09 10:23:08 -08:00
|
|
|
"date_approved",
|
|
|
|
"date_published",
|
|
|
|
"reject_reason",
|
2018-09-10 09:55:26 -07:00
|
|
|
"title",
|
2018-11-14 08:43:00 -08:00
|
|
|
"brief",
|
2018-09-10 09:55:26 -07:00
|
|
|
"proposal_id",
|
2018-11-14 13:18:40 -08:00
|
|
|
"target",
|
2019-01-29 15:50:27 -08:00
|
|
|
"contributed",
|
2019-01-31 14:56:16 -08:00
|
|
|
"is_staked",
|
2019-02-15 19:35:25 -08:00
|
|
|
"is_failed",
|
2018-12-27 10:00:04 -08:00
|
|
|
"funded",
|
2018-11-14 08:43:00 -08:00
|
|
|
"content",
|
2018-11-02 09:24:28 -07:00
|
|
|
"updates",
|
2018-09-10 09:55:26 -07:00
|
|
|
"milestones",
|
2019-02-11 13:08:51 -08:00
|
|
|
"current_milestone",
|
2018-09-25 13:09:25 -07:00
|
|
|
"category",
|
2018-11-14 08:43:00 -08:00
|
|
|
"team",
|
|
|
|
"payout_address",
|
|
|
|
"deadline_duration",
|
2019-01-29 15:50:27 -08:00
|
|
|
"contribution_matching",
|
2019-03-06 12:25:58 -08:00
|
|
|
"contribution_bounty",
|
2019-02-01 11:13:30 -08:00
|
|
|
"invites",
|
2019-02-05 12:45:26 -08:00
|
|
|
"rfp",
|
2019-03-06 12:25:58 -08:00
|
|
|
"rfp_opt_in",
|
2019-10-11 12:51:10 -07:00
|
|
|
"arbiter",
|
2019-10-16 20:43:20 -07:00
|
|
|
"accepted_with_funding",
|
2019-10-23 14:34:10 -07:00
|
|
|
"is_version_two",
|
|
|
|
"authed_follows",
|
2019-10-24 10:32:00 -07:00
|
|
|
"followers_count",
|
|
|
|
"authed_liked",
|
|
|
|
"likes_count"
|
2018-09-10 09:55:26 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
date_created = ma.Method("get_date_created")
|
2019-01-09 10:23:08 -08:00
|
|
|
date_approved = ma.Method("get_date_approved")
|
|
|
|
date_published = ma.Method("get_date_published")
|
2018-09-10 09:55:26 -07:00
|
|
|
proposal_id = ma.Method("get_proposal_id")
|
2019-10-11 12:51:10 -07:00
|
|
|
is_version_two = ma.Method("get_is_version_two")
|
2018-09-10 09:55:26 -07:00
|
|
|
|
2018-11-02 09:24:28 -07:00
|
|
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
2018-09-25 13:09:25 -07:00
|
|
|
team = ma.Nested("UserSchema", many=True)
|
2018-09-10 09:55:26 -07:00
|
|
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
2019-02-11 13:08:51 -08:00
|
|
|
current_milestone = ma.Nested("MilestoneSchema")
|
2018-11-15 13:51:32 -08:00
|
|
|
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
2019-02-01 11:13:30 -08:00
|
|
|
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
2019-02-09 18:58:40 -08:00
|
|
|
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
def get_proposal_id(self, obj):
|
2018-11-07 09:33:19 -08:00
|
|
|
return obj.id
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
def get_date_created(self, obj):
|
|
|
|
return dt_to_unix(obj.date_created)
|
|
|
|
|
2019-01-09 10:23:08 -08:00
|
|
|
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
|
|
|
|
|
2019-10-11 12:51:10 -07:00
|
|
|
def get_is_version_two(self, obj):
|
|
|
|
return True if obj.version == '2' else False
|
2018-09-10 09:55:26 -07:00
|
|
|
|
|
|
|
proposal_schema = ProposalSchema()
|
|
|
|
proposals_schema = ProposalSchema(many=True)
|
2019-01-09 13:57:15 -08:00
|
|
|
user_fields = [
|
|
|
|
"proposal_id",
|
|
|
|
"status",
|
|
|
|
"title",
|
|
|
|
"brief",
|
|
|
|
"target",
|
2019-01-31 14:56:16 -08:00
|
|
|
"is_staked",
|
2019-01-09 13:57:15 -08:00
|
|
|
"funded",
|
2019-01-29 15:50:27 -08:00
|
|
|
"contribution_matching",
|
2019-01-09 13:57:15 -08:00
|
|
|
"date_created",
|
|
|
|
"date_approved",
|
|
|
|
"date_published",
|
|
|
|
"reject_reason",
|
|
|
|
"team",
|
2019-10-23 14:34:10 -07:00
|
|
|
"is_version_two",
|
2019-10-24 10:32:00 -07:00
|
|
|
"authed_follows",
|
|
|
|
"authed_liked"
|
2019-01-09 13:57:15 -08:00
|
|
|
]
|
|
|
|
user_proposal_schema = ProposalSchema(only=user_fields)
|
|
|
|
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
|
2018-11-02 09:24:28 -07:00
|
|
|
|
2019-01-16 14:26:45 -08:00
|
|
|
|
2018-11-02 09:24:28 -07:00
|
|
|
class ProposalUpdateSchema(ma.Schema):
|
|
|
|
class Meta:
|
|
|
|
model = ProposalUpdate
|
|
|
|
# Fields to expose
|
|
|
|
fields = (
|
2018-11-07 09:33:19 -08:00
|
|
|
"update_id",
|
2018-11-02 09:24:28 -07:00
|
|
|
"date_created",
|
|
|
|
"proposal_id",
|
|
|
|
"title",
|
|
|
|
"content"
|
|
|
|
)
|
|
|
|
|
|
|
|
date_created = ma.Method("get_date_created")
|
|
|
|
proposal_id = ma.Method("get_proposal_id")
|
2018-11-07 09:33:19 -08:00
|
|
|
update_id = ma.Method("get_update_id")
|
|
|
|
|
|
|
|
def get_update_id(self, obj):
|
|
|
|
return obj.id
|
2018-11-02 09:24:28 -07:00
|
|
|
|
|
|
|
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()
|
2018-11-07 09:33:19 -08:00
|
|
|
proposals_update_schema = ProposalUpdateSchema(many=True)
|
2018-11-15 13:51:32 -08:00
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
2018-11-30 15:52:00 -08:00
|
|
|
|
2018-11-15 13:51:32 -08:00
|
|
|
proposal_team_invite_schema = ProposalTeamInviteSchema()
|
|
|
|
proposal_team_invites_schema = ProposalTeamInviteSchema(many=True)
|
2018-11-16 10:50:47 -08:00
|
|
|
|
2019-01-22 21:35:22 -08:00
|
|
|
|
2018-11-16 10:50:47 -08:00
|
|
|
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)
|
|
|
|
|
2018-11-30 15:52:00 -08:00
|
|
|
|
2018-11-16 10:50:47 -08:00
|
|
|
invite_with_proposal_schema = InviteWithProposalSchema()
|
2018-11-26 17:14:00 -08:00
|
|
|
invites_with_proposal_schema = InviteWithProposalSchema(many=True)
|
|
|
|
|
|
|
|
|
2018-11-21 19:18:22 -08:00
|
|
|
class ProposalContributionSchema(ma.Schema):
|
|
|
|
class Meta:
|
|
|
|
model = ProposalContribution
|
|
|
|
# Fields to expose
|
|
|
|
fields = (
|
|
|
|
"id",
|
2019-01-06 14:48:07 -08:00
|
|
|
"proposal",
|
|
|
|
"user",
|
2019-01-08 09:44:54 -08:00
|
|
|
"status",
|
2018-11-21 19:18:22 -08:00
|
|
|
"tx_id",
|
|
|
|
"amount",
|
|
|
|
"date_created",
|
2019-01-06 22:58:33 -08:00
|
|
|
"addresses",
|
2019-02-25 11:46:47 -08:00
|
|
|
"is_anonymous",
|
2019-06-11 19:49:14 -07:00
|
|
|
"private"
|
2018-11-21 19:18:22 -08:00
|
|
|
)
|
|
|
|
|
2019-01-06 14:48:07 -08:00
|
|
|
proposal = ma.Nested("ProposalSchema")
|
2019-02-23 12:31:07 -08:00
|
|
|
user = ma.Nested("UserSchema", default=anonymous_user)
|
2019-01-06 14:48:07 -08:00
|
|
|
date_created = ma.Method("get_date_created")
|
2019-01-06 22:58:33 -08:00
|
|
|
addresses = ma.Method("get_addresses")
|
2019-02-25 11:46:47 -08:00
|
|
|
is_anonymous = ma.Method("get_is_anonymous")
|
2018-11-21 19:18:22 -08:00
|
|
|
|
|
|
|
def get_date_created(self, obj):
|
|
|
|
return dt_to_unix(obj.date_created)
|
|
|
|
|
2019-01-06 22:58:33 -08:00
|
|
|
def get_addresses(self, obj):
|
2019-02-23 12:19:33 -08:00
|
|
|
# Omit 'memo' and 'sprout' for now
|
2019-03-13 14:39:50 -07:00
|
|
|
# NOTE: Add back in 'sapling' when ready
|
2019-02-23 12:19:33 -08:00
|
|
|
addresses = blockchain_get('/contribution/addresses', {'contributionId': obj.id})
|
|
|
|
return {
|
|
|
|
'transparent': addresses['transparent'],
|
|
|
|
}
|
2019-03-06 12:25:58 -08:00
|
|
|
|
2019-02-25 11:46:47 -08:00
|
|
|
def get_is_anonymous(self, obj):
|
2019-06-11 19:49:14 -07:00
|
|
|
return not obj.user_id or obj.private
|
2019-01-06 22:58:33 -08:00
|
|
|
|
2019-02-23 12:31:07 -08:00
|
|
|
@post_dump
|
|
|
|
def stub_anonymous_user(self, data):
|
2019-06-11 19:49:14 -07:00
|
|
|
if 'user' in data and data['user'] is None or data['private']:
|
2019-02-23 12:31:07 -08:00
|
|
|
data['user'] = anonymous_user
|
|
|
|
return data
|
|
|
|
|
2018-11-30 15:52:00 -08:00
|
|
|
|
2018-11-21 19:18:22 -08:00
|
|
|
proposal_contribution_schema = ProposalContributionSchema()
|
2019-01-09 11:07:50 -08:00
|
|
|
proposal_contributions_schema = ProposalContributionSchema(many=True)
|
2019-01-09 12:48:41 -08:00
|
|
|
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'])
|
2019-02-09 18:58:40 -08:00
|
|
|
|
|
|
|
|
2019-02-17 08:52:35 -08:00
|
|
|
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",
|
2019-03-13 16:36:24 -07:00
|
|
|
"staking",
|
2019-06-11 19:49:14 -07:00
|
|
|
"private",
|
2019-02-17 08:52:35 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
2019-02-09 18:58:40 -08:00
|
|
|
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'])
|