""" Entity submission services for ThrillWiki. This module implements entity creation through the Sacred Pipeline. All entities (Parks, Rides, Companies, RideModels) must flow through the ContentSubmission moderation workflow. Services: - BaseEntitySubmissionService: Abstract base for all entity submissions - ParkSubmissionService: Park creation through Sacred Pipeline - RideSubmissionService: Ride creation through Sacred Pipeline - CompanySubmissionService: Company creation through Sacred Pipeline - RideModelSubmissionService: RideModel creation through Sacred Pipeline """ import logging from django.db import transaction from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from apps.moderation.services import ModerationService logger = logging.getLogger(__name__) class BaseEntitySubmissionService: """ Base service for entity submissions through the Sacred Pipeline. This abstract base class provides common functionality for creating entities via the ContentSubmission moderation workflow. Subclasses must define: - entity_model: The Django model class (e.g., Park, Ride) - entity_type_name: Human-readable name for logging (e.g., 'Park') - required_fields: List of required field names (e.g., ['name', 'park_type']) Features: - Moderator bypass: Auto-approves for users with moderator role - Atomic transactions: All-or-nothing database operations - Comprehensive logging: Full audit trail - Submission items: Each field tracked separately for selective approval - Placeholder entities: Created immediately for ContentSubmission reference Usage: class ParkSubmissionService(BaseEntitySubmissionService): entity_model = Park entity_type_name = 'Park' required_fields = ['name', 'park_type'] submission, park = ParkSubmissionService.create_entity_submission( user=request.user, data={'name': 'Cedar Point', 'park_type': 'theme_park'}, source='api' ) """ # Subclasses must override these entity_model = None entity_type_name = None required_fields = [] @classmethod def _validate_configuration(cls): """Validate that subclass has configured required attributes.""" if cls.entity_model is None: raise NotImplementedError(f"{cls.__name__} must define entity_model") if cls.entity_type_name is None: raise NotImplementedError(f"{cls.__name__} must define entity_type_name") if not cls.required_fields: raise NotImplementedError(f"{cls.__name__} must define required_fields") @classmethod @transaction.atomic def create_entity_submission(cls, user, data, **kwargs): """ Create entity submission through Sacred Pipeline. This method creates a ContentSubmission with SubmissionItems for each field. A placeholder entity is created immediately to satisfy ContentSubmission's entity reference requirement. The entity is "activated" upon approval. For moderators, the submission is auto-approved and the entity is immediately created with all fields populated. Args: user: User creating the entity (must be authenticated) data: Dict of entity field data Example: {'name': 'Cedar Point', 'park_type': 'theme_park', ...} **kwargs: Additional metadata - source: Submission source ('api', 'web', etc.) - default: 'api' - ip_address: User's IP address (optional) - user_agent: User's user agent string (optional) Returns: tuple: (ContentSubmission, Entity or None) Entity will be None if pending moderation (non-moderators) Entity will be populated if moderator (auto-approved) Raises: ValidationError: If required fields are missing or invalid NotImplementedError: If subclass not properly configured Example: submission, park = ParkSubmissionService.create_entity_submission( user=request.user, data={ 'name': 'Cedar Point', 'park_type': 'theme_park', 'status': 'operating', 'latitude': Decimal('41.4792'), 'longitude': Decimal('-82.6839') }, source='api', ip_address='192.168.1.1' ) if park: # Moderator - entity created immediately logger.info(f"Park created: {park.id}") else: # Regular user - awaiting moderation logger.info(f"Submission pending: {submission.id}") """ # Validate configuration cls._validate_configuration() # Validate required fields for field in cls.required_fields: if field not in data or data[field] is None: raise ValidationError(f"Required field missing: {field}") # Check if user is moderator (for bypass) is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False logger.info( f"{cls.entity_type_name} submission starting: " f"user={user.email if user else 'anonymous'}, " f"is_moderator={is_moderator}, " f"fields={list(data.keys())}" ) # Build submission items for each field items_data = [] order = 0 for field_name, value in data.items(): # Skip None values for non-required fields if value is None and field_name not in cls.required_fields: continue # Convert value to string for storage # Handle special types if value is None: str_value = None elif hasattr(value, 'id'): # Foreign key - store UUID str_value = str(value.id) else: str_value = str(value) items_data.append({ 'field_name': field_name, 'field_label': field_name.replace('_', ' ').title(), 'old_value': None, 'new_value': str_value, 'change_type': 'add', 'is_required': field_name in cls.required_fields, 'order': order }) order += 1 logger.info(f"Built {len(items_data)} submission items for {cls.entity_type_name}") # Create placeholder entity for submission # Only set required fields to avoid validation errors placeholder_data = {} for field in cls.required_fields: if field in data: placeholder_data[field] = data[field] try: placeholder_entity = cls.entity_model(**placeholder_data) placeholder_entity.save() logger.info( f"Placeholder {cls.entity_type_name} created: {placeholder_entity.id}" ) except Exception as e: logger.error( f"Failed to create placeholder {cls.entity_type_name}: {str(e)}" ) raise ValidationError(f"Entity validation failed: {str(e)}") # Create submission through ModerationService try: submission = ModerationService.create_submission( user=user, entity=placeholder_entity, submission_type='create', title=f"Create {cls.entity_type_name}: {data.get('name', 'Unnamed')}", description=f"User creating new {cls.entity_type_name}", items_data=items_data, metadata={ 'entity_type': cls.entity_type_name, 'creation_data': data }, auto_submit=True, source=kwargs.get('source', 'api'), ip_address=kwargs.get('ip_address'), user_agent=kwargs.get('user_agent', '') ) logger.info( f"{cls.entity_type_name} submission created: {submission.id} " f"(status: {submission.status})" ) except Exception as e: # Rollback: delete placeholder entity placeholder_entity.delete() logger.error( f"Failed to create submission for {cls.entity_type_name}: {str(e)}" ) raise # MODERATOR BYPASS: Auto-approve and create entity entity = None if is_moderator: logger.info( f"Moderator bypass activated for submission {submission.id}" ) try: # Approve submission through ModerationService submission = ModerationService.approve_submission(submission.id, user) logger.info( f"Submission {submission.id} auto-approved " f"(new status: {submission.status})" ) # Update placeholder entity with all approved fields entity = placeholder_entity for item in submission.items.filter(status='approved'): field_name = item.field_name # Handle foreign key fields if hasattr(cls.entity_model, field_name): field = cls.entity_model._meta.get_field(field_name) if field.is_relation: # Foreign key - convert UUID string back to model instance if item.new_value: try: related_model = field.related_model related_instance = related_model.objects.get( id=item.new_value ) setattr(entity, field_name, related_instance) except Exception as e: logger.warning( f"Failed to set FK {field_name}: {str(e)}" ) else: # Regular field - set directly setattr(entity, field_name, data.get(field_name)) entity.save() logger.info( f"{cls.entity_type_name} auto-created for moderator: {entity.id} " f"(name: {getattr(entity, 'name', 'N/A')})" ) except Exception as e: logger.error( f"Failed to auto-approve {cls.entity_type_name} " f"submission {submission.id}: {str(e)}" ) # Don't raise - submission still exists in pending state else: logger.info( f"{cls.entity_type_name} submission {submission.id} " f"pending moderation (user: {user.email})" ) return submission, entity @classmethod @transaction.atomic def update_entity_submission(cls, entity, user, update_data, **kwargs): """ Update an existing entity by creating an update submission. This follows the Sacred Pipeline by creating a ContentSubmission for the update. Changes must be approved before taking effect (unless user is moderator). Args: entity: Existing entity instance to update user: User making the update update_data: Dict of fields to update **kwargs: Additional metadata (source, ip_address, user_agent) Returns: ContentSubmission: The update submission Raises: ValidationError: If validation fails """ cls._validate_configuration() # Check if user is moderator (for bypass) is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False # Build submission items for changed fields items_data = [] order = 0 for field_name, new_value in update_data.items(): old_value = getattr(entity, field_name, None) # Only include if value actually changed if old_value != new_value: items_data.append({ 'field_name': field_name, 'field_label': field_name.replace('_', ' ').title(), 'old_value': str(old_value) if old_value is not None else None, 'new_value': str(new_value) if new_value is not None else None, 'change_type': 'modify', 'is_required': field_name in cls.required_fields, 'order': order }) order += 1 if not items_data: raise ValidationError("No changes detected") # Create update submission submission = ModerationService.create_submission( user=user, entity=entity, submission_type='update', title=f"Update {cls.entity_type_name}: {getattr(entity, 'name', str(entity.id))}", description=f"User updating {cls.entity_type_name}", items_data=items_data, metadata={ 'entity_type': cls.entity_type_name, 'entity_id': str(entity.id) }, auto_submit=True, source=kwargs.get('source', 'api'), ip_address=kwargs.get('ip_address'), user_agent=kwargs.get('user_agent', '') ) logger.info(f"{cls.entity_type_name} update submission created: {submission.id}") # MODERATOR BYPASS: Auto-approve and apply changes if is_moderator: submission = ModerationService.approve_submission(submission.id, user) # Apply updates to entity for item in submission.items.filter(status='approved'): setattr(entity, item.field_name, item.new_value) entity.save() logger.info(f"{cls.entity_type_name} update auto-approved: {entity.id}") return submission @classmethod @transaction.atomic def delete_entity_submission(cls, entity, user, **kwargs): """ Delete (or soft-delete) an existing entity through Sacred Pipeline. This follows the Sacred Pipeline by creating a ContentSubmission for the deletion. Deletion must be approved before taking effect (unless user is moderator). **Deletion Strategy:** - Soft Delete (default): Sets entity status to 'closed' - keeps data for audit trail - Hard Delete: Actually removes entity from database (moderators only) Args: entity: Existing entity instance to delete user: User requesting the deletion **kwargs: Additional metadata - deletion_type: 'soft' (default) or 'hard' - deletion_reason: User-provided reason for deletion - source: Submission source ('api', 'web', etc.) - default: 'api' - ip_address: User's IP address (optional) - user_agent: User's user agent string (optional) Returns: tuple: (ContentSubmission, deletion_applied: bool) deletion_applied is True if moderator (immediate deletion) deletion_applied is False if regular user (pending moderation) Raises: ValidationError: If validation fails Example: submission, deleted = ParkSubmissionService.delete_entity_submission( entity=park, user=request.user, deletion_type='soft', deletion_reason='Park permanently closed', source='api', ip_address='192.168.1.1' ) if deleted: # Moderator - deletion applied immediately logger.info(f"Park deleted: {park.id}") else: # Regular user - awaiting moderation logger.info(f"Deletion pending: {submission.id}") """ cls._validate_configuration() # Check if user is moderator (for bypass) is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False # Get deletion parameters deletion_type = kwargs.get('deletion_type', 'soft') deletion_reason = kwargs.get('deletion_reason', '') # Validate deletion type if deletion_type not in ['soft', 'hard']: raise ValidationError("deletion_type must be 'soft' or 'hard'") # Only moderators can hard delete if deletion_type == 'hard' and not is_moderator: deletion_type = 'soft' logger.warning( f"Non-moderator {user.email} attempted hard delete, " f"falling back to soft delete" ) logger.info( f"{cls.entity_type_name} deletion request: " f"entity={entity.id}, user={user.email if user else 'anonymous'}, " f"type={deletion_type}, is_moderator={is_moderator}" ) # Build submission items for deletion items_data = [] # For soft delete, track status change if deletion_type == 'soft': if hasattr(entity, 'status'): old_status = getattr(entity, 'status', 'operating') items_data.append({ 'field_name': 'status', 'field_label': 'Status', 'old_value': old_status, 'new_value': 'closed', 'change_type': 'modify', 'is_required': True, 'order': 0 }) # Add deletion metadata item items_data.append({ 'field_name': '_deletion_marker', 'field_label': 'Deletion Request', 'old_value': 'active', 'new_value': 'deleted' if deletion_type == 'hard' else 'closed', 'change_type': 'remove' if deletion_type == 'hard' else 'modify', 'is_required': True, 'order': 1 }) # Create entity snapshot for potential restoration entity_snapshot = {} for field in entity._meta.fields: if not field.primary_key: try: value = getattr(entity, field.name) if value is not None: if hasattr(value, 'id'): entity_snapshot[field.name] = str(value.id) else: entity_snapshot[field.name] = str(value) except: pass # Create deletion submission through ModerationService try: submission = ModerationService.create_submission( user=user, entity=entity, submission_type='delete', title=f"Delete {cls.entity_type_name}: {getattr(entity, 'name', str(entity.id))}", description=deletion_reason or f"User requesting {deletion_type} deletion of {cls.entity_type_name}", items_data=items_data, metadata={ 'entity_type': cls.entity_type_name, 'entity_id': str(entity.id), 'entity_name': getattr(entity, 'name', str(entity.id)), 'deletion_type': deletion_type, 'deletion_reason': deletion_reason, 'entity_snapshot': entity_snapshot }, auto_submit=True, source=kwargs.get('source', 'api'), ip_address=kwargs.get('ip_address'), user_agent=kwargs.get('user_agent', '') ) logger.info( f"{cls.entity_type_name} deletion submission created: {submission.id} " f"(status: {submission.status})" ) except Exception as e: logger.error( f"Failed to create deletion submission for {cls.entity_type_name}: {str(e)}" ) raise # MODERATOR BYPASS: Auto-approve and apply deletion deletion_applied = False if is_moderator: logger.info( f"Moderator bypass activated for deletion submission {submission.id}" ) try: # Approve submission through ModerationService submission = ModerationService.approve_submission(submission.id, user) deletion_applied = True logger.info( f"Deletion submission {submission.id} auto-approved " f"(new status: {submission.status})" ) if deletion_type == 'soft': # Entity status was set to 'closed' by approval logic logger.info( f"{cls.entity_type_name} soft-deleted (marked as closed): {entity.id} " f"(name: {getattr(entity, 'name', 'N/A')})" ) else: # Entity was hard-deleted by approval logic logger.info( f"{cls.entity_type_name} hard-deleted from database: {entity.id} " f"(name: {getattr(entity, 'name', 'N/A')})" ) except Exception as e: logger.error( f"Failed to auto-approve {cls.entity_type_name} " f"deletion submission {submission.id}: {str(e)}" ) # Don't raise - submission still exists in pending state else: logger.info( f"{cls.entity_type_name} deletion submission {submission.id} " f"pending moderation (user: {user.email})" ) return submission, deletion_applied