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:
Danny Skubak 2019-11-13 17:38:17 -05:00 committed by Daniel Ternyak
parent 8ced452411
commit ed6d98ceec
24 changed files with 683 additions and 111 deletions

View File

@ -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',

View File

@ -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>
))}

View File

@ -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;

View File

@ -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.',

View File

@ -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

View File

@ -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,

View File

@ -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')

View File

@ -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()

View File

@ -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
}

View File

@ -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>

View File

@ -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 }}

View File

@ -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 ###

View File

@ -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"

View File

@ -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
}

View File

View File

@ -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)

View File

@ -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()

View File

@ -31,7 +31,7 @@ milestones = [
{
"title": "All the money straightaway",
"content": "cool stuff with it",
"dateEstimated": 1549505307,
"daysEstimated": "30",
"payoutPercent": "100",
"immediatePayout": False
}

View File

@ -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,
})
}
>

View File

@ -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>}

View File

@ -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,
},

View File

@ -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,

View File

@ -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,

View File

@ -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;
}