diff --git a/CHANGELOG.md b/CHANGELOG.md index 366b243..9336413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ### Added - Added `GITHUB_APP["LOG_ALL_EVENTS"]` setting to control webhook event logging. When `False`, only events with registered handlers are stored in the database. +- Added admin action to bulk delete EventLog entries older than a specified number of days. ## [0.6.1] diff --git a/src/django_github_app/admin.py b/src/django_github_app/admin.py index f57795e..632cb36 100644 --- a/src/django_github_app/admin.py +++ b/src/django_github_app/admin.py @@ -1,17 +1,108 @@ from __future__ import annotations +import datetime +from collections.abc import Sequence + +from django import forms from django.contrib import admin +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.http import HttpRequest +from django.http import HttpResponse +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import URLPattern +from django.urls import URLResolver +from django.urls import path +from django.urls import reverse +from django.utils import timezone +from ._typing import override +from .conf import app_settings from .models import EventLog from .models import Installation from .models import Repository +class EventLogCleanupForm(forms.Form): + days_to_keep = forms.IntegerField( + label="Days to keep", + min_value=0, + initial=app_settings.DAYS_TO_KEEP_EVENTS, + help_text="Event logs older than this number of days will be deleted.", + ) + + def save(self) -> int: + """Delete the events and return the count.""" + days_to_keep = self.cleaned_data["days_to_keep"] + deleted_count, _ = EventLog.objects.cleanup_events(days_to_keep) + return deleted_count + + @property + def to_delete_count(self) -> int: + if not hasattr(self, "cleaned_data"): # pragma: no cover + raise ValidationError( + "Form must be validated before accessing to_delete_count" + ) + return EventLog.objects.filter(received_at__lte=self.cutoff_date).count() + + @property + def cutoff_date(self) -> datetime.datetime: + if not hasattr(self, "cleaned_data"): # pragma: no cover + raise ValidationError("Form must be validated before accessing cutoff_date") + days_to_keep = self.cleaned_data["days_to_keep"] + return timezone.now() - datetime.timedelta(days=days_to_keep) + + @admin.register(EventLog) class EventLogModelAdmin(admin.ModelAdmin): list_display = ["id", "event", "action", "received_at"] readonly_fields = ["event", "payload", "received_at"] + @override + def get_urls(self) -> Sequence[URLResolver | URLPattern]: # type: ignore[override] + urls = super().get_urls() + custom_urls = [ + path( + "cleanup/", + self.admin_site.admin_view(self.cleanup_view), + name="django_github_app_eventlog_cleanup", + ), + ] + return custom_urls + urls + + def cleanup_view(self, request: HttpRequest) -> HttpResponse: + form = EventLogCleanupForm(request.POST or None) + + # handle confirmation + if request.POST.get("post") == "yes" and form.is_valid(): + deleted_count = form.save() + days_to_keep = form.cleaned_data["days_to_keep"] + event_text = "event" if deleted_count == 1 else "events" + day_text = "day" if days_to_keep == 1 else "days" + messages.success( + request, + f"Successfully deleted {deleted_count} {event_text} older than {days_to_keep} {day_text}.", + ) + return HttpResponseRedirect( + reverse("admin:django_github_app_eventlog_changelist") + ) + + context = { + **self.admin_site.each_context(request), + "form": form, + "opts": self.model._meta, + } + + if form.is_valid(): + context["title"] = f"Confirm {self.model._meta.verbose_name} deletion" + template = "cleanup_confirmation.html" + else: + context["title"] = f"Clean up {self.model._meta.verbose_name_plural}" + template = "cleanup.html" + + return render(request, f"admin/django_github_app/eventlog/{template}", context) + @admin.register(Installation) class InstallationModelAdmin(admin.ModelAdmin): diff --git a/src/django_github_app/templates/admin/django_github_app/eventlog/change_list.html b/src/django_github_app/templates/admin/django_github_app/eventlog/change_list.html new file mode 100644 index 0000000..e555fe2 --- /dev/null +++ b/src/django_github_app/templates/admin/django_github_app/eventlog/change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} +{% block object-tools-items %} +
  • + {% url opts|admin_urlname:'cleanup' as cleanup_url %} + {% blocktranslate with verbose_name_plural=opts.verbose_name_plural %}Clean up {{ verbose_name_plural }}{% endblocktranslate %} +
  • + {{ block.super }} +{% endblock %} diff --git a/src/django_github_app/templates/admin/django_github_app/eventlog/cleanup.html b/src/django_github_app/templates/admin/django_github_app/eventlog/cleanup.html new file mode 100644 index 0000000..bc54575 --- /dev/null +++ b/src/django_github_app/templates/admin/django_github_app/eventlog/cleanup.html @@ -0,0 +1,35 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} +{% block bodyclass %} + {{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation +{% endblock %} +{% block breadcrumbs %} + {% include "admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html" %} +{% endblock %} +{% block content %} +
    + {% csrf_token %} +
    + {% for field in form %} +
    + {{ field.errors }} + {{ field.label_tag }} + {{ field }} +
    +
    {{ field.help_text|safe }}
    +
    +
    + {% endfor %} +
    +
    + + {% url opts|admin_urlname:'changelist' as changelist_url %} + {% translate 'Cancel' %} +
    +
    +{% endblock %} diff --git a/src/django_github_app/templates/admin/django_github_app/eventlog/cleanup_confirmation.html b/src/django_github_app/templates/admin/django_github_app/eventlog/cleanup_confirmation.html new file mode 100644 index 0000000..6f02e84 --- /dev/null +++ b/src/django_github_app/templates/admin/django_github_app/eventlog/cleanup_confirmation.html @@ -0,0 +1,28 @@ +{% extends "admin/delete_confirmation.html" %} +{% load i18n admin_urls %} +{% block breadcrumbs %} + {% include "admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html" %} +{% endblock %} +{% block delete_confirm %} +

    + {% blocktranslate count counter=form.to_delete_count with verbose_name=opts.verbose_name verbose_name_plural=opts.verbose_name_plural days_to_keep=form.cleaned_data.days_to_keep %}You are about to delete {{ counter }} {{ verbose_name }} older than {{ days_to_keep }} days.{% plural %}You are about to delete {{ counter }} {{ verbose_name_plural }} older than {{ days_to_keep }} days.{% endblocktranslate %} +

    +

    + {% blocktranslate with verbose_name_plural=opts.verbose_name_plural cutoff_date=form.cutoff_date %}All {{ verbose_name_plural }} received before {{ cutoff_date }} will be permanently deleted.{% endblocktranslate %} +

    + {% if form.to_delete_count %} +

    {% translate "Summary" %}

    + + {% endif %} +
    + {% csrf_token %} +
    + + + + {% translate 'No, take me back' %} +
    +
    +{% endblock %} diff --git a/src/django_github_app/templates/admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html b/src/django_github_app/templates/admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html new file mode 100644 index 0000000..8e30277 --- /dev/null +++ b/src/django_github_app/templates/admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html @@ -0,0 +1,7 @@ +{% load i18n admin_urls %} + diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..5f974ce --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import datetime +from unittest.mock import patch + +import pytest +from django.contrib.admin.sites import AdminSite +from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages +from django.test import RequestFactory +from django.urls import reverse +from django.utils import timezone + +from django_github_app.admin import EventLogModelAdmin +from django_github_app.models import EventLog + +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def admin_user(): + return User.objects.create_superuser( + username="admin", email="admin@test.com", password="adminpass" + ) + + +@pytest.fixture +def admin_site(): + return AdminSite() + + +@pytest.fixture +def eventlog_admin(admin_site): + return EventLogModelAdmin(EventLog, admin_site) + + +@pytest.fixture +def factory(): + return RequestFactory() + + +class TestEventLogModelAdmin: + def test_cleanup_url_exists(self, client, admin_user): + client.login(username="admin", password="adminpass") + response = client.get(reverse("admin:django_github_app_eventlog_changelist")) + + assert response.status_code == 200 + # Check that the cleanup URL is in the rendered HTML + cleanup_url = reverse("admin:django_github_app_eventlog_cleanup") + assert cleanup_url.encode() in response.content + + def test_cleanup_view_get(self, factory, admin_user, eventlog_admin): + request = factory.get("/admin/django_github_app/eventlog/cleanup/") + request.user = admin_user + response = eventlog_admin.cleanup_view(request) + + assert response.status_code == 200 + assert b"Clean up event logs" in response.content + assert b"Days to keep" in response.content + + def test_cleanup_view_post_shows_confirmation(self, client, admin_user, baker): + # Create some test events + now = timezone.now() + baker.make(EventLog, _quantity=3, received_at=now - datetime.timedelta(days=10)) + baker.make(EventLog, _quantity=2, received_at=now - datetime.timedelta(days=2)) + + client.login(username="admin", password="adminpass") + response = client.post( + reverse("admin:django_github_app_eventlog_cleanup"), + {"days_to_keep": "5"}, + ) + + assert response.status_code == 200 + assert b"You are about to delete 3 event logs" in response.content + assert b"Yes, I" in response.content and b"m sure" in response.content + + @patch("django_github_app.models.EventLog.objects.cleanup_events") + def test_cleanup_view_confirm_deletion(self, mock_cleanup, client, admin_user): + mock_cleanup.return_value = (5, {"django_github_app.EventLog": 5}) + + client.login(username="admin", password="adminpass") + response = client.post( + reverse("admin:django_github_app_eventlog_cleanup"), + {"post": "yes", "days_to_keep": "3"}, + ) + + assert response.status_code == 302 + assert response.url == reverse("admin:django_github_app_eventlog_changelist") + mock_cleanup.assert_called_once_with(3) + + # Check success message + messages = list(get_messages(response.wsgi_request)) + assert len(messages) == 1 + assert "Successfully deleted 5 events older than 3 days" in str(messages[0]) + + @patch("django_github_app.models.EventLog.objects.cleanup_events") + def test_cleanup_view_confirm_deletion_singular_day( + self, mock_cleanup, client, admin_user + ): + mock_cleanup.return_value = (2, {"django_github_app.EventLog": 2}) + + client.login(username="admin", password="adminpass") + response = client.post( + reverse("admin:django_github_app_eventlog_cleanup"), + {"post": "yes", "days_to_keep": "1"}, + ) + + assert response.status_code == 302 + + # Check success message uses singular "day" and plural "events" + messages = list(get_messages(response.wsgi_request)) + assert len(messages) == 1 + assert "Successfully deleted 2 events older than 1 day" in str(messages[0]) + + @patch("django_github_app.models.EventLog.objects.cleanup_events") + def test_cleanup_view_confirm_deletion_zero_events( + self, mock_cleanup, client, admin_user + ): + mock_cleanup.return_value = (0, {}) + + client.login(username="admin", password="adminpass") + response = client.post( + reverse("admin:django_github_app_eventlog_cleanup"), + {"post": "yes", "days_to_keep": "7"}, + ) + + assert response.status_code == 302 + + # Check success message uses plural "events" for zero + messages = list(get_messages(response.wsgi_request)) + assert len(messages) == 1 + assert "Successfully deleted 0 events older than 7 days" in str(messages[0]) + + def test_cleanup_view_integration(self, client, admin_user, baker): + now = timezone.now() + + # Create test EventLog entries using baker + old_event = baker.make( + EventLog, + event="push", + payload={"action": "created"}, + received_at=now - datetime.timedelta(days=10), + ) + recent_event = baker.make( + EventLog, + event="pull_request", + payload={"action": "opened"}, + received_at=now - datetime.timedelta(days=2), + ) + + client.login(username="admin", password="adminpass") + + # Test GET request + response = client.get(reverse("admin:django_github_app_eventlog_cleanup")) + assert response.status_code == 200 + + # Test POST request - Step 1: Show confirmation + response = client.post( + reverse("admin:django_github_app_eventlog_cleanup"), + {"days_to_keep": "5"}, + ) + assert response.status_code == 200 + assert b"You are about to delete 1 event log" in response.content + + # Test POST request - Step 2: Confirm deletion + response = client.post( + reverse("admin:django_github_app_eventlog_cleanup"), + {"post": "yes", "days_to_keep": "5"}, + ) + assert response.status_code == 302 + + # Check that old event was deleted and recent event remains + assert not EventLog.objects.filter(id=old_event.id).exists() + assert EventLog.objects.filter(id=recent_event.id).exists() + + # Check success message + messages = list(get_messages(response.wsgi_request)) + assert len(messages) == 1 + assert "Successfully deleted 1 event older than 5 days" in str(messages[0])