From c66be86c54724ffc21f5992388795a9d5569e7cc Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Tue, 5 Nov 2019 14:38:34 -0500 Subject: [PATCH] Prune Empty Drafts (#54) * prune empty drafts after 72 hours * add additional noops, update tests --- backend/grant/proposal/views.py | 5 +- backend/grant/task/jobs.py | 47 ++++++++++++- backend/tests/task/test_api.py | 115 +++++++++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 8cf29860..8a508508 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -14,7 +14,7 @@ from grant.milestone.models import Milestone from grant.parser import body, query, paginated_fields from grant.rfp.models import RFP from grant.settings import PROPOSAL_STAKING_AMOUNT -from grant.task.jobs import ProposalDeadline +from grant.task.jobs import ProposalDeadline, PruneDraft from grant.user.models import User from grant.utils import pagination from grant.utils.auth import ( @@ -196,6 +196,9 @@ def make_proposal_draft(rfp_id): rfp.proposals.append(proposal) db.session.add(rfp) + task = PruneDraft(proposal) + task.make_task() + db.session.add(proposal) db.session.commit() return proposal_schema.dump(proposal), 201 diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index ae148f1e..10fcb95f 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from grant.extensions import db from grant.email.send import send_email -from grant.utils.enums import ProposalStage, ContributionStatus +from grant.utils.enums import ProposalStage, ContributionStatus, ProposalStatus from grant.utils.misc import make_url from flask import current_app @@ -126,8 +126,53 @@ class ContributionExpired: }) +class PruneDraft: + JOB_TYPE = 4 + PRUNE_TIME = 259200 # 72 hours in seconds + + def __init__(self, proposal): + self.proposal = proposal + + def blobify(self): + return { + "proposal_id": self.proposal.id, + } + + def make_task(self): + from .models import Task + + task = Task( + job_type=self.JOB_TYPE, + blob=self.blobify(), + execute_after=self.proposal.date_created + timedelta(seconds=self.PRUNE_TIME), + ) + db.session.add(task) + db.session.commit() + + @staticmethod + def process_task(task): + from grant.proposal.models import Proposal + proposal = Proposal.query.filter_by(id=task.blob["proposal_id"]).first() + + # If it was deleted or moved out of a draft, noop out + if not proposal or proposal.status != ProposalStatus.DRAFT: + return + + # If any of the proposal fields are filled, noop out + if proposal.title or proposal.brief or proposal.content or proposal.category or proposal.target != "0": + return + + if proposal.payout_address or proposal.milestones: + return + + # Otherwise, delete the empty proposal + db.session.delete(proposal) + db.session.commit() + + JOBS = { 1: ProposalReminder.process_task, 2: ProposalDeadline.process_task, 3: ContributionExpired.process_task, + 4: PruneDraft.process_task } diff --git a/backend/tests/task/test_api.py b/backend/tests/task/test_api.py index 7dd0cbe2..5072b561 100644 --- a/backend/tests/task/test_api.py +++ b/backend/tests/task/test_api.py @@ -1,6 +1,12 @@ -from datetime import datetime +from datetime import datetime, timedelta -from grant.task.models import Task +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 mock import patch, Mock from ..config import BaseProposalCreatorConfig @@ -22,3 +28,108 @@ class TestTaskAPI(BaseProposalCreatorConfig): tasks = Task.query.filter(Task.execute_after <= datetime.now()).filter_by(completed=False).all() self.assertEqual(tasks, []) + @patch('grant.task.views.datetime') + def test_proposal_pruning(self, mock_datetime): + self.login_default_user() + resp = self.app.post( + "/api/v1/proposals/drafts", + ) + proposal_id = resp.json['proposalId'] + + # make sure proposal was created + proposal = Proposal.query.get(proposal_id) + self.assertIsNotNone(proposal) + + # make sure the task was created + self.assertStatus(resp, 201) + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + task = tasks[0] + self.assertEqual(resp.json['proposalId'], task.blob['proposal_id']) + self.assertFalse(task.completed) + + # mock time so task will run when called + after_time = datetime.now() + timedelta(seconds=PruneDraft.PRUNE_TIME + 100) + mock_datetime.now = Mock(return_value=after_time) + + # run task + resp = self.app.get("/api/v1/task") + self.assert200(resp) + + # make sure task ran successfully + tasks = Task.query.all() + self.assertEqual(len(tasks), 1) + task = tasks[0] + self.assertTrue(task.completed) + proposal = Proposal.query.get(proposal_id) + self.assertIsNone(proposal) + + def test_proposal_pruning_noops(self): + # ensure all proposal noop states work as expected + + def status(p): + p.status = ProposalStatus.LIVE + + def title(p): + p.title = 'title' + + def brief(p): + p.brief = 'brief' + + def content(p): + p.content = 'content' + + def category(p): + p.category = Category.DEV_TOOL + + def target(p): + p.target = '100' + + def payout_address(p): + 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 = [ + status, + title, + brief, + content, + category, + target, + payout_address, + milestones + ] + + for modifier in modifiers: + proposal = Proposal.create(status=ProposalStatus.DRAFT) + proposal_id = proposal.id + modifier(proposal) + + db.session.add(proposal) + db.session.commit() + + blob = { + "proposal_id": proposal_id, + } + + task = Task( + job_type=PruneDraft.JOB_TYPE, + blob=blob, + execute_after=datetime.now() + ) + + PruneDraft.process_task(task) + + proposal = Proposal.query.get(proposal_id) + self.assertIsNotNone(proposal)