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