Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,734 @@
"""
Park endpoints for API v1.
Provides CRUD operations for Park entities with filtering, search, and geographic queries.
Supports both SQLite (lat/lng) and PostGIS (location_point) modes.
"""
from typing import List, Optional
from uuid import UUID
from decimal import Decimal
from django.shortcuts import get_object_or_404
from django.db.models import Q
from django.conf import settings
from ninja import Router, Query
from ninja.pagination import paginate, PageNumberPagination
import math
from apps.entities.models import Park, Company, _using_postgis
from apps.entities.services.park_submission import ParkSubmissionService
from apps.users.permissions import jwt_auth, require_auth
from ..schemas import (
ParkCreate,
ParkUpdate,
ParkOut,
ParkListOut,
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=["Parks"])
class ParkPagination(PageNumberPagination):
"""Custom pagination for parks."""
page_size = 50
@router.get(
"/",
response={200: List[ParkOut]},
summary="List parks",
description="Get a paginated list of parks with optional filtering"
)
@paginate(ParkPagination)
def list_parks(
request,
search: Optional[str] = Query(None, description="Search by park name"),
park_type: Optional[str] = Query(None, description="Filter by park type"),
status: Optional[str] = Query(None, description="Filter by status"),
operator_id: Optional[UUID] = Query(None, description="Filter by operator"),
ordering: Optional[str] = Query("-created", description="Sort by field (prefix with - for descending)")
):
"""
List all parks with optional filters.
**Filters:**
- search: Search park names (case-insensitive partial match)
- park_type: Filter by park type
- status: Filter by operational status
- operator_id: Filter by operator company
- ordering: Sort results (default: -created)
**Returns:** Paginated list of parks
"""
queryset = Park.objects.select_related('operator').all()
# Apply search filter
if search:
queryset = queryset.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Apply park type filter
if park_type:
queryset = queryset.filter(park_type=park_type)
# Apply status filter
if status:
queryset = queryset.filter(status=status)
# Apply operator filter
if operator_id:
queryset = queryset.filter(operator_id=operator_id)
# Apply ordering
valid_order_fields = ['name', 'created', 'modified', 'opening_date', 'ride_count', 'coaster_count']
order_field = ordering.lstrip('-')
if order_field in valid_order_fields:
queryset = queryset.order_by(ordering)
else:
queryset = queryset.order_by('-created')
# Annotate with operator name
for park in queryset:
park.operator_name = park.operator.name if park.operator else None
return queryset
@router.get(
"/{park_id}",
response={200: ParkOut, 404: ErrorResponse},
summary="Get park",
description="Retrieve a single park by ID"
)
def get_park(request, park_id: UUID):
"""
Get a park by ID.
**Parameters:**
- park_id: UUID of the park
**Returns:** Park details
"""
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
return park
@router.get(
"/nearby/",
response={200: List[ParkOut]},
summary="Find nearby parks",
description="Find parks within a radius of given coordinates. Uses PostGIS in production, bounding box in SQLite."
)
def find_nearby_parks(
request,
latitude: float = Query(..., description="Latitude coordinate"),
longitude: float = Query(..., description="Longitude coordinate"),
radius: float = Query(50, description="Search radius in kilometers"),
limit: int = Query(50, description="Maximum number of results")
):
"""
Find parks near a geographic point.
**Geographic Search Modes:**
- **PostGIS (Production)**: Uses accurate distance-based search with location_point field
- **SQLite (Local Dev)**: Uses bounding box approximation with latitude/longitude fields
**Parameters:**
- latitude: Center point latitude
- longitude: Center point longitude
- radius: Search radius in kilometers (default: 50)
- limit: Maximum results to return (default: 50)
**Returns:** List of nearby parks
"""
if _using_postgis:
# Use PostGIS for accurate distance-based search
try:
from django.contrib.gis.measure import D
from django.contrib.gis.geos import Point
user_point = Point(longitude, latitude, srid=4326)
nearby_parks = Park.objects.filter(
location_point__distance_lte=(user_point, D(km=radius))
).select_related('operator')[:limit]
except Exception as e:
return {"detail": f"Geographic search error: {str(e)}"}, 500
else:
# Use bounding box approximation for SQLite
# Calculate rough bounding box (1 degree ≈ 111 km at equator)
lat_offset = radius / 111.0
lng_offset = radius / (111.0 * math.cos(math.radians(latitude)))
min_lat = latitude - lat_offset
max_lat = latitude + lat_offset
min_lng = longitude - lng_offset
max_lng = longitude + lng_offset
nearby_parks = Park.objects.filter(
latitude__gte=Decimal(str(min_lat)),
latitude__lte=Decimal(str(max_lat)),
longitude__gte=Decimal(str(min_lng)),
longitude__lte=Decimal(str(max_lng))
).select_related('operator')[:limit]
# Annotate results
results = []
for park in nearby_parks:
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
results.append(park)
return results
@router.post(
"/",
response={201: ParkOut, 202: dict, 400: ErrorResponse, 401: ErrorResponse},
summary="Create park",
description="Create a new park through the Sacred Pipeline (requires authentication)"
)
@require_auth
def create_park(request, payload: ParkCreate):
"""
Create a new park through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- payload: Park data (name, park_type, operator, coordinates, etc.)
**Returns:** Created park (moderators) or submission confirmation (regular users)
**Flow:**
- Moderators: Park created immediately (bypass moderation)
- Regular users: Submission created, enters moderation queue
**Note:** All parks flow through ContentSubmission pipeline for moderation.
"""
try:
user = request.auth
# Create park through Sacred Pipeline
submission, park = ParkSubmissionService.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, Park was created immediately
if park:
logger.info(f"Park created (moderator): {park.id} by {user.email}")
park.operator_name = park.operator.name if park.operator else None
park.coordinates = park.coordinates
return 201, park
# Regular user: submission pending moderation
logger.info(f"Park submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Park 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 park: {e}")
return 400, {'detail': str(e)}
@router.put(
"/{park_id}",
response={200: ParkOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
summary="Update park",
description="Update an existing park through the Sacred Pipeline (requires authentication)"
)
@require_auth
def update_park(request, park_id: UUID, payload: ParkUpdate):
"""
Update a park through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- park_id: UUID of the park
- payload: Updated park data
**Returns:** Updated park (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.
"""
try:
user = request.auth
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
data = payload.dict(exclude_unset=True)
# Handle coordinates separately
latitude = data.pop('latitude', None)
longitude = data.pop('longitude', None)
# Update park through Sacred Pipeline
submission, updated_park = ParkSubmissionService.update_entity_submission(
entity=park,
user=user,
update_data=data,
latitude=latitude,
longitude=longitude,
source='api',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# If moderator bypass happened, park was updated immediately
if updated_park:
logger.info(f"Park updated (moderator): {updated_park.id} by {user.email}")
updated_park.operator_name = updated_park.operator.name if updated_park.operator else None
updated_park.coordinates = updated_park.coordinates
return 200, updated_park
# Regular user: submission pending moderation
logger.info(f"Park update submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Park 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 park: {e}")
return 400, {'detail': str(e)}
@router.patch(
"/{park_id}",
response={200: ParkOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
summary="Partial update park",
description="Partially update an existing park through the Sacred Pipeline (requires authentication)"
)
@require_auth
def partial_update_park(request, park_id: UUID, payload: ParkUpdate):
"""
Partially update a park through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- park_id: UUID of the park
- payload: Fields to update (only provided fields are updated)
**Returns:** Updated park (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.
"""
try:
user = request.auth
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
data = payload.dict(exclude_unset=True)
# Handle coordinates separately
latitude = data.pop('latitude', None)
longitude = data.pop('longitude', None)
# Update park through Sacred Pipeline
submission, updated_park = ParkSubmissionService.update_entity_submission(
entity=park,
user=user,
update_data=data,
latitude=latitude,
longitude=longitude,
source='api',
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
# If moderator bypass happened, park was updated immediately
if updated_park:
logger.info(f"Park partially updated (moderator): {updated_park.id} by {user.email}")
updated_park.operator_name = updated_park.operator.name if updated_park.operator else None
updated_park.coordinates = updated_park.coordinates
return 200, updated_park
# Regular user: submission pending moderation
logger.info(f"Park partial update submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Park 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 park: {e}")
return 400, {'detail': str(e)}
@router.delete(
"/{park_id}",
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
summary="Delete park",
description="Delete a park through the Sacred Pipeline (requires authentication)"
)
@require_auth
def delete_park(request, park_id: UUID):
"""
Delete a park through the Sacred Pipeline.
**Authentication:** Required
**Parameters:**
- park_id: UUID of the park
**Returns:** Deletion confirmation (moderators) or submission confirmation (regular users)
**Flow:**
- Moderators: Park soft-deleted immediately (status set to 'closed')
- Regular users: Deletion request created, enters moderation queue
**Deletion Strategy:**
- Soft Delete (default): Sets park 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
park = get_object_or_404(Park.objects.select_related('operator'), id=park_id)
# Delete park through Sacred Pipeline (soft delete by default)
submission, deleted = ParkSubmissionService.delete_entity_submission(
entity=park,
user=user,
deletion_type='soft', # Can be made configurable via query param
deletion_reason='', # Can be provided in request body
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"Park deleted (moderator): {park_id} by {user.email}")
return 200, {
'message': 'Park deleted successfully',
'entity_id': str(park_id),
'deletion_type': 'soft'
}
# Regular user: deletion pending moderation
logger.info(f"Park deletion submission created: {submission.id} by {user.email}")
return 202, {
'submission_id': str(submission.id),
'status': submission.status,
'message': 'Park deletion request pending moderation. You will be notified when it is approved.',
'entity_id': str(park_id)
}
except ValidationError as e:
return 400, {'detail': str(e)}
except Exception as e:
logger.error(f"Error deleting park: {e}")
return 400, {'detail': str(e)}
@router.get(
"/{park_id}/rides",
response={200: List[dict], 404: ErrorResponse},
summary="Get park rides",
description="Get all rides at a park"
)
def get_park_rides(request, park_id: UUID):
"""
Get all rides at a park.
**Parameters:**
- park_id: UUID of the park
**Returns:** List of rides
"""
park = get_object_or_404(Park, id=park_id)
rides = park.rides.select_related('manufacturer').all().values(
'id', 'name', 'slug', 'status', 'ride_category', 'is_coaster', 'manufacturer__name'
)
return list(rides)
# ============================================================================
# History Endpoints
# ============================================================================
@router.get(
'/{park_id}/history/',
response={200: HistoryListResponse, 404: ErrorSchema},
summary="Get park history",
description="Get historical changes for a park"
)
def get_park_history(
request,
park_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 park."""
from datetime import datetime
# Verify park exists
park = get_object_or_404(Park, id=park_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(
'park', str(park_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(park_id),
'entity_type': 'park',
'entity_name': park.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(
'/{park_id}/history/{event_id}/',
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
summary="Get specific park history event",
description="Get detailed information about a specific historical event"
)
def get_park_history_event(request, park_id: UUID, event_id: int):
"""Get a specific history event for a park."""
park = get_object_or_404(Park, id=park_id)
event = HistoryService.get_event('park', 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(park_id),
'entity_type': 'park',
'entity_name': park.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(
'/{park_id}/history/compare/',
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
summary="Compare two park history events",
description="Compare two historical events for a park"
)
def compare_park_history(
request,
park_id: UUID,
event1: int = Query(..., description="First event ID"),
event2: int = Query(..., description="Second event ID")
):
"""Compare two historical events for a park."""
park = get_object_or_404(Park, id=park_id)
try:
comparison = HistoryService.compare_events(
'park', event1, event2, request.user
)
if not comparison:
return 404, {"error": "One or both events not found"}
return {
'entity_id': str(park_id),
'entity_type': 'park',
'entity_name': park.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(
'/{park_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 park state"
)
def diff_park_history_with_current(request, park_id: UUID, event_id: int):
"""Compare historical event with current park state."""
park = get_object_or_404(Park, id=park_id)
try:
diff = HistoryService.compare_with_current(
'park', event_id, park, request.user
)
if not diff:
return 404, {"error": "Event not found"}
return {
'entity_id': str(park_id),
'entity_type': 'park',
'entity_name': park.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(
'/{park_id}/history/{event_id}/rollback/',
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
summary="Rollback park to historical state",
description="Rollback park to a historical state (Moderators/Admins only)"
)
def rollback_park(request, park_id: UUID, event_id: int, payload: RollbackRequestSchema):
"""
Rollback park 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"}
park = get_object_or_404(Park, id=park_id)
try:
result = HistoryService.rollback_to_event(
park, 'park', 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(
'/{park_id}/history/field/{field_name}/',
response={200: FieldHistorySchema, 404: ErrorSchema},
summary="Get field-specific history",
description="Get history of changes to a specific park field"
)
def get_park_field_history(request, park_id: UUID, field_name: str):
"""Get history of changes to a specific park field."""
park = get_object_or_404(Park, id=park_id)
history = HistoryService.get_field_history(
'park', str(park_id), field_name, request.user
)
return {
'entity_id': str(park_id),
'entity_type': 'park',
'entity_name': park.name,
'field': field_name,
'field_type': 'CharField', # Could introspect this
**history
}
@router.get(
'/{park_id}/history/summary/',
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
summary="Get park activity summary",
description="Get activity summary for a park"
)
def get_park_activity_summary(request, park_id: UUID):
"""Get activity summary for a park."""
park = get_object_or_404(Park, id=park_id)
summary = HistoryService.get_activity_summary(
'park', str(park_id), request.user
)
return {
'entity_id': str(park_id),
'entity_type': 'park',
'entity_name': park.name,
**summary
}