Repository: akvo/akvo-rsr

Issue:
Allow passing date range for perform_iati_checks
Currently, [`perform_iati_checks`](https://github.com/akvo/akvo-rsr/blob/master/akvo/rsr/management/commands/perform_iati_checks.py) only has the `--all` option. It should be possible to pass a date range of projects to execute the script on.

Executing it on all projects is unnecessary as most are probably inactive.

This is related to #4779 as some project will have to be rechecked and we don't want to check all projects.


Discussion:

Resolving patch:
diff --git a/akvo/rsr/management/commands/perform_iati_checks.py b/akvo/rsr/management/commands/perform_iati_checks.py
--- a/akvo/rsr/management/commands/perform_iati_checks.py
+++ b/akvo/rsr/management/commands/perform_iati_checks.py
@@ -3,25 +3,60 @@
 # 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 argparse
+import datetime
 
 from akvo.rsr.models.project import Project
 
 from django.core.management.base import BaseCommand
 
 
+def date_arg_type(string):
+    return datetime.datetime.strptime(string, '%Y-%m-%d').date()
+
+
 class Command(BaseCommand):
     help = "Perform all IATI checks for projects."
 
-    def add_arguments(self, parser):
+    def add_arguments(self, parser: argparse.ArgumentParser):
         parser.add_argument(
             '--all',
             action='store_true',
             default=False,
             help='Run IATI checks for all the projects in the DB.',
+
+        )
+        parser.add_argument(
+            '--date-start',
+            type=date_arg_type,
+            help='Limit to projects created on and after this day',
+        )
+        parser.add_argument(
+            '--date-end',
+            type=date_arg_type,
+            help='Limit to projects created on or before this day',
         )
 
     def handle(self, *args, **options):
-        projects = Project.objects.all() if options['all'] else Project.objects.filter(run_iati_checks=True)
+        all_option = options["all"]
+        date_start = options["date_start"]
+        date_end = options["date_end"]
+
+        # Filter projects with options
+        projects = Project.objects.all()
+        if not (all_option or date_start or date_end):
+            self.stdout.write("No options provided: only checking projects with run_iati_checks=True")
+            projects = projects.filter(run_iati_checks=True)
+        elif all_option:
+            self.stdout.write("Checking ALL projects. This might take a while...")
+        else:
+            if date_start:
+                self.stdout.write("Filtering projects on and after %s" % date_start)
+                projects = projects.filter(created_at__gte=date_start)
+            if date_end:
+                self.stdout.write("Filtering projects on and before %s" % date_end)
+                projects = projects.filter(created_at__lte=date_end)
+
         self.stdout.write('Performing IATI checks for {} ...'.format(projects.count()))
         for project in projects:
             self.stdout.write('Performing IATI checks for project {0}...'.format(project.pk))


Test patch:
diff --git a/akvo/rsr/management/commands/tests/__init__.py b/akvo/rsr/management/commands/tests/__init__.py
new file mode 100644
diff --git a/akvo/rsr/management/commands/tests/base.py b/akvo/rsr/management/commands/tests/base.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/management/commands/tests/base.py
@@ -0,0 +1,38 @@
+from io import StringIO
+from typing import Generic, Type, TypeVar
+from unittest.mock import patch
+
+from django.core.management import BaseCommand
+
+from akvo.rsr.tests.base import BaseTestCase
+
+C = TypeVar("C", bound=BaseCommand)
+
+
+class BaseCommandTestCase(BaseTestCase, Generic[C]):
+    """
+    A helper class to test custom django commands
+    """
+
+    command_class: Type[C]
+
+    def setUp(self):
+        super().setUp()
+
+        # Commands allow replacing the stdout+stderr which we use later to check their contents
+        self.stdout = StringIO()
+        self.stderr = StringIO()
+        self.command = self.command_class(
+            stdout=self.stdout,
+            stderr=self.stderr,
+        )
+
+    def run_command(self, *argv: str):
+        """
+        Emulate running the command of our command_class from the command line
+
+        :param argv: command line args
+        """
+        # The commands try to close all connections, but we need those for the test
+        with patch("django.db.connections.close_all"):
+            self.command.run_from_argv(["manage.py", str(self.command_class)] + list(argv))
diff --git a/akvo/rsr/management/commands/tests/test_perform_iati_checks.py b/akvo/rsr/management/commands/tests/test_perform_iati_checks.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/management/commands/tests/test_perform_iati_checks.py
@@ -0,0 +1,79 @@
+import datetime
+
+from akvo.rsr.management.commands.perform_iati_checks import Command
+from akvo.rsr.management.commands.tests.base import BaseCommandTestCase
+from akvo.rsr.models import Project
+
+
+class TestPerformIatiChecks(BaseCommandTestCase[Command]):
+    command_class = Command
+
+    def test_no_options(self):
+        project_ids_to_check = [
+            Project.objects.create(title=f"Check Project {i}", run_iati_checks=True).pk
+            for i in range(10)
+        ]
+        for i in range(10):
+            Project.objects.create(title=f"No check Project {i}", run_iati_checks=False)
+
+        self.run_command()
+        self.assertIn("No options provided:", self.stdout.getvalue())
+
+        self.assertEqual(
+            Project.objects.filter(
+                pk__in=project_ids_to_check,
+                run_iati_checks=True,
+            ).count(), 0
+        )
+
+    def test_option_all(self):
+        """pass --all to check all projects"""
+        for i in range(10):
+            Project.objects.create(title=f"Project {i}", run_iati_checks=True)
+
+        self.run_command("--all")
+        self.assertIn("Checking ALL projects", self.stdout.getvalue())
+
+        self.assertEqual(Project.objects.filter(run_iati_checks=True).count(), 0)
+
+
+class TestDateFilters(BaseCommandTestCase[Command]):
+    command_class = Command
+
+    def setUp(self):
+        super().setUp()
+        # 10 projects marked to be checked
+        self.projects = []
+        for i in range(1, 11):
+            project = self.create_project(f"Project {i}", created_at=datetime.datetime(2010, 10, i))
+            project.run_iati_checks = True
+            self.projects.append(project)
+        Project.objects.bulk_update(self.projects, ["run_iati_checks"])
+
+    def test_option_date_start(self):
+        """Projects after the given date should be checked"""
+
+        self.run_command("--date-start", "2010-10-6")
+        self.assertIn("Filtering projects on and after", self.stdout.getvalue())
+
+        # Half of the project should've been checked
+        self.assertEqual(Project.objects.filter(run_iati_checks=True).count(), 5)
+
+    def test_option_date_end(self):
+        """Projects after the given date should be checked"""
+
+        self.run_command("--date-end", "2010-10-5")
+        self.assertIn("Filtering projects on and before", self.stdout.getvalue())
+
+        # Half of the project should've been checked
+        self.assertEqual(Project.objects.filter(run_iati_checks=True).count(), 5)
+
+    def test_date_range(self):
+        """Project between two dates should be checked"""
+
+        self.run_command("--date-start", "2010-10-4", "--date-end", "2010-10-6")
+        self.assertIn("Filtering projects on and before", self.stdout.getvalue())
+        self.assertIn("Filtering projects on and after", self.stdout.getvalue())
+
+        # Half of the project should've been checked
+        self.assertEqual(Project.objects.filter(run_iati_checks=False).count(), 3)