mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:51:09 -05:00
Add history tracking functionality using django-pghistory; implement views, templates, and middleware for event serialization and context management
This commit is contained in:
14
core/middleware.py
Normal file
14
core/middleware.py
Normal 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
|
||||||
|
})
|
||||||
@@ -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
12
history/apps.py
Normal 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()
|
||||||
29
history/templates/history/partials/history_timeline.html
Normal file
29
history/templates/history/partials/history_timeline.html
Normal 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>
|
||||||
17
history/templatetags/history_tags.py
Normal file
17
history/templatetags/history_tags.py
Normal 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
10
history/urls.py
Normal 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
41
history/views.py
Normal 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]
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
28
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user