From 52cb51cb143dec9471f8f05d38e4d98ca32a731d Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sun, 9 Feb 2025 09:52:19 -0500 Subject: [PATCH] Add history tracking functionality using django-pghistory; implement views, templates, and middleware for event serialization and context management --- core/middleware.py | 14 +++++++ core/models.py | 17 +++++++- history/apps.py | 12 ++++++ .../history/partials/history_timeline.html | 29 +++++++++++++ history/templatetags/history_tags.py | 17 ++++++++ history/urls.py | 10 +++++ history/views.py | 41 +++++++++++++++++++ moderation/admin.py | 29 +++++++++++++ moderation/models.py | 11 ++--- pyproject.toml | 3 +- requirements.txt | 1 + thrillwiki/settings.py | 4 ++ thrillwiki/urls.py | 1 + uv.lock | 28 +++++++++++++ 14 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 core/middleware.py create mode 100644 history/apps.py create mode 100644 history/templates/history/partials/history_timeline.html create mode 100644 history/templatetags/history_tags.py create mode 100644 history/urls.py create mode 100644 history/views.py diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 00000000..c453236b --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,14 @@ +import pghistory + +def setup_pghistory_context(): + """ + Set up pghistory context middleware to track request information. + This function configures what contextual information is stored + with each history record. + """ + pghistory.context(lambda request: { + 'user': str(request.user) if request.user.is_authenticated else None, + 'ip': request.META.get('REMOTE_ADDR'), + 'user_agent': request.META.get('HTTP_USER_AGENT'), + 'session_key': request.session.session_key + }) \ No newline at end of file diff --git a/core/models.py b/core/models.py index 0392ad6a..a717d3e2 100644 --- a/core/models.py +++ b/core/models.py @@ -2,6 +2,18 @@ from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.utils.text import slugify +import pghistory + +@pghistory.track() +class HistoricalModel(models.Model): + """ + Abstract base model that provides universal history tracking via django-pghistory. + """ + class Meta: + abstract = True + + def save(self, *args, **kwargs): + return super().save(*args, **kwargs) class SlugHistory(models.Model): """ @@ -26,9 +38,11 @@ class SlugHistory(models.Model): def __str__(self): return f"Old slug '{self.old_slug}' for {self.content_object}" -class SluggedModel(models.Model): +@pghistory.track() +class SluggedModel(HistoricalModel): """ Abstract base model that provides slug functionality with history tracking. + Inherits from HistoricalModel to get universal history tracking. """ name = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True) @@ -55,6 +69,7 @@ class SluggedModel(models.Model): if not self.slug: self.slug = slugify(self.name) + # Call HistoricalModel's save to ensure history tracking super().save(*args, **kwargs) def get_id_field_name(self): diff --git a/history/apps.py b/history/apps.py new file mode 100644 index 00000000..95bc91d8 --- /dev/null +++ b/history/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +class HistoryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'history' + verbose_name = 'History Tracking' + + def ready(self): + """Initialize app and signal handlers""" + from django.dispatch import Signal + # Create a signal for history updates + self.history_updated = Signal() \ No newline at end of file diff --git a/history/templates/history/partials/history_timeline.html b/history/templates/history/partials/history_timeline.html new file mode 100644 index 00000000..541fcac2 --- /dev/null +++ b/history/templates/history/partials/history_timeline.html @@ -0,0 +1,29 @@ +
+
+ {% for event in events %} +
+
+ {{ event.pgh_label|title }} + +
+
+ {% if event.pgh_context.metadata.user %} +
+ + + + {{ event.pgh_context.metadata.user }} +
+ {% endif %} + {% if event.pgh_data %} +
+
{{ event.pgh_data|pprint }}
+
+ {% endif %} +
+
+ {% endfor %} +
+
\ No newline at end of file diff --git a/history/templatetags/history_tags.py b/history/templatetags/history_tags.py new file mode 100644 index 00000000..0c1b4177 --- /dev/null +++ b/history/templatetags/history_tags.py @@ -0,0 +1,17 @@ +from django import template +import json + +register = template.Library() + +@register.filter +def pprint(value): + """Pretty print JSON data""" + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return value + + if isinstance(value, (dict, list)): + return json.dumps(value, indent=2) + return str(value) \ No newline at end of file diff --git a/history/urls.py b/history/urls.py new file mode 100644 index 00000000..4382537f --- /dev/null +++ b/history/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import HistoryTimelineView + +app_name = 'history' + +urlpatterns = [ + path('timeline///', + HistoryTimelineView.as_view(), + name='timeline'), +] \ No newline at end of file diff --git a/history/views.py b/history/views.py new file mode 100644 index 00000000..9b4d50b2 --- /dev/null +++ b/history/views.py @@ -0,0 +1,41 @@ +from django.views import View +from django.shortcuts import render +from django.http import JsonResponse +from django.contrib.contenttypes.models import ContentType +import pghistory + +def serialize_event(event): + """Serialize a history event for JSON response""" + return { + 'label': event.pgh_label, + 'created_at': event.pgh_created_at.isoformat(), + 'context': event.pgh_context, + 'data': event.pgh_data, + } + +class HistoryTimelineView(View): + """View for displaying object history timeline""" + + def get(self, request, content_type_id, object_id): + # Get content type and object + content_type = ContentType.objects.get_for_id(content_type_id) + obj = content_type.get_object_for_this_type(id=object_id) + + # Get history events + events = pghistory.models.Event.objects.filter( + pgh_obj_model=content_type.model_class(), + pgh_obj_id=object_id + ).order_by('-pgh_created_at')[:25] + + context = { + 'events': events, + 'content_type': content_type, + 'object': obj, + } + + if request.htmx: + return render(request, "history/partials/history_timeline.html", context) + + return JsonResponse({ + 'history': [serialize_event(e) for e in events] + }) \ No newline at end of file diff --git a/moderation/admin.py b/moderation/admin.py index 43c377bb..b789b30a 100644 --- a/moderation/admin.py +++ b/moderation/admin.py @@ -2,6 +2,8 @@ from django.contrib import admin from django.contrib.admin import AdminSite from django.utils.html import format_html from django.urls import reverse +from django.utils.safestring import mark_safe +import pghistory from .models import EditSubmission, PhotoSubmission class ModerationAdminSite(AdminSite): @@ -75,6 +77,33 @@ class PhotoSubmissionAdmin(admin.ModelAdmin): obj.reject(request.user, obj.notes) super().save_model(request, obj, form, change) +class HistoryAdmin(admin.ModelAdmin): + """Admin interface for viewing model history events""" + list_display = ['pgh_label', 'pgh_created_at', 'get_object_link', 'get_context'] + list_filter = ['pgh_label', 'pgh_created_at'] + readonly_fields = ['pgh_label', 'pgh_obj_id', 'pgh_data', 'pgh_context', 'pgh_created_at'] + date_hierarchy = 'pgh_created_at' + + def get_object_link(self, obj): + """Display a link to the related object if possible""" + if obj.pgh_obj and hasattr(obj.pgh_obj, 'get_absolute_url'): + url = obj.pgh_obj.get_absolute_url() + return format_html('{}', url, str(obj.pgh_obj)) + return str(obj.pgh_obj or '') + get_object_link.short_description = 'Object' + + def get_context(self, obj): + """Format the context data nicely""" + if not obj.pgh_context: + return '-' + html = [''] + for key, value in obj.pgh_context.items(): + html.append(f'') + html.append('
{key}{value}
') + return mark_safe(''.join(html)) + get_context.short_description = 'Context' + # Register with moderation site only moderation_site.register(EditSubmission, EditSubmissionAdmin) moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin) +moderation_site.register(pghistory.models.Event, HistoryAdmin) diff --git a/moderation/models.py b/moderation/models.py index e8f3192a..2823442c 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -9,11 +9,13 @@ from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import AnonymousUser from django.utils.text import slugify +import pghistory +from core.models import HistoricalModel UserType = Union[AbstractBaseUser, AnonymousUser] - -class EditSubmission(models.Model): +@pghistory.track() # Track all changes by default +class EditSubmission(HistoricalModel): STATUS_CHOICES = [ ("PENDING", "Pending"), ("APPROVED", "Approved"), @@ -196,8 +198,8 @@ class EditSubmission(models.Model): self.handled_at = timezone.now() self.save() - -class PhotoSubmission(models.Model): +@pghistory.track() # Track all changes by default +class PhotoSubmission(HistoricalModel): STATUS_CHOICES = [ ("PENDING", "Pending"), ("APPROVED", "Approved"), @@ -287,7 +289,6 @@ class PhotoSubmission(models.Model): if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]: self.approve(self.user) - def escalate(self, moderator: UserType, notes: str = "") -> None: """Escalate the photo submission to admin""" self.status = "ESCALATED" diff --git a/pyproject.toml b/pyproject.toml index 2764ca5f..4e60358e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,5 +55,6 @@ dependencies = [ "django-simple-history>=3.5.0", "django-tailwind-cli>=2.21.1", "playwright>=1.41.0", - "pytest-playwright>=0.4.3" + "pytest-playwright>=0.4.3", + "django-pghistory>=3.5.2", ] diff --git a/requirements.txt b/requirements.txt index fbe75e88..1492d3f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ pyjwt==2.10.1 # Database psycopg2-binary==2.9.10 dj-database-url==2.3.0 +django-pghistory==2.9.0 # Added for model history tracking # Email requests==2.32.3 # For ForwardEmail.net API diff --git a/thrillwiki/settings.py b/thrillwiki/settings.py index ed356cdf..284a4797 100644 --- a/thrillwiki/settings.py +++ b/thrillwiki/settings.py @@ -29,6 +29,9 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "django.contrib.sites", "django.contrib.gis", # Add GeoDjango + "pghistory", # Add django-pghistory + "pgtrigger", # Required by django-pghistory + "history.apps.HistoryConfig", # History timeline app "allauth", "allauth.account", "allauth.socialaccount", @@ -65,6 +68,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "core.middleware.setup_pghistory_context", # Add history context tracking "allauth.account.middleware.AccountMiddleware", "django.middleware.cache.FetchFromCacheMiddleware", "simple_history.middleware.HistoryRequestMiddleware", diff --git a/thrillwiki/urls.py b/thrillwiki/urls.py index 1dc28f9f..2f77a9c3 100644 --- a/thrillwiki/urls.py +++ b/thrillwiki/urls.py @@ -52,6 +52,7 @@ urlpatterns = [ path("user/", accounts_views.user_redirect_view, name="user_redirect"), # Moderation URLs - placed after other URLs but before static/media serving path("moderation/", include("moderation.urls", namespace="moderation")), + path("history/", include("history.urls", namespace="history")), path( "env-settings/", views.environment_and_settings_view, diff --git a/uv.lock b/uv.lock index f2747247..fcc3106f 100644 --- a/uv.lock +++ b/uv.lock @@ -328,6 +328,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/40/e556bc19ba65356fe5f0e48ca01c50e81f7c630042fa7411b6ab428ecf68/django_oauth_toolkit-3.0.1-py3-none-any.whl", hash = "sha256:3ef00b062a284f2031b0732b32dc899e3bbf0eac221bbb1cffcb50b8932e55ed", size = 77299 }, ] +[[package]] +name = "django-pghistory" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-pgtrigger" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f0/a3170aafc42875a43b04a0ae6f1cce103f16eed4013147d6b5d7b6f46c29/django_pghistory-3.5.2.tar.gz", hash = "sha256:49d2fb0a5b86cffd409cfc9bfce1dee5401fbbdb18779b04bc26cea8c4e967d7", size = 31221 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/43/83ad03cda0e613d8e9df6398c5d6ce07cdbca8cdaad9e69841f9c58e3d1e/django_pghistory-3.5.2-py3-none-any.whl", hash = "sha256:5f81a386e4c93e34b95de2064bbf42aabe4e20be53f7ad6638d25f137338f101", size = 38319 }, +] + +[[package]] +name = "django-pgtrigger" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/98/d93f658316901c54a00ec0caefc3e73796b2f7ffcc1fd11188da22c45027/django_pgtrigger-4.13.3.tar.gz", hash = "sha256:c525f9e81f120d166c4bd5fe8c3770640356f0644edf0fc2b7f6426008e52f77", size = 30723 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/cb/3bb87d45b1b46ef36f3786fbfcda0a43f80c8fa4e742a350a2cd1512557e/django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:d6e4d17021bbd5e425a308f07414b237b9b34423275d86ad756b90c307df3ca4", size = 34059 }, +] + [[package]] name = "django-simple-history" version = "3.7.0" @@ -626,6 +651,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712 }, { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155 }, { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356 }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224 }, ] [[package]] @@ -887,6 +913,7 @@ dependencies = [ { name = "django-filter" }, { name = "django-htmx" }, { name = "django-oauth-toolkit" }, + { name = "django-pghistory" }, { name = "django-simple-history" }, { name = "django-tailwind-cli" }, { name = "django-webpack-loader" }, @@ -920,6 +947,7 @@ requires-dist = [ { name = "django-filter", specifier = ">=23.5" }, { name = "django-htmx", specifier = ">=1.17.2" }, { name = "django-oauth-toolkit", specifier = ">=3.0.1" }, + { name = "django-pghistory", specifier = ">=3.5.2" }, { name = "django-simple-history", specifier = ">=3.5.0" }, { name = "django-tailwind-cli", specifier = ">=2.21.1" }, { name = "django-webpack-loader", specifier = ">=3.1.1" },