Paginated audit log API for staff and the compliance export
Python · Django · beginner · modification
Adds the audit log API the staff audit page has been waiting for, with the paging the compliance export needs. Plain Django view on session auth: anonymous 401, non-staff 403. `page` and `page_size` are validated up front (400 with a generic message), the ordering has an `id` tie-breaker so entries that share a timestamp keep a stable order across pages, `select_related` keeps the actor lookup out of the loop, and a page past the end skips the row query. Checked on staging (30 entries): one page with all 30 and `has_more` false; bad parameters return 400.
Django 5.1 on PostgreSQL 16, `USE_TZ = True`, session auth. `audit/models.py` (existing, with its migration) defines `AuditEntry`: `actor` (foreign key to `User`, `null=True`, `on_delete=SET_NULL`), `action` (`CharField`) and `created_at` (`DateTimeField`), with an index on (`created_at`, `id`). Entries are inserted by the existing `audit.record()` helper and are never deleted; only `actor` is cleared when its user is deleted. The `audit` app is already in `INSTALLED_APPS`; the project URLconf is `config/urls.py`. Production holds about 40,000 entries; the staging seed has 30.
Requirements
- Add `GET /api/audit-log/` for the staff audit page and the compliance export. Staff only: anonymous requests get `401` and signed-in users who are not staff get `403` (the app uses Django session auth). Other HTTP methods get `405`.
- Entries come newest first: `created_at` descending, ties broken by `id` descending. The response is `{"entries": [...], "page": <n>, "total": <number of entries>, "has_more": <bool>}`, each entry `{"id", "action", "actor", "created_at"}`, with `actor` the username or `null` (actors can be deleted) and `created_at` in ISO 8601.
- Offset pagination with page numbers (the agreed design: the staff page shows page numbers and the total). `page` is 1-based and defaults to 1; `page_size` defaults to 50, at most 200. Both are parsed with Python's `int()`, so forms it accepts (`+3`, ` 7 `) are valid. A value `int()` rejects, a `page` below 1 or a `page_size` outside 1-200 gets `400` with `{"error": "invalid pagination"}`. A `page` past the end returns no entries and `has_more: false`.
- Every entry must be reachable: when no entries are written during the walk, a client that requests pages 1, 2, 3, ... with the same `page_size` until `has_more` is `false` receives each entry exactly once. The compliance export does this with `page_size=200` against the nightly read-only snapshot. On the live database the staff page accepts that entries written while someone pages can shift rows between pages.
Files touched
- audit/views.py
- audit/urls.py
- config/urls.py
--- audit/views.py
+from django.http import JsonResponse
+from django.views.decorators.http import require_GET
+
+from .models import AuditEntry
+
+DEFAULT_PAGE_SIZE = 50
+MAX_PAGE_SIZE = 200
+
+
+def _bad_pagination():
+ return JsonResponse({"error": "invalid pagination"}, status=400)
+
+
+@require_GET
+def audit_log(request):
+ if not request.user.is_authenticated:
+ return JsonResponse({"error": "authentication required"}, status=401)
+ if not request.user.is_staff:
+ return JsonResponse({"error": "forbidden"}, status=403)
+
+ try:
+ page = int(request.GET.get("page", 1))
+ page_size = int(request.GET.get("page_size", DEFAULT_PAGE_SIZE))
+ except ValueError:
+ return _bad_pagination()
+ if page < 1 or not 1 <= page_size <= MAX_PAGE_SIZE:
+ return _bad_pagination()
+
+ entries = AuditEntry.objects.select_related("actor").order_by("-created_at", "-id")