mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 18:11:12 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
633
django-backend/PRIORITY_5_HISTORY_API_IMPLEMENTATION_GUIDE.md
Normal file
633
django-backend/PRIORITY_5_HISTORY_API_IMPLEMENTATION_GUIDE.md
Normal file
@@ -0,0 +1,633 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user