Skip to content

Commit e9cf501

Browse files
add "clean up events" button to EventLog admin (#84)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 4790b19 commit e9cf501

File tree

7 files changed

+352
-0
lines changed

7 files changed

+352
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2121
### Added
2222

2323
- Added `GITHUB_APP["LOG_ALL_EVENTS"]` setting to control webhook event logging. When `False`, only events with registered handlers are stored in the database.
24+
- Added admin action to bulk delete EventLog entries older than a specified number of days.
2425

2526
## [0.6.1]
2627

src/django_github_app/admin.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,108 @@
11
from __future__ import annotations
22

3+
import datetime
4+
from collections.abc import Sequence
5+
6+
from django import forms
37
from django.contrib import admin
8+
from django.contrib import messages
9+
from django.core.exceptions import ValidationError
10+
from django.http import HttpRequest
11+
from django.http import HttpResponse
12+
from django.http import HttpResponseRedirect
13+
from django.shortcuts import render
14+
from django.urls import URLPattern
15+
from django.urls import URLResolver
16+
from django.urls import path
17+
from django.urls import reverse
18+
from django.utils import timezone
419

20+
from ._typing import override
21+
from .conf import app_settings
522
from .models import EventLog
623
from .models import Installation
724
from .models import Repository
825

926

27+
class EventLogCleanupForm(forms.Form):
28+
days_to_keep = forms.IntegerField(
29+
label="Days to keep",
30+
min_value=0,
31+
initial=app_settings.DAYS_TO_KEEP_EVENTS,
32+
help_text="Event logs older than this number of days will be deleted.",
33+
)
34+
35+
def save(self) -> int:
36+
"""Delete the events and return the count."""
37+
days_to_keep = self.cleaned_data["days_to_keep"]
38+
deleted_count, _ = EventLog.objects.cleanup_events(days_to_keep)
39+
return deleted_count
40+
41+
@property
42+
def to_delete_count(self) -> int:
43+
if not hasattr(self, "cleaned_data"): # pragma: no cover
44+
raise ValidationError(
45+
"Form must be validated before accessing to_delete_count"
46+
)
47+
return EventLog.objects.filter(received_at__lte=self.cutoff_date).count()
48+
49+
@property
50+
def cutoff_date(self) -> datetime.datetime:
51+
if not hasattr(self, "cleaned_data"): # pragma: no cover
52+
raise ValidationError("Form must be validated before accessing cutoff_date")
53+
days_to_keep = self.cleaned_data["days_to_keep"]
54+
return timezone.now() - datetime.timedelta(days=days_to_keep)
55+
56+
1057
@admin.register(EventLog)
1158
class EventLogModelAdmin(admin.ModelAdmin):
1259
list_display = ["id", "event", "action", "received_at"]
1360
readonly_fields = ["event", "payload", "received_at"]
1461

62+
@override
63+
def get_urls(self) -> Sequence[URLResolver | URLPattern]: # type: ignore[override]
64+
urls = super().get_urls()
65+
custom_urls = [
66+
path(
67+
"cleanup/",
68+
self.admin_site.admin_view(self.cleanup_view),
69+
name="django_github_app_eventlog_cleanup",
70+
),
71+
]
72+
return custom_urls + urls
73+
74+
def cleanup_view(self, request: HttpRequest) -> HttpResponse:
75+
form = EventLogCleanupForm(request.POST or None)
76+
77+
# handle confirmation
78+
if request.POST.get("post") == "yes" and form.is_valid():
79+
deleted_count = form.save()
80+
days_to_keep = form.cleaned_data["days_to_keep"]
81+
event_text = "event" if deleted_count == 1 else "events"
82+
day_text = "day" if days_to_keep == 1 else "days"
83+
messages.success(
84+
request,
85+
f"Successfully deleted {deleted_count} {event_text} older than {days_to_keep} {day_text}.",
86+
)
87+
return HttpResponseRedirect(
88+
reverse("admin:django_github_app_eventlog_changelist")
89+
)
90+
91+
context = {
92+
**self.admin_site.each_context(request),
93+
"form": form,
94+
"opts": self.model._meta,
95+
}
96+
97+
if form.is_valid():
98+
context["title"] = f"Confirm {self.model._meta.verbose_name} deletion"
99+
template = "cleanup_confirmation.html"
100+
else:
101+
context["title"] = f"Clean up {self.model._meta.verbose_name_plural}"
102+
template = "cleanup.html"
103+
104+
return render(request, f"admin/django_github_app/eventlog/{template}", context)
105+
15106

16107
@admin.register(Installation)
17108
class InstallationModelAdmin(admin.ModelAdmin):
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends "admin/change_list.html" %}
2+
{% load i18n admin_urls %}
3+
{% block object-tools-items %}
4+
<li>
5+
{% url opts|admin_urlname:'cleanup' as cleanup_url %}
6+
<a href="{{ cleanup_url }}">{% blocktranslate with verbose_name_plural=opts.verbose_name_plural %}Clean up {{ verbose_name_plural }}{% endblocktranslate %}</a>
7+
</li>
8+
{{ block.super }}
9+
{% endblock %}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load i18n admin_urls %}
3+
{% block bodyclass %}
4+
{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation
5+
{% endblock %}
6+
{% block breadcrumbs %}
7+
{% include "admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html" %}
8+
{% endblock %}
9+
{% block content %}
10+
<form method="post">
11+
{% csrf_token %}
12+
<div class="module">
13+
{% for field in form %}
14+
<div>
15+
{{ field.errors }}
16+
{{ field.label_tag }}
17+
{{ field }}
18+
<div class="help"
19+
{% if field.id_for_label %}id="{{ field.id_for_label }}_helptext"{% endif %}>
20+
<div>{{ field.help_text|safe }}</div>
21+
</div>
22+
</div>
23+
{% endfor %}
24+
</div>
25+
<div class="submit-row">
26+
<input type="submit"
27+
value="{% translate 'Delete' %}"
28+
class="default"
29+
name="_save">
30+
{% url opts|admin_urlname:'changelist' as changelist_url %}
31+
<a href="{% add_preserved_filters changelist_url %}"
32+
class="button cancel-link">{% translate 'Cancel' %}</a>
33+
</div>
34+
</form>
35+
{% endblock %}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{% extends "admin/delete_confirmation.html" %}
2+
{% load i18n admin_urls %}
3+
{% block breadcrumbs %}
4+
{% include "admin/django_github_app/eventlog/includes/cleanup_breadcrumbs.html" %}
5+
{% endblock %}
6+
{% block delete_confirm %}
7+
<p>
8+
{% 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 %}
9+
</p>
10+
<p>
11+
{% 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 %}
12+
</p>
13+
{% if form.to_delete_count %}
14+
<h2>{% translate "Summary" %}</h2>
15+
<ul>
16+
<li>{% blocktranslate with name=opts.verbose_name_plural count=form.to_delete_count %}{{ name }}: {{ count }}{% endblocktranslate %}</li>
17+
</ul>
18+
{% endif %}
19+
<form method="post">
20+
{% csrf_token %}
21+
<div>
22+
<input type="hidden" name="post" value="yes">
23+
<input type="hidden" name="days_to_keep" value="{{ form.cleaned_data.days_to_keep }}">
24+
<input type="submit" value="{% translate "Yes, I'm sure" %}">
25+
<a href="#" class="button cancel-link">{% translate 'No, take me back' %}</a>
26+
</div>
27+
</form>
28+
{% endblock %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% load i18n admin_urls %}
2+
<div class="breadcrumbs">
3+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
4+
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
5+
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
6+
&rsaquo; {{ title }}
7+
</div>

tests/test_admin.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
from unittest.mock import patch
5+
6+
import pytest
7+
from django.contrib.admin.sites import AdminSite
8+
from django.contrib.auth import get_user_model
9+
from django.contrib.messages import get_messages
10+
from django.test import RequestFactory
11+
from django.urls import reverse
12+
from django.utils import timezone
13+
14+
from django_github_app.admin import EventLogModelAdmin
15+
from django_github_app.models import EventLog
16+
17+
User = get_user_model()
18+
19+
pytestmark = pytest.mark.django_db
20+
21+
22+
@pytest.fixture
23+
def admin_user():
24+
return User.objects.create_superuser(
25+
username="admin", email="admin@test.com", password="adminpass"
26+
)
27+
28+
29+
@pytest.fixture
30+
def admin_site():
31+
return AdminSite()
32+
33+
34+
@pytest.fixture
35+
def eventlog_admin(admin_site):
36+
return EventLogModelAdmin(EventLog, admin_site)
37+
38+
39+
@pytest.fixture
40+
def factory():
41+
return RequestFactory()
42+
43+
44+
class TestEventLogModelAdmin:
45+
def test_cleanup_url_exists(self, client, admin_user):
46+
client.login(username="admin", password="adminpass")
47+
response = client.get(reverse("admin:django_github_app_eventlog_changelist"))
48+
49+
assert response.status_code == 200
50+
# Check that the cleanup URL is in the rendered HTML
51+
cleanup_url = reverse("admin:django_github_app_eventlog_cleanup")
52+
assert cleanup_url.encode() in response.content
53+
54+
def test_cleanup_view_get(self, factory, admin_user, eventlog_admin):
55+
request = factory.get("/admin/django_github_app/eventlog/cleanup/")
56+
request.user = admin_user
57+
response = eventlog_admin.cleanup_view(request)
58+
59+
assert response.status_code == 200
60+
assert b"Clean up event logs" in response.content
61+
assert b"Days to keep" in response.content
62+
63+
def test_cleanup_view_post_shows_confirmation(self, client, admin_user, baker):
64+
# Create some test events
65+
now = timezone.now()
66+
baker.make(EventLog, _quantity=3, received_at=now - datetime.timedelta(days=10))
67+
baker.make(EventLog, _quantity=2, received_at=now - datetime.timedelta(days=2))
68+
69+
client.login(username="admin", password="adminpass")
70+
response = client.post(
71+
reverse("admin:django_github_app_eventlog_cleanup"),
72+
{"days_to_keep": "5"},
73+
)
74+
75+
assert response.status_code == 200
76+
assert b"You are about to delete 3 event logs" in response.content
77+
assert b"Yes, I" in response.content and b"m sure" in response.content
78+
79+
@patch("django_github_app.models.EventLog.objects.cleanup_events")
80+
def test_cleanup_view_confirm_deletion(self, mock_cleanup, client, admin_user):
81+
mock_cleanup.return_value = (5, {"django_github_app.EventLog": 5})
82+
83+
client.login(username="admin", password="adminpass")
84+
response = client.post(
85+
reverse("admin:django_github_app_eventlog_cleanup"),
86+
{"post": "yes", "days_to_keep": "3"},
87+
)
88+
89+
assert response.status_code == 302
90+
assert response.url == reverse("admin:django_github_app_eventlog_changelist")
91+
mock_cleanup.assert_called_once_with(3)
92+
93+
# Check success message
94+
messages = list(get_messages(response.wsgi_request))
95+
assert len(messages) == 1
96+
assert "Successfully deleted 5 events older than 3 days" in str(messages[0])
97+
98+
@patch("django_github_app.models.EventLog.objects.cleanup_events")
99+
def test_cleanup_view_confirm_deletion_singular_day(
100+
self, mock_cleanup, client, admin_user
101+
):
102+
mock_cleanup.return_value = (2, {"django_github_app.EventLog": 2})
103+
104+
client.login(username="admin", password="adminpass")
105+
response = client.post(
106+
reverse("admin:django_github_app_eventlog_cleanup"),
107+
{"post": "yes", "days_to_keep": "1"},
108+
)
109+
110+
assert response.status_code == 302
111+
112+
# Check success message uses singular "day" and plural "events"
113+
messages = list(get_messages(response.wsgi_request))
114+
assert len(messages) == 1
115+
assert "Successfully deleted 2 events older than 1 day" in str(messages[0])
116+
117+
@patch("django_github_app.models.EventLog.objects.cleanup_events")
118+
def test_cleanup_view_confirm_deletion_zero_events(
119+
self, mock_cleanup, client, admin_user
120+
):
121+
mock_cleanup.return_value = (0, {})
122+
123+
client.login(username="admin", password="adminpass")
124+
response = client.post(
125+
reverse("admin:django_github_app_eventlog_cleanup"),
126+
{"post": "yes", "days_to_keep": "7"},
127+
)
128+
129+
assert response.status_code == 302
130+
131+
# Check success message uses plural "events" for zero
132+
messages = list(get_messages(response.wsgi_request))
133+
assert len(messages) == 1
134+
assert "Successfully deleted 0 events older than 7 days" in str(messages[0])
135+
136+
def test_cleanup_view_integration(self, client, admin_user, baker):
137+
now = timezone.now()
138+
139+
# Create test EventLog entries using baker
140+
old_event = baker.make(
141+
EventLog,
142+
event="push",
143+
payload={"action": "created"},
144+
received_at=now - datetime.timedelta(days=10),
145+
)
146+
recent_event = baker.make(
147+
EventLog,
148+
event="pull_request",
149+
payload={"action": "opened"},
150+
received_at=now - datetime.timedelta(days=2),
151+
)
152+
153+
client.login(username="admin", password="adminpass")
154+
155+
# Test GET request
156+
response = client.get(reverse("admin:django_github_app_eventlog_cleanup"))
157+
assert response.status_code == 200
158+
159+
# Test POST request - Step 1: Show confirmation
160+
response = client.post(
161+
reverse("admin:django_github_app_eventlog_cleanup"),
162+
{"days_to_keep": "5"},
163+
)
164+
assert response.status_code == 200
165+
assert b"You are about to delete 1 event log" in response.content
166+
167+
# Test POST request - Step 2: Confirm deletion
168+
response = client.post(
169+
reverse("admin:django_github_app_eventlog_cleanup"),
170+
{"post": "yes", "days_to_keep": "5"},
171+
)
172+
assert response.status_code == 302
173+
174+
# Check that old event was deleted and recent event remains
175+
assert not EventLog.objects.filter(id=old_event.id).exists()
176+
assert EventLog.objects.filter(id=recent_event.id).exists()
177+
178+
# Check success message
179+
messages = list(get_messages(response.wsgi_request))
180+
assert len(messages) == 1
181+
assert "Successfully deleted 1 event older than 5 days" in str(messages[0])

0 commit comments

Comments
 (0)