Files
thrilltrack-explorer/django-backend/apps/entities/services/__init__.py

563 lines
22 KiB
Python

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