mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 04:51:22 -05:00
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.
This commit is contained in:
473
django/apps/versioning/services.py
Normal file
473
django/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