# Priority 5: History API Implementation Guide **Date:** 2025-11-08 **Status:** ๐Ÿšง IN PROGRESS - Service Layer Complete ## Overview Implementation of comprehensive history API using pghistory Event models to replace the old custom versioning system. Provides history tracking, comparison, and rollback capabilities with role-based access control. --- ## โœ… Completed ### 1. Service Layer (`django/api/v1/services/history_service.py`) **Status:** โœ… COMPLETE **Features Implemented:** - `get_history()` - Query entity history with access control - `get_event()` - Retrieve specific historical event - `compare_events()` - Compare two historical snapshots - `compare_with_current()` - Compare historical state with current - `rollback_to_event()` - Rollback entity to historical state (admin only) - `get_field_history()` - Track changes to specific field - `get_activity_summary()` - Activity statistics **Access Control:** - Unauthenticated: Last 30 days - Authenticated: Last 1 year - Moderators/Admins/Superusers: Unlimited **Models Supported:** - Park โ†’ ParkEvent - Ride โ†’ RideEvent - Company โ†’ CompanyEvent - RideModel โ†’ RideModelEvent - Review โ†’ ReviewEvent --- ## ๐Ÿ“‹ Remaining Implementation Tasks ### Phase 1: API Schemas **File:** `django/api/v1/schemas.py` **Add the following schemas:** ```python # History Event Schema 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 # History List Response 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 # Event Detail Response 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 # Comparison Response 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 # Diff with Current Response 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 # Field History Response 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: Any current_value: Any # Activity Summary Response 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] # Rollback Request class RollbackRequestSchema(BaseModel): """Request body for rollback operation.""" fields: Optional[List[str]] = None comment: str = "" create_backup: bool = True # Rollback Response 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] ``` --- ### Phase 2: Generic History Endpoints **File:** `django/api/v1/endpoints/history.py` (CREATE NEW) **Implementation:** ```python """ 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"} # Build response # ... (format event data) 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(...), event1: int = Query(...), event2: int = Query(...) ): """Compare two historical events.""" try: comparison = HistoryService.compare_events( entity_type, event1, event2, request.user ) # Format response # ... (build comparison response) return response_data except ValueError as e: return 400, {"error": str(e)} ``` --- ### Phase 3: Entity-Specific History Routes **Add to each entity endpoint file:** #### Parks (`django/api/v1/endpoints/parks.py`) ```python @router.get( '/{park_id}/history/', response={200: HistoryListResponse, 404: ErrorSchema}, summary="Get park history" ) 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[date] = Query(None), date_to: Optional[date] = Query(None) ): """Get history for a park.""" # Verify park exists park = get_object_or_404(Park, id=park_id) # Get history offset = (page - 1) * page_size events, accessible_count = HistoryService.get_history( 'park', str(park_id), request.user, date_from=date_from, date_to=date_to, limit=page_size, offset=offset ) # Format response 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': [/* format each event */], 'pagination': {/* pagination info */} } @router.get( '/{park_id}/history/{event_id}/', response={200: HistoryEventDetailSchema, 404: ErrorSchema}, summary="Get specific park history 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"} # Format and return event details # ... @router.get( '/{park_id}/history/compare/', response={200: HistoryComparisonSchema, 400: ErrorSchema}, summary="Compare two park history events" ) def compare_park_history( request, park_id: UUID, event1: int = Query(...), event2: int = Query(...) ): """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 ) # Format and return comparison # ... 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" ) 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 ) # Format and return diff # ... 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" ) 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, PermissionDenied) 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" ) 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" ) 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 } ``` **Repeat similar patterns for:** - `rides.py` - `companies.py` - `ride_models.py` - `reviews.py` --- ### Phase 4: Register Routes **File:** `django/api/v1/api.py` **Add:** ```python from .endpoints.history import router as history_router # After other routers: api.add_router("/history", history_router) ``` --- ### Phase 5: Documentation **File:** `django/API_HISTORY_ENDPOINTS.md` (CREATE NEW) Document all history endpoints with: - Endpoint URLs - Request/response schemas - Authentication requirements - Access control rules - Example requests/responses - Rollback safety guidelines --- ## ๐Ÿ”’ Security Considerations ### Rollback Protection 1. **Permission Checks:** Only moderators/admins can rollback 2. **Audit Trail:** Every rollback creates new event 3. **Backup Option:** create_backup flag preserves pre-rollback state 4. **Validation:** Ensure entity exists and event matches entity ### Access Control 1. **Time-Based Limits:** - Public: 30 days - Authenticated: 1 year - Privileged: Unlimited 2. **Event Visibility:** Users can only access events within their time window 3. **Rate Limiting:** Consider adding rate limits for rollback operations --- ## ๐Ÿงช Testing Checklist ### Unit Tests - [ ] HistoryService access control rules - [ ] Event comparison logic - [ ] Field history tracking - [ ] Rollback functionality - [ ] Access level determination ### Integration Tests - [ ] List history with different user types - [ ] Get specific events - [ ] Compare events - [ ] Field-specific history - [ ] Activity summaries - [ ] Rollback operations (mocked) ### API Tests - [ ] All GET endpoints return correct data - [ ] Pagination works correctly - [ ] Filtering (date range, etc.) works - [ ] POST rollback requires authentication - [ ] POST rollback requires proper permissions - [ ] Invalid requests return appropriate errors --- ## ๐Ÿ“Š Performance Optimization ### Database 1. **Indexes:** pghistory automatically indexes `pgh_obj_id` and `pgh_created_at` 2. **Query Optimization:** Use `.only()` to fetch minimal fields 3. **Pagination:** Always paginate large result sets ### Caching Consider caching: - Recent history for popular entities (e.g., last 10 events) - Activity summaries (TTL: 1 hour) - Field statistics ### Limits - Max page_size: 100 events - Max field_history: 100 changes - Max activity summary: Last 10 events --- ## ๐Ÿš€ Deployment Checklist - [ ] All schemas added to `schemas.py` - [ ] History service tested and working - [ ] Generic history endpoints created - [ ] Entity-specific routes added to all 5 entity types - [ ] Routes registered in `api.py` - [ ] Documentation complete - [ ] Tests passing - [ ] API documentation updated - [ ] Security review completed - [ ] Performance tested with large datasets --- ## ๐Ÿ“– Usage Examples ### Get Park History ```bash # Public user (last 30 days) GET /api/v1/parks/{park_id}/history/ # Authenticated user (last 1 year) GET /api/v1/parks/{park_id}/history/ Authorization: Bearer {token} # With pagination GET /api/v1/parks/{park_id}/history/?page=2&page_size=50 # With date filtering GET /api/v1/parks/{park_id}/history/?date_from=2024-01-01&date_to=2024-12-31 ``` ### Compare Events ```bash GET /api/v1/parks/{park_id}/history/compare/?event1=12340&event2=12345 Authorization: Bearer {token} ``` ### Rollback (Admin Only) ```bash POST /api/v1/parks/{park_id}/history/{event_id}/rollback/ Authorization: Bearer {admin_token} Content-Type: application/json { "fields": ["status", "description"], "comment": "Reverting accidental changes", "create_backup": true } ``` ### Field History ```bash GET /api/v1/parks/{park_id}/history/field/status/ ``` ### Activity Summary ```bash GET /api/v1/parks/{park_id}/history/summary/ ``` --- ## ๐ŸŽฏ Next Steps 1. **Implement Schemas** - Add all history schemas to `schemas.py` 2. **Create Generic Endpoints** - Implement `history.py` 3. **Add Entity Routes** - Add history routes to each entity endpoint file 4. **Register Routes** - Update `api.py` 5. **Test** - Write and run tests 6. **Document** - Create API documentation 7. **Deploy** - Roll out to production --- ## ๐Ÿ“ž Support For questions or issues with the history API: 1. Review this implementation guide 2. Check the HistoryService docstrings 3. Review pghistory documentation: https://django-pghistory.readthedocs.io/ --- **Status:** Service layer complete. API endpoints and schemas ready for implementation. **Next Action:** Add history schemas to `schemas.py`, then implement endpoint routes.