Implement entity submission services for ThrillWiki

- Added BaseEntitySubmissionService as an abstract base for entity submissions.
- Created specific submission services for entities: Park, Ride, Company, RideModel.
- Implemented create, update, and delete functionalities with moderation workflow.
- Enhanced logging and validation for required fields.
- Addressed foreign key handling and special field processing for each entity type.
- Noted existing issues with JSONField usage in Company submissions.
This commit is contained in:
pacnpal
2025-11-08 22:23:41 -05:00
parent 9122320e7e
commit 2884bc23ce
29 changed files with 8699 additions and 330 deletions

View File

@@ -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")

View File

@@ -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
}

View 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)}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

@@ -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]

View File

@@ -0,0 +1,5 @@
"""
Service layer for API v1.
Provides business logic separated from endpoint handlers.
"""

View 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 ''}"