mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 11:11: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:
@@ -17,6 +17,7 @@ from .endpoints.search import router as search_router
|
||||
from .endpoints.reviews import router as reviews_router
|
||||
from .endpoints.ride_credits import router as ride_credits_router
|
||||
from .endpoints.top_lists import router as top_lists_router
|
||||
from .endpoints.history import router as history_router
|
||||
|
||||
|
||||
# Create the main API instance
|
||||
@@ -111,6 +112,9 @@ api.add_router("/reviews", reviews_router)
|
||||
api.add_router("/ride-credits", ride_credits_router)
|
||||
api.add_router("/top-lists", top_lists_router)
|
||||
|
||||
# Add history router
|
||||
api.add_router("/history", history_router)
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@api.get("/health", tags=["System"], summary="Health check")
|
||||
|
||||
@@ -11,13 +11,29 @@ from ninja import Router, Query
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
|
||||
from apps.entities.models import Company
|
||||
from apps.entities.services.company_submission import CompanySubmissionService
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
CompanyCreate,
|
||||
CompanyUpdate,
|
||||
CompanyOut,
|
||||
CompanyListOut,
|
||||
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=["Companies"])
|
||||
@@ -101,38 +117,70 @@ def get_company(request, company_id: UUID):
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response={201: CompanyOut, 400: ErrorResponse},
|
||||
response={201: CompanyOut, 202: dict, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Create company",
|
||||
description="Create a new company (requires authentication)"
|
||||
description="Create a new company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_company(request, payload: CompanyCreate):
|
||||
"""
|
||||
Create a new company.
|
||||
Create a new company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Company data
|
||||
- payload: Company data (name, company_types, headquarters, etc.)
|
||||
|
||||
**Returns:** Created company
|
||||
**Returns:** Created company (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Company created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All companies flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
# TODO: Add authentication check
|
||||
# if not request.auth:
|
||||
# return 401, {"detail": "Authentication required"}
|
||||
|
||||
company = Company.objects.create(**payload.dict())
|
||||
return 201, company
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create company through Sacred Pipeline
|
||||
submission, company = CompanySubmissionService.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, Company was created immediately
|
||||
if company:
|
||||
logger.info(f"Company created (moderator): {company.id} by {user.email}")
|
||||
return 201, company
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Company submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company 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 company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{company_id}",
|
||||
response={200: CompanyOut, 404: ErrorResponse, 400: ErrorResponse},
|
||||
response={200: CompanyOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update company",
|
||||
description="Update an existing company (requires authentication)"
|
||||
description="Update an existing company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def update_company(request, company_id: UUID, payload: CompanyUpdate):
|
||||
"""
|
||||
Update a company.
|
||||
Update a company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
@@ -140,78 +188,177 @@ def update_company(request, company_id: UUID, payload: CompanyUpdate):
|
||||
- company_id: UUID of the company
|
||||
- payload: Updated company data
|
||||
|
||||
**Returns:** Updated company
|
||||
**Returns:** Updated company (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"}
|
||||
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
# Update only provided fields
|
||||
for key, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(company, key, value)
|
||||
|
||||
company.save()
|
||||
return company
|
||||
try:
|
||||
user = request.auth
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update company through Sacred Pipeline
|
||||
submission, updated_company = CompanySubmissionService.update_entity_submission(
|
||||
entity=company,
|
||||
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, company was updated immediately
|
||||
if updated_company:
|
||||
logger.info(f"Company updated (moderator): {updated_company.id} by {user.email}")
|
||||
return 200, updated_company
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Company update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company 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 company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{company_id}",
|
||||
response={200: CompanyOut, 404: ErrorResponse, 400: ErrorResponse},
|
||||
response={200: CompanyOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update company",
|
||||
description="Partially update an existing company (requires authentication)"
|
||||
description="Partially update an existing company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def partial_update_company(request, company_id: UUID, payload: CompanyUpdate):
|
||||
"""
|
||||
Partially update a company.
|
||||
Partially update a company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- company_id: UUID of the company
|
||||
- payload: Fields to update
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated company
|
||||
**Returns:** Updated company (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"}
|
||||
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
# Update only provided fields
|
||||
for key, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(company, key, value)
|
||||
|
||||
company.save()
|
||||
return company
|
||||
try:
|
||||
user = request.auth
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update company through Sacred Pipeline
|
||||
submission, updated_company = CompanySubmissionService.update_entity_submission(
|
||||
entity=company,
|
||||
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, company was updated immediately
|
||||
if updated_company:
|
||||
logger.info(f"Company partially updated (moderator): {updated_company.id} by {user.email}")
|
||||
return 200, updated_company
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Company partial update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company 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 company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{company_id}",
|
||||
response={204: None, 404: ErrorResponse},
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete company",
|
||||
description="Delete a company (requires authentication)"
|
||||
description="Delete a company through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_company(request, company_id: UUID):
|
||||
"""
|
||||
Delete a company.
|
||||
Delete a company through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- company_id: UUID of the company
|
||||
|
||||
**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)
|
||||
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
company.delete()
|
||||
return 204, None
|
||||
**Flow:**
|
||||
- Moderators: Company hard-deleted immediately (removed from database)
|
||||
- Regular users: Deletion request created, enters moderation queue
|
||||
|
||||
**Deletion Strategy:**
|
||||
- Hard Delete: Removes company from database (Company has no status field for soft delete)
|
||||
|
||||
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
|
||||
**Warning:** Deleting a company may affect related parks and rides.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
# Delete company through Sacred Pipeline (hard delete - no status field)
|
||||
submission, deleted = CompanySubmissionService.delete_entity_submission(
|
||||
entity=company,
|
||||
user=user,
|
||||
deletion_type='hard', # Company has no status field
|
||||
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"Company deleted (moderator): {company_id} by {user.email}")
|
||||
return 200, {
|
||||
'message': 'Company deleted successfully',
|
||||
'entity_id': str(company_id),
|
||||
'deletion_type': 'hard'
|
||||
}
|
||||
|
||||
# Regular user: deletion pending moderation
|
||||
logger.info(f"Company deletion submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Company deletion request pending moderation. You will be notified when it is approved.',
|
||||
'entity_id': str(company_id)
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting company: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -252,3 +399,252 @@ def get_company_rides(request, company_id: UUID):
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
rides = company.manufactured_rides.all().values('id', 'name', 'slug', 'status', 'ride_category')
|
||||
return list(rides)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get company history",
|
||||
description="Get historical changes for a company"
|
||||
)
|
||||
def get_company_history(
|
||||
request,
|
||||
company_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 company."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify company exists
|
||||
company = get_object_or_404(Company, id=company_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(
|
||||
'company', str(company_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(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.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(
|
||||
'/{company_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific company history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_company_history_event(request, company_id: UUID, event_id: int):
|
||||
"""Get a specific history event for a company."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
event = HistoryService.get_event('company', 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(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.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(
|
||||
'/{company_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two company history events",
|
||||
description="Compare two historical events for a company"
|
||||
)
|
||||
def compare_company_history(
|
||||
request,
|
||||
company_id: UUID,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a company."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'company', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.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(
|
||||
'/{company_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 company state"
|
||||
)
|
||||
def diff_company_history_with_current(request, company_id: UUID, event_id: int):
|
||||
"""Compare historical event with current company state."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'company', event_id, company, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.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(
|
||||
'/{company_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback company to historical state",
|
||||
description="Rollback company to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_company(request, company_id: UUID, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback company 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"}
|
||||
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
company, 'company', 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(
|
||||
'/{company_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific company field"
|
||||
)
|
||||
def get_company_field_history(request, company_id: UUID, field_name: str):
|
||||
"""Get history of changes to a specific company field."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'company', str(company_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{company_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get company activity summary",
|
||||
description="Get activity summary for a company"
|
||||
)
|
||||
def get_company_activity_summary(request, company_id: UUID):
|
||||
"""Get activity summary for a company."""
|
||||
company = get_object_or_404(Company, id=company_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'company', str(company_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(company_id),
|
||||
'entity_type': 'company',
|
||||
'entity_name': company.name,
|
||||
**summary
|
||||
}
|
||||
|
||||
100
django/api/v1/endpoints/history.py
Normal file
100
django/api/v1/endpoints/history.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Generic history endpoints for all entity types.
|
||||
|
||||
Provides cross-entity history operations and utilities.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import Http404
|
||||
from ninja import Router, Query
|
||||
|
||||
from api.v1.services.history_service import HistoryService
|
||||
from api.v1.schemas import (
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
ErrorSchema
|
||||
)
|
||||
|
||||
router = Router(tags=['History'])
|
||||
|
||||
|
||||
@router.get(
|
||||
'/events/{event_id}',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get event by ID",
|
||||
description="Retrieve any historical event by its ID (requires entity_type parameter)"
|
||||
)
|
||||
def get_event_by_id(
|
||||
request,
|
||||
event_id: int,
|
||||
entity_type: str = Query(..., description="Entity type (park, ride, company, ridemodel, review)")
|
||||
):
|
||||
"""Get a specific historical event by ID."""
|
||||
try:
|
||||
event = HistoryService.get_event(entity_type, event_id, request.user)
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
# Get entity info for response
|
||||
entity_id = str(event['entity_id'])
|
||||
entity_name = event.get('entity_name', 'Unknown')
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'entity_name': entity_name,
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None # Could add rollback preview logic if needed
|
||||
}
|
||||
|
||||
return response_data
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/compare',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two events",
|
||||
description="Compare two historical events (must be same entity)"
|
||||
)
|
||||
def compare_events(
|
||||
request,
|
||||
entity_type: str = Query(..., description="Entity type (park, ride, company, ridemodel, review)"),
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events."""
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
entity_type, event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found or not accessible"}
|
||||
|
||||
# Format response
|
||||
response_data = {
|
||||
'entity_id': comparison['entity_id'],
|
||||
'entity_type': entity_type,
|
||||
'entity_name': comparison.get('entity_name', 'Unknown'),
|
||||
'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']
|
||||
}
|
||||
|
||||
return response_data
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
@@ -15,13 +15,29 @@ 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
|
||||
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"])
|
||||
@@ -185,54 +201,72 @@ def find_nearby_parks(
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response={201: ParkOut, 400: ErrorResponse},
|
||||
response={201: ParkOut, 202: dict, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Create park",
|
||||
description="Create a new park (requires authentication)"
|
||||
description="Create a new park through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_park(request, payload: ParkCreate):
|
||||
"""
|
||||
Create a new park.
|
||||
Create a new park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Park data
|
||||
- payload: Park data (name, park_type, operator, coordinates, etc.)
|
||||
|
||||
**Returns:** Created park
|
||||
**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.
|
||||
"""
|
||||
# TODO: Add authentication check
|
||||
# if not request.auth:
|
||||
# return 401, {"detail": "Authentication required"}
|
||||
|
||||
data = payload.dict()
|
||||
|
||||
# Extract coordinates to use set_location method
|
||||
latitude = data.pop('latitude', None)
|
||||
longitude = data.pop('longitude', None)
|
||||
|
||||
park = Park.objects.create(**data)
|
||||
|
||||
# Set location using helper method (handles both SQLite and PostGIS)
|
||||
if latitude is not None and longitude is not None:
|
||||
park.set_location(longitude, latitude)
|
||||
park.save()
|
||||
|
||||
park.coordinates = park.coordinates
|
||||
if park.operator:
|
||||
park.operator_name = park.operator.name
|
||||
|
||||
return 201, park
|
||||
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, 404: ErrorResponse, 400: ErrorResponse},
|
||||
response={200: ParkOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update park",
|
||||
description="Update an existing park (requires authentication)"
|
||||
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.
|
||||
Update a park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
@@ -240,104 +274,193 @@ def update_park(request, park_id: UUID, payload: ParkUpdate):
|
||||
- park_id: UUID of the park
|
||||
- payload: Updated park data
|
||||
|
||||
**Returns:** Updated park
|
||||
**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.
|
||||
"""
|
||||
# TODO: Add authentication check
|
||||
# if not request.auth:
|
||||
# return 401, {"detail": "Authentication required"}
|
||||
|
||||
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 other fields
|
||||
for key, value in data.items():
|
||||
setattr(park, key, value)
|
||||
|
||||
# Update location if coordinates provided
|
||||
if latitude is not None and longitude is not None:
|
||||
park.set_location(longitude, latitude)
|
||||
|
||||
park.save()
|
||||
park.operator_name = park.operator.name if park.operator else None
|
||||
park.coordinates = park.coordinates
|
||||
|
||||
return park
|
||||
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, 404: ErrorResponse, 400: ErrorResponse},
|
||||
response={200: ParkOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update park",
|
||||
description="Partially update an existing park (requires authentication)"
|
||||
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.
|
||||
Partially update a park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- park_id: UUID of the park
|
||||
- payload: Fields to update
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated park
|
||||
**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.
|
||||
"""
|
||||
# TODO: Add authentication check
|
||||
# if not request.auth:
|
||||
# return 401, {"detail": "Authentication required"}
|
||||
|
||||
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 other fields
|
||||
for key, value in data.items():
|
||||
setattr(park, key, value)
|
||||
|
||||
# Update location if coordinates provided
|
||||
if latitude is not None and longitude is not None:
|
||||
park.set_location(longitude, latitude)
|
||||
|
||||
park.save()
|
||||
park.operator_name = park.operator.name if park.operator else None
|
||||
park.coordinates = park.coordinates
|
||||
|
||||
return park
|
||||
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={204: None, 404: ErrorResponse},
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete park",
|
||||
description="Delete a park (requires authentication)"
|
||||
description="Delete a park through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_park(request, park_id: UUID):
|
||||
"""
|
||||
Delete a park.
|
||||
Delete a park through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- park_id: UUID of the park
|
||||
|
||||
**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)
|
||||
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
park.delete()
|
||||
return 204, None
|
||||
**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(
|
||||
@@ -360,3 +483,252 @@ def get_park_rides(request, park_id: UUID):
|
||||
'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
|
||||
}
|
||||
|
||||
@@ -28,7 +28,17 @@ from ..schemas import (
|
||||
VoteResponse,
|
||||
ErrorResponse,
|
||||
UserSchema,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema,
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
|
||||
router = Router(tags=["Reviews"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -583,3 +593,252 @@ def get_review_stats(request, entity_type: str, entity_id: UUID):
|
||||
'total_reviews': stats['total_reviews'] or 0,
|
||||
'rating_distribution': distribution,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get review history",
|
||||
description="Get historical changes for a review"
|
||||
)
|
||||
def get_review_history(
|
||||
request,
|
||||
review_id: int,
|
||||
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 review."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify review exists
|
||||
review = get_object_or_404(Review, id=review_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(
|
||||
'review', str(review_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(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'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(
|
||||
'/{review_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific review history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_review_history_event(request, review_id: int, event_id: int):
|
||||
"""Get a specific history event for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
event = HistoryService.get_event('review', 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(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'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(
|
||||
'/{review_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two review history events",
|
||||
description="Compare two historical events for a review"
|
||||
)
|
||||
def compare_review_history(
|
||||
request,
|
||||
review_id: int,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'review', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'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(
|
||||
'/{review_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 review state"
|
||||
)
|
||||
def diff_review_history_with_current(request, review_id: int, event_id: int):
|
||||
"""Compare historical event with current review state."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'review', event_id, review, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'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(
|
||||
'/{review_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback review to historical state",
|
||||
description="Rollback review to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_review(request, review_id: int, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback review 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"}
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
review, 'review', 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(
|
||||
'/{review_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific review field"
|
||||
)
|
||||
def get_review_field_history(request, review_id: int, field_name: str):
|
||||
"""Get history of changes to a specific review field."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'review', str(review_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get review activity summary",
|
||||
description="Get activity summary for a review"
|
||||
)
|
||||
def get_review_activity_summary(request, review_id: int):
|
||||
"""Get activity summary for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'review', str(review_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
**summary
|
||||
}
|
||||
|
||||
@@ -11,13 +11,29 @@ from ninja import Router, Query
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
|
||||
from apps.entities.models import RideModel, Company
|
||||
from apps.entities.services.ride_model_submission import RideModelSubmissionService
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
RideModelCreate,
|
||||
RideModelUpdate,
|
||||
RideModelOut,
|
||||
RideModelListOut,
|
||||
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=["Ride Models"])
|
||||
@@ -106,42 +122,71 @@ def get_ride_model(request, model_id: UUID):
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response={201: RideModelOut, 400: ErrorResponse, 404: ErrorResponse},
|
||||
response={201: RideModelOut, 202: dict, 400: ErrorResponse, 401: ErrorResponse, 404: ErrorResponse},
|
||||
summary="Create ride model",
|
||||
description="Create a new ride model (requires authentication)"
|
||||
description="Create a new ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def create_ride_model(request, payload: RideModelCreate):
|
||||
"""
|
||||
Create a new ride model.
|
||||
Create a new ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- payload: Ride model data
|
||||
- payload: Ride model data (name, manufacturer, model_type, specifications, etc.)
|
||||
|
||||
**Returns:** Created ride model
|
||||
**Returns:** Created ride model (moderators) or submission confirmation (regular users)
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Ride model created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All ride models flow through ContentSubmission pipeline for moderation.
|
||||
"""
|
||||
# TODO: Add authentication check
|
||||
# if not request.auth:
|
||||
# return 401, {"detail": "Authentication required"}
|
||||
|
||||
# Verify manufacturer exists
|
||||
manufacturer = get_object_or_404(Company, id=payload.manufacturer_id)
|
||||
|
||||
model = RideModel.objects.create(**payload.dict())
|
||||
model.manufacturer_name = manufacturer.name
|
||||
return 201, model
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Create ride model through Sacred Pipeline
|
||||
submission, ride_model = RideModelSubmissionService.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, RideModel was created immediately
|
||||
if ride_model:
|
||||
logger.info(f"RideModel created (moderator): {ride_model.id} by {user.email}")
|
||||
ride_model.manufacturer_name = ride_model.manufacturer.name if ride_model.manufacturer else None
|
||||
return 201, ride_model
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"RideModel submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model 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 model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{model_id}",
|
||||
response={200: RideModelOut, 404: ErrorResponse, 400: ErrorResponse},
|
||||
response={200: RideModelOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Update ride model",
|
||||
description="Update an existing ride model (requires authentication)"
|
||||
description="Update an existing ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
|
||||
"""
|
||||
Update a ride model.
|
||||
Update a ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
@@ -149,80 +194,179 @@ def update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
|
||||
- model_id: UUID of the ride model
|
||||
- payload: Updated ride model data
|
||||
|
||||
**Returns:** Updated ride model
|
||||
**Returns:** Updated ride model (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"}
|
||||
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
# Update only provided fields
|
||||
for key, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(model, key, value)
|
||||
|
||||
model.save()
|
||||
model.manufacturer_name = model.manufacturer.name if model.manufacturer else None
|
||||
return model
|
||||
try:
|
||||
user = request.auth
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update ride model through Sacred Pipeline
|
||||
submission, updated_model = RideModelSubmissionService.update_entity_submission(
|
||||
entity=model,
|
||||
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 model was updated immediately
|
||||
if updated_model:
|
||||
logger.info(f"RideModel updated (moderator): {updated_model.id} by {user.email}")
|
||||
updated_model.manufacturer_name = updated_model.manufacturer.name if updated_model.manufacturer else None
|
||||
return 200, updated_model
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"RideModel update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model 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 model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{model_id}",
|
||||
response={200: RideModelOut, 404: ErrorResponse, 400: ErrorResponse},
|
||||
response={200: RideModelOut, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Partial update ride model",
|
||||
description="Partially update an existing ride model (requires authentication)"
|
||||
description="Partially update an existing ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def partial_update_ride_model(request, model_id: UUID, payload: RideModelUpdate):
|
||||
"""
|
||||
Partially update a ride model.
|
||||
Partially update a ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- model_id: UUID of the ride model
|
||||
- payload: Fields to update
|
||||
- payload: Fields to update (only provided fields are updated)
|
||||
|
||||
**Returns:** Updated ride model
|
||||
**Returns:** Updated ride model (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"}
|
||||
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
# Update only provided fields
|
||||
for key, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(model, key, value)
|
||||
|
||||
model.save()
|
||||
model.manufacturer_name = model.manufacturer.name if model.manufacturer else None
|
||||
return model
|
||||
try:
|
||||
user = request.auth
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
data = payload.dict(exclude_unset=True)
|
||||
|
||||
# Update ride model through Sacred Pipeline
|
||||
submission, updated_model = RideModelSubmissionService.update_entity_submission(
|
||||
entity=model,
|
||||
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 model was updated immediately
|
||||
if updated_model:
|
||||
logger.info(f"RideModel partially updated (moderator): {updated_model.id} by {user.email}")
|
||||
updated_model.manufacturer_name = updated_model.manufacturer.name if updated_model.manufacturer else None
|
||||
return 200, updated_model
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"RideModel partial update submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model 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 model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{model_id}",
|
||||
response={204: None, 404: ErrorResponse},
|
||||
response={200: dict, 202: dict, 404: ErrorResponse, 400: ErrorResponse, 401: ErrorResponse},
|
||||
summary="Delete ride model",
|
||||
description="Delete a ride model (requires authentication)"
|
||||
description="Delete a ride model through the Sacred Pipeline (requires authentication)"
|
||||
)
|
||||
@require_auth
|
||||
def delete_ride_model(request, model_id: UUID):
|
||||
"""
|
||||
Delete a ride model.
|
||||
Delete a ride model through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- model_id: UUID of the ride model
|
||||
|
||||
**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)
|
||||
|
||||
model = get_object_or_404(RideModel, id=model_id)
|
||||
model.delete()
|
||||
return 204, None
|
||||
**Flow:**
|
||||
- Moderators: RideModel hard-deleted immediately (removed from database)
|
||||
- Regular users: Deletion request created, enters moderation queue
|
||||
|
||||
**Deletion Strategy:**
|
||||
- Hard Delete: Removes ride model from database (RideModel has no status field for soft delete)
|
||||
|
||||
**Note:** All deletions flow through ContentSubmission pipeline for moderation.
|
||||
**Warning:** Deleting a ride model may affect related rides.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
model = get_object_or_404(RideModel.objects.select_related('manufacturer'), id=model_id)
|
||||
|
||||
# Delete ride model through Sacred Pipeline (hard delete - no status field)
|
||||
submission, deleted = RideModelSubmissionService.delete_entity_submission(
|
||||
entity=model,
|
||||
user=user,
|
||||
deletion_type='hard', # RideModel has no status field
|
||||
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"RideModel deleted (moderator): {model_id} by {user.email}")
|
||||
return 200, {
|
||||
'message': 'Ride model deleted successfully',
|
||||
'entity_id': str(model_id),
|
||||
'deletion_type': 'hard'
|
||||
}
|
||||
|
||||
# Regular user: deletion pending moderation
|
||||
logger.info(f"RideModel deletion submission created: {submission.id} by {user.email}")
|
||||
return 202, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': submission.status,
|
||||
'message': 'Ride model deletion request pending moderation. You will be notified when it is approved.',
|
||||
'entity_id': str(model_id)
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride model: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -245,3 +389,252 @@ def get_ride_model_installations(request, model_id: UUID):
|
||||
'id', 'name', 'slug', 'status', 'park__name', 'park__id'
|
||||
)
|
||||
return list(rides)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get ride model history",
|
||||
description="Get historical changes for a ride model"
|
||||
)
|
||||
def get_ride_model_history(
|
||||
request,
|
||||
model_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 model."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify ride model exists
|
||||
ride_model = get_object_or_404(RideModel, id=model_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(
|
||||
'ridemodel', str(model_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(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.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(
|
||||
'/{model_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific ride model history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_ride_model_history_event(request, model_id: UUID, event_id: int):
|
||||
"""Get a specific history event for a ride model."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
event = HistoryService.get_event('ridemodel', 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(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.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(
|
||||
'/{model_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two ride model history events",
|
||||
description="Compare two historical events for a ride model"
|
||||
)
|
||||
def compare_ride_model_history(
|
||||
request,
|
||||
model_id: UUID,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a ride model."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'ridemodel', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.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(
|
||||
'/{model_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 model state"
|
||||
)
|
||||
def diff_ride_model_history_with_current(request, model_id: UUID, event_id: int):
|
||||
"""Compare historical event with current ride model state."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'ridemodel', event_id, ride_model, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.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(
|
||||
'/{model_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback ride model to historical state",
|
||||
description="Rollback ride model to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_ride_model(request, model_id: UUID, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback ride model 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_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
ride_model, 'ridemodel', 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(
|
||||
'/{model_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific ride model field"
|
||||
)
|
||||
def get_ride_model_field_history(request, model_id: UUID, field_name: str):
|
||||
"""Get history of changes to a specific ride model field."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'ridemodel', str(model_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{model_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get ride model activity summary",
|
||||
description="Get activity summary for a ride model"
|
||||
)
|
||||
def get_ride_model_activity_summary(request, model_id: UUID):
|
||||
"""Get activity summary for a ride model."""
|
||||
ride_model = get_object_or_404(RideModel, id=model_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'ridemodel', str(model_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(model_id),
|
||||
'entity_type': 'ridemodel',
|
||||
'entity_name': ride_model.name,
|
||||
**summary
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1169,3 +1169,120 @@ class TopListListOut(BaseModel):
|
||||
class ReorderItemsRequest(BaseModel):
|
||||
"""Schema for reordering list items."""
|
||||
item_positions: dict = Field(..., description="Map of item_id to new_position")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History/Versioning Schemas
|
||||
# ============================================================================
|
||||
|
||||
class HistoryEventSchema(BaseModel):
|
||||
"""Schema for a single history event."""
|
||||
id: int
|
||||
timestamp: datetime
|
||||
operation: str # 'INSERT' or 'UPDATE'
|
||||
snapshot: dict
|
||||
changed_fields: Optional[dict] = None
|
||||
change_summary: str
|
||||
can_rollback: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HistoryListResponse(BaseModel):
|
||||
"""Response for list history endpoint."""
|
||||
entity_id: UUID
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
total_events: int
|
||||
accessible_events: int
|
||||
access_limited: bool
|
||||
access_reason: str
|
||||
events: List[HistoryEventSchema]
|
||||
pagination: dict
|
||||
|
||||
|
||||
class HistoryEventDetailSchema(BaseModel):
|
||||
"""Detailed event with rollback preview."""
|
||||
id: int
|
||||
timestamp: datetime
|
||||
operation: str
|
||||
entity_id: UUID
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
snapshot: dict
|
||||
changed_fields: Optional[dict] = None
|
||||
metadata: dict
|
||||
can_rollback: bool
|
||||
rollback_preview: Optional[dict] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HistoryComparisonSchema(BaseModel):
|
||||
"""Response for event comparison."""
|
||||
entity_id: UUID
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
event1: dict
|
||||
event2: dict
|
||||
differences: dict
|
||||
changed_field_count: int
|
||||
unchanged_field_count: int
|
||||
time_between: str
|
||||
|
||||
|
||||
class HistoryDiffCurrentSchema(BaseModel):
|
||||
"""Response for comparing event with current state."""
|
||||
entity_id: UUID
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
event: dict
|
||||
current_state: dict
|
||||
differences: dict
|
||||
changed_field_count: int
|
||||
time_since: str
|
||||
|
||||
|
||||
class FieldHistorySchema(BaseModel):
|
||||
"""Response for field-specific history."""
|
||||
entity_id: UUID
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
field: str
|
||||
field_type: str
|
||||
history: List[dict]
|
||||
total_changes: int
|
||||
first_value: Optional[str] = None
|
||||
current_value: Optional[str] = None
|
||||
|
||||
|
||||
class HistoryActivitySummarySchema(BaseModel):
|
||||
"""Response for activity summary."""
|
||||
entity_id: UUID
|
||||
entity_type: str
|
||||
entity_name: str
|
||||
total_events: int
|
||||
accessible_events: int
|
||||
summary: dict
|
||||
most_changed_fields: Optional[List[dict]] = None
|
||||
recent_activity: List[dict]
|
||||
|
||||
|
||||
class RollbackRequestSchema(BaseModel):
|
||||
"""Request body for rollback operation."""
|
||||
fields: Optional[List[str]] = None
|
||||
comment: str = ""
|
||||
create_backup: bool = True
|
||||
|
||||
|
||||
class RollbackResponseSchema(BaseModel):
|
||||
"""Response for rollback operation."""
|
||||
success: bool
|
||||
message: str
|
||||
entity_id: UUID
|
||||
rollback_event_id: int
|
||||
new_event_id: Optional[int]
|
||||
fields_changed: dict
|
||||
backup_event_id: Optional[int]
|
||||
|
||||
5
django/api/v1/services/__init__.py
Normal file
5
django/api/v1/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Service layer for API v1.
|
||||
|
||||
Provides business logic separated from endpoint handlers.
|
||||
"""
|
||||
629
django/api/v1/services/history_service.py
Normal file
629
django/api/v1/services/history_service.py
Normal file
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
History service for pghistory Event models.
|
||||
|
||||
Provides business logic for history queries, comparisons, and rollbacks
|
||||
using pghistory Event models (CompanyEvent, ParkEvent, RideEvent, etc.).
|
||||
"""
|
||||
|
||||
from datetime import timedelta, date, datetime
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from django.utils import timezone
|
||||
from django.db.models import QuerySet, Q
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
class HistoryService:
|
||||
"""
|
||||
Service for managing entity history via pghistory Event models.
|
||||
|
||||
Provides:
|
||||
- History queries with role-based access control
|
||||
- Event comparisons and diffs
|
||||
- Rollback functionality
|
||||
- Field-specific history tracking
|
||||
"""
|
||||
|
||||
# Mapping of entity types to their pghistory Event model paths
|
||||
EVENT_MODELS = {
|
||||
'park': ('apps.entities.models', 'ParkEvent'),
|
||||
'ride': ('apps.entities.models', 'RideEvent'),
|
||||
'company': ('apps.entities.models', 'CompanyEvent'),
|
||||
'ridemodel': ('apps.entities.models', 'RideModelEvent'),
|
||||
'review': ('apps.reviews.models', 'ReviewEvent'),
|
||||
}
|
||||
|
||||
# Mapping of entity types to their main model paths
|
||||
ENTITY_MODELS = {
|
||||
'park': ('apps.entities.models', 'Park'),
|
||||
'ride': ('apps.entities.models', 'Ride'),
|
||||
'company': ('apps.entities.models', 'Company'),
|
||||
'ridemodel': ('apps.entities.models', 'RideModel'),
|
||||
'review': ('apps.reviews.models', 'Review'),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_event_model(cls, entity_type: str):
|
||||
"""
|
||||
Get the pghistory Event model class for an entity type.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity ('park', 'ride', 'company', 'ridemodel', 'review')
|
||||
|
||||
Returns:
|
||||
Event model class (e.g., ParkEvent)
|
||||
|
||||
Raises:
|
||||
ValueError: If entity type is unknown
|
||||
"""
|
||||
entity_type_lower = entity_type.lower()
|
||||
if entity_type_lower not in cls.EVENT_MODELS:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
|
||||
module_path, class_name = cls.EVENT_MODELS[entity_type_lower]
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
return getattr(module, class_name)
|
||||
|
||||
@classmethod
|
||||
def get_entity_model(cls, entity_type: str):
|
||||
"""Get the main entity model class for an entity type."""
|
||||
entity_type_lower = entity_type.lower()
|
||||
if entity_type_lower not in cls.ENTITY_MODELS:
|
||||
raise ValueError(f"Unknown entity type: {entity_type}")
|
||||
|
||||
module_path, class_name = cls.ENTITY_MODELS[entity_type_lower]
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
return getattr(module, class_name)
|
||||
|
||||
@classmethod
|
||||
def get_history(
|
||||
cls,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
user=None,
|
||||
operation: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
field_changed: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> Tuple[QuerySet, int]:
|
||||
"""
|
||||
Get history for an entity with filtering and access control.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
entity_id: UUID of the entity
|
||||
user: User making the request (for access control)
|
||||
operation: Filter by operation type ('INSERT' or 'UPDATE')
|
||||
date_from: Filter events after this date
|
||||
date_to: Filter events before this date
|
||||
field_changed: Filter events that changed this field (requires comparison)
|
||||
limit: Maximum number of events to return
|
||||
offset: Number of events to skip (for pagination)
|
||||
|
||||
Returns:
|
||||
Tuple of (queryset, total_count)
|
||||
"""
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
|
||||
# Base queryset for this entity
|
||||
queryset = EventModel.objects.filter(
|
||||
pgh_obj_id=entity_id
|
||||
).order_by('-pgh_created_at')
|
||||
|
||||
# Get total count before access control for informational purposes
|
||||
total_count = queryset.count()
|
||||
|
||||
# Apply access control (time-based filtering)
|
||||
queryset = cls._apply_access_control(queryset, user)
|
||||
accessible_count = queryset.count()
|
||||
|
||||
# Apply additional filters
|
||||
if date_from:
|
||||
queryset = queryset.filter(pgh_created_at__gte=date_from)
|
||||
|
||||
if date_to:
|
||||
queryset = queryset.filter(pgh_created_at__lte=date_to)
|
||||
|
||||
# Note: field_changed filtering requires comparing consecutive events
|
||||
# This is expensive and should be done in the API layer if needed
|
||||
|
||||
return queryset[offset:offset + limit], accessible_count
|
||||
|
||||
@classmethod
|
||||
def _apply_access_control(cls, queryset: QuerySet, user) -> QuerySet:
|
||||
"""
|
||||
Apply time-based access control based on user role.
|
||||
|
||||
Access Rules:
|
||||
- Unauthenticated: Last 30 days
|
||||
- Authenticated: Last 1 year
|
||||
- Moderators/Admins/Superusers: Unlimited
|
||||
|
||||
Args:
|
||||
queryset: Base queryset to filter
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Filtered queryset
|
||||
"""
|
||||
# Check for privileged users first
|
||||
if user and user.is_authenticated:
|
||||
# Superusers and staff get unlimited access
|
||||
if user.is_superuser or user.is_staff:
|
||||
return queryset
|
||||
|
||||
# Check for moderator/admin role if role system exists
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return queryset
|
||||
|
||||
# Regular authenticated users: 1 year
|
||||
cutoff = timezone.now() - timedelta(days=365)
|
||||
return queryset.filter(pgh_created_at__gte=cutoff)
|
||||
|
||||
# Unauthenticated users: 30 days
|
||||
cutoff = timezone.now() - timedelta(days=30)
|
||||
return queryset.filter(pgh_created_at__gte=cutoff)
|
||||
|
||||
@classmethod
|
||||
def get_access_reason(cls, user) -> str:
|
||||
"""Get human-readable description of access level."""
|
||||
if user and user.is_authenticated:
|
||||
if user.is_superuser or user.is_staff:
|
||||
return "Full access (administrator)"
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return "Full access (moderator)"
|
||||
return "Limited to last 1 year (authenticated user)"
|
||||
return "Limited to last 30 days (public access)"
|
||||
|
||||
@classmethod
|
||||
def is_access_limited(cls, user) -> bool:
|
||||
"""Check if user has limited access."""
|
||||
if not user or not user.is_authenticated:
|
||||
return True
|
||||
if user.is_superuser or user.is_staff:
|
||||
return False
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_event(
|
||||
cls,
|
||||
entity_type: str,
|
||||
event_id: int,
|
||||
user=None
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Get a specific event by ID with access control.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
event_id: ID of the event (pgh_id)
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Event object or None if not found/not accessible
|
||||
"""
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
|
||||
try:
|
||||
event = EventModel.objects.get(pgh_id=event_id)
|
||||
|
||||
# Check if user has access to this event based on timestamp
|
||||
queryset = EventModel.objects.filter(pgh_id=event_id)
|
||||
if not cls._apply_access_control(queryset, user).exists():
|
||||
return None # User doesn't have access to this event
|
||||
|
||||
return event
|
||||
except EventModel.DoesNotExist:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def compare_events(
|
||||
cls,
|
||||
entity_type: str,
|
||||
event_id1: int,
|
||||
event_id2: int,
|
||||
user=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare two historical events.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
event_id1: ID of first event
|
||||
event_id2: ID of second event
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Dictionary containing comparison results
|
||||
|
||||
Raises:
|
||||
ValueError: If events not found or not accessible
|
||||
"""
|
||||
event1 = cls.get_event(entity_type, event_id1, user)
|
||||
event2 = cls.get_event(entity_type, event_id2, user)
|
||||
|
||||
if not event1 or not event2:
|
||||
raise ValueError("One or both events not found or not accessible")
|
||||
|
||||
# Ensure events are for the same entity
|
||||
if event1.pgh_obj_id != event2.pgh_obj_id:
|
||||
raise ValueError("Events must be for the same entity")
|
||||
|
||||
# Compute differences
|
||||
differences = cls._compute_differences(event1, event2)
|
||||
|
||||
# Calculate time between events
|
||||
time_delta = abs(event2.pgh_created_at - event1.pgh_created_at)
|
||||
|
||||
return {
|
||||
'event1': event1,
|
||||
'event2': event2,
|
||||
'differences': differences,
|
||||
'changed_field_count': len(differences),
|
||||
'unchanged_field_count': cls._get_field_count(event1) - len(differences),
|
||||
'time_between': cls._format_timedelta(time_delta)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def compare_with_current(
|
||||
cls,
|
||||
entity_type: str,
|
||||
event_id: int,
|
||||
entity,
|
||||
user=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare historical event with current entity state.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
event_id: ID of historical event
|
||||
entity: Current entity instance
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Dictionary containing comparison results
|
||||
|
||||
Raises:
|
||||
ValueError: If event not found or not accessible
|
||||
"""
|
||||
event = cls.get_event(entity_type, event_id, user)
|
||||
if not event:
|
||||
raise ValueError("Event not found or not accessible")
|
||||
|
||||
# Ensure event is for this entity
|
||||
if str(event.pgh_obj_id) != str(entity.id):
|
||||
raise ValueError("Event is not for the specified entity")
|
||||
|
||||
# Compute differences between historical and current
|
||||
differences = {}
|
||||
fields = cls._get_entity_fields(event)
|
||||
|
||||
for field in fields:
|
||||
historical_val = getattr(event, field, None)
|
||||
current_val = getattr(entity, field, None)
|
||||
|
||||
if historical_val != current_val:
|
||||
differences[field] = {
|
||||
'historical_value': cls._serialize_value(historical_val),
|
||||
'current_value': cls._serialize_value(current_val),
|
||||
'changed': True
|
||||
}
|
||||
|
||||
# Calculate time since event
|
||||
time_delta = timezone.now() - event.pgh_created_at
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
'current_state': entity,
|
||||
'differences': differences,
|
||||
'changed_field_count': len(differences),
|
||||
'time_since': cls._format_timedelta(time_delta)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_rollback(cls, user) -> bool:
|
||||
"""Check if user has permission to perform rollbacks."""
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser or user.is_staff:
|
||||
return True
|
||||
if hasattr(user, 'role') and user.role in ['moderator', 'admin']:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def rollback_to_event(
|
||||
cls,
|
||||
entity,
|
||||
entity_type: str,
|
||||
event_id: int,
|
||||
user,
|
||||
fields: Optional[List[str]] = None,
|
||||
comment: str = "",
|
||||
create_backup: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Rollback entity to a historical state.
|
||||
|
||||
IMPORTANT: This modifies the entity and saves it!
|
||||
|
||||
Args:
|
||||
entity: Current entity instance
|
||||
entity_type: Type of entity
|
||||
event_id: ID of event to rollback to
|
||||
user: User performing the rollback
|
||||
fields: Optional list of specific fields to rollback (None = all fields)
|
||||
comment: Optional comment explaining the rollback
|
||||
create_backup: Whether to note the backup event ID
|
||||
|
||||
Returns:
|
||||
Dictionary containing rollback results
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If user doesn't have rollback permission
|
||||
ValueError: If event not found or invalid
|
||||
"""
|
||||
# Permission check
|
||||
if not cls.can_rollback(user):
|
||||
raise PermissionDenied("Only moderators and administrators can perform rollbacks")
|
||||
|
||||
event = cls.get_event(entity_type, event_id, user)
|
||||
if not event:
|
||||
raise ValueError("Event not found or not accessible")
|
||||
|
||||
# Ensure event is for this entity
|
||||
if str(event.pgh_obj_id) != str(entity.id):
|
||||
raise ValueError("Event is not for the specified entity")
|
||||
|
||||
# Track pre-rollback state for backup reference
|
||||
backup_event_id = None
|
||||
if create_backup:
|
||||
# The current state will be captured automatically by pghistory
|
||||
# when we save. We just need to note what the last event was.
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
last_event = EventModel.objects.filter(
|
||||
pgh_obj_id=entity.id
|
||||
).order_by('-pgh_created_at').first()
|
||||
if last_event:
|
||||
backup_event_id = last_event.pgh_id
|
||||
|
||||
# Determine which fields to rollback
|
||||
if fields is None:
|
||||
fields = cls._get_entity_fields(event)
|
||||
|
||||
# Track changes
|
||||
changes = {}
|
||||
for field in fields:
|
||||
if hasattr(entity, field) and hasattr(event, field):
|
||||
old_val = getattr(entity, field)
|
||||
new_val = getattr(event, field)
|
||||
|
||||
if old_val != new_val:
|
||||
setattr(entity, field, new_val)
|
||||
changes[field] = {
|
||||
'from': cls._serialize_value(old_val),
|
||||
'to': cls._serialize_value(new_val)
|
||||
}
|
||||
|
||||
# Save entity (pghistory will automatically create new event)
|
||||
entity.save()
|
||||
|
||||
# Get the new event that was just created
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
new_event = EventModel.objects.filter(
|
||||
pgh_obj_id=entity.id
|
||||
).order_by('-pgh_created_at').first()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Successfully rolled back {len(changes)} field(s) to state from {event.pgh_created_at.strftime("%Y-%m-%d")}',
|
||||
'entity_id': str(entity.id),
|
||||
'rollback_event_id': event_id,
|
||||
'new_event_id': new_event.pgh_id if new_event else None,
|
||||
'fields_changed': changes,
|
||||
'backup_event_id': backup_event_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_field_history(
|
||||
cls,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
field_name: str,
|
||||
user=None,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get history of changes to a specific field.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
entity_id: UUID of the entity
|
||||
field_name: Name of the field to track
|
||||
user: User making the request
|
||||
limit: Maximum number of changes to return
|
||||
|
||||
Returns:
|
||||
List of field changes
|
||||
"""
|
||||
events, _ = cls.get_history(entity_type, entity_id, user, limit=limit)
|
||||
|
||||
field_history = []
|
||||
previous_value = None
|
||||
first_value = None
|
||||
|
||||
# Iterate through events in reverse chronological order
|
||||
for event in events:
|
||||
if not hasattr(event, field_name):
|
||||
continue
|
||||
|
||||
current_value = getattr(event, field_name, None)
|
||||
|
||||
# Track first (oldest) value
|
||||
if first_value is None:
|
||||
first_value = current_value
|
||||
|
||||
# Detect changes
|
||||
if previous_value is not None and current_value != previous_value:
|
||||
field_history.append({
|
||||
'timestamp': event.pgh_created_at,
|
||||
'event_id': event.pgh_id,
|
||||
'old_value': cls._serialize_value(previous_value),
|
||||
'new_value': cls._serialize_value(current_value),
|
||||
'change_type': 'UPDATE'
|
||||
})
|
||||
elif previous_value is None:
|
||||
# First event we're seeing (most recent)
|
||||
field_history.append({
|
||||
'timestamp': event.pgh_created_at,
|
||||
'event_id': event.pgh_id,
|
||||
'old_value': None,
|
||||
'new_value': cls._serialize_value(current_value),
|
||||
'change_type': 'INSERT' if len(list(events)) == 1 else 'UPDATE'
|
||||
})
|
||||
|
||||
previous_value = current_value
|
||||
|
||||
return {
|
||||
'history': field_history,
|
||||
'total_changes': len(field_history),
|
||||
'first_value': cls._serialize_value(first_value),
|
||||
'current_value': cls._serialize_value(previous_value) if previous_value is not None else None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_activity_summary(
|
||||
cls,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
user=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get activity summary for an entity.
|
||||
|
||||
Args:
|
||||
entity_type: Type of entity
|
||||
entity_id: UUID of the entity
|
||||
user: User making the request
|
||||
|
||||
Returns:
|
||||
Dictionary with activity statistics
|
||||
"""
|
||||
EventModel = cls.get_event_model(entity_type)
|
||||
now = timezone.now()
|
||||
|
||||
# Get all events for this entity (respecting access control)
|
||||
all_events = EventModel.objects.filter(pgh_obj_id=entity_id)
|
||||
total_events = all_events.count()
|
||||
|
||||
accessible_events = cls._apply_access_control(all_events, user)
|
||||
accessible_count = accessible_events.count()
|
||||
|
||||
# Time-based summaries
|
||||
last_24h = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=1)
|
||||
).count()
|
||||
|
||||
last_7d = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
last_30d = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
last_year = accessible_events.filter(
|
||||
pgh_created_at__gte=now - timedelta(days=365)
|
||||
).count()
|
||||
|
||||
# Get recent activity (last 10 events)
|
||||
recent_activity = accessible_events.order_by('-pgh_created_at')[:10]
|
||||
|
||||
return {
|
||||
'total_events': total_events,
|
||||
'accessible_events': accessible_count,
|
||||
'summary': {
|
||||
'last_24_hours': last_24h,
|
||||
'last_7_days': last_7d,
|
||||
'last_30_days': last_30d,
|
||||
'last_year': last_year
|
||||
},
|
||||
'recent_activity': [
|
||||
{
|
||||
'timestamp': event.pgh_created_at,
|
||||
'event_id': event.pgh_id,
|
||||
'operation': 'INSERT' if event == accessible_events.last() else 'UPDATE'
|
||||
}
|
||||
for event in recent_activity
|
||||
]
|
||||
}
|
||||
|
||||
# Helper methods
|
||||
|
||||
@classmethod
|
||||
def _compute_differences(cls, event1, event2) -> Dict[str, Any]:
|
||||
"""Compute differences between two events."""
|
||||
differences = {}
|
||||
fields = cls._get_entity_fields(event1)
|
||||
|
||||
for field in fields:
|
||||
val1 = getattr(event1, field, None)
|
||||
val2 = getattr(event2, field, None)
|
||||
|
||||
if val1 != val2:
|
||||
differences[field] = {
|
||||
'event1_value': cls._serialize_value(val1),
|
||||
'event2_value': cls._serialize_value(val2)
|
||||
}
|
||||
|
||||
return differences
|
||||
|
||||
@classmethod
|
||||
def _get_entity_fields(cls, event) -> List[str]:
|
||||
"""Get list of entity field names (excluding pghistory fields)."""
|
||||
return [
|
||||
f.name for f in event._meta.fields
|
||||
if not f.name.startswith('pgh_') and f.name not in ['id']
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _get_field_count(cls, event) -> int:
|
||||
"""Get count of entity fields."""
|
||||
return len(cls._get_entity_fields(event))
|
||||
|
||||
@classmethod
|
||||
def _serialize_value(cls, value) -> Any:
|
||||
"""Serialize a value for JSON response."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if hasattr(value, 'id'): # Foreign key
|
||||
return str(value.id)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _format_timedelta(cls, delta: timedelta) -> str:
|
||||
"""Format a timedelta as human-readable string."""
|
||||
days = delta.days
|
||||
if days == 0:
|
||||
hours = delta.seconds // 3600
|
||||
if hours == 0:
|
||||
minutes = delta.seconds // 60
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
elif days < 30:
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
elif days < 365:
|
||||
months = days // 30
|
||||
return f"{months} month{'s' if months != 1 else ''}"
|
||||
else:
|
||||
years = days // 365
|
||||
months = (days % 365) // 30
|
||||
if months > 0:
|
||||
return f"{years} year{'s' if years != 1 else ''}, {months} month{'s' if months != 1 else ''}"
|
||||
return f"{years} year{'s' if years != 1 else ''}"
|
||||
Reference in New Issue
Block a user