500 lines
16 KiB
Python
500 lines
16 KiB
Python
import datetime
|
|
from typing import List
|
|
from sqlalchemy import func, or_
|
|
from functools import reduce
|
|
|
|
from grant.comment.models import Comment
|
|
from grant.extensions import ma, db
|
|
from grant.utils.misc import dt_to_unix, make_url
|
|
from grant.utils.exceptions import ValidationException
|
|
from grant.utils.requests import blockchain_get
|
|
from grant.email.send import send_email
|
|
|
|
|
|
# Proposal states
|
|
DRAFT = 'DRAFT'
|
|
PENDING = 'PENDING'
|
|
APPROVED = 'APPROVED'
|
|
REJECTED = 'REJECTED'
|
|
LIVE = 'LIVE'
|
|
DELETED = 'DELETED'
|
|
STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED]
|
|
|
|
# Funding stages
|
|
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
|
|
COMPLETED = 'COMPLETED'
|
|
PROPOSAL_STAGES = [FUNDING_REQUIRED, COMPLETED]
|
|
|
|
# Proposal categories
|
|
DAPP = "DAPP"
|
|
DEV_TOOL = "DEV_TOOL"
|
|
CORE_DEV = "CORE_DEV"
|
|
COMMUNITY = "COMMUNITY"
|
|
DOCUMENTATION = "DOCUMENTATION"
|
|
ACCESSIBILITY = "ACCESSIBILITY"
|
|
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
|
|
|
|
# Contribution states
|
|
# PENDING = 'PENDING'
|
|
CONFIRMED = 'CONFIRMED'
|
|
|
|
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'))
|
|
)
|
|
|
|
|
|
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
|
|
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.proposal_id = proposal_id
|
|
self.title = title
|
|
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))
|
|
|
|
user = db.relationship("User")
|
|
|
|
def __init__(
|
|
self,
|
|
proposal_id: int,
|
|
user_id: int,
|
|
amount: str
|
|
):
|
|
self.proposal_id = proposal_id
|
|
self.user_id = user_id
|
|
self.amount = amount
|
|
self.date_created = datetime.datetime.now()
|
|
self.status = PENDING
|
|
|
|
@staticmethod
|
|
def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
|
|
return ProposalContribution.query.filter_by(
|
|
user_id=user_id,
|
|
proposal_id=proposal_id,
|
|
amount=amount,
|
|
status=PENDING,
|
|
).first()
|
|
|
|
@staticmethod
|
|
def get_by_userid(user_id):
|
|
return ProposalContribution.query \
|
|
.filter(ProposalContribution.user_id == user_id) \
|
|
.filter(ProposalContribution.status != DELETED) \
|
|
.order_by(ProposalContribution.date_created.desc()) \
|
|
.all()
|
|
|
|
def confirm(self, tx_id: str, amount: str):
|
|
self.status = CONFIRMED
|
|
self.tx_id = tx_id
|
|
self.amount = amount
|
|
|
|
|
|
class Proposal(db.Model):
|
|
__tablename__ = "proposal"
|
|
|
|
id = db.Column(db.Integer(), primary_key=True)
|
|
date_created = db.Column(db.DateTime)
|
|
|
|
# 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)
|
|
category = db.Column(db.String(255), nullable=False)
|
|
date_approved = db.Column(db.DateTime)
|
|
date_published = db.Column(db.DateTime)
|
|
reject_reason = db.Column(db.String(255))
|
|
|
|
# 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=False)
|
|
|
|
# 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", lazy=True, cascade="all, delete-orphan")
|
|
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
|
|
|
def __init__(
|
|
self,
|
|
status: str = 'DRAFT',
|
|
title: str = '',
|
|
brief: str = '',
|
|
content: str = '',
|
|
stage: str = '',
|
|
target: str = '0',
|
|
payout_address: str = '',
|
|
deadline_duration: int = 5184000, # 60 days
|
|
category: str = ''
|
|
):
|
|
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
|
|
|
|
@staticmethod
|
|
def validate(proposal):
|
|
title = proposal.get('title')
|
|
stage = proposal.get('stage')
|
|
category = proposal.get('category')
|
|
if title and len(title) > 60:
|
|
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
|
if stage and stage not in PROPOSAL_STAGES:
|
|
raise ValidationException("Proposal stage {} not in {}".format(stage, PROPOSAL_STAGES))
|
|
if category and category not in CATEGORIES:
|
|
raise ValidationException("Category {} not in {}".format(category, CATEGORIES))
|
|
|
|
def validate_publishable(self):
|
|
# Require certain fields
|
|
# TODO: I'm an idiot, make this a loop.
|
|
if not self.title:
|
|
raise ValidationException("Proposal must have a title")
|
|
if not self.content:
|
|
raise ValidationException("Proposal must have content")
|
|
if not self.brief:
|
|
raise ValidationException("Proposal must have a brief")
|
|
if not self.category:
|
|
raise ValidationException("Proposal must have a category")
|
|
if not self.target:
|
|
raise ValidationException("Proposal must have a target amount")
|
|
if not self.payout_address:
|
|
raise ValidationException("Proposal must have a payout address")
|
|
# Then run through regular validation
|
|
Proposal.validate(vars(self))
|
|
|
|
@staticmethod
|
|
def create(**kwargs):
|
|
Proposal.validate(kwargs)
|
|
return Proposal(
|
|
**kwargs
|
|
)
|
|
|
|
@staticmethod
|
|
def get_by_user(user, statuses=[LIVE]):
|
|
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 = '',
|
|
deadline_duration: int = 5184000 # 60 days
|
|
):
|
|
self.title = title
|
|
self.brief = brief
|
|
self.category = category
|
|
self.content = content
|
|
self.target = target
|
|
self.payout_address = payout_address
|
|
self.deadline_duration = deadline_duration
|
|
Proposal.validate(vars(self))
|
|
|
|
def submit_for_approval(self):
|
|
self.validate_publishable()
|
|
allowed_statuses = [DRAFT, 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.status = PENDING
|
|
|
|
def approve_pending(self, is_approve, reject_reason=None):
|
|
self.validate_publishable()
|
|
# specific validation
|
|
if not self.status == PENDING:
|
|
raise ValidationException(f"Proposal status must be {PENDING} to approve or reject")
|
|
|
|
if is_approve:
|
|
self.status = APPROVED
|
|
self.date_approved = datetime.datetime.now()
|
|
for t in self.team:
|
|
send_email(t.email_address, 'proposal_approved', {
|
|
'user': t,
|
|
'proposal': self,
|
|
'proposal_url': make_url(f'/proposals/{self.id}'),
|
|
'admin_note': 'Congratulations! Your proposal has been approved.'
|
|
})
|
|
else:
|
|
if not reject_reason:
|
|
raise ValidationException("Please provide a reason for rejecting the proposal")
|
|
self.status = 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
|
|
})
|
|
|
|
def publish(self):
|
|
self.validate_publishable()
|
|
# specific validation
|
|
if not self.status == APPROVED:
|
|
raise ValidationException(f"Proposal status must be {APPROVED}")
|
|
|
|
self.date_published = datetime.datetime.now()
|
|
self.status = LIVE
|
|
|
|
def get_amount_funded(self):
|
|
contributions = ProposalContribution.query \
|
|
.filter_by(proposal_id=self.id, status=CONFIRMED) \
|
|
.all()
|
|
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
|
|
return str(funded)
|
|
|
|
|
|
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",
|
|
"funded",
|
|
"content",
|
|
"comments",
|
|
"updates",
|
|
"milestones",
|
|
"category",
|
|
"team",
|
|
"payout_address",
|
|
"deadline_duration",
|
|
"invites"
|
|
)
|
|
|
|
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")
|
|
funded = ma.Method("get_funded")
|
|
|
|
comments = ma.Nested("CommentSchema", many=True)
|
|
updates = ma.Nested("ProposalUpdateSchema", many=True)
|
|
team = ma.Nested("UserSchema", many=True)
|
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
|
invites = ma.Nested("ProposalTeamInviteSchema", many=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_funded(self, obj):
|
|
return obj.get_amount_funded()
|
|
|
|
|
|
proposal_schema = ProposalSchema()
|
|
proposals_schema = ProposalSchema(many=True)
|
|
user_fields = [
|
|
"proposal_id",
|
|
"status",
|
|
"title",
|
|
"brief",
|
|
"target",
|
|
"funded",
|
|
"date_created",
|
|
"date_approved",
|
|
"date_published",
|
|
"reject_reason",
|
|
"team",
|
|
]
|
|
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 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)
|
|
|
|
# TODO: Find a way to extend ProposalTeamInviteSchema instead of redefining
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
proposal = ma.Nested("ProposalSchema")
|
|
user = ma.Nested("UserSchema", exclude=["email_address"])
|
|
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})
|
|
|
|
|
|
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'])
|