Repository: akvo/akvo-rsr

Issue:
Feature Request: Cleanup untitled and unpublished projects periodically
### What are you trying to do?

Clean up untitled and unpublished projects created for more than seven days.

### Describe the solution you'd like

Create a script and run it as a cronjob once a day.

### Have you consider alternatives?

_No response_

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/akvo/rsr/management/commands/cleanup_untitled_and_unpublished_projects.py b/akvo/rsr/management/commands/cleanup_untitled_and_unpublished_projects.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/management/commands/cleanup_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 >.
+
+from datetime import datetime, timedelta
+from tablib import Dataset
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from akvo.rsr.models import Project, PublishingStatus, IndicatorPeriodData, Result, IatiActivityImport
+from akvo.rsr.management.utils import VerbosityAwareWriter
+
+
+class Command(BaseCommand):
+    help = """\
+    Delete all Untitled and Unpublished projects
+    """
+
+    DEFAULT_DATE = datetime.now() - timedelta(days=7)
+
+    def add_arguments(self, parser):
+        parser.add_argument('--date', type=lambda date: datetime.strptime(date, '%Y-%m-%d').date(), default=self.DEFAULT_DATE.date())
+        parser.add_argument('--dry-run', action='store_true', help='Do not actually delete projects')
+
+    def handle(self, *args, **options):
+        the_date = options['date']
+        dry_run = options['dry_run']
+        writer = VerbosityAwareWriter(self.stdout, options['verbosity'])
+        projects = Project.objects.filter(
+            created_at__lt=the_date,
+            title__exact='',
+            publishingstatus__status=PublishingStatus.STATUS_UNPUBLISHED
+        )
+        if not dry_run:
+            with transaction.atomic():
+                updates = IndicatorPeriodData.objects.filter(period__indicator__result__project__in=projects)
+                writer.write(f"Deleting {updates.count()} period updates")
+                updates.delete()
+                iati_import = IatiActivityImport.objects.filter(project__in=projects)
+                writer.write(f"Deleting {iati_import.count()} iati activity import")
+                iati_import.delete()
+                results = Result.objects.filter(project__in=projects)
+                writer.write(f"Deleting {results.count()} results")
+                results.delete()
+                writer.write(f"Deleting {projects.count()} projects")
+                projects.delete()
+        else:
+            if options['verbosity'] > 1:
+                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
+                    ])
+                writer.write(data.export('csv'))
+            writer.write(f'Found {projects.count()} projects to delete.')
diff --git a/akvo/rsr/management/commands/delete_untitled_and_unpublished_projects.py b/akvo/rsr/management/commands/delete_untitled_and_unpublished_projects.py
deleted file mode 100644
--- a/akvo/rsr/management/commands/delete_untitled_and_unpublished_projects.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# -*- 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.')
diff --git a/akvo/rsr/management/utils.py b/akvo/rsr/management/utils.py
--- a/akvo/rsr/management/utils.py
+++ b/akvo/rsr/management/utils.py
@@ -6,6 +6,7 @@
 
 
 import sys
+from io import TextIOBase
 
 
 def spinning_cursor():
@@ -21,3 +22,14 @@ def rotate_spinner():
 
 
 spinner = spinning_cursor()
+
+
+class VerbosityAwareWriter:
+
+    def __init__(self, output: TextIOBase, verbosity: int):
+        self.output = output
+        self.verbosity = verbosity
+
+    def write(self, message: str, level: int = 0):
+        if self.verbosity > level:
+            self.output.write(message)


Test patch:
diff --git a/akvo/rsr/tests/commands/test_cleanup_untitled_and_unpublished_projects.py b/akvo/rsr/tests/commands/test_cleanup_untitled_and_unpublished_projects.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/commands/test_cleanup_untitled_and_unpublished_projects.py
@@ -0,0 +1,108 @@
+# -*- 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 datetime, timedelta
+from django.core import management
+from django.core.files import File
+from django.core.files.temp import NamedTemporaryFile
+from django.utils import timezone
+from akvo.rsr.tests.base import BaseTestCase
+from akvo.rsr.models import Project, Result, Indicator, IndicatorPeriod, IndicatorPeriodData, IatiImport, IatiImportJob, IatiActivityImport
+from akvo.rsr.tests.iati_import.xml_files import IATI_V2_STRING
+
+
+class CleanupUntitledAndUnpublishedProjectsTestCase(BaseTestCase):
+
+    TWO_DAYS_AGO = timezone.now() - timedelta(days=2)
+    SIX_DAYS_AGO = timezone.now() - timedelta(days=6)
+    SEVEN_DAYS_AGO = timezone.now() - timedelta(days=7)
+    EIGHT_DAYS_AGO = timezone.now() - timedelta(days=8)
+
+    def run_cleanup(self, the_date=None, dry_run=False):
+        args = ['-v', '0']
+        if isinstance(the_date, datetime):
+            args.extend(['--date', the_date.strftime('%Y-%m-%d')])
+        if dry_run:
+            args.append('--dry-run')
+        management.call_command('cleanup_untitled_and_unpublished_projects', *args)
+
+    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 CleanupOptionsTestCase(CleanupUntitledAndUnpublishedProjectsTestCase):
+
+    def test_cleanup_default_opts(self):
+        remove = self.create_project(title='', published=False, created_at=self.EIGHT_DAYS_AGO)
+        keep = self.create_project(title='', published=False, created_at=self.SIX_DAYS_AGO)
+        self.run_cleanup()
+        self.assertFalse(Project.objects.filter(id=remove.id).exists())
+        self.assertTrue(Project.objects.filter(id=keep.id).exists())
+
+    def test_cleanup_with_date_option(self):
+        remove = self.create_project(title='', published=False, created_at=self.SEVEN_DAYS_AGO)
+        keep = self.create_project(title='', published=False, created_at=self.TWO_DAYS_AGO)
+        self.run_cleanup(self.SIX_DAYS_AGO)
+        self.assertFalse(Project.objects.filter(id=remove.id).exists())
+        self.assertTrue(Project.objects.filter(id=keep.id).exists())
+
+    def test_dry_run(self):
+        project = self.create_project(title='', published=False, created_at=self.EIGHT_DAYS_AGO)
+        self.run_cleanup(dry_run=True)
+        self.assertTrue(Project.objects.filter(id=project.id).exists())
+
+
+class KeepProjectsTestCase(CleanupUntitledAndUnpublishedProjectsTestCase):
+
+    def test_keep_unpublished_project_with_title(self):
+        project = self.create_project(title='Test project', published=False, created_at=self.EIGHT_DAYS_AGO)
+        self.run_cleanup()
+        self.assertTrue(Project.objects.filter(id=project.id).exists())
+
+    def test_keep_untitled_project_with_published_status(self):
+        project = self.create_project(title='', published=True, created_at=self.EIGHT_DAYS_AGO)
+        self.run_cleanup()
+        self.assertTrue(Project.objects.filter(id=project.id).exists())
+
+
+class HaveRelationshipTestCase(CleanupUntitledAndUnpublishedProjectsTestCase):
+    def setUp(self):
+        super().setUp()
+        self.project = self.create_project(title='', published=False, created_at=self.EIGHT_DAYS_AGO)
+
+    def test_have_period_update(self):
+        self.make_period_update(self.project)
+        self.run_cleanup()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())
+
+    def test_have_result(self):
+        self.make_result(self.project)
+        self.run_cleanup()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())
+
+    def test_have_iati_activity_import(self):
+        self.make_iati_activity_import(self.project)
+        self.run_cleanup()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())
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
deleted file mode 100644
--- a/akvo/rsr/tests/commands/test_delete_untitled_and_unpublished_projects.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# -*- 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())
diff --git a/akvo/rsr/tests/commands/test_utils.py b/akvo/rsr/tests/commands/test_utils.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/commands/test_utils.py
@@ -0,0 +1,29 @@
+# -*- 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 io import TextIOBase
+from unittest.mock import Mock
+from django.test import SimpleTestCase
+from akvo.rsr.management.utils import VerbosityAwareWriter
+
+
+class VerbosityAwareWriterTestCase(SimpleTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.output = Mock(spec=TextIOBase)
+        self.verbosity = 1
+        self.writer = VerbosityAwareWriter(self.output, self.verbosity)
+
+    def test_no_write(self):
+        self.writer.write('test', level=2)
+        self.output.write.assert_not_called()
+
+    def test_write(self):
+        self.writer.write('test', level=0)
+        self.output.write.assert_called()