mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 22:11:12 -05:00
563 lines
22 KiB
Python
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
|