mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:11:17 -05:00
- 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.
474 lines
16 KiB
Python
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')
|