mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 23:11:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
0
django-backend/apps/versioning/__init__.py
Normal file
0
django-backend/apps/versioning/__init__.py
Normal file
236
django-backend/apps/versioning/admin.py
Normal file
236
django-backend/apps/versioning/admin.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Admin interface for versioning models.
|
||||
|
||||
Provides Django admin interface for viewing version history,
|
||||
comparing versions, and managing version records.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from unfold.admin import ModelAdmin
|
||||
|
||||
from apps.versioning.models import EntityVersion
|
||||
|
||||
|
||||
@admin.register(EntityVersion)
|
||||
class EntityVersionAdmin(ModelAdmin):
|
||||
"""
|
||||
Admin interface for EntityVersion model.
|
||||
|
||||
Provides read-only view of version history with search and filtering.
|
||||
"""
|
||||
|
||||
# Display settings
|
||||
list_display = [
|
||||
'version_number',
|
||||
'entity_link',
|
||||
'change_type',
|
||||
'changed_by_link',
|
||||
'submission_link',
|
||||
'changed_field_count',
|
||||
'created',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'change_type',
|
||||
'entity_type',
|
||||
'created',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'entity_id',
|
||||
'comment',
|
||||
'changed_by__email',
|
||||
'changed_by__username',
|
||||
]
|
||||
|
||||
ordering = ['-created']
|
||||
|
||||
date_hierarchy = 'created'
|
||||
|
||||
# Read-only admin (versions should not be modified)
|
||||
readonly_fields = [
|
||||
'id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'entity_link',
|
||||
'version_number',
|
||||
'change_type',
|
||||
'snapshot_display',
|
||||
'changed_fields_display',
|
||||
'changed_by',
|
||||
'submission',
|
||||
'comment',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'created',
|
||||
'modified',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Version Information', {
|
||||
'fields': (
|
||||
'id',
|
||||
'version_number',
|
||||
'change_type',
|
||||
'created',
|
||||
'modified',
|
||||
)
|
||||
}),
|
||||
('Entity', {
|
||||
'fields': (
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'entity_link',
|
||||
)
|
||||
}),
|
||||
('Changes', {
|
||||
'fields': (
|
||||
'changed_fields_display',
|
||||
'snapshot_display',
|
||||
)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': (
|
||||
'changed_by',
|
||||
'submission',
|
||||
'comment',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable adding versions manually."""
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Disable deleting versions."""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Only allow viewing versions, not editing."""
|
||||
return False
|
||||
|
||||
def entity_link(self, obj):
|
||||
"""Display link to the entity."""
|
||||
try:
|
||||
entity = obj.entity
|
||||
if entity:
|
||||
# Try to get admin URL for entity
|
||||
admin_url = reverse(
|
||||
f'admin:{obj.entity_type.app_label}_{obj.entity_type.model}_change',
|
||||
args=[entity.pk]
|
||||
)
|
||||
return format_html(
|
||||
'<a href="{}">{}</a>',
|
||||
admin_url,
|
||||
str(entity)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
return f"{obj.entity_type.model}:{obj.entity_id}"
|
||||
entity_link.short_description = 'Entity'
|
||||
|
||||
def changed_by_link(self, obj):
|
||||
"""Display link to user who made the change."""
|
||||
if obj.changed_by:
|
||||
try:
|
||||
admin_url = reverse(
|
||||
'admin:users_user_change',
|
||||
args=[obj.changed_by.pk]
|
||||
)
|
||||
return format_html(
|
||||
'<a href="{}">{}</a>',
|
||||
admin_url,
|
||||
obj.changed_by.email
|
||||
)
|
||||
except:
|
||||
return obj.changed_by.email
|
||||
return '-'
|
||||
changed_by_link.short_description = 'Changed By'
|
||||
|
||||
def submission_link(self, obj):
|
||||
"""Display link to content submission if applicable."""
|
||||
if obj.submission:
|
||||
try:
|
||||
admin_url = reverse(
|
||||
'admin:moderation_contentsubmission_change',
|
||||
args=[obj.submission.pk]
|
||||
)
|
||||
return format_html(
|
||||
'<a href="{}">#{}</a>',
|
||||
admin_url,
|
||||
obj.submission.pk
|
||||
)
|
||||
except:
|
||||
return str(obj.submission.pk)
|
||||
return '-'
|
||||
submission_link.short_description = 'Submission'
|
||||
|
||||
def changed_field_count(self, obj):
|
||||
"""Display count of changed fields."""
|
||||
count = len(obj.changed_fields)
|
||||
if count == 0:
|
||||
return '-'
|
||||
return f"{count} field{'s' if count != 1 else ''}"
|
||||
changed_field_count.short_description = 'Changed Fields'
|
||||
|
||||
def snapshot_display(self, obj):
|
||||
"""Display snapshot in a formatted way."""
|
||||
import json
|
||||
snapshot = obj.get_snapshot_dict()
|
||||
|
||||
# Format as pretty JSON
|
||||
formatted = json.dumps(snapshot, indent=2, sort_keys=True)
|
||||
|
||||
return format_html(
|
||||
'<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto;">{}</pre>',
|
||||
formatted
|
||||
)
|
||||
snapshot_display.short_description = 'Snapshot'
|
||||
|
||||
def changed_fields_display(self, obj):
|
||||
"""Display changed fields in a formatted way."""
|
||||
if not obj.changed_fields:
|
||||
return format_html('<em>No fields changed</em>')
|
||||
|
||||
html_parts = ['<table style="width: 100%; border-collapse: collapse;">']
|
||||
html_parts.append('<thead><tr style="background: #f5f5f5;">')
|
||||
html_parts.append('<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Field</th>')
|
||||
html_parts.append('<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Old Value</th>')
|
||||
html_parts.append('<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">New Value</th>')
|
||||
html_parts.append('</tr></thead><tbody>')
|
||||
|
||||
for field_name, change in obj.changed_fields.items():
|
||||
old_val = change.get('old', '-')
|
||||
new_val = change.get('new', '-')
|
||||
|
||||
# Truncate long values
|
||||
if isinstance(old_val, str) and len(old_val) > 100:
|
||||
old_val = old_val[:97] + '...'
|
||||
if isinstance(new_val, str) and len(new_val) > 100:
|
||||
new_val = new_val[:97] + '...'
|
||||
|
||||
html_parts.append('<tr>')
|
||||
html_parts.append(f'<td style="padding: 8px; border: 1px solid #ddd;"><strong>{field_name}</strong></td>')
|
||||
html_parts.append(f'<td style="padding: 8px; border: 1px solid #ddd; color: #d32f2f;">{old_val}</td>')
|
||||
html_parts.append(f'<td style="padding: 8px; border: 1px solid #ddd; color: #388e3c;">{new_val}</td>')
|
||||
html_parts.append('</tr>')
|
||||
|
||||
html_parts.append('</tbody></table>')
|
||||
|
||||
return format_html(''.join(html_parts))
|
||||
changed_fields_display.short_description = 'Changed Fields'
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related(
|
||||
'entity_type',
|
||||
'changed_by',
|
||||
'submission',
|
||||
'submission__user'
|
||||
)
|
||||
11
django-backend/apps/versioning/apps.py
Normal file
11
django-backend/apps/versioning/apps.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Versioning app configuration.
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class VersioningConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.versioning'
|
||||
verbose_name = 'Versioning'
|
||||
165
django-backend/apps/versioning/migrations/0001_initial.py
Normal file
165
django-backend/apps/versioning/migrations/0001_initial.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 17:51
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import django_lifecycle.mixins
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("moderation", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EntityVersion",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"entity_id",
|
||||
models.UUIDField(db_index=True, help_text="ID of the entity"),
|
||||
),
|
||||
(
|
||||
"version_number",
|
||||
models.PositiveIntegerField(
|
||||
default=1, help_text="Sequential version number for this entity"
|
||||
),
|
||||
),
|
||||
(
|
||||
"change_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("created", "Created"),
|
||||
("updated", "Updated"),
|
||||
("deleted", "Deleted"),
|
||||
("restored", "Restored"),
|
||||
],
|
||||
db_index=True,
|
||||
help_text="Type of change",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"snapshot",
|
||||
models.JSONField(
|
||||
help_text="Complete snapshot of entity state as JSON"
|
||||
),
|
||||
),
|
||||
(
|
||||
"changed_fields",
|
||||
models.JSONField(
|
||||
default=dict,
|
||||
help_text="Dict of changed fields with old/new values: {'field': {'old': ..., 'new': ...}}",
|
||||
),
|
||||
),
|
||||
(
|
||||
"comment",
|
||||
models.TextField(
|
||||
blank=True, help_text="Optional comment about this version"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ip_address",
|
||||
models.GenericIPAddressField(
|
||||
blank=True, help_text="IP address of change origin", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_agent",
|
||||
models.CharField(
|
||||
blank=True, help_text="User agent string", max_length=500
|
||||
),
|
||||
),
|
||||
(
|
||||
"changed_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who made the change",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="entity_versions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"entity_type",
|
||||
models.ForeignKey(
|
||||
help_text="Type of entity (Park, Ride, Company, etc.)",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="entity_versions",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"submission",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Submission that caused this version (if applicable)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="versions",
|
||||
to="moderation.contentsubmission",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Entity Version",
|
||||
"verbose_name_plural": "Entity Versions",
|
||||
"ordering": ["-created"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_id", "-created"],
|
||||
name="versioning__entity__8eabd9_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["entity_type", "entity_id", "-version_number"],
|
||||
name="versioning__entity__fe6f1b_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["change_type"], name="versioning__change__17de57_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["changed_by"], name="versioning__changed_39d5fd_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["submission"], name="versioning__submiss_345f6b_idx"
|
||||
),
|
||||
],
|
||||
"unique_together": {("entity_type", "entity_id", "version_number")},
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
]
|
||||
287
django-backend/apps/versioning/models.py
Normal file
287
django-backend/apps/versioning/models.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Versioning models for ThrillWiki.
|
||||
|
||||
This module provides automatic version tracking for all entities:
|
||||
- EntityVersion: Generic version model using ContentType
|
||||
- Full snapshot storage in JSON
|
||||
- Changed fields tracking with old/new values
|
||||
- Link to ContentSubmission when changes come from moderation
|
||||
"""
|
||||
|
||||
import json
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
class EntityVersion(BaseModel):
|
||||
"""
|
||||
Generic version tracking for all entities.
|
||||
|
||||
Stores a complete snapshot of the entity state at the time of change,
|
||||
along with metadata about what changed and who made the change.
|
||||
"""
|
||||
|
||||
CHANGE_TYPE_CHOICES = [
|
||||
('created', 'Created'),
|
||||
('updated', 'Updated'),
|
||||
('deleted', 'Deleted'),
|
||||
('restored', 'Restored'),
|
||||
]
|
||||
|
||||
# Entity reference (generic)
|
||||
entity_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='entity_versions',
|
||||
help_text="Type of entity (Park, Ride, Company, etc.)"
|
||||
)
|
||||
entity_id = models.UUIDField(
|
||||
db_index=True,
|
||||
help_text="ID of the entity"
|
||||
)
|
||||
entity = GenericForeignKey('entity_type', 'entity_id')
|
||||
|
||||
# Version info
|
||||
version_number = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Sequential version number for this entity"
|
||||
)
|
||||
change_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=CHANGE_TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Type of change"
|
||||
)
|
||||
|
||||
# Snapshot of entity state
|
||||
snapshot = models.JSONField(
|
||||
help_text="Complete snapshot of entity state as JSON"
|
||||
)
|
||||
|
||||
# Changed fields tracking
|
||||
changed_fields = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Dict of changed fields with old/new values: {'field': {'old': ..., 'new': ...}}"
|
||||
)
|
||||
|
||||
# User who made the change
|
||||
changed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='entity_versions',
|
||||
help_text="User who made the change"
|
||||
)
|
||||
|
||||
# Link to ContentSubmission (if change came from moderation)
|
||||
submission = models.ForeignKey(
|
||||
'moderation.ContentSubmission',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='versions',
|
||||
help_text="Submission that caused this version (if applicable)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
comment = models.TextField(
|
||||
blank=True,
|
||||
help_text="Optional comment about this version"
|
||||
)
|
||||
ip_address = models.GenericIPAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="IP address of change origin"
|
||||
)
|
||||
user_agent = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="User agent string"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Entity Version'
|
||||
verbose_name_plural = 'Entity Versions'
|
||||
ordering = ['-created']
|
||||
indexes = [
|
||||
models.Index(fields=['entity_type', 'entity_id', '-created']),
|
||||
models.Index(fields=['entity_type', 'entity_id', '-version_number']),
|
||||
models.Index(fields=['change_type']),
|
||||
models.Index(fields=['changed_by']),
|
||||
models.Index(fields=['submission']),
|
||||
]
|
||||
unique_together = [['entity_type', 'entity_id', 'version_number']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.entity_type.model} v{self.version_number} ({self.change_type})"
|
||||
|
||||
@property
|
||||
def entity_name(self):
|
||||
"""Get display name of the entity."""
|
||||
try:
|
||||
entity = self.entity
|
||||
if entity:
|
||||
return str(entity)
|
||||
except:
|
||||
pass
|
||||
return f"{self.entity_type.model}:{self.entity_id}"
|
||||
|
||||
def get_snapshot_dict(self):
|
||||
"""
|
||||
Get snapshot as Python dict.
|
||||
|
||||
Returns:
|
||||
dict: Entity snapshot
|
||||
"""
|
||||
if isinstance(self.snapshot, str):
|
||||
return json.loads(self.snapshot)
|
||||
return self.snapshot
|
||||
|
||||
def get_changed_fields_list(self):
|
||||
"""
|
||||
Get list of changed field names.
|
||||
|
||||
Returns:
|
||||
list: Field names that changed
|
||||
"""
|
||||
return list(self.changed_fields.keys())
|
||||
|
||||
def get_field_change(self, field_name):
|
||||
"""
|
||||
Get old and new values for a specific field.
|
||||
|
||||
Args:
|
||||
field_name: Name of the field
|
||||
|
||||
Returns:
|
||||
dict: {'old': old_value, 'new': new_value} or None if field didn't change
|
||||
"""
|
||||
return self.changed_fields.get(field_name)
|
||||
|
||||
def compare_with(self, other_version):
|
||||
"""
|
||||
Compare this version with another version.
|
||||
|
||||
Args:
|
||||
other_version: EntityVersion to compare with
|
||||
|
||||
Returns:
|
||||
dict: Comparison result with differences
|
||||
"""
|
||||
if not other_version or self.entity_id != other_version.entity_id:
|
||||
return None
|
||||
|
||||
this_snapshot = self.get_snapshot_dict()
|
||||
other_snapshot = other_version.get_snapshot_dict()
|
||||
|
||||
differences = {}
|
||||
all_keys = set(this_snapshot.keys()) | set(other_snapshot.keys())
|
||||
|
||||
for key in all_keys:
|
||||
this_val = this_snapshot.get(key)
|
||||
other_val = other_snapshot.get(key)
|
||||
|
||||
if this_val != other_val:
|
||||
differences[key] = {
|
||||
'this': this_val,
|
||||
'other': other_val
|
||||
}
|
||||
|
||||
return {
|
||||
'this_version': self.version_number,
|
||||
'other_version': other_version.version_number,
|
||||
'differences': differences,
|
||||
'changed_field_count': len(differences)
|
||||
}
|
||||
|
||||
def get_diff_summary(self):
|
||||
"""
|
||||
Get human-readable summary of changes in this version.
|
||||
|
||||
Returns:
|
||||
str: Summary of changes
|
||||
"""
|
||||
if self.change_type == 'created':
|
||||
return f"Created {self.entity_name}"
|
||||
|
||||
if self.change_type == 'deleted':
|
||||
return f"Deleted {self.entity_name}"
|
||||
|
||||
changed_count = len(self.changed_fields)
|
||||
if changed_count == 0:
|
||||
return f"No changes to {self.entity_name}"
|
||||
|
||||
field_names = ', '.join(self.get_changed_fields_list()[:3])
|
||||
if changed_count > 3:
|
||||
field_names += f" and {changed_count - 3} more"
|
||||
|
||||
return f"Updated {field_names}"
|
||||
|
||||
@classmethod
|
||||
def get_latest_version_number(cls, entity_type, entity_id):
|
||||
"""
|
||||
Get the latest version number for an entity.
|
||||
|
||||
Args:
|
||||
entity_type: ContentType of entity
|
||||
entity_id: UUID of entity
|
||||
|
||||
Returns:
|
||||
int: Latest version number (0 if no versions exist)
|
||||
"""
|
||||
latest = cls.objects.filter(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id
|
||||
).aggregate(
|
||||
max_version=models.Max('version_number')
|
||||
)
|
||||
return latest['max_version'] or 0
|
||||
|
||||
@classmethod
|
||||
def get_history(cls, entity_type, entity_id, limit=50):
|
||||
"""
|
||||
Get version history for an entity.
|
||||
|
||||
Args:
|
||||
entity_type: ContentType of entity
|
||||
entity_id: UUID of entity
|
||||
limit: Maximum number of versions to return
|
||||
|
||||
Returns:
|
||||
QuerySet: Ordered list of versions (newest first)
|
||||
"""
|
||||
return cls.objects.filter(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id
|
||||
).select_related(
|
||||
'changed_by',
|
||||
'submission',
|
||||
'submission__user'
|
||||
).order_by('-version_number')[:limit]
|
||||
|
||||
@classmethod
|
||||
def get_version_by_number(cls, entity_type, entity_id, version_number):
|
||||
"""
|
||||
Get a specific version by number.
|
||||
|
||||
Args:
|
||||
entity_type: ContentType of entity
|
||||
entity_id: UUID of entity
|
||||
version_number: Version number to retrieve
|
||||
|
||||
Returns:
|
||||
EntityVersion or None
|
||||
"""
|
||||
try:
|
||||
return cls.objects.get(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
version_number=version_number
|
||||
)
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
473
django-backend/apps/versioning/services.py
Normal file
473
django-backend/apps/versioning/services.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Versioning services for ThrillWiki.
|
||||
|
||||
This module provides the business logic for creating and managing entity versions:
|
||||
- Creating versions automatically via lifecycle hooks
|
||||
- Generating snapshots and tracking changed fields
|
||||
- Linking versions to content submissions
|
||||
- Retrieving version history and diffs
|
||||
- Restoring previous versions
|
||||
"""
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from datetime import date, datetime
|
||||
from django.db import models, transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.versioning.models import EntityVersion
|
||||
|
||||
|
||||
class VersionService:
|
||||
"""
|
||||
Service class for versioning operations.
|
||||
|
||||
All methods handle automatic version creation and tracking.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_version(
|
||||
entity,
|
||||
change_type='updated',
|
||||
changed_fields=None,
|
||||
user=None,
|
||||
submission=None,
|
||||
comment='',
|
||||
ip_address=None,
|
||||
user_agent=''
|
||||
):
|
||||
"""
|
||||
Create a version record for an entity.
|
||||
|
||||
This is called automatically by the VersionedModel lifecycle hooks,
|
||||
but can also be called manually when needed.
|
||||
|
||||
Args:
|
||||
entity: Entity instance (Park, Ride, Company, etc.)
|
||||
change_type: Type of change ('created', 'updated', 'deleted', 'restored')
|
||||
changed_fields: Dict of dirty fields from DirtyFieldsMixin
|
||||
user: User who made the change (optional)
|
||||
submission: ContentSubmission that caused this change (optional)
|
||||
comment: Optional comment about the change
|
||||
ip_address: IP address of the change origin
|
||||
user_agent: User agent string
|
||||
|
||||
Returns:
|
||||
EntityVersion instance
|
||||
"""
|
||||
# Get ContentType for entity
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
# Get next version number
|
||||
version_number = EntityVersion.get_latest_version_number(
|
||||
entity_type, entity.id
|
||||
) + 1
|
||||
|
||||
# Create snapshot of current entity state
|
||||
snapshot = VersionService._create_snapshot(entity)
|
||||
|
||||
# Build changed_fields dict with old/new values
|
||||
changed_fields_data = {}
|
||||
if changed_fields and change_type == 'updated':
|
||||
changed_fields_data = VersionService._build_changed_fields(
|
||||
entity, changed_fields
|
||||
)
|
||||
|
||||
# Try to get user from submission if not provided
|
||||
if not user and submission:
|
||||
user = submission.user
|
||||
|
||||
# Create version record
|
||||
version = EntityVersion.objects.create(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity.id,
|
||||
version_number=version_number,
|
||||
change_type=change_type,
|
||||
snapshot=snapshot,
|
||||
changed_fields=changed_fields_data,
|
||||
changed_by=user,
|
||||
submission=submission,
|
||||
comment=comment,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
@staticmethod
|
||||
def _create_snapshot(entity):
|
||||
"""
|
||||
Create a JSON snapshot of the entity's current state.
|
||||
|
||||
Args:
|
||||
entity: Entity instance
|
||||
|
||||
Returns:
|
||||
dict: Serializable snapshot of entity
|
||||
"""
|
||||
snapshot = {}
|
||||
|
||||
# Get all model fields
|
||||
for field in entity._meta.get_fields():
|
||||
# Skip reverse relations
|
||||
if field.is_relation and field.many_to_one is False and field.one_to_many is True:
|
||||
continue
|
||||
if field.is_relation and field.many_to_many is True:
|
||||
continue
|
||||
|
||||
field_name = field.name
|
||||
|
||||
try:
|
||||
value = getattr(entity, field_name)
|
||||
|
||||
# Handle different field types
|
||||
if value is None:
|
||||
snapshot[field_name] = None
|
||||
elif isinstance(value, (str, int, float, bool)):
|
||||
snapshot[field_name] = value
|
||||
elif isinstance(value, Decimal):
|
||||
snapshot[field_name] = float(value)
|
||||
elif isinstance(value, (date, datetime)):
|
||||
snapshot[field_name] = value.isoformat()
|
||||
elif isinstance(value, models.Model):
|
||||
# Store FK as ID
|
||||
snapshot[field_name] = str(value.id) if value.id else None
|
||||
elif isinstance(value, dict):
|
||||
# JSONField
|
||||
snapshot[field_name] = value
|
||||
elif isinstance(value, list):
|
||||
# JSONField array
|
||||
snapshot[field_name] = value
|
||||
else:
|
||||
# Try to serialize as string
|
||||
snapshot[field_name] = str(value)
|
||||
except Exception:
|
||||
# Skip fields that can't be serialized
|
||||
continue
|
||||
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _build_changed_fields(entity, dirty_fields):
|
||||
"""
|
||||
Build a dict of changed fields with old and new values.
|
||||
|
||||
Args:
|
||||
entity: Entity instance
|
||||
dirty_fields: Dict from DirtyFieldsMixin.get_dirty_fields()
|
||||
|
||||
Returns:
|
||||
dict: Changed fields with old/new values
|
||||
"""
|
||||
changed = {}
|
||||
|
||||
for field_name, old_value in dirty_fields.items():
|
||||
try:
|
||||
new_value = getattr(entity, field_name)
|
||||
|
||||
# Normalize values for JSON
|
||||
old_normalized = VersionService._normalize_value(old_value)
|
||||
new_normalized = VersionService._normalize_value(new_value)
|
||||
|
||||
changed[field_name] = {
|
||||
'old': old_normalized,
|
||||
'new': new_normalized
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _normalize_value(value):
|
||||
"""
|
||||
Normalize a value for JSON serialization.
|
||||
|
||||
Args:
|
||||
value: Value to normalize
|
||||
|
||||
Returns:
|
||||
Normalized value
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
elif isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
elif isinstance(value, Decimal):
|
||||
return float(value)
|
||||
elif isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, models.Model):
|
||||
return str(value.id) if value.id else None
|
||||
elif isinstance(value, (dict, list)):
|
||||
return value
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def get_version_history(entity, limit=50):
|
||||
"""
|
||||
Get version history for an entity.
|
||||
|
||||
Args:
|
||||
entity: Entity instance
|
||||
limit: Maximum number of versions to return
|
||||
|
||||
Returns:
|
||||
QuerySet: Ordered list of versions (newest first)
|
||||
"""
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
return EntityVersion.get_history(entity_type, entity.id, limit)
|
||||
|
||||
@staticmethod
|
||||
def get_version_by_number(entity, version_number):
|
||||
"""
|
||||
Get a specific version by number.
|
||||
|
||||
Args:
|
||||
entity: Entity instance
|
||||
version_number: Version number to retrieve
|
||||
|
||||
Returns:
|
||||
EntityVersion or None
|
||||
"""
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
return EntityVersion.get_version_by_number(entity_type, entity.id, version_number)
|
||||
|
||||
@staticmethod
|
||||
def get_latest_version(entity):
|
||||
"""
|
||||
Get the latest version for an entity.
|
||||
|
||||
Args:
|
||||
entity: Entity instance
|
||||
|
||||
Returns:
|
||||
EntityVersion or None
|
||||
"""
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
return EntityVersion.objects.filter(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity.id
|
||||
).order_by('-version_number').first()
|
||||
|
||||
@staticmethod
|
||||
def compare_versions(version1, version2):
|
||||
"""
|
||||
Compare two versions of the same entity.
|
||||
|
||||
Args:
|
||||
version1: First EntityVersion
|
||||
version2: Second EntityVersion
|
||||
|
||||
Returns:
|
||||
dict: Comparison result with differences
|
||||
"""
|
||||
if version1.entity_id != version2.entity_id:
|
||||
raise ValidationError("Versions must be for the same entity")
|
||||
|
||||
return version1.compare_with(version2)
|
||||
|
||||
@staticmethod
|
||||
def get_diff_with_current(version):
|
||||
"""
|
||||
Compare a version with the current entity state.
|
||||
|
||||
Args:
|
||||
version: EntityVersion to compare
|
||||
|
||||
Returns:
|
||||
dict: Differences between version and current state
|
||||
"""
|
||||
entity = version.entity
|
||||
if not entity:
|
||||
raise ValidationError("Entity no longer exists")
|
||||
|
||||
current_snapshot = VersionService._create_snapshot(entity)
|
||||
version_snapshot = version.get_snapshot_dict()
|
||||
|
||||
differences = {}
|
||||
all_keys = set(current_snapshot.keys()) | set(version_snapshot.keys())
|
||||
|
||||
for key in all_keys:
|
||||
current_val = current_snapshot.get(key)
|
||||
version_val = version_snapshot.get(key)
|
||||
|
||||
if current_val != version_val:
|
||||
differences[key] = {
|
||||
'current': current_val,
|
||||
'version': version_val
|
||||
}
|
||||
|
||||
return {
|
||||
'version_number': version.version_number,
|
||||
'differences': differences,
|
||||
'changed_field_count': len(differences)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def restore_version(version, user=None, comment=''):
|
||||
"""
|
||||
Restore an entity to a previous version.
|
||||
|
||||
This creates a new version with change_type='restored'.
|
||||
|
||||
Args:
|
||||
version: EntityVersion to restore
|
||||
user: User performing the restore
|
||||
comment: Optional comment about the restore
|
||||
|
||||
Returns:
|
||||
EntityVersion: New version created by restore
|
||||
|
||||
Raises:
|
||||
ValidationError: If entity doesn't exist
|
||||
"""
|
||||
entity = version.entity
|
||||
if not entity:
|
||||
raise ValidationError("Entity no longer exists")
|
||||
|
||||
# Get snapshot to restore
|
||||
snapshot = version.get_snapshot_dict()
|
||||
|
||||
# Track which fields are changing
|
||||
changed_fields = {}
|
||||
|
||||
# Apply snapshot values to entity
|
||||
for field_name, value in snapshot.items():
|
||||
# Skip metadata fields
|
||||
if field_name in ['id', 'created', 'modified']:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get current value
|
||||
current_value = getattr(entity, field_name, None)
|
||||
current_normalized = VersionService._normalize_value(current_value)
|
||||
|
||||
# Check if value is different
|
||||
if current_normalized != value:
|
||||
changed_fields[field_name] = {
|
||||
'old': current_normalized,
|
||||
'new': value
|
||||
}
|
||||
|
||||
# Apply restored value
|
||||
# Handle special field types
|
||||
field = entity._meta.get_field(field_name)
|
||||
|
||||
if isinstance(field, models.ForeignKey):
|
||||
# FK fields need model instance
|
||||
if value:
|
||||
related_model = field.related_model
|
||||
try:
|
||||
related_obj = related_model.objects.get(id=value)
|
||||
setattr(entity, field_name, related_obj)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
setattr(entity, field_name, None)
|
||||
elif isinstance(field, models.DateField):
|
||||
# Date fields
|
||||
if value:
|
||||
setattr(entity, field_name, datetime.fromisoformat(value).date())
|
||||
else:
|
||||
setattr(entity, field_name, None)
|
||||
elif isinstance(field, models.DateTimeField):
|
||||
# DateTime fields
|
||||
if value:
|
||||
setattr(entity, field_name, datetime.fromisoformat(value))
|
||||
else:
|
||||
setattr(entity, field_name, None)
|
||||
elif isinstance(field, models.DecimalField):
|
||||
# Decimal fields
|
||||
if value is not None:
|
||||
setattr(entity, field_name, Decimal(str(value)))
|
||||
else:
|
||||
setattr(entity, field_name, None)
|
||||
else:
|
||||
# Regular fields
|
||||
setattr(entity, field_name, value)
|
||||
except Exception:
|
||||
# Skip fields that can't be restored
|
||||
continue
|
||||
|
||||
# Save entity (this will trigger lifecycle hooks)
|
||||
# But we need to create the version manually to mark it as 'restored'
|
||||
entity.save()
|
||||
|
||||
# Create restore version
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
version_number = EntityVersion.get_latest_version_number(
|
||||
entity_type, entity.id
|
||||
) + 1
|
||||
|
||||
restored_version = EntityVersion.objects.create(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity.id,
|
||||
version_number=version_number,
|
||||
change_type='restored',
|
||||
snapshot=VersionService._create_snapshot(entity),
|
||||
changed_fields=changed_fields,
|
||||
changed_by=user,
|
||||
comment=f"Restored from version {version.version_number}. {comment}".strip()
|
||||
)
|
||||
|
||||
return restored_version
|
||||
|
||||
@staticmethod
|
||||
def get_version_count(entity):
|
||||
"""
|
||||
Get total number of versions for an entity.
|
||||
|
||||
Args:
|
||||
entity: Entity instance
|
||||
|
||||
Returns:
|
||||
int: Number of versions
|
||||
"""
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
return EntityVersion.objects.filter(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity.id
|
||||
).count()
|
||||
|
||||
@staticmethod
|
||||
def get_versions_by_user(user, limit=50):
|
||||
"""
|
||||
Get versions created by a specific user.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
limit: Maximum number of versions to return
|
||||
|
||||
Returns:
|
||||
QuerySet: Versions by user (newest first)
|
||||
"""
|
||||
return EntityVersion.objects.filter(
|
||||
changed_by=user
|
||||
).select_related(
|
||||
'entity_type',
|
||||
'submission'
|
||||
).order_by('-created')[:limit]
|
||||
|
||||
@staticmethod
|
||||
def get_versions_by_submission(submission):
|
||||
"""
|
||||
Get all versions created by a content submission.
|
||||
|
||||
Args:
|
||||
submission: ContentSubmission instance
|
||||
|
||||
Returns:
|
||||
QuerySet: Versions from submission
|
||||
"""
|
||||
return EntityVersion.objects.filter(
|
||||
submission=submission
|
||||
).select_related(
|
||||
'entity_type',
|
||||
'changed_by'
|
||||
).order_by('-created')
|
||||
Reference in New Issue
Block a user