Add history tracking functionality using django-pghistory; implement views, templates, and middleware for event serialization and context management

This commit is contained in:
pacnpal
2025-02-09 09:52:19 -05:00
parent a148d34cf9
commit 7ecf43f1a4
14 changed files with 210 additions and 7 deletions

14
core/middleware.py Normal file
View File

@@ -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
})

View File

@@ -2,6 +2,18 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify 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): class SlugHistory(models.Model):
""" """
@@ -26,9 +38,11 @@ class SlugHistory(models.Model):
def __str__(self): def __str__(self):
return f"Old slug '{self.old_slug}' for {self.content_object}" 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. Abstract base model that provides slug functionality with history tracking.
Inherits from HistoricalModel to get universal history tracking.
""" """
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True) slug = models.SlugField(max_length=200, unique=True)
@@ -55,6 +69,7 @@ class SluggedModel(models.Model):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = slugify(self.name)
# Call HistoricalModel's save to ensure history tracking
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_id_field_name(self): def get_id_field_name(self):

12
history/apps.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,29 @@
<div id="history-timeline"
hx-get="{% url 'history:timeline' content_type_id=content_type.id object_id=object.id %}"
hx-trigger="every 30s, historyUpdate from:body">
<div class="space-y-4">
{% for event in events %}
<div class="component-wrapper bg-white p-4 shadow-sm">
<div class="component-header flex items-center gap-2 mb-2">
<span class="text-sm font-medium">{{ event.pgh_label|title }}</span>
<time class="text-xs text-gray-500">{{ event.pgh_created_at|date:"M j, Y H:i" }}</time>
</div>
<div class="component-content text-sm">
{% if event.pgh_context.metadata.user %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
</svg>
<span>{{ event.pgh_context.metadata.user }}</span>
</div>
{% endif %}
{% if event.pgh_data %}
<div class="mt-2 text-gray-600">
<pre class="text-xs">{{ event.pgh_data|pprint }}</pre>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>

View File

@@ -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)

10
history/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from .views import HistoryTimelineView
app_name = 'history'
urlpatterns = [
path('timeline/<int:content_type_id>/<int:object_id>/',
HistoryTimelineView.as_view(),
name='timeline'),
]

41
history/views.py Normal file
View File

@@ -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]
})

View File

@@ -2,6 +2,8 @@ from django.contrib import admin
from django.contrib.admin import AdminSite from django.contrib.admin import AdminSite
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
import pghistory
from .models import EditSubmission, PhotoSubmission from .models import EditSubmission, PhotoSubmission
class ModerationAdminSite(AdminSite): class ModerationAdminSite(AdminSite):
@@ -75,6 +77,33 @@ class PhotoSubmissionAdmin(admin.ModelAdmin):
obj.reject(request.user, obj.notes) obj.reject(request.user, obj.notes)
super().save_model(request, obj, form, change) 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('<a href="{}">{}</a>', 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 = ['<table>']
for key, value in obj.pgh_context.items():
html.append(f'<tr><th>{key}</th><td>{value}</td></tr>')
html.append('</table>')
return mark_safe(''.join(html))
get_context.short_description = 'Context'
# Register with moderation site only # Register with moderation site only
moderation_site.register(EditSubmission, EditSubmissionAdmin) moderation_site.register(EditSubmission, EditSubmissionAdmin)
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin) moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
moderation_site.register(pghistory.models.Event, HistoryAdmin)

View File

@@ -9,11 +9,13 @@ from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.utils.text import slugify from django.utils.text import slugify
import pghistory
from core.models import HistoricalModel
UserType = Union[AbstractBaseUser, AnonymousUser] UserType = Union[AbstractBaseUser, AnonymousUser]
@pghistory.track() # Track all changes by default
class EditSubmission(models.Model): class EditSubmission(HistoricalModel):
STATUS_CHOICES = [ STATUS_CHOICES = [
("PENDING", "Pending"), ("PENDING", "Pending"),
("APPROVED", "Approved"), ("APPROVED", "Approved"),
@@ -196,8 +198,8 @@ class EditSubmission(models.Model):
self.handled_at = timezone.now() self.handled_at = timezone.now()
self.save() self.save()
@pghistory.track() # Track all changes by default
class PhotoSubmission(models.Model): class PhotoSubmission(HistoricalModel):
STATUS_CHOICES = [ STATUS_CHOICES = [
("PENDING", "Pending"), ("PENDING", "Pending"),
("APPROVED", "Approved"), ("APPROVED", "Approved"),
@@ -287,7 +289,6 @@ class PhotoSubmission(models.Model):
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]: if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
self.approve(self.user) self.approve(self.user)
def escalate(self, moderator: UserType, notes: str = "") -> None: def escalate(self, moderator: UserType, notes: str = "") -> None:
"""Escalate the photo submission to admin""" """Escalate the photo submission to admin"""
self.status = "ESCALATED" self.status = "ESCALATED"

View File

@@ -55,5 +55,6 @@ dependencies = [
"django-simple-history>=3.5.0", "django-simple-history>=3.5.0",
"django-tailwind-cli>=2.21.1", "django-tailwind-cli>=2.21.1",
"playwright>=1.41.0", "playwright>=1.41.0",
"pytest-playwright>=0.4.3" "pytest-playwright>=0.4.3",
"django-pghistory>=3.5.2",
] ]

View File

@@ -13,6 +13,7 @@ pyjwt==2.10.1
# Database # Database
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
dj-database-url==2.3.0 dj-database-url==2.3.0
django-pghistory==2.9.0 # Added for model history tracking
# Email # Email
requests==2.32.3 # For ForwardEmail.net API requests==2.32.3 # For ForwardEmail.net API

View File

@@ -29,6 +29,9 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
"django.contrib.gis", # Add GeoDjango "django.contrib.gis", # Add GeoDjango
"pghistory", # Add django-pghistory
"pgtrigger", # Required by django-pghistory
"history.apps.HistoryConfig", # History timeline app
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount", "allauth.socialaccount",
@@ -65,6 +68,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"core.middleware.setup_pghistory_context", # Add history context tracking
"allauth.account.middleware.AccountMiddleware", "allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware", "django.middleware.cache.FetchFromCacheMiddleware",
"simple_history.middleware.HistoryRequestMiddleware", "simple_history.middleware.HistoryRequestMiddleware",

View File

@@ -52,6 +52,7 @@ urlpatterns = [
path("user/", accounts_views.user_redirect_view, name="user_redirect"), path("user/", accounts_views.user_redirect_view, name="user_redirect"),
# Moderation URLs - placed after other URLs but before static/media serving # Moderation URLs - placed after other URLs but before static/media serving
path("moderation/", include("moderation.urls", namespace="moderation")), path("moderation/", include("moderation.urls", namespace="moderation")),
path("history/", include("history.urls", namespace="history")),
path( path(
"env-settings/", "env-settings/",
views***REMOVED***ironment_and_settings_view, views***REMOVED***ironment_and_settings_view,

28
uv.lock generated
View File

@@ -328,6 +328,31 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_oauth_toolkit-3.0.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]221bbb1cffcb50b8932e55ed", size = 77299 }, { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_oauth_toolkit-3.0.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]221bbb1cffcb50b8932e55ed", 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.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pghistory-3.5.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]18779b04bc26cea8c4e967d7", size = 31221 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pghistory-3.5.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]53f7ad6638d25f137338f101", size = 38319 },
]
[[package]]
name = "django-pgtrigger"
version = "4.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]4edf0fc2b7f6426008e52f77", size = 30723 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]275d86ad756b90c307df3ca4", size = 34059 },
]
[[package]] [[package]]
name = "django-simple-history" name = "django-simple-history"
version = "3.7.0" version = "3.7.0"
@@ -626,6 +651,7 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:[AWS-SECRET-REMOVED]07c6df12b7737febc40f0909", size = 2822712 }, { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:[AWS-SECRET-REMOVED]07c6df12b7737febc40f0909", size = 2822712 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:[AWS-SECRET-REMOVED]860ff3bbe1384130828714b1", size = 2920155 }, { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:[AWS-SECRET-REMOVED]860ff3bbe1384130828714b1", size = 2920155 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]998122abe1dce6428bd86567", size = 2959356 }, { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]998122abe1dce6428bd86567", size = 2959356 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:[AWS-SECRET-REMOVED]9b46e6fd07c3eb46e4535142", size = 2569224 },
] ]
[[package]] [[package]]
@@ -887,6 +913,7 @@ dependencies = [
{ name = "django-filter" }, { name = "django-filter" },
{ name = "django-htmx" }, { name = "django-htmx" },
{ name = "django-oauth-toolkit" }, { name = "django-oauth-toolkit" },
{ name = "django-pghistory" },
{ name = "django-simple-history" }, { name = "django-simple-history" },
{ name = "django-tailwind-cli" }, { name = "django-tailwind-cli" },
{ name = "django-webpack-loader" }, { name = "django-webpack-loader" },
@@ -920,6 +947,7 @@ requires-dist = [
{ name = "django-filter", specifier = ">=23.5" }, { name = "django-filter", specifier = ">=23.5" },
{ name = "django-htmx", specifier = ">=1.17.2" }, { name = "django-htmx", specifier = ">=1.17.2" },
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" }, { 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-simple-history", specifier = ">=3.5.0" },
{ name = "django-tailwind-cli", specifier = ">=2.21.1" }, { name = "django-tailwind-cli", specifier = ">=2.21.1" },
{ name = "django-webpack-loader", specifier = ">=3.1.1" }, { name = "django-webpack-loader", specifier = ">=3.1.1" },