diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index f7df432b..4ea886cb 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -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', diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index b2826bef..c9e3b62b 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -391,9 +391,19 @@ class ProposalDetailNaked extends React.Component { extra={`${milestone.payoutPercent}% Payout`} key={i} > + {p.isVersionTwo && ( +

+ Estimated Days to Complete:{' '} + {milestone.immediatePayout ? 'N/A' : milestone.daysEstimated}{' '} +

+ )}

- Estimated Date: {formatDateSeconds(milestone.dateEstimated)}{' '} + Estimated Date:{' '} + {milestone.dateEstimated + ? formatDateSeconds(milestone.dateEstimated) + : 'N/A'}{' '}

+

{milestone.content}

))} diff --git a/admin/src/types.ts b/admin/src/types.ts index 729efb46..5c094dae 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index a357c534..f4f9f24b 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -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.', diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 77112a91..ccdc4aa7 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index cdf9830e..ba65db8c 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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, diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index 695a90b6..48040922 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -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') diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 571f6e49..45e38c9b 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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() diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index 10fcb95f..b5ce9c11 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -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 } diff --git a/backend/grant/templates/emails/milestone_deadline.html b/backend/grant/templates/emails/milestone_deadline.html new file mode 100644 index 00000000..e70a8688 --- /dev/null +++ b/backend/grant/templates/emails/milestone_deadline.html @@ -0,0 +1,32 @@ +

+ The estimated deadline has been reached for proposal milestone + + {{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}. +

+ + + + + +
+ + + + +
+ + View the milestone + +
+
diff --git a/backend/grant/templates/emails/milestone_deadline.txt b/backend/grant/templates/emails/milestone_deadline.txt new file mode 100644 index 00000000..be948c6f --- /dev/null +++ b/backend/grant/templates/emails/milestone_deadline.txt @@ -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 }} diff --git a/backend/migrations/versions/4ca14e6e8976_.py b/backend/migrations/versions/4ca14e6e8976_.py new file mode 100644 index 00000000..849440d2 --- /dev/null +++ b/backend/migrations/versions/4ca14e6e8976_.py @@ -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 ### diff --git a/backend/tests/admin/test_admin_api.py b/backend/tests/admin/test_admin_api.py index f0b5c9f8..9bbc50b5 100644 --- a/backend/tests/admin/test_admin_api.py +++ b/backend/tests/admin/test_admin_api.py @@ -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" diff --git a/backend/tests/config.py b/backend/tests/config.py index bdf196b3..bd8c8b5a 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -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 } diff --git a/backend/tests/milestone/__init__.py b/backend/tests/milestone/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/milestone/test_milestone_methods.py b/backend/tests/milestone/test_milestone_methods.py new file mode 100644 index 00000000..59804986 --- /dev/null +++ b/backend/tests/milestone/test_milestone_methods.py @@ -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) diff --git a/backend/tests/task/test_api.py b/backend/tests/task/test_api.py index 5072b561..d878e04c 100644 --- a/backend/tests/task/test_api.py +++ b/backend/tests/task/test_api.py @@ -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() diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index d7c323c3..d39d3434 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -31,7 +31,7 @@ milestones = [ { "title": "All the money straightaway", "content": "cool stuff with it", - "dateEstimated": 1549505307, + "daysEstimated": "30", "payoutPercent": "100", "immediatePayout": False } diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 2e060621..33df5988 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -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 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 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) => (
@@ -153,35 +147,20 @@ const MilestoneFields = ({
- - onChange(index, { ...milestone, dateEstimated: time.startOf('month').unix() }) - } + { - 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} /> diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 187e9028..cb9ce0a6 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -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 = ({
{m.title || No title}
- {moment(m.dateEstimated * 1000).format('MMMM YYYY')} + {m.immediatePayout || !m.daysEstimated ? '0' : m.daysEstimated} days {' – '} - {m.payoutPercent}% of funds + {m.payoutPercent || '0'}% of funds
{m.content || No description} diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 6168dee2..d46953c1 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -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 => { 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 => { 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 => { 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, }, diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index c6daddb4..6984ffc4 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -331,7 +331,9 @@ interface MilestoneProps extends MSProps { isFunded: boolean; } const Milestone: React.SFC = 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 = ; const getAlertProps = { [MILESTONE_STAGE.IDLE]: () => null, diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index b096186a..69c7a87f 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -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, diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 2809503f..22c43d92 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -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; }