Files
thrilltrack-explorer/django/apps/versioning/services.py
pacnpal d6ff4cc3a3 Add email templates for user notifications and account management
- Created a base email template (base.html) for consistent styling across all emails.
- Added moderation approval email template (moderation_approved.html) to notify users of approved submissions.
- Added moderation rejection email template (moderation_rejected.html) to inform users of required changes for their submissions.
- Created password reset email template (password_reset.html) for users requesting to reset their passwords.
- Developed a welcome email template (welcome.html) to greet new users and provide account details and tips for using ThrillWiki.
2025-11-08 15:34:04 -05:00

474 lines
16 KiB
Python

"""
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')