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:
pacnpal
2025-11-08 22:23:41 -05:00
parent 9122320e7e
commit 2884bc23ce
29 changed files with 8699 additions and 330 deletions

View File

@@ -11,13 +11,29 @@ from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
from apps.entities.models import Ride, Park, Company, RideModel
from apps.entities.services.ride_submission import RideSubmissionService
from apps.users.permissions import jwt_auth, require_auth
from ..schemas import (
RideCreate,
RideUpdate,
RideOut,
RideListOut,
ErrorResponse
ErrorResponse,
HistoryListResponse,
HistoryEventDetailSchema,
HistoryComparisonSchema,
HistoryDiffCurrentSchema,
FieldHistorySchema,
HistoryActivitySummarySchema,
RollbackRequestSchema,
RollbackResponseSchema,
ErrorSchema
)
from ..services.history_service import HistoryService
from django.core.exceptions import ValidationError
import logging
logger = logging.getLogger(__name__)
router = Router(tags=["Rides"])
@@ -104,6 +120,255 @@ def list_rides(
return queryset
# ============================================================================
# History Endpoints
# ============================================================================
@router.get(
'/{ride_id}/history/',
response={200: HistoryListResponse, 404: ErrorSchema},
summary="Get ride history",
description="Get historical changes for a ride"
)
def get_ride_history(
request,
ride_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
):
"""Get history for a ride."""
from datetime import datetime
# Verify ride exists
ride = get_object_or_404(Ride, id=ride_id)
# Parse dates if provided
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
# Get history
offset = (page - 1) * page_size
events, accessible_count = HistoryService.get_history(
'ride', str(ride_id), request.user,
date_from=date_from_obj, date_to=date_to_obj,
limit=page_size, offset=offset
)
# Format events
formatted_events = []
for event in events:
formatted_events.append({
'id': event['id'],
'timestamp': event['timestamp'],
'operation': event['operation'],
'snapshot': event['snapshot'],
'changed_fields': event.get('changed_fields'),
'change_summary': event.get('change_summary', ''),
'can_rollback': HistoryService.can_rollback(request.user)
})
# Calculate pagination
total_pages = (accessible_count + page_size - 1) // page_size
return {
'entity_id': str(ride_id),
'entity_type': 'ride',
'entity_name': ride.name,
'total_events': accessible_count,
'accessible_events': accessible_count,
'access_limited': HistoryService.is_access_limited(request.user),
'access_reason': HistoryService.get_access_reason(request.user),
'events': formatted_events,
'pagination': {
'page': page,
'page_size': page_size,
'total_pages': total_pages,
'total_items': accessible_count
}
}
@router.get(
'/{ride_id}/history/{event_id}/',
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
summary="Get specific ride history event",
description="Get detailed information about a specific historical event"
)
def get_ride_history_event(request, ride_id: UUID, event_id: int):
"""Get a specific history event for a ride."""
ride = get_object_or_404(Ride, id=ride_id)
event = HistoryService.get_event('ride', event_id, request.user)
if not event:
return 404, {"error": "Event not found or not accessible"}
return {
'id': event['id'],
'timestamp': event['timestamp'],
'operation': event['operation'],
'entity_id': str(ride_id),
'entity_type': 'ride',
'entity_name': ride.name,
'snapshot': event['snapshot'],
'changed_fields': event.get('changed_fields'),
'metadata': event.get('metadata', {}),
'can_rollback': HistoryService.can_rollback(request.user),
'rollback_preview': None
}
@router.get(
'/{ride_id}/history/compare/',
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
summary="Compare two ride history events",
description="Compare two historical events for a ride"
)
def compare_ride_history(
request,
ride_id: UUID,
event1: int = Query(..., description="First event ID"),
event2: int = Query(..., description="Second event ID")
):
"""Compare two historical events for a ride."""
ride = get_object_or_404(Ride, id=ride_id)
try:
comparison = HistoryService.compare_events(
'ride', event1, event2, request.user
)
if not comparison:
return 404, {"error": "One or both events not found"}
return {
'entity_id': str(ride_id),
'entity_type': 'ride',
'entity_name': ride.name,
'event1': comparison['event1'],
'event2': comparison['event2'],
'differences': comparison['differences'],
'changed_field_count': comparison['changed_field_count'],
'unchanged_field_count': comparison['unchanged_field_count'],
'time_between': comparison['time_between']
}
except ValueError as e:
return 400, {"error": str(e)}
@router.get(
'/{ride_id}/history/{event_id}/diff-current/',
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
summary="Compare historical event with current state",
description="Compare a historical event with the current ride state"
)
def diff_ride_history_with_current(request, ride_id: UUID, event_id: int):
"""Compare historical event with current ride state."""
ride = get_object_or_404(Ride, id=ride_id)
try:
diff = HistoryService.compare_with_current(
'ride', event_id, ride, request.user
)
if not diff:
return 404, {"error": "Event not found"}
return {
'entity_id': str(ride_id),
'entity_type': 'ride',
'entity_name': ride.name,
'event': diff['event'],
'current_state': diff['current_state'],
'differences': diff['differences'],
'changed_field_count': diff['changed_field_count'],
'time_since': diff['time_since']
}
except ValueError as e:
return 404, {"error": str(e)}
@router.post(
'/{ride_id}/history/{event_id}/rollback/',
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
summary="Rollback ride to historical state",
description="Rollback ride to a historical state (Moderators/Admins only)"
)
def rollback_ride(request, ride_id: UUID, event_id: int, payload: RollbackRequestSchema):
"""
Rollback ride to a historical state.
**Permission:** Moderators, Admins, Superusers only
"""
# Check authentication
if not request.user or not request.user.is_authenticated:
return 401, {"error": "Authentication required"}
# Check rollback permission
if not HistoryService.can_rollback(request.user):
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
ride = get_object_or_404(Ride, id=ride_id)
try:
result = HistoryService.rollback_to_event(
ride, 'ride', event_id, request.user,
fields=payload.fields,
comment=payload.comment,
create_backup=payload.create_backup
)
return result
except (ValueError, PermissionError) as e:
return 400, {"error": str(e)}
@router.get(
'/{ride_id}/history/field/{field_name}/',
response={200: FieldHistorySchema, 404: ErrorSchema},
summary="Get field-specific history",
description="Get history of changes to a specific ride field"
)
def get_ride_field_history(request, ride_id: UUID, field_name: str):
"""Get history of changes to a specific ride field."""
ride = get_object_or_404(Ride, id=ride_id)
history = HistoryService.get_field_history(
'ride', str(ride_id), field_name, request.user
)
return {
'entity_id': str(ride_id),
'entity_type': 'ride',
'entity_name': ride.name,
'field': field_name,
'field_type': 'CharField', # Could introspect this
**history
}
@router.get(
'/{ride_id}/history/summary/',
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
summary="Get ride activity summary",
description="Get activity summary for a ride"
)
def get_ride_activity_summary(request, ride_id: UUID):
"""Get activity summary for a ride."""
ride = get_object_or_404(Ride, id=ride_id)
summary = HistoryService.get_activity_summary(
'ride', str(ride_id), request.user
)
return {
'entity_id': str(ride_id),
'entity_type': 'ride',
'entity_name': ride.name,
**summary
}
@router.get(
"/{ride_id}",
response={200: RideOut, 404: ErrorResponse},
@@ -131,56 +396,73 @@ def get_ride(request, ride_id: UUID):
@router.post(
"/",
response={201: RideOut, 400: ErrorResponse, 404: ErrorResponse},
response={201: RideOut, 202: dict, 400: ErrorResponse, 401: ErrorResponse, 404: ErrorResponse},
summary="Create ride",
description="Create a new ride (requires authentication)"
description="Create a new ride through the Sacred Pipeline (requires authentication)"
)
@require_auth
def create_ride(request, payload: RideCreate):
"""
Create a new ride.
Create a new ride through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- payload: Ride data
- payload: Ride data (name, park, ride_category, manufacturer, model, etc.)
**Returns:** Created ride
**Returns:** Created ride (moderators) or submission confirmation (regular users)
**Flow:**
- Moderators: Ride created immediately (bypass moderation)
- Regular users: Submission created, enters moderation queue
**Note:** All rides flow through ContentSubmission pipeline for moderation.
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
# Verify park exists
park = get_object_or_404(Park, id=payload.park_id)
# Verify manufacturer if provided
if payload.manufacturer_id:
get_object_or_404(Company, id=payload.manufacturer_id)
# Verify model if provided
if payload.model_id:
get_object_or_404(RideModel, id=payload.model_id)
ride = Ride.objects.create(**payload.dict())
# Reload with related objects
ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return 201, ride
try:
user = request.auth
# Create ride through Sacred Pipeline
submission, ride = RideSubmissionService.create_entity_submission(
user=user,
data=payload.dict(),
source='api',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# If moderator bypass happened, Ride was created immediately
if ride:
logger.info(f"Ride created (moderator): {ride.id} by {user.email}")
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return 201, ride
# Regular user: submission pending moderation
logger.info(f"Ride submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Ride submission pending moderation. You will be notified when it is approved.',
}
except ValidationError as e:
return 400, {'detail': str(e)}
except Exception as e:
logger.error(f"Error creating ride: {e}")
return 400, {'detail': str(e)}
@router.put(
"/{ride_id}",
response={200: RideOut, 404: ErrorResponse, 400: ErrorResponse},
response={200: RideOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
summary="Update ride",
description="Update an existing ride (requires authentication)"
description="Update an existing ride through the Sacred Pipeline (requires authentication)"
)
@require_auth
def update_ride(request, ride_id: UUID, payload: RideUpdate):
"""
Update a ride.
Update a ride through the Sacred Pipeline.
**Authentication:** Required
@@ -188,98 +470,189 @@ def update_ride(request, ride_id: UUID, payload: RideUpdate):
- ride_id: UUID of the ride
- payload: Updated ride data
**Returns:** Updated ride
**Returns:** Updated ride (moderators) or submission confirmation (regular users)
**Flow:**
- Moderators: Updates applied immediately (bypass moderation)
- Regular users: Submission created, enters moderation queue
**Note:** All updates flow through ContentSubmission pipeline for moderation.
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(ride, key, value)
ride.save()
# Reload to get updated relationships
ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return ride
try:
user = request.auth
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
data = payload.dict(exclude_unset=True)
# Update ride through Sacred Pipeline
submission, updated_ride = RideSubmissionService.update_entity_submission(
entity=ride,
user=user,
update_data=data,
source='api',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# If moderator bypass happened, ride was updated immediately
if updated_ride:
logger.info(f"Ride updated (moderator): {updated_ride.id} by {user.email}")
updated_ride.park_name = updated_ride.park.name if updated_ride.park else None
updated_ride.manufacturer_name = updated_ride.manufacturer.name if updated_ride.manufacturer else None
updated_ride.model_name = updated_ride.model.name if updated_ride.model else None
return 200, updated_ride
# Regular user: submission pending moderation
logger.info(f"Ride update submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Ride update pending moderation. You will be notified when it is approved.',
}
except ValidationError as e:
return 400, {'detail': str(e)}
except Exception as e:
logger.error(f"Error updating ride: {e}")
return 400, {'detail': str(e)}
@router.patch(
"/{ride_id}",
response={200: RideOut, 404: ErrorResponse, 400: ErrorResponse},
response={200: RideOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
summary="Partial update ride",
description="Partially update an existing ride (requires authentication)"
description="Partially update an existing ride through the Sacred Pipeline (requires authentication)"
)
@require_auth
def partial_update_ride(request, ride_id: UUID, payload: RideUpdate):
"""
Partially update a ride.
Partially update a ride through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- ride_id: UUID of the ride
- payload: Fields to update
- payload: Fields to update (only provided fields are updated)
**Returns:** Updated ride
**Returns:** Updated ride (moderators) or submission confirmation (regular users)
**Flow:**
- Moderators: Updates applied immediately (bypass moderation)
- Regular users: Submission created, enters moderation queue
**Note:** All updates flow through ContentSubmission pipeline for moderation.
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
# Update only provided fields
for key, value in payload.dict(exclude_unset=True).items():
setattr(ride, key, value)
ride.save()
# Reload to get updated relationships
ride = Ride.objects.select_related('park', 'manufacturer', 'model').get(id=ride.id)
ride.park_name = ride.park.name if ride.park else None
ride.manufacturer_name = ride.manufacturer.name if ride.manufacturer else None
ride.model_name = ride.model.name if ride.model else None
return ride
try:
user = request.auth
ride = get_object_or_404(
Ride.objects.select_related('park', 'manufacturer', 'model'),
id=ride_id
)
data = payload.dict(exclude_unset=True)
# Update ride through Sacred Pipeline
submission, updated_ride = RideSubmissionService.update_entity_submission(
entity=ride,
user=user,
update_data=data,
source='api',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# If moderator bypass happened, ride was updated immediately
if updated_ride:
logger.info(f"Ride partially updated (moderator): {updated_ride.id} by {user.email}")
updated_ride.park_name = updated_ride.park.name if updated_ride.park else None
updated_ride.manufacturer_name = updated_ride.manufacturer.name if updated_ride.manufacturer else None
updated_ride.model_name = updated_ride.model.name if updated_ride.model else None
return 200, updated_ride
# Regular user: submission pending moderation
logger.info(f"Ride partial update submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Ride update pending moderation. You will be notified when it is approved.',
}
except ValidationError as e:
return 400, {'detail': str(e)}
except Exception as e:
logger.error(f"Error partially updating ride: {e}")
return 400, {'detail': str(e)}
@router.delete(
"/{ride_id}",
response={204: None, 404: ErrorResponse},
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
summary="Delete ride",
description="Delete a ride (requires authentication)"
description="Delete a ride through the Sacred Pipeline (requires authentication)"
)
@require_auth
def delete_ride(request, ride_id: UUID):
"""
Delete a ride.
Delete a ride through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- ride_id: UUID of the ride
**Returns:** No content (204)
"""
# TODO: Add authentication check
# if not request.auth:
# return 401, {"detail": "Authentication required"}
**Returns:** Deletion confirmation (moderators) or submission confirmation (regular users)
ride = get_object_or_404(Ride, id=ride_id)
ride.delete()
return 204, None
**Flow:**
- Moderators: Ride soft-deleted immediately (status set to 'closed')
- Regular users: Deletion request created, enters moderation queue
**Deletion Strategy:**
- Soft Delete (default): Sets ride status to 'closed', preserves data
- Hard Delete: Actually removes from database (moderators only)
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
"""
try:
user = request.auth
ride = get_object_or_404(Ride.objects.select_related('park', 'manufacturer'), id=ride_id)
# Delete ride through Sacred Pipeline (soft delete by default)
submission, deleted = RideSubmissionService.delete_entity_submission(
entity=ride,
user=user,
deletion_type='soft',
deletion_reason='',
source='api',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# If moderator bypass happened, deletion was applied immediately
if deleted:
logger.info(f"Ride deleted (moderator): {ride_id} by {user.email}")
return 200, {
'message': 'Ride deleted successfully',
'entity_id': str(ride_id),
'deletion_type': 'soft'
}
# Regular user: deletion pending moderation
logger.info(f"Ride deletion submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Ride deletion request pending moderation. You will be notified when it is approved.',
'entity_id': str(ride_id)
}
except ValidationError as e:
return 400, {'detail': str(e)}
except Exception as e:
logger.error(f"Error deleting ride: {e}")
return 400, {'detail': str(e)}
@router.get(