mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 20:51:13 -05:00
Implement entity submission services for ThrillWiki
- Added BaseEntitySubmissionService as an abstract base for entity submissions. - Created specific submission services for entities: Park, Ride, Company, RideModel. - Implemented create, update, and delete functionalities with moderation workflow. - Enhanced logging and validation for required fields. - Addressed foreign key handling and special field processing for each entity type. - Noted existing issues with JSONField usage in Company submissions.
This commit is contained in:
@@ -16,6 +16,58 @@ import pghistory
|
||||
|
||||
from apps.core.models import VersionedModel, BaseModel
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class CompanyType(BaseModel):
|
||||
"""
|
||||
Lookup table for company types (manufacturer, operator, designer, etc.).
|
||||
|
||||
This replaces the previous JSONField approach to maintain proper relational integrity.
|
||||
"""
|
||||
|
||||
TYPE_CHOICES = [
|
||||
('manufacturer', 'Manufacturer'),
|
||||
('operator', 'Operator'),
|
||||
('designer', 'Designer'),
|
||||
('supplier', 'Supplier'),
|
||||
('contractor', 'Contractor'),
|
||||
]
|
||||
|
||||
code = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
choices=TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Unique code identifier for the company type"
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Display name for the company type"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of what this company type represents"
|
||||
)
|
||||
company_count = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Cached count of companies with this type"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'company_types'
|
||||
verbose_name = 'Company Type'
|
||||
verbose_name_plural = 'Company Types'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def update_company_count(self):
|
||||
"""Update cached company count."""
|
||||
self.company_count = self.companies.count()
|
||||
self.save(update_fields=['company_count'])
|
||||
|
||||
|
||||
# Conditionally import GIS models only if using PostGIS backend
|
||||
# This allows migrations to run on SQLite during local development
|
||||
_using_postgis = (
|
||||
@@ -61,10 +113,12 @@ class Company(VersionedModel):
|
||||
help_text="Company description and history"
|
||||
)
|
||||
|
||||
# Company Types (can be multiple)
|
||||
company_types = models.JSONField(
|
||||
default=list,
|
||||
help_text="List of company types (manufacturer, operator, etc.)"
|
||||
# Company Types (M2M relationship - replaces old JSONField)
|
||||
types = models.ManyToManyField(
|
||||
'CompanyType',
|
||||
related_name='companies',
|
||||
blank=True,
|
||||
help_text="Types of company (manufacturer, operator, etc.)"
|
||||
)
|
||||
|
||||
# Location
|
||||
@@ -177,6 +231,25 @@ class Company(VersionedModel):
|
||||
self.ride_count = self.manufactured_rides.count()
|
||||
self.save(update_fields=['park_count', 'ride_count'])
|
||||
|
||||
@property
|
||||
def company_types(self):
|
||||
"""
|
||||
Backward-compatible property that returns list of type codes.
|
||||
|
||||
This maintains API compatibility with the old JSONField approach.
|
||||
Returns: List of type codes (e.g., ['manufacturer', 'operator'])
|
||||
"""
|
||||
return list(self.types.values_list('code', flat=True))
|
||||
|
||||
@property
|
||||
def type_names(self):
|
||||
"""
|
||||
Get display names for company types.
|
||||
|
||||
Returns: List of type display names (e.g., ['Manufacturer', 'Operator'])
|
||||
"""
|
||||
return list(self.types.values_list('name', flat=True))
|
||||
|
||||
def get_photos(self, photo_type=None, approved_only=True):
|
||||
"""Get photos for this company."""
|
||||
from apps.media.services import PhotoService
|
||||
|
||||
562
django/apps/entities/services/__init__.py
Normal file
562
django/apps/entities/services/__init__.py
Normal file
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
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
|
||||
86
django/apps/entities/services/company_submission.py
Normal file
86
django/apps/entities/services/company_submission.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Company submission service for ThrillWiki.
|
||||
|
||||
Handles Company entity creation and updates through the Sacred Pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.entities.models import Company
|
||||
from apps.entities.services import BaseEntitySubmissionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompanySubmissionService(BaseEntitySubmissionService):
|
||||
"""
|
||||
Service for creating Company submissions through the Sacred Pipeline.
|
||||
|
||||
Companies represent manufacturers, operators, designers, and other entities
|
||||
in the amusement industry.
|
||||
|
||||
Required fields:
|
||||
- name: Company name
|
||||
|
||||
Known Issue:
|
||||
- company_types is currently a JSONField but should be M2M relationship
|
||||
TODO: Convert company_types from JSONField to Many-to-Many relationship
|
||||
This violates the project rule: "NEVER use JSON/JSONB in SQL"
|
||||
|
||||
Example:
|
||||
from apps.entities.services.company_submission import CompanySubmissionService
|
||||
|
||||
submission, company = CompanySubmissionService.create_entity_submission(
|
||||
user=request.user,
|
||||
data={
|
||||
'name': 'Bolliger & Mabillard',
|
||||
'company_types': ['manufacturer', 'designer'],
|
||||
'description': 'Swiss roller coaster manufacturer...',
|
||||
'website': 'https://www.bolliger-mabillard.com',
|
||||
},
|
||||
source='api'
|
||||
)
|
||||
"""
|
||||
|
||||
entity_model = Company
|
||||
entity_type_name = 'Company'
|
||||
required_fields = ['name']
|
||||
|
||||
@classmethod
|
||||
def create_entity_submission(cls, user, data, **kwargs):
|
||||
"""
|
||||
Create a Company submission.
|
||||
|
||||
Note: The company_types field currently uses JSONField which violates
|
||||
project standards. This should be converted to a proper M2M relationship.
|
||||
|
||||
Args:
|
||||
user: User creating the company
|
||||
data: Company field data (must include name)
|
||||
**kwargs: Additional metadata (source, ip_address, user_agent)
|
||||
|
||||
Returns:
|
||||
tuple: (ContentSubmission, Company or None)
|
||||
"""
|
||||
# TODO: Remove this warning once company_types is converted to M2M
|
||||
if 'company_types' in data:
|
||||
logger.warning(
|
||||
"Company.company_types uses JSONField which violates project rules. "
|
||||
"This should be converted to Many-to-Many relationship."
|
||||
)
|
||||
|
||||
# Validate and normalize location FK if provided
|
||||
location = data.get('location')
|
||||
if location and isinstance(location, str):
|
||||
try:
|
||||
from apps.core.models import Locality
|
||||
location = Locality.objects.get(id=location)
|
||||
data['location'] = location
|
||||
except:
|
||||
raise ValidationError(f"Location not found: {location}")
|
||||
|
||||
# Create submission through base class
|
||||
submission, company = super().create_entity_submission(user, data, **kwargs)
|
||||
|
||||
return submission, company
|
||||
89
django/apps/entities/services/park_submission.py
Normal file
89
django/apps/entities/services/park_submission.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Park submission service for ThrillWiki.
|
||||
|
||||
Handles Park entity creation and updates through the Sacred Pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.entities.models import Park
|
||||
from apps.entities.services import BaseEntitySubmissionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParkSubmissionService(BaseEntitySubmissionService):
|
||||
"""
|
||||
Service for creating Park submissions through the Sacred Pipeline.
|
||||
|
||||
Parks require special handling for:
|
||||
- Geographic coordinates (latitude/longitude)
|
||||
- Location point (PostGIS in production)
|
||||
- Park type and status fields
|
||||
|
||||
Required fields:
|
||||
- name: Park name
|
||||
- park_type: Type of park (theme_park, amusement_park, etc.)
|
||||
|
||||
Example:
|
||||
from apps.entities.services.park_submission import ParkSubmissionService
|
||||
|
||||
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'),
|
||||
'description': 'Legendary amusement park...',
|
||||
},
|
||||
source='api',
|
||||
ip_address=request.META.get('REMOTE_ADDR')
|
||||
)
|
||||
"""
|
||||
|
||||
entity_model = Park
|
||||
entity_type_name = 'Park'
|
||||
required_fields = ['name', 'park_type']
|
||||
|
||||
@classmethod
|
||||
def create_entity_submission(cls, user, data, **kwargs):
|
||||
"""
|
||||
Create a Park submission with special coordinate handling.
|
||||
|
||||
Coordinates (latitude/longitude) are processed using the Park model's
|
||||
set_location() method which handles both SQLite and PostGIS modes.
|
||||
|
||||
Args:
|
||||
user: User creating the park
|
||||
data: Park field data (must include name and park_type)
|
||||
**kwargs: Additional metadata (source, ip_address, user_agent)
|
||||
|
||||
Returns:
|
||||
tuple: (ContentSubmission, Park or None)
|
||||
"""
|
||||
# Extract coordinates for special handling
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
# Create submission through base class
|
||||
submission, park = super().create_entity_submission(user, data, **kwargs)
|
||||
|
||||
# If park was created (moderator bypass), set location using helper method
|
||||
if park and latitude is not None and longitude is not None:
|
||||
try:
|
||||
park.set_location(float(longitude), float(latitude))
|
||||
park.save()
|
||||
logger.info(
|
||||
f"Park {park.id} location set: "
|
||||
f"({latitude}, {longitude})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to set location for Park {park.id}: {str(e)}"
|
||||
)
|
||||
|
||||
return submission, park
|
||||
87
django/apps/entities/services/ride_model_submission.py
Normal file
87
django/apps/entities/services/ride_model_submission.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
RideModel submission service for ThrillWiki.
|
||||
|
||||
Handles RideModel entity creation and updates through the Sacred Pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.entities.models import RideModel, Company
|
||||
from apps.entities.services import BaseEntitySubmissionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideModelSubmissionService(BaseEntitySubmissionService):
|
||||
"""
|
||||
Service for creating RideModel submissions through the Sacred Pipeline.
|
||||
|
||||
RideModels represent specific ride models from manufacturers.
|
||||
For example: "B&M Inverted Coaster", "Vekoma Boomerang"
|
||||
|
||||
Required fields:
|
||||
- name: Model name (e.g., "Inverted Coaster")
|
||||
- manufacturer: Company instance or company ID (UUID)
|
||||
- model_type: Type of model (coaster_model, flat_ride_model, etc.)
|
||||
|
||||
Example:
|
||||
from apps.entities.services.ride_model_submission import RideModelSubmissionService
|
||||
|
||||
manufacturer = Company.objects.get(name='Bolliger & Mabillard')
|
||||
|
||||
submission, model = RideModelSubmissionService.create_entity_submission(
|
||||
user=request.user,
|
||||
data={
|
||||
'name': 'Inverted Coaster',
|
||||
'manufacturer': manufacturer,
|
||||
'model_type': 'coaster_model',
|
||||
'description': 'Suspended coaster with inversions...',
|
||||
'typical_height': Decimal('120'),
|
||||
'typical_speed': Decimal('55'),
|
||||
},
|
||||
source='api'
|
||||
)
|
||||
"""
|
||||
|
||||
entity_model = RideModel
|
||||
entity_type_name = 'RideModel'
|
||||
required_fields = ['name', 'manufacturer', 'model_type']
|
||||
|
||||
@classmethod
|
||||
def create_entity_submission(cls, user, data, **kwargs):
|
||||
"""
|
||||
Create a RideModel submission with foreign key handling.
|
||||
|
||||
The 'manufacturer' field can be provided as either:
|
||||
- A Company instance
|
||||
- A UUID string (will be converted to Company instance)
|
||||
|
||||
Args:
|
||||
user: User creating the ride model
|
||||
data: RideModel field data (must include name, manufacturer, and model_type)
|
||||
**kwargs: Additional metadata (source, ip_address, user_agent)
|
||||
|
||||
Returns:
|
||||
tuple: (ContentSubmission, RideModel or None)
|
||||
|
||||
Raises:
|
||||
ValidationError: If manufacturer not found or invalid
|
||||
"""
|
||||
# Validate and normalize manufacturer FK
|
||||
manufacturer = data.get('manufacturer')
|
||||
if manufacturer:
|
||||
if isinstance(manufacturer, str):
|
||||
# UUID string - convert to Company instance
|
||||
try:
|
||||
manufacturer = Company.objects.get(id=manufacturer)
|
||||
data['manufacturer'] = manufacturer
|
||||
except Company.DoesNotExist:
|
||||
raise ValidationError(f"Manufacturer not found: {manufacturer}")
|
||||
elif not isinstance(manufacturer, Company):
|
||||
raise ValidationError(f"Invalid manufacturer type: {type(manufacturer)}")
|
||||
|
||||
# Create submission through base class
|
||||
submission, ride_model = super().create_entity_submission(user, data, **kwargs)
|
||||
|
||||
return submission, ride_model
|
||||
113
django/apps/entities/services/ride_submission.py
Normal file
113
django/apps/entities/services/ride_submission.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Ride submission service for ThrillWiki.
|
||||
|
||||
Handles Ride entity creation and updates through the Sacred Pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.entities.models import Ride, Park
|
||||
from apps.entities.services import BaseEntitySubmissionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RideSubmissionService(BaseEntitySubmissionService):
|
||||
"""
|
||||
Service for creating Ride submissions through the Sacred Pipeline.
|
||||
|
||||
Rides require special handling for:
|
||||
- Park foreign key relationship
|
||||
- Manufacturer foreign key relationship (optional)
|
||||
- Ride model foreign key relationship (optional)
|
||||
- is_coaster flag (auto-set based on ride_category)
|
||||
|
||||
Required fields:
|
||||
- name: Ride name
|
||||
- park: Park instance or park ID (UUID)
|
||||
- ride_category: Category of ride (roller_coaster, flat_ride, etc.)
|
||||
|
||||
Example:
|
||||
from apps.entities.services.ride_submission import RideSubmissionService
|
||||
|
||||
park = Park.objects.get(slug='cedar-point')
|
||||
|
||||
submission, ride = RideSubmissionService.create_entity_submission(
|
||||
user=request.user,
|
||||
data={
|
||||
'name': 'Steel Vengeance',
|
||||
'park': park,
|
||||
'ride_category': 'roller_coaster',
|
||||
'status': 'operating',
|
||||
'height': Decimal('205'),
|
||||
'speed': Decimal('74'),
|
||||
'description': 'Hybrid steel-wooden coaster...',
|
||||
},
|
||||
source='api'
|
||||
)
|
||||
"""
|
||||
|
||||
entity_model = Ride
|
||||
entity_type_name = 'Ride'
|
||||
required_fields = ['name', 'park', 'ride_category']
|
||||
|
||||
@classmethod
|
||||
def create_entity_submission(cls, user, data, **kwargs):
|
||||
"""
|
||||
Create a Ride submission with foreign key handling.
|
||||
|
||||
The 'park' field can be provided as either:
|
||||
- A Park instance
|
||||
- A UUID string (will be converted to Park instance)
|
||||
|
||||
The 'is_coaster' flag is automatically set based on ride_category.
|
||||
|
||||
Args:
|
||||
user: User creating the ride
|
||||
data: Ride field data (must include name, park, and ride_category)
|
||||
**kwargs: Additional metadata (source, ip_address, user_agent)
|
||||
|
||||
Returns:
|
||||
tuple: (ContentSubmission, Ride or None)
|
||||
|
||||
Raises:
|
||||
ValidationError: If park not found or invalid
|
||||
"""
|
||||
# Validate and normalize park FK
|
||||
park = data.get('park')
|
||||
if park:
|
||||
if isinstance(park, str):
|
||||
# UUID string - convert to Park instance
|
||||
try:
|
||||
park = Park.objects.get(id=park)
|
||||
data['park'] = park
|
||||
except Park.DoesNotExist:
|
||||
raise ValidationError(f"Park not found: {park}")
|
||||
elif not isinstance(park, Park):
|
||||
raise ValidationError(f"Invalid park type: {type(park)}")
|
||||
|
||||
# Validate and normalize manufacturer FK if provided
|
||||
manufacturer = data.get('manufacturer')
|
||||
if manufacturer and isinstance(manufacturer, str):
|
||||
try:
|
||||
from apps.entities.models import Company
|
||||
manufacturer = Company.objects.get(id=manufacturer)
|
||||
data['manufacturer'] = manufacturer
|
||||
except Company.DoesNotExist:
|
||||
raise ValidationError(f"Manufacturer not found: {manufacturer}")
|
||||
|
||||
# Validate and normalize model FK if provided
|
||||
model = data.get('model')
|
||||
if model and isinstance(model, str):
|
||||
try:
|
||||
from apps.entities.models import RideModel
|
||||
model = RideModel.objects.get(id=model)
|
||||
data['model'] = model
|
||||
except RideModel.DoesNotExist:
|
||||
raise ValidationError(f"Ride model not found: {model}")
|
||||
|
||||
# Create submission through base class
|
||||
submission, ride = super().create_entity_submission(user, data, **kwargs)
|
||||
|
||||
return submission, ride
|
||||
@@ -74,6 +74,7 @@ class ContentSubmission(BaseModel):
|
||||
('create', 'Create'),
|
||||
('update', 'Update'),
|
||||
('delete', 'Delete'),
|
||||
('review', 'Review'),
|
||||
]
|
||||
|
||||
submission_type = models.CharField(
|
||||
|
||||
@@ -171,6 +171,10 @@ class ModerationService:
|
||||
This method uses atomic transactions to ensure all-or-nothing behavior.
|
||||
If any part fails, the entire operation is rolled back.
|
||||
|
||||
Handles different submission types polymorphically:
|
||||
- 'review': Delegates to ReviewSubmissionService to create Review record
|
||||
- 'create'/'update'/'delete': Applies changes to entity directly
|
||||
|
||||
Args:
|
||||
submission_id: UUID of submission
|
||||
reviewer: User approving the submission
|
||||
@@ -192,26 +196,73 @@ class ModerationService:
|
||||
if not submission.can_review(reviewer):
|
||||
raise ValidationError("Submission cannot be reviewed at this time")
|
||||
|
||||
# Apply all changes
|
||||
entity = submission.entity
|
||||
if not entity:
|
||||
raise ValidationError("Entity no longer exists")
|
||||
|
||||
# Get all pending items
|
||||
items = submission.items.filter(status='pending')
|
||||
|
||||
for item in items:
|
||||
# Apply change to entity
|
||||
if item.change_type in ['add', 'modify']:
|
||||
setattr(entity, item.field_name, item.new_value)
|
||||
elif item.change_type == 'remove':
|
||||
setattr(entity, item.field_name, None)
|
||||
# POLYMORPHIC HANDLING BASED ON SUBMISSION TYPE
|
||||
if submission.submission_type == 'review':
|
||||
# Handle review submissions - delegate to ReviewSubmissionService
|
||||
logger.info(f"Approving review submission {submission_id}")
|
||||
|
||||
# Mark item as approved
|
||||
item.approve(reviewer)
|
||||
|
||||
# Save entity (this will trigger versioning through lifecycle hooks)
|
||||
entity.save()
|
||||
from apps.reviews.services import ReviewSubmissionService
|
||||
review = ReviewSubmissionService.apply_review_approval(submission)
|
||||
|
||||
# Mark all items as approved
|
||||
for item in items:
|
||||
item.approve(reviewer)
|
||||
|
||||
logger.info(f"Review created: {review.id} from submission {submission_id}")
|
||||
|
||||
elif submission.submission_type in ['create', 'update', 'delete']:
|
||||
# Handle entity submissions
|
||||
entity = submission.entity
|
||||
if not entity:
|
||||
raise ValidationError("Entity no longer exists")
|
||||
|
||||
logger.info(f"Approving {submission.submission_type} submission {submission_id}")
|
||||
|
||||
if submission.submission_type == 'create':
|
||||
# Entity was created in draft state, now apply all fields and make visible
|
||||
for item in items:
|
||||
if item.change_type in ['add', 'modify']:
|
||||
setattr(entity, item.field_name, item.new_value)
|
||||
item.approve(reviewer)
|
||||
entity.save()
|
||||
|
||||
elif submission.submission_type == 'update':
|
||||
# Apply updates to existing entity
|
||||
for item in items:
|
||||
if item.change_type in ['add', 'modify']:
|
||||
setattr(entity, item.field_name, item.new_value)
|
||||
elif item.change_type == 'remove':
|
||||
setattr(entity, item.field_name, None)
|
||||
item.approve(reviewer)
|
||||
entity.save()
|
||||
|
||||
elif submission.submission_type == 'delete':
|
||||
# Check deletion type from metadata
|
||||
deletion_type = submission.metadata.get('deletion_type', 'soft')
|
||||
|
||||
if deletion_type == 'soft':
|
||||
# Soft delete: Apply status change to 'closed'
|
||||
for item in items:
|
||||
if item.field_name == 'status':
|
||||
# Apply status change
|
||||
setattr(entity, 'status', 'closed')
|
||||
item.approve(reviewer)
|
||||
entity.save()
|
||||
logger.info(f"Entity soft-deleted (status=closed): {entity.id}")
|
||||
else:
|
||||
# Hard delete: Remove from database
|
||||
for item in items:
|
||||
item.approve(reviewer)
|
||||
entity.delete()
|
||||
logger.info(f"Entity hard-deleted from database: {entity.id}")
|
||||
|
||||
logger.info(f"Entity changes applied for submission {submission_id}")
|
||||
|
||||
else:
|
||||
raise ValidationError(f"Unknown submission type: {submission.submission_type}")
|
||||
|
||||
# Approve submission (FSM transition)
|
||||
submission.approve(reviewer)
|
||||
|
||||
Reference in New Issue
Block a user