Files
thrilltrack-explorer/django-backend/PRIORITY_5_HISTORY_API_IMPLEMENTATION_GUIDE.md

16 KiB

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:

# 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:

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

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

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

# 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

GET /api/v1/parks/{park_id}/history/compare/?event1=12340&event2=12345
Authorization: Bearer {token}

Rollback (Admin Only)

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

GET /api/v1/parks/{park_id}/history/field/status/

Activity Summary

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.