Milestone Estimate in Days (#59)
* init admin milestone estimate in days * init frontend milestone estimate in days * init backend milestone estimate in days * fix bugs * fix bugs * fix tests * add tests * add milestone_deadline email to examples * fix type errors * fix tests * remove comment * temp prep for merge * restore changes, update tests * add db migration * add tests and comments for set_v2_date_estimates
This commit is contained in:
parent
8ced452411
commit
ed6d98ceec
|
@ -130,6 +130,11 @@ export default [
|
|||
title: 'Milestone paid',
|
||||
description: 'Sent when milestone is paid',
|
||||
},
|
||||
{
|
||||
id: 'milestone_deadline',
|
||||
title: 'Milestone deadline',
|
||||
description: 'Sent when the estimated deadline for milestone has been reached',
|
||||
},
|
||||
{
|
||||
id: 'admin_approval',
|
||||
title: 'Admin Approval',
|
||||
|
|
|
@ -391,9 +391,19 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
extra={`${milestone.payoutPercent}% Payout`}
|
||||
key={i}
|
||||
>
|
||||
{p.isVersionTwo && (
|
||||
<p>
|
||||
<b>Estimated Days to Complete:</b>{' '}
|
||||
{milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<b>Estimated Date:</b> {formatDateSeconds(milestone.dateEstimated)}{' '}
|
||||
<b>Estimated Date:</b>{' '}
|
||||
{milestone.dateEstimated
|
||||
? formatDateSeconds(milestone.dateEstimated)
|
||||
: 'N/A'}{' '}
|
||||
</p>
|
||||
|
||||
<p>{milestone.content}</p>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
@ -17,7 +17,8 @@ export interface Milestone {
|
|||
index: number;
|
||||
content: string;
|
||||
dateCreated: number;
|
||||
dateEstimated: number;
|
||||
dateEstimated?: number;
|
||||
daysEstimated?: string;
|
||||
dateRequested: number;
|
||||
dateAccepted: number;
|
||||
dateRejected: number;
|
||||
|
|
|
@ -149,6 +149,10 @@ example_email_args = {
|
|||
'proposal': proposal,
|
||||
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||
},
|
||||
'milestone_deadline': {
|
||||
'proposal': proposal,
|
||||
'proposal_milestones_url': 'http://zfnd.org/proposals/999-my-proposal?tab=milestones',
|
||||
},
|
||||
'milestone_reject': {
|
||||
'proposal': proposal,
|
||||
'admin_note': 'We noticed that the tests were failing for the features outlined in this milestone. Please address these issues.',
|
||||
|
|
|
@ -363,6 +363,10 @@ def approve_proposal(id, is_accepted, with_funding, reject_reason=None):
|
|||
proposal = Proposal.query.filter_by(id=id).first()
|
||||
if proposal:
|
||||
proposal.approve_pending(is_accepted, with_funding, reject_reason)
|
||||
|
||||
if is_accepted and with_funding:
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
||||
|
@ -383,6 +387,7 @@ def change_proposal_to_accepted_with_funding(id):
|
|||
return {"message": "Only live or approved proposals can be modified by this endpoint"}, 404
|
||||
|
||||
proposal.update_proposal_with_funding()
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -415,12 +420,14 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
|||
return {"message": "Proposal is not fully funded"}, 400
|
||||
for ms in proposal.milestones:
|
||||
if ms.id == int(mid):
|
||||
is_final_milestone = False
|
||||
ms.mark_paid(tx_id)
|
||||
db.session.add(ms)
|
||||
db.session.flush()
|
||||
# check if this is the final ms, and update proposal.stage
|
||||
num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0)
|
||||
if num_paid == len(proposal.milestones):
|
||||
is_final_milestone = True
|
||||
proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED
|
||||
db.session.add(proposal)
|
||||
db.session.flush()
|
||||
|
@ -442,6 +449,11 @@ def paid_milestone_payout_request(id, mid, tx_id):
|
|||
email_args={"milestone": ms},
|
||||
url_suffix="?tab=milestones",
|
||||
)
|
||||
|
||||
if not is_final_milestone:
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
db.session.commit()
|
||||
|
||||
return proposal_schema.dump(proposal), 200
|
||||
|
||||
return {"message": "No milestone matching id"}, 404
|
||||
|
|
|
@ -245,6 +245,17 @@ def milestone_request(email_args):
|
|||
}
|
||||
|
||||
|
||||
def milestone_deadline(email_args):
|
||||
p = email_args['proposal']
|
||||
ms = p.current_milestone
|
||||
return {
|
||||
'subject': f'Milestone deadline reached for {p.title} - {ms.title}',
|
||||
'title': f'Milestone deadline reached',
|
||||
'preview': f'The estimated deadline for milestone {ms.title} has been reached.',
|
||||
'subscription': EmailSubscription.ARBITER,
|
||||
}
|
||||
|
||||
|
||||
def milestone_reject(email_args):
|
||||
p = email_args['proposal']
|
||||
ms = p.current_milestone
|
||||
|
@ -351,6 +362,7 @@ get_info_lookup = {
|
|||
'comment_reply': comment_reply,
|
||||
'proposal_arbiter': proposal_arbiter,
|
||||
'milestone_request': milestone_request,
|
||||
'milestone_deadline': milestone_deadline,
|
||||
'milestone_reject': milestone_reject,
|
||||
'milestone_accept': milestone_accept,
|
||||
'milestone_paid': milestone_paid,
|
||||
|
|
|
@ -5,6 +5,7 @@ from grant.utils.enums import MilestoneStage
|
|||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.ma_fields import UnixDate
|
||||
from grant.utils.misc import gen_random_id
|
||||
from grant.task.jobs import MilestoneDeadline
|
||||
|
||||
|
||||
class MilestoneException(Exception):
|
||||
|
@ -22,7 +23,8 @@ class Milestone(db.Model):
|
|||
content = db.Column(db.Text, nullable=False)
|
||||
payout_percent = db.Column(db.String(255), nullable=False)
|
||||
immediate_payout = db.Column(db.Boolean)
|
||||
date_estimated = db.Column(db.DateTime, nullable=False)
|
||||
date_estimated = db.Column(db.DateTime, nullable=True)
|
||||
days_estimated = db.Column(db.String(255), nullable=True)
|
||||
|
||||
stage = db.Column(db.String(255), nullable=False)
|
||||
|
||||
|
@ -46,7 +48,7 @@ class Milestone(db.Model):
|
|||
index: int,
|
||||
title: str,
|
||||
content: str,
|
||||
date_estimated: datetime,
|
||||
days_estimated: str,
|
||||
payout_percent: str,
|
||||
immediate_payout: bool,
|
||||
stage: str = MilestoneStage.IDLE,
|
||||
|
@ -56,13 +58,14 @@ class Milestone(db.Model):
|
|||
self.title = title[:255]
|
||||
self.content = content[:255]
|
||||
self.stage = stage
|
||||
self.date_estimated = date_estimated
|
||||
self.days_estimated = days_estimated[:255]
|
||||
self.payout_percent = payout_percent[:255]
|
||||
self.immediate_payout = immediate_payout
|
||||
self.proposal_id = proposal_id
|
||||
self.date_created = datetime.datetime.now()
|
||||
self.index = index
|
||||
|
||||
|
||||
@staticmethod
|
||||
def make(milestones_data, proposal):
|
||||
if milestones_data:
|
||||
|
@ -72,7 +75,7 @@ class Milestone(db.Model):
|
|||
m = Milestone(
|
||||
title=milestone_data["title"][:255],
|
||||
content=milestone_data["content"][:255],
|
||||
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
|
||||
days_estimated=str(milestone_data["days_estimated"])[:255],
|
||||
payout_percent=str(milestone_data["payout_percent"])[:255],
|
||||
immediate_payout=milestone_data["immediate_payout"],
|
||||
proposal_id=proposal.id,
|
||||
|
@ -80,6 +83,55 @@ class Milestone(db.Model):
|
|||
)
|
||||
db.session.add(m)
|
||||
|
||||
# The purpose of this method is to set the `date_estimated` property on all milestones in a proposal. This works
|
||||
# by figuring out a starting point for each milestone (the `base_date` below) and adding `days_estimated`.
|
||||
#
|
||||
# As proposal creators now estimate their milestones in days (instead of picking months), this method allows us to
|
||||
# keep `date_estimated` in sync throughout the lifecycle of a proposal. For example, if a user misses their
|
||||
# first milestone deadline by a week, this method would take the actual completion date of that milestone and
|
||||
# adjust the `date_estimated` of the remaining milestones accordingly.
|
||||
#
|
||||
@staticmethod
|
||||
def set_v2_date_estimates(proposal):
|
||||
if not proposal.date_approved:
|
||||
raise MilestoneException(f'Cannot estimate milestone dates because proposal has no date_approved set')
|
||||
|
||||
# The milestone being actively worked on
|
||||
current_milestone = proposal.current_milestone
|
||||
|
||||
if current_milestone.stage == MilestoneStage.PAID:
|
||||
raise MilestoneException(f'Cannot estimate milestone dates because they are all completed')
|
||||
|
||||
# The starting point for `date_estimated` calculation for each uncompleted milestone
|
||||
# We add `days_estimated` to `base_date` to calculate `date_estimated`
|
||||
base_date = None
|
||||
|
||||
for index, milestone in enumerate(proposal.milestones):
|
||||
if index == 0:
|
||||
# If it's the first milestone, use the proposal approval date as a `base_date`
|
||||
base_date = proposal.date_approved
|
||||
|
||||
if milestone.date_paid:
|
||||
# If milestone has been paid, set `base_date` for the next milestone and noop out
|
||||
base_date = milestone.date_paid
|
||||
continue
|
||||
|
||||
days_estimated = milestone.days_estimated if not milestone.immediate_payout else "0"
|
||||
date_estimated = base_date + datetime.timedelta(days=int(days_estimated))
|
||||
milestone.date_estimated = date_estimated
|
||||
|
||||
# Set the `base_date` for the next milestone using the estimate completion date of the current milestone
|
||||
base_date = date_estimated
|
||||
db.session.add(milestone)
|
||||
|
||||
# Skip task creation if current milestone has an immediate payout
|
||||
if current_milestone.immediate_payout:
|
||||
return
|
||||
|
||||
# Create MilestoneDeadline task for the current milestone so arbiters will be alerted if the deadline is missed
|
||||
task = MilestoneDeadline(proposal, current_milestone)
|
||||
task.make_task()
|
||||
|
||||
def request_payout(self, user_id: int):
|
||||
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
||||
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
||||
|
@ -140,6 +192,7 @@ class MilestoneSchema(ma.Schema):
|
|||
"date_rejected",
|
||||
"date_accepted",
|
||||
"date_paid",
|
||||
"days_estimated"
|
||||
)
|
||||
|
||||
date_created = UnixDate(attribute='date_created')
|
||||
|
|
|
@ -381,13 +381,24 @@ class Proposal(db.Model):
|
|||
# Then run through regular validation
|
||||
Proposal.simple_validate(vars(self))
|
||||
|
||||
# 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)
|
||||
|
||||
def validate_milestone_days(self):
|
||||
for milestone in self.milestones:
|
||||
if present > milestone.date_estimated:
|
||||
raise ValidationException("Milestone date estimate must be in the future ")
|
||||
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):
|
||||
|
@ -499,7 +510,7 @@ class Proposal(db.Model):
|
|||
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
||||
def submit_for_approval(self):
|
||||
self.validate_publishable()
|
||||
self.validate_milestone_dates()
|
||||
self.validate_milestone_days()
|
||||
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
||||
# specific validation
|
||||
if self.status not in allowed_statuses:
|
||||
|
@ -536,6 +547,11 @@ class Proposal(db.Model):
|
|||
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
|
||||
|
||||
with_or_out = 'without'
|
||||
if with_funding:
|
||||
self.fully_fund_contibution_bounty()
|
||||
|
|
|
@ -170,9 +170,69 @@ class PruneDraft:
|
|||
db.session.commit()
|
||||
|
||||
|
||||
class MilestoneDeadline:
|
||||
JOB_TYPE = 5
|
||||
|
||||
def __init__(self, proposal, milestone):
|
||||
self.proposal = proposal
|
||||
self.milestone = milestone
|
||||
|
||||
def blobify(self):
|
||||
from grant.proposal.models import ProposalUpdate
|
||||
|
||||
update_count = len(ProposalUpdate.query.filter_by(proposal_id=self.proposal.id).all())
|
||||
return {
|
||||
"proposal_id": self.proposal.id,
|
||||
"milestone_id": self.milestone.id,
|
||||
"update_count": update_count
|
||||
}
|
||||
|
||||
def make_task(self):
|
||||
from .models import Task
|
||||
task = Task(
|
||||
job_type=self.JOB_TYPE,
|
||||
blob=self.blobify(),
|
||||
execute_after=self.milestone.date_estimated,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def process_task(task):
|
||||
from grant.proposal.models import Proposal, ProposalUpdate
|
||||
from grant.milestone.models import Milestone
|
||||
|
||||
proposal_id = task.blob["proposal_id"]
|
||||
milestone_id = task.blob["milestone_id"]
|
||||
update_count = task.blob["update_count"]
|
||||
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
milestone = Milestone.query.filter_by(id=milestone_id).first()
|
||||
current_update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all())
|
||||
|
||||
# if proposal was deleted or cancelled, noop out
|
||||
if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED:
|
||||
return
|
||||
|
||||
# if milestone was deleted, noop out
|
||||
if not milestone:
|
||||
return
|
||||
|
||||
# if milestone payout has been requested or an update has been posted, noop out
|
||||
if current_update_count > update_count or milestone.date_requested:
|
||||
return
|
||||
|
||||
# send email to arbiter notifying milestone deadline has been missed
|
||||
send_email(proposal.arbiter.user.email_address, 'milestone_deadline', {
|
||||
'proposal': proposal,
|
||||
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
|
||||
})
|
||||
|
||||
|
||||
JOBS = {
|
||||
1: ProposalReminder.process_task,
|
||||
2: ProposalDeadline.process_task,
|
||||
3: ContributionExpired.process_task,
|
||||
4: PruneDraft.process_task
|
||||
4: PruneDraft.process_task,
|
||||
5: MilestoneDeadline.process_task
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The estimated deadline has been reached for proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="border-radius: 3px;"
|
||||
bgcolor="{{ UI.PRIMARY }}"
|
||||
>
|
||||
<a
|
||||
href="{{ args.proposal_milestones_url }}"
|
||||
target="_blank"
|
||||
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
|
||||
UI.PRIMARY
|
||||
}}; display: inline-block;"
|
||||
>
|
||||
View the milestone
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
|
@ -0,0 +1,3 @@
|
|||
The estimated deadline has been reached for proposal milestone "{{ args.proposal.title }} - {{args.proposal.current_milestone.title }}".
|
||||
|
||||
View the milestone: {{ args.proposal_milestones_url }}
|
|
@ -0,0 +1,34 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 4ca14e6e8976
|
||||
Revises: 7fea7427e9d6
|
||||
Create Date: 2019-11-06 12:58:45.503087
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4ca14e6e8976'
|
||||
down_revision = '7fea7427e9d6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('milestone', sa.Column('days_estimated', sa.String(length=255), nullable=True))
|
||||
op.alter_column('milestone', 'date_estimated',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('milestone', 'date_estimated',
|
||||
existing_type=postgresql.TIMESTAMP(),
|
||||
nullable=False)
|
||||
op.drop_column('milestone', 'days_estimated')
|
||||
# ### end Alembic commands ###
|
|
@ -260,6 +260,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
self.assertEqual(resp.json["acceptedWithFunding"], True)
|
||||
self.assertEqual(resp.json["target"], resp.json["contributionBounty"])
|
||||
|
||||
# milestones should have estimated dates
|
||||
for milestone in resp.json["milestones"]:
|
||||
self.assertIsNotNone(milestone["dateEstimated"])
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_accept_proposal_without_funding(self, mock_get):
|
||||
self.login_admin()
|
||||
|
@ -278,6 +282,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
self.assertEqual(resp.json["acceptedWithFunding"], False)
|
||||
self.assertEqual(resp.json["contributionBounty"], "0")
|
||||
|
||||
# milestones should not have estimated dates
|
||||
for milestone in resp.json["milestones"]:
|
||||
self.assertIsNone(milestone["dateEstimated"])
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_change_proposal_to_accepted_with_funding(self, mock_get):
|
||||
self.login_admin()
|
||||
|
@ -300,6 +308,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
self.assert200(resp)
|
||||
self.assertEqual(resp.json["acceptedWithFunding"], True)
|
||||
|
||||
# milestones should have estimated dates
|
||||
for milestone in resp.json["milestones"]:
|
||||
self.assertIsNotNone(milestone["dateEstimated"])
|
||||
|
||||
# should fail if proposal is already accepted with funding
|
||||
resp = self.app.put(
|
||||
f"/api/v1/admin/proposals/{self.proposal.id}/accept/fund"
|
||||
|
|
|
@ -138,14 +138,14 @@ class BaseProposalCreatorConfig(BaseUserConfig):
|
|||
{
|
||||
"title": "Milestone 1",
|
||||
"content": "Content 1",
|
||||
"date_estimated": (datetime.now() + timedelta(days=364)).timestamp(), # random unix time in the future
|
||||
"days_estimated": "30",
|
||||
"payout_percent": 50,
|
||||
"immediate_payout": True
|
||||
},
|
||||
{
|
||||
"title": "Milestone 2",
|
||||
"content": "Content 2",
|
||||
"date_estimated": (datetime.now() + timedelta(days=365)).timestamp(), # random unix time in the future
|
||||
"days_estimated": "20",
|
||||
"payout_percent": 50,
|
||||
"immediate_payout": False
|
||||
}
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
import json
|
||||
import datetime
|
||||
from mock import patch
|
||||
from grant.proposal.models import Proposal, db, proposal_schema
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.task.models import Task
|
||||
from grant.task.jobs import MilestoneDeadline
|
||||
from grant.utils.enums import ProposalStatus, Category, MilestoneStage
|
||||
from ..config import BaseUserConfig
|
||||
from ..test_data import test_team, mock_blockchain_api_requests
|
||||
|
||||
|
||||
test_milestones = [
|
||||
{
|
||||
"title": "first milestone",
|
||||
"content": "content",
|
||||
"daysEstimated": "30",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
},
|
||||
{
|
||||
"title": "second milestone",
|
||||
"content": "content",
|
||||
"daysEstimated": "10",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
},
|
||||
{
|
||||
"title": "third milestone",
|
||||
"content": "content",
|
||||
"daysEstimated": "20",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
},
|
||||
{
|
||||
"title": "fourth milestone",
|
||||
"content": "content",
|
||||
"daysEstimated": "30",
|
||||
"payoutPercent": "25",
|
||||
"immediatePayout": False
|
||||
}
|
||||
]
|
||||
|
||||
test_proposal = {
|
||||
"team": test_team,
|
||||
"content": "## My Proposal",
|
||||
"title": "Give Me Money",
|
||||
"brief": "$$$",
|
||||
"milestones": test_milestones,
|
||||
"category": Category.ACCESSIBILITY,
|
||||
"target": "123.456",
|
||||
"payoutAddress": "123",
|
||||
}
|
||||
|
||||
|
||||
class TestMilestoneMethods(BaseUserConfig):
|
||||
|
||||
def init_proposal(self, proposal_data):
|
||||
self.login_default_user()
|
||||
resp = self.app.post(
|
||||
"/api/v1/proposals/drafts"
|
||||
)
|
||||
self.assertStatus(resp, 201)
|
||||
proposal_id = resp.json["proposalId"]
|
||||
|
||||
resp = self.app.put(
|
||||
f"/api/v1/proposals/{proposal_id}",
|
||||
data=json.dumps(proposal_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# accept with funding
|
||||
proposal.approve_pending(True, True)
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
print(proposal_schema.dump(proposal))
|
||||
return proposal
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_set_v2_date_estimates(self, mock_get):
|
||||
proposal_data = test_proposal.copy()
|
||||
proposal = self.init_proposal(proposal_data)
|
||||
total_days_estimated = 0
|
||||
|
||||
# make sure date_estimated has been populated on all milestones
|
||||
for milestone in proposal.milestones:
|
||||
total_days_estimated += int(milestone.days_estimated)
|
||||
self.assertIsNotNone(milestone.date_estimated)
|
||||
|
||||
# check the proposal `date_approved` has been used for first milestone calculation
|
||||
first_milestone = proposal.milestones[0]
|
||||
expected_base_date = proposal.date_approved
|
||||
expected_days_estimated = first_milestone.days_estimated
|
||||
expected_date_estimated = expected_base_date + datetime.timedelta(days=int(expected_days_estimated))
|
||||
|
||||
self.assertEqual(first_milestone.date_estimated, expected_date_estimated)
|
||||
|
||||
# check that the `date_estimated` of the final milestone has been calculated with the cumulative
|
||||
# `days_estimated` of the previous milestones
|
||||
last_milestone = proposal.milestones[-1]
|
||||
expected_date_estimated = expected_base_date + datetime.timedelta(days=int(total_days_estimated))
|
||||
self.assertEqual(last_milestone.date_estimated, expected_date_estimated)
|
||||
|
||||
# check to see a task has been created
|
||||
tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all()
|
||||
self.assertEqual(len(tasks), 1)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_set_v2_date_estimates_immediate_payout(self, mock_get):
|
||||
proposal_data = test_proposal.copy()
|
||||
proposal_data["milestones"][0]["immediate_payout"] = True
|
||||
|
||||
self.init_proposal(proposal_data)
|
||||
tasks = Task.query.filter_by(job_type=MilestoneDeadline.JOB_TYPE).all()
|
||||
|
||||
# ensure MilestoneDeadline task not created when immediate payout is set
|
||||
self.assertEqual(len(tasks), 0)
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_set_v2_date_estimates_deadline_recalculation(self, mock_get):
|
||||
proposal_data = test_proposal.copy()
|
||||
proposal = self.init_proposal(proposal_data)
|
||||
|
||||
first_ms = proposal.milestones[0]
|
||||
second_ms = proposal.milestones[1]
|
||||
|
||||
first_ms.stage = MilestoneStage.PAID
|
||||
first_ms.date_paid = datetime.datetime.now()
|
||||
|
||||
expected_base_date = datetime.datetime.now() + datetime.timedelta(days=42)
|
||||
second_ms.stage = MilestoneStage.PAID
|
||||
second_ms.date_paid = expected_base_date
|
||||
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
Milestone.set_v2_date_estimates(proposal)
|
||||
|
||||
proposal = Proposal.query.get(proposal.id)
|
||||
third_ms = proposal.milestones[2]
|
||||
expected_date_estimated = expected_base_date + datetime.timedelta(days=int(third_ms.days_estimated))
|
||||
|
||||
# ensure `date_estimated` was recalculated as expected
|
||||
self.assertEqual(third_ms.date_estimated, expected_date_estimated)
|
|
@ -1,17 +1,71 @@
|
|||
import json
|
||||
|
||||
from grant.utils import totp_2fa
|
||||
from grant.task.jobs import MilestoneDeadline
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from grant.task.models import Task, db
|
||||
from grant.task.jobs import PruneDraft
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.proposal.models import Proposal
|
||||
from grant.utils.enums import ProposalStatus, Category
|
||||
from grant.proposal.models import Proposal, ProposalUpdate
|
||||
from grant.utils.enums import ProposalStatus, ProposalStage, Category
|
||||
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
from ..test_data import mock_blockchain_api_requests
|
||||
|
||||
from mock import patch, Mock
|
||||
|
||||
from ..config import BaseProposalCreatorConfig
|
||||
test_update = {
|
||||
"title": "Update Title",
|
||||
"content": "Update content."
|
||||
}
|
||||
|
||||
milestones_data = [
|
||||
{
|
||||
"title": "All the money straightaway",
|
||||
"content": "cool stuff with it",
|
||||
"days_estimated": 30,
|
||||
"payout_percent": "100",
|
||||
"immediate_payout": False
|
||||
}
|
||||
]
|
||||
|
||||
class TestTaskAPI(BaseProposalCreatorConfig):
|
||||
def p(self, path, data):
|
||||
return self.app.post(path, data=json.dumps(data), content_type="application/json")
|
||||
|
||||
def login_admin(self):
|
||||
# set admin
|
||||
self.user.set_admin(True)
|
||||
db.session.commit()
|
||||
|
||||
# login
|
||||
r = self.p("/api/v1/admin/login", {
|
||||
"username": self.user.email_address,
|
||||
"password": self.user_password
|
||||
})
|
||||
self.assert200(r)
|
||||
|
||||
# 2fa on the natch
|
||||
r = self.app.get("/api/v1/admin/2fa")
|
||||
self.assert200(r)
|
||||
|
||||
# ... init
|
||||
r = self.app.get("/api/v1/admin/2fa/init")
|
||||
self.assert200(r)
|
||||
|
||||
codes = r.json['backupCodes']
|
||||
secret = r.json['totpSecret']
|
||||
uri = r.json['totpUri']
|
||||
|
||||
# ... enable/verify
|
||||
r = self.p("/api/v1/admin/2fa/enable", {
|
||||
"backupCodes": codes,
|
||||
"totpSecret": secret,
|
||||
"verifyCode": totp_2fa.current_totp(secret)
|
||||
})
|
||||
self.assert200(r)
|
||||
return r
|
||||
|
||||
def test_proposal_reminder_task_is_created(self):
|
||||
tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all()
|
||||
|
@ -89,15 +143,6 @@ class TestTaskAPI(BaseProposalCreatorConfig):
|
|||
p.payout_address = 'address'
|
||||
|
||||
def milestones(p):
|
||||
milestones_data = [
|
||||
{
|
||||
"title": "All the money straightaway",
|
||||
"content": "cool stuff with it",
|
||||
"date_estimated": 1549505307,
|
||||
"payout_percent": "100",
|
||||
"immediate_payout": False
|
||||
}
|
||||
]
|
||||
Milestone.make(milestones_data, p)
|
||||
|
||||
modifiers = [
|
||||
|
@ -133,3 +178,166 @@ class TestTaskAPI(BaseProposalCreatorConfig):
|
|||
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
self.assertIsNotNone(proposal)
|
||||
|
||||
@patch('grant.task.jobs.send_email')
|
||||
@patch('grant.task.views.datetime')
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_milestone_deadline(self, mock_get, mock_datetime, mock_send_email):
|
||||
tasks = Task.query.filter_by(completed=False).all()
|
||||
self.assertEqual(len(tasks), 0)
|
||||
|
||||
self.proposal.arbiter.user = self.user
|
||||
db.session.add(self.proposal)
|
||||
|
||||
# unset immediate_payout so task will be added
|
||||
for milestone in self.proposal.milestones:
|
||||
if milestone.immediate_payout:
|
||||
milestone.immediate_payout = False
|
||||
db.session.add(milestone)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
self.login_admin()
|
||||
|
||||
# proposal needs to be PENDING
|
||||
self.proposal.status = ProposalStatus.PENDING
|
||||
|
||||
# approve proposal with funding
|
||||
resp = self.app.put(
|
||||
"/api/v1/admin/proposals/{}/accept".format(self.proposal.id),
|
||||
data=json.dumps({"isAccepted": True, "withFunding": True})
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
||||
tasks = Task.query.filter_by(completed=False).all()
|
||||
self.assertEqual(len(tasks), 1)
|
||||
|
||||
# fast forward the clock so task will run
|
||||
after_time = datetime.now() + timedelta(days=365)
|
||||
mock_datetime.now = Mock(return_value=after_time)
|
||||
|
||||
# run task
|
||||
resp = self.app.get("/api/v1/task")
|
||||
self.assert200(resp)
|
||||
|
||||
# make sure task ran
|
||||
tasks = Task.query.filter_by(completed=False).all()
|
||||
self.assertEqual(len(tasks), 0)
|
||||
mock_send_email.assert_called()
|
||||
|
||||
@patch('grant.task.jobs.send_email')
|
||||
def test_milestone_deadline_update_posted(self, mock_send_email):
|
||||
tasks = Task.query.all()
|
||||
self.assertEqual(len(tasks), 0)
|
||||
|
||||
# set date_estimated on milestone to be in the past
|
||||
milestone = self.proposal.milestones[0]
|
||||
milestone.date_estimated = datetime.now() - timedelta(hours=1)
|
||||
db.session.add(milestone)
|
||||
db.session.commit()
|
||||
|
||||
# make task
|
||||
ms_deadline = MilestoneDeadline(self.proposal, milestone)
|
||||
ms_deadline.make_task()
|
||||
|
||||
# check make task
|
||||
tasks = Task.query.all()
|
||||
self.assertEqual(len(tasks), 1)
|
||||
|
||||
# login and post proposal update
|
||||
self.login_default_user()
|
||||
resp = self.app.post(
|
||||
"/api/v1/proposals/{}/updates".format(self.proposal.id),
|
||||
data=json.dumps(test_update),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertStatus(resp, 201)
|
||||
|
||||
# run task
|
||||
resp = self.app.get("/api/v1/task")
|
||||
self.assert200(resp)
|
||||
|
||||
# make sure task ran and did NOT send out an email
|
||||
tasks = Task.query.filter_by(completed=False).all()
|
||||
self.assertEqual(len(tasks), 0)
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
@patch('grant.task.jobs.send_email')
|
||||
def test_milestone_deadline_noops(self, mock_send_email):
|
||||
# make sure all milestone deadline noop states work as expected
|
||||
|
||||
def proposal_delete(p, m):
|
||||
db.session.delete(p)
|
||||
|
||||
def proposal_status(p, m):
|
||||
p.status = ProposalStatus.DELETED
|
||||
db.session.add(p)
|
||||
|
||||
def proposal_stage(p, m):
|
||||
p.stage = ProposalStage.CANCELED
|
||||
db.session.add(p)
|
||||
|
||||
def milestone_delete(p, m):
|
||||
db.session.delete(m)
|
||||
|
||||
def milestone_date_requested(p, m):
|
||||
m.date_requested = datetime.now()
|
||||
db.session.add(m)
|
||||
|
||||
def update_posted(p, m):
|
||||
# login and post proposal update
|
||||
self.login_default_user()
|
||||
resp = self.app.post(
|
||||
"/api/v1/proposals/{}/updates".format(proposal.id),
|
||||
data=json.dumps(test_update),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertStatus(resp, 201)
|
||||
|
||||
modifiers = [
|
||||
proposal_delete,
|
||||
proposal_status,
|
||||
proposal_stage,
|
||||
milestone_delete,
|
||||
milestone_date_requested,
|
||||
update_posted
|
||||
]
|
||||
|
||||
for modifier in modifiers:
|
||||
# make proposal and milestone
|
||||
proposal = Proposal.create(status=ProposalStatus.LIVE)
|
||||
proposal.arbiter.user = self.other_user
|
||||
proposal.team.append(self.user)
|
||||
proposal_id = proposal.id
|
||||
Milestone.make(milestones_data, proposal)
|
||||
|
||||
db.session.add(proposal)
|
||||
db.session.commit()
|
||||
|
||||
# grab update count for blob
|
||||
update_count = len(ProposalUpdate.query.filter_by(proposal_id=proposal_id).all())
|
||||
|
||||
# run modifications to trigger noop
|
||||
proposal = Proposal.query.get(proposal_id)
|
||||
milestone = proposal.milestones[0]
|
||||
milestone_id = milestone.id
|
||||
modifier(proposal, milestone)
|
||||
db.session.commit()
|
||||
|
||||
# make task
|
||||
blob = {
|
||||
"proposal_id": proposal_id,
|
||||
"milestone_id": milestone_id,
|
||||
"update_count": update_count
|
||||
}
|
||||
task = Task(
|
||||
job_type=MilestoneDeadline.JOB_TYPE,
|
||||
blob=blob,
|
||||
execute_after=datetime.now()
|
||||
)
|
||||
|
||||
# run task
|
||||
MilestoneDeadline.process_task(task)
|
||||
|
||||
# check to make sure noop occurred
|
||||
mock_send_email.assert_not_called()
|
||||
|
|
|
@ -31,7 +31,7 @@ milestones = [
|
|||
{
|
||||
"title": "All the money straightaway",
|
||||
"content": "cool stuff with it",
|
||||
"dateEstimated": 1549505307,
|
||||
"daysEstimated": "30",
|
||||
"payoutPercent": "100",
|
||||
"immediatePayout": False
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Form, Input, DatePicker, Card, Icon, Alert, Checkbox, Button } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { Form, Input, Card, Icon, Alert, Checkbox, Button } from 'antd';
|
||||
import { ProposalDraft, CreateMilestone } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
|
||||
|
@ -18,7 +17,7 @@ const DEFAULT_STATE: State = {
|
|||
{
|
||||
title: '',
|
||||
content: '',
|
||||
dateEstimated: moment().unix(),
|
||||
daysEstimated: '',
|
||||
payoutPercent: '',
|
||||
immediatePayout: false,
|
||||
},
|
||||
|
@ -78,11 +77,7 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
|
|||
milestone={milestone}
|
||||
index={idx}
|
||||
error={errors.milestones && errors.milestones[idx]}
|
||||
previousMilestoneDateEstimate={
|
||||
milestones[idx - 1] && milestones[idx - 1].dateEstimated
|
||||
? moment(milestones[idx - 1].dateEstimated * 1000)
|
||||
: undefined
|
||||
}
|
||||
|
||||
onChange={this.handleMilestoneChange}
|
||||
onRemove={this.removeMilestone}
|
||||
/>
|
||||
|
@ -101,7 +96,7 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
|
|||
interface MilestoneFieldsProps {
|
||||
index: number;
|
||||
milestone: CreateMilestone;
|
||||
previousMilestoneDateEstimate: moment.Moment | undefined;
|
||||
// previousMilestoneDateEstimate: moment.Moment | undefined;
|
||||
error: Falsy | string;
|
||||
onChange(index: number, milestone: CreateMilestone): void;
|
||||
onRemove(index: number): void;
|
||||
|
@ -113,7 +108,6 @@ const MilestoneFields = ({
|
|||
error,
|
||||
onChange,
|
||||
onRemove,
|
||||
previousMilestoneDateEstimate,
|
||||
}: MilestoneFieldsProps) => (
|
||||
<Card style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', marginBottom: '0.5rem', alignItems: 'center' }}>
|
||||
|
@ -153,35 +147,20 @@ const MilestoneFields = ({
|
|||
</div>
|
||||
|
||||
<div style={{ display: 'flex' }}>
|
||||
<DatePicker.MonthPicker
|
||||
style={{ flex: 1, marginRight: '0.5rem' }}
|
||||
placeholder="Expected completion date"
|
||||
value={
|
||||
milestone.dateEstimated ? moment(milestone.dateEstimated * 1000) : undefined
|
||||
}
|
||||
format="MMMM YYYY"
|
||||
allowClear={false}
|
||||
onChange={time =>
|
||||
onChange(index, { ...milestone, dateEstimated: time.startOf('month').unix() })
|
||||
}
|
||||
<Input
|
||||
value={milestone.daysEstimated}
|
||||
disabled={milestone.immediatePayout}
|
||||
disabledDate={current => {
|
||||
if (!previousMilestoneDateEstimate) {
|
||||
return current
|
||||
? current <
|
||||
moment()
|
||||
.subtract(1, 'month')
|
||||
.endOf('month')
|
||||
: false;
|
||||
} else {
|
||||
return current
|
||||
? current <
|
||||
moment()
|
||||
.subtract(1, 'month')
|
||||
.endOf('month') || current < previousMilestoneDateEstimate
|
||||
: false;
|
||||
}
|
||||
}}
|
||||
placeholder="Estimated days to complete"
|
||||
onChange={ev =>{
|
||||
return onChange(index, {
|
||||
...milestone,
|
||||
daysEstimated: ev.currentTarget.value
|
||||
})
|
||||
}
|
||||
}
|
||||
addonAfter="days"
|
||||
style={{ flex: 1, marginRight: '0.5rem' }}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Input
|
||||
value={milestone.payoutPercent}
|
||||
|
@ -204,9 +183,6 @@ const MilestoneFields = ({
|
|||
onChange(index, {
|
||||
...milestone,
|
||||
immediatePayout: ev.target.checked,
|
||||
dateEstimated: ev.target.checked
|
||||
? moment().unix()
|
||||
: milestone.dateEstimated,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Icon, Timeline } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
|
||||
import Markdown from 'components/Markdown';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
|
@ -190,9 +189,9 @@ const ReviewMilestones = ({
|
|||
<div className="ReviewMilestone">
|
||||
<div className="ReviewMilestone-title">{m.title || <em>No title</em>}</div>
|
||||
<div className="ReviewMilestone-info">
|
||||
{moment(m.dateEstimated * 1000).format('MMMM YYYY')}
|
||||
{m.immediatePayout || !m.daysEstimated ? '0' : m.daysEstimated} days
|
||||
{' – '}
|
||||
{m.payoutPercent}% of funds
|
||||
{m.payoutPercent || '0'}% of funds
|
||||
</div>
|
||||
<div className="ReviewMilestone-description">
|
||||
{m.content || <em>No description</em>}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import moment from 'moment';
|
||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import { ProposalDraft } from 'types';
|
||||
|
||||
|
@ -20,9 +19,7 @@ const createExampleProposal = (): Partial<ProposalDraft> => {
|
|||
title: 'Initial Funding',
|
||||
content:
|
||||
'This will be used to pay for a professional designer to hand-craft each letter on the shirt.',
|
||||
dateEstimated: moment()
|
||||
.add(1, 'month')
|
||||
.unix(),
|
||||
daysEstimated: '40',
|
||||
payoutPercent: '30',
|
||||
immediatePayout: true,
|
||||
},
|
||||
|
@ -30,9 +27,7 @@ const createExampleProposal = (): Partial<ProposalDraft> => {
|
|||
title: 'Test Prints',
|
||||
content:
|
||||
"We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.",
|
||||
dateEstimated: moment()
|
||||
.add(2, 'month')
|
||||
.unix(),
|
||||
daysEstimated: '30',
|
||||
payoutPercent: '20',
|
||||
immediatePayout: false,
|
||||
},
|
||||
|
@ -40,9 +35,7 @@ const createExampleProposal = (): Partial<ProposalDraft> => {
|
|||
title: 'All Shirts Printed',
|
||||
content:
|
||||
"All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.",
|
||||
dateEstimated: moment()
|
||||
.add(3, 'month')
|
||||
.unix(),
|
||||
daysEstimated: '30',
|
||||
payoutPercent: '50',
|
||||
immediatePayout: false,
|
||||
},
|
||||
|
|
|
@ -331,7 +331,9 @@ interface MilestoneProps extends MSProps {
|
|||
isFunded: boolean;
|
||||
}
|
||||
const Milestone: React.SFC<MilestoneProps> = p => {
|
||||
const estimatedDate = moment(p.dateEstimated * 1000).format('MMMM YYYY');
|
||||
const estimatedDate = p.dateEstimated
|
||||
? moment(p.dateEstimated * 1000).format('MMMM YYYY')
|
||||
: 'N/A';
|
||||
const reward = <UnitDisplay value={p.amount} symbol="ZEC" displayShortBalance={4} />;
|
||||
const getAlertProps = {
|
||||
[MILESTONE_STAGE.IDLE]: () => null,
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
ProposalDraft,
|
||||
STATUS,
|
||||
MILESTONE_STAGE,
|
||||
PROPOSAL_ARBITER_STATUS,
|
||||
CreateMilestone,
|
||||
} from 'types';
|
||||
import moment from 'moment';
|
||||
import { ProposalDraft, STATUS, MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS } from 'types';
|
||||
import { User } from 'types';
|
||||
import {
|
||||
getAmountError,
|
||||
|
@ -131,7 +124,6 @@ export function getCreateErrors(
|
|||
// Milestones
|
||||
if (milestones) {
|
||||
let cumulativeMilestonePct = 0;
|
||||
let lastMsEst: CreateMilestone['dateEstimated'] = 0;
|
||||
const milestoneErrors = milestones.map((ms, idx) => {
|
||||
// check payout first so we collect the cumulativePayout even if other fields are invalid
|
||||
if (!ms.payoutPercent) {
|
||||
|
@ -161,22 +153,18 @@ export function getCreateErrors(
|
|||
return 'Description can only be 200 characters maximum';
|
||||
}
|
||||
|
||||
if (!ms.dateEstimated) {
|
||||
return 'Estimate date is required';
|
||||
} else {
|
||||
// FE validation on milestone estimation
|
||||
if (
|
||||
ms.dateEstimated <
|
||||
moment(Date.now())
|
||||
.startOf('month')
|
||||
.unix()
|
||||
) {
|
||||
return 'Estimate date should be in the future';
|
||||
if (!ms.immediatePayout) {
|
||||
if (!ms.daysEstimated) {
|
||||
return 'Estimate in days is required';
|
||||
} else if (Number.isNaN(parseInt(ms.daysEstimated, 10))) {
|
||||
return 'Days estimated must be a valid number';
|
||||
} else if (parseInt(ms.daysEstimated, 10) !== parseFloat(ms.daysEstimated)) {
|
||||
return 'Days estimated must be a whole number, no decimals';
|
||||
} else if (parseInt(ms.daysEstimated, 10) <= 0) {
|
||||
return 'Days estimated must be greater than 0';
|
||||
} else if (parseInt(ms.daysEstimated, 10) > 365) {
|
||||
return 'Days estimated must be less than or equal to 365';
|
||||
}
|
||||
if (ms.dateEstimated <= lastMsEst) {
|
||||
return 'Estimate date should be later than previous estimate date';
|
||||
}
|
||||
lastMsEst = ms.dateEstimated;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -260,7 +248,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
|
|||
title: m.title,
|
||||
content: m.content,
|
||||
amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)),
|
||||
dateEstimated: m.dateEstimated,
|
||||
daysEstimated: m.daysEstimated,
|
||||
immediatePayout: m.immediatePayout,
|
||||
payoutPercent: m.payoutPercent.toString(),
|
||||
stage: MILESTONE_STAGE.IDLE,
|
||||
|
|
|
@ -21,7 +21,8 @@ export interface Milestone {
|
|||
stage: MILESTONE_STAGE;
|
||||
amount: Zat;
|
||||
immediatePayout: boolean;
|
||||
dateEstimated: number;
|
||||
dateEstimated?: number;
|
||||
daysEstimated?: string;
|
||||
dateRequested?: number;
|
||||
dateRejected?: number;
|
||||
dateAccepted?: number;
|
||||
|
@ -40,7 +41,7 @@ export interface ProposalMilestone extends Milestone {
|
|||
export interface CreateMilestone {
|
||||
title: string;
|
||||
content: string;
|
||||
dateEstimated: number;
|
||||
daysEstimated?: string;
|
||||
payoutPercent: string;
|
||||
immediatePayout: boolean;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue