Repository: maykinmedia/django-timeline-logger (language: python)

Issue:
Add a management command to prune logs after a certain amount of time
From [this comment](https://github.com/open-formulieren/open-forms/pull/3819#discussion_r1469602846)


Discussion / hints:

Fix patch:
diff --git a/pyproject.toml b/pyproject.toml
index 753fe56..47193ca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ Changelog = "https://github.com/maykinmedia/django-timeline-logger/blob/master/d
 [project.optional-dependencies]
 tests = [
     "factory-boy",
+    "time-machine",
     "psycopg2",
     "pytest",
     "pytest-cov",
diff --git a/timeline_logger/management/commands/prune_timeline_logs.py b/timeline_logger/management/commands/prune_timeline_logs.py
new file mode 100644
index 0000000..cd119bb
--- /dev/null
+++ b/timeline_logger/management/commands/prune_timeline_logs.py
@@ -0,0 +1,54 @@
+from textwrap import dedent
+
+from django.core.management.base import BaseCommand
+
+from timeline_logger.service import prune_timeline_logs
+
+
+class Command(BaseCommand):
+    help = "Removes timeline logs objects older than the specified date."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "--noinput",
+            "--no-input",
+            action="store_false",
+            dest="interactive",
+            help="Tells Django to NOT prompt the user for input of any kind.",
+        )
+        exclusive_group = parser.add_mutually_exclusive_group(required=True)
+
+        exclusive_group.add_argument(
+            "--all",
+            action="store_true",
+            help="Whether to delete all log records.",
+        )
+
+        exclusive_group.add_argument(
+            "--keep-days",
+            type=int,
+            help="Only delete records older than the specified number of days.",
+        )
+
+    def handle(self, *args, **options):
+        all = options["all"]
+        keep_days = options["keep_days"]
+        interactive = options["interactive"]
+
+        if all and interactive:
+            confirm = input(
+                dedent(
+                    """You have specified "--all", meaning all timeline logs will be deleted.
+                Are you sure you want to do this?
+
+                Type 'yes' to continue, or 'no' to cancel: """
+                )
+            )
+        else:
+            confirm = "yes"
+
+        if confirm == "yes":
+            number = prune_timeline_logs(keep_days=0 if all else keep_days)
+            self.stdout.write(f"Successfully deleted {number} timeline logs.")
+        else:
+            self.stdout.write("Flush cancelled.")
diff --git a/timeline_logger/service.py b/timeline_logger/service.py
new file mode 100644
index 0000000..3a074fb
--- /dev/null
+++ b/timeline_logger/service.py
@@ -0,0 +1,19 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from .models import TimelineLog
+
+
+def prune_timeline_logs(*, keep_days: int | None = None) -> int:
+    """Delete the timeline logs instances.
+
+    :param keep_days: If specified, only delete records older than the specified number of days.
+    :returns: The number of deleted instances.
+    """
+    limit = timezone.now()
+    if keep_days is not None:
+        limit -= timedelta(days=keep_days)
+
+    number, _ = TimelineLog.objects.filter(timestamp__lte=limit).delete()
+    return number


Test patch:
diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py
index 79ab265..7d1c847 100644
--- a/tests/test_management_commands.py
+++ b/tests/test_management_commands.py
@@ -1,4 +1,5 @@
-from datetime import timedelta
+from datetime import datetime, timedelta, timezone as dt_timezone
+from io import StringIO
 
 from django.conf import settings
 from django.core import mail
@@ -7,13 +8,16 @@
 from django.test import TestCase, override_settings
 from django.utils import timezone
 
+import time_machine
+
 from timeline_logger.models import TimelineLog
 
-from .factories import ArticleFactory, UserFactory
+from .factories import ArticleFactory, TimelineLogFactory, UserFactory
 
 
 class ReportMailingTestCase(TestCase):
     def setUp(self):
+        super().setUp()
         self.article = ArticleFactory.create()
 
         self.user = UserFactory.create(email="jose@maykinmedia.nl")
@@ -152,3 +156,46 @@ def test_timeline_digest_from_email_setting(self):
 
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(mail.outbox[0].from_email, settings.TIMELINE_DIGEST_FROM_EMAIL)
+
+
+@time_machine.travel(datetime(2024, 3, 5, 0, 0, 0, tzinfo=dt_timezone.utc))
+class PruneTimelineLogsTestCase(TestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.log_1 = TimelineLogFactory.create()
+        self.log_1.timestamp = datetime(2024, 3, 1, 0, 0, 0, tzinfo=dt_timezone.utc)
+        self.log_1.save()
+
+        self.log_2 = TimelineLogFactory.create()
+        self.log_2.timestamp = datetime(2024, 3, 4, 0, 0, 0, tzinfo=dt_timezone.utc)
+        self.log_2.save()
+
+    def test_prune_timeline_logs_no_date(self):
+        stdout = StringIO()
+
+        call_command(
+            "prune_timeline_logs",
+            "--all",
+            interactive=False,
+            verbosity=0,
+            stdout=stdout,
+        )
+
+        self.assertEqual(TimelineLog.objects.count(), 0)
+        self.assertEqual(
+            stdout.getvalue().strip(), "Successfully deleted 2 timeline logs."
+        )
+
+    def test_prune_timeline_logs_date(self):
+        call_command(
+            "prune_timeline_logs",
+            "--keep-days",
+            "2",
+            interactive=False,
+            verbosity=0,
+            stdout=StringIO(),
+        )
+
+        self.assertEqual(TimelineLog.objects.count(), 1)
+        self.assertEqual(TimelineLog.objects.first().pk, self.log_2.pk)