Repository: akvo/akvo-rsr

Issue:
Task: Clean up orphaned indicator periods 
This is a follow up to #4824 which fixed the bug of parent indicator periods not deleting their children. Unfortunately, it's not possible to delete the orphaned indicator periods in the UI, so a script is needed to do so.

What counts as an orphan is an indicator period without a parent period but with a parent **indicator** that does have periods.


Discussion:

Resolving patch:
diff --git a/akvo/rsr/management/commands/cleanup_indicator_periods.py b/akvo/rsr/management/commands/cleanup_indicator_periods.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/management/commands/cleanup_indicator_periods.py
@@ -0,0 +1,77 @@
+import argparse
+
+import tablib
+from django.core.management import BaseCommand
+from django.db.models import QuerySet
+
+from akvo.rsr.models import IndicatorPeriod
+
+
+class Command(BaseCommand):
+    help = "Find and delete problematic indicator periods"
+
+    def add_arguments(self, parser: argparse.ArgumentParser):
+        parser.add_argument("--exec", action="store_true")
+
+    def handle(self, *args, **options):
+        execute = options["exec"]
+        self.handle_orphans(execute)
+
+    def write(self, msg: str):
+        self.stdout.write(msg)
+
+    def handle_orphans(self, execute=False):
+        orphans = self.get_orphans()
+        distinct_orphans = orphans.distinct()  # can't call delete after distinct()
+        distinct_orphan_count = distinct_orphans.count()
+        if distinct_orphan_count <= 0:
+            self.write("No orphaned periods to delete")
+            return
+
+        tabber = tablib.Dataset(
+            headers=[
+                "Period ID",
+                "Indicator ID",
+                "Project ID",
+                "Parent Indicator ID",
+                "Parent Project ID",
+                "Start date",
+                "End date",
+            ]
+        )
+        period_orphans_w_related = distinct_orphans.select_related(
+            "indicator",
+            "indicator__result__project",
+            "indicator__parent_indicator",
+            "indicator__parent_indicator__result__project",
+        )
+        for period in period_orphans_w_related:
+            tabber.append(
+                [
+                    period.id,
+                    period.indicator.id,
+                    period.indicator.result.project.id,
+                    period.indicator.parent_indicator.id,
+                    period.indicator.parent_indicator.result.project.id,
+                    period.period_start,
+                    period.period_end,
+                ]
+            )
+        self.write(tabber.export('csv'))
+
+        if execute:
+            orphans.delete()
+            self.write(f"Deleted {distinct_orphan_count} orphaned period(s)")
+        else:
+            self.write(f"Would've deleted {distinct_orphan_count} orphaned period(s)")
+
+    @staticmethod
+    def get_orphans() -> QuerySet[IndicatorPeriod]:
+        """
+        Leftover periods from when parent periods were deleted but the children stayed
+        """
+        return IndicatorPeriod.objects.filter(
+            parent_period__isnull=True,
+            indicator__parent_indicator__isnull=False,
+            data__isnull=True,  # Can't delete periods with data
+        ).order_by("id")


Test patch:
diff --git a/akvo/rsr/tests/commands/test_cleanup_indicator_periods.py b/akvo/rsr/tests/commands/test_cleanup_indicator_periods.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/commands/test_cleanup_indicator_periods.py
@@ -0,0 +1,95 @@
+from akvo.rsr.management.commands.cleanup_indicator_periods import Command
+from akvo.rsr.management.commands.tests.base import BaseCommandTestCase
+from akvo.rsr.models import IndicatorPeriod
+from akvo.rsr.tests.base import BaseTestCase
+from akvo.rsr.tests.utils import ProjectFixtureBuilder
+
+
+class TestHierarchyBase(BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.org = self.create_organisation('My Org')
+        self.root_project_facade = ProjectFixtureBuilder() \
+            .with_results(
+            [{
+                'title': 'Result #1',
+                'indicators': [
+                    {'title': 'Indicator #1', 'periods': [{'period_start': '2020-01-01', 'period_end': '2020-01-31'}]},
+                    {'title': 'Indicator #2', 'periods': [{'period_start': '2020-03-01', 'period_end': '2020-03-31'}]},
+                ]
+            }]
+        ) \
+            .with_contributors(
+            [
+                {'title': 'Contrib #1'},
+            ]
+        ) \
+            .build()
+        self.program = self.create_project_hierarchy(self.org, self.root_project_facade.object, 2)
+        self.root_project = self.root_project_facade.object
+        self.contributor_project = self.root_project_facade.get_contributor(title="Contrib #1").object
+        self.parent_period = IndicatorPeriod.objects.filter(indicator__result=self.root_project.results.first()).first()
+        self.child_period = IndicatorPeriod.objects.filter(
+            indicator__result=self.contributor_project.results.first()
+        ).first()
+
+    def create_orphan(self):
+        # Create orphan
+        self.child_period.parent_period = None
+        self.child_period.save()
+        self.parent_period.delete()
+
+
+class TestFindOrphan(TestHierarchyBase):
+
+    def test_find_orphan(self):
+        self.create_orphan()
+
+        # Ensure our orphan can be found
+        self.assertIn(self.child_period, Command.get_orphans())
+
+    def test_cannot_find_orphans(self):
+        self.assertEqual(Command.get_orphans().count(), 0)
+
+
+class TestDeleteOrphans(BaseCommandTestCase, TestHierarchyBase):
+    command_class = Command
+
+    def test_with_orphan(self):
+        self.create_orphan()
+
+        self.run_command()
+        self.assertIn("Would've deleted 1 orphaned period(s)", self.stdout.getvalue())
+
+        # Ensure our orphan can still be found
+        self.assertIn(self.child_period, Command.get_orphans())
+
+    def test_with_exec(self):
+        self.create_orphan()
+
+        self.run_command("--exec")
+        self.assertIn("Deleted 1 orphaned period(s)", self.stdout.getvalue())
+
+        # Ensure our orphan can still be found
+        self.assertEqual(Command.get_orphans().count(), 0)
+
+    def test_without_orphan(self):
+        self.run_command()
+        self.assertIn("No orphaned periods to delete", self.stdout.getvalue())
+
+
+class TestNoOrphans(BaseTestCase):
+
+    def test_cannot_find_orphans(self):
+        ProjectFixtureBuilder() \
+            .with_results(
+            [{
+                'title': 'Result #1',
+                'indicators': [
+                    {'title': 'Indicator #1', 'periods': [{'period_start': '2020-01-01', 'period_end': '2020-01-31'}]},
+                ]
+            }]
+        ) \
+            .build()
+
+        self.assertEqual(Command.get_orphans().count(), 0)
diff --git a/akvo/rsr/tests/utils.py b/akvo/rsr/tests/utils.py
--- a/akvo/rsr/tests/utils.py
+++ b/akvo/rsr/tests/utils.py
@@ -6,8 +6,9 @@
 For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 """
 from akvo.rsr.models import (
-    Result, Indicator, IndicatorPeriod, IndicatorPeriodData, Disaggregation, DisaggregationTarget,
-    IndicatorDimensionName, IndicatorDimensionValue, IndicatorPeriodLabel, IndicatorPeriodDataComment)
+    Project, Result, Indicator, IndicatorPeriod, IndicatorPeriodData, Disaggregation, DisaggregationTarget,
+    IndicatorDimensionName, IndicatorDimensionValue, IndicatorPeriodLabel, IndicatorPeriodDataComment,
+)
 from akvo.rsr.tests.base import BaseTestCase
 from datetime import date, timedelta
 import random
@@ -134,7 +135,7 @@ class ProjectFacade(object):
     Test helper to work alongside the ProjectFixtureBuilder to make accessing project
     results framework object more easy for testing purpose.
     """
-    def __init__(self, project):
+    def __init__(self, project: Project):
         self.project = project
         self._descendants = None