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