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 controlget_event()- Retrieve specific historical eventcompare_events()- Compare two historical snapshotscompare_with_current()- Compare historical state with currentrollback_to_event()- Rollback entity to historical state (admin only)get_field_history()- Track changes to specific fieldget_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.pycompanies.pyride_models.pyreviews.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
- Permission Checks: Only moderators/admins can rollback
- Audit Trail: Every rollback creates new event
- Backup Option: create_backup flag preserves pre-rollback state
- Validation: Ensure entity exists and event matches entity
Access Control
-
Time-Based Limits:
- Public: 30 days
- Authenticated: 1 year
- Privileged: Unlimited
-
Event Visibility: Users can only access events within their time window
-
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
- Indexes: pghistory automatically indexes
pgh_obj_idandpgh_created_at - Query Optimization: Use
.only()to fetch minimal fields - 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
- Implement Schemas - Add all history schemas to
schemas.py - Create Generic Endpoints - Implement
history.py - Add Entity Routes - Add history routes to each entity endpoint file
- Register Routes - Update
api.py - Test - Write and run tests
- Document - Create API documentation
- Deploy - Roll out to production
📞 Support
For questions or issues with the history API:
- Review this implementation guide
- Check the HistoryService docstrings
- 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.