Repository: akvo/akvo-rsr

Issue:
Create a script that deletes all Untitled and Unpublished projects



Discussion:

Resolving patch:
diff --git a/akvo/rsr/management/commands/delete_untitled_and_unpublished_projects.py b/akvo/rsr/management/commands/delete_untitled_and_unpublished_projects.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/management/commands/delete_untitled_and_unpublished_projects.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+# Akvo Reporting is covered by the GNU Affero General Public License.
+# See more details in the license.txt file located at the root folder of the Akvo RSR module.
+# For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
+
+import datetime
+
+from tablib import Dataset
+from django.core.management.base import BaseCommand
+from django.db.models import Q
+from akvo.rsr.models import Project, PublishingStatus, IndicatorPeriodData, Result, IatiActivityImport
+
+
+class Command(BaseCommand):
+    help = """\
+    Delete all Untitled and Unpublished projects created before the given date
+    <script> <date:%Y-%m-%d> --delete
+    """
+
+    def add_arguments(self, parser):
+        parser.add_argument('date', type=lambda date: datetime.datetime.strptime(date, '%Y-%m-%d').date())
+        parser.add_argument('--delete', action='store_true', help='Actually delete projects')
+        parser.add_argument('--quiet', action='store_true', help='Silent output messages')
+
+    def handle(self, *args, **options):
+        the_date = options['date']
+        verbose = not options['quiet']
+        projects = Project.objects\
+            .filter(created_at__lt=the_date)\
+            .filter(Q(title__exact='') | Q(publishingstatus__status=PublishingStatus.STATUS_UNPUBLISHED))
+        project_ids = projects.values_list('id', flat=True)
+        if options['delete']:
+            updates = IndicatorPeriodData.objects.filter(period__indicator__result__project__in=project_ids)
+            if verbose:
+                print(f"Deleting {updates.count()} period updates")
+            updates.delete()
+            iati_import = IatiActivityImport.objects.filter(project__in=project_ids)
+            if verbose:
+                print(f"Deleting {iati_import.count()} iati activity import")
+            iati_import.delete()
+            results = Result.objects.filter(project__in=project_ids)
+            if verbose:
+                print(f"Deleting {results.count()} results")
+            results.delete()
+            if verbose:
+                print(f"Deleting {projects.count()} projects)")
+            projects.delete()
+        else:
+            data = Dataset()
+            data.headers = [
+                'project_id',
+                'project_title',
+                'is_published',
+                'created_at'
+            ]
+            for p in projects:
+                data.append([
+                    p.id,
+                    p.title,
+                    p.is_published(),
+                    p.created_at
+                ])
+            print(data.export('csv'))
+            print(f'Found {projects.count()} projects to delete.')


Test patch:
diff --git a/akvo/rsr/tests/base.py b/akvo/rsr/tests/base.py
--- a/akvo/rsr/tests/base.py
+++ b/akvo/rsr/tests/base.py
@@ -4,6 +4,8 @@
 # For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 import logging
 
+from datetime import datetime
+from unittest import mock
 from django.conf import settings
 from django.contrib.auth import user_login_failed
 from django.contrib.auth.models import Group
@@ -68,9 +70,18 @@ def create_organisation(name, can_create_projects=True):
         return org
 
     @staticmethod
-    def create_project(title, published=True, public=True):
-        """Create an project with the given title."""
-        project = Project.objects.create(title=title, is_public=public)
+    def _create_project_at(title, public, created_at):
+        """Private internal method to create a project with mocked created_at datetime."""
+        with mock.patch('django.utils.timezone.now') as mocked_now:
+            mocked_now.return_value = created_at
+            return Project.objects.create(title=title, is_public=public)
+
+    @staticmethod
+    def create_project(title, published=True, public=True, created_at=None):
+        """Create a project with the given title."""
+        project = BaseTestCase._create_project_at(title, public, created_at) \
+            if isinstance(created_at, datetime) \
+            else Project.objects.create(title=title, is_public=public)
         status = (
             PublishingStatus.STATUS_PUBLISHED if published else PublishingStatus.STATUS_UNPUBLISHED
         )
diff --git a/akvo/rsr/tests/commands/test_delete_untitled_and_unpublished_projects.py b/akvo/rsr/tests/commands/test_delete_untitled_and_unpublished_projects.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/commands/test_delete_untitled_and_unpublished_projects.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+
+"""Akvo RSR is covered by the GNU Affero General Public License.
+
+See more details in the license.txt file located at the root folder of the Akvo RSR module.
+For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
+"""
+
+from datetime import timedelta
+from django.core import management
+from django.utils import timezone
+from django.core.files import File
+from django.core.files.temp import NamedTemporaryFile
+from akvo.rsr.tests.base import BaseTestCase
+from akvo.rsr.models import Project, IatiImport, IatiImportJob, IatiActivityImport, Result, Indicator, IndicatorPeriod, IndicatorPeriodData
+from akvo.rsr.tests.iati_import.xml_files import IATI_V2_STRING
+
+
+class DeleteUntitledAndUnpublishedProjectsCommand(BaseTestCase):
+
+    TWO_DAYS_AGO = timezone.now() - timedelta(days=2)
+    SEVEN_DAYS_AGO = timezone.now() - timedelta(days=7)
+
+    def delete_before_two_days_ago(self):
+        management.call_command('delete_untitled_and_unpublished_projects', self.TWO_DAYS_AGO.strftime('%Y-%m-%d'), '--delete', '--quiet')
+
+    def make_iati_activity_import(self, project):
+        user = self.create_user('test@akvo.org', 'password', is_admin=True)
+        iati_import = IatiImport.objects.create(label='Test', user=user)
+        iati_xml_file = NamedTemporaryFile(delete=True)
+        iati_xml_file.write(IATI_V2_STRING)
+        iati_xml_file.flush()
+        iati_import_job = IatiImportJob.objects.create(iati_import=iati_import, iati_xml_file=File(iati_xml_file))
+        return IatiActivityImport.objects.create(iati_import_job=iati_import_job, project=project, activity_xml='')
+
+    def make_result(self, project):
+        return Result.objects.create(title='Test', project=project)
+
+    def make_period_update(self, project):
+        result = self.make_result(project)
+        indicator = Indicator.objects.create(title='Test', result=result)
+        project_date = project.created_at.date()
+        period = IndicatorPeriod.objects.create(period_start=project_date, period_end=project_date + timedelta(days=30), indicator=indicator)
+        user = self.create_user('test@akvo.org', 'password', is_admin=True)
+        return IndicatorPeriodData.objects.create(period=period, user=user, value=1)
+
+
+class CreatedBeforeOrAfterGivenDateTestCase(DeleteUntitledAndUnpublishedProjectsCommand):
+
+    def test_keep_after(self):
+        project = self.create_program(title='')
+        self.delete_before_two_days_ago()
+        self.assertTrue(Project.objects.filter(id=project.id).exists())
+
+    def test_remove_before(self):
+        project = self.create_project(title='', created_at=self.SEVEN_DAYS_AGO)
+        self.delete_before_two_days_ago()
+        self.assertFalse(Project.objects.filter(id=project.id).exists())
+
+    def test_remove_unpublished(self):
+        project = self.create_project(title='Test project', published=False, created_at=self.SEVEN_DAYS_AGO)
+        self.delete_before_two_days_ago()
+        self.assertFalse(Project.objects.filter(id=project.id).exists())
+
+
+class HaveRelationshipTestCase(DeleteUntitledAndUnpublishedProjectsCommand):
+
+    def setUp(self):
+        super().setUp()
+        self.project = self.create_project(title='', created_at=self.SEVEN_DAYS_AGO)
+
+    def test_have_iati_activity_import(self):
+        self.make_iati_activity_import(self.project)
+        self.delete_before_two_days_ago()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())
+
+    def test_have_result(self):
+        self.make_result(self.project)
+        self.delete_before_two_days_ago()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())
+
+    def test_have_period_update(self):
+        self.make_period_update(self.project)
+        self.delete_before_two_days_ago()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())