mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 12:31:07 -05:00
feat: Implement Entity Suggestion Manager and Modal components
- Added EntitySuggestionManager.vue to manage entity suggestions and authentication. - Created EntitySuggestionModal.vue for displaying suggestions and adding new entities. - Integrated AuthManager for user authentication within the suggestion modal. - Enhanced signal handling in start-servers.sh for graceful shutdown of servers. - Improved server startup script to ensure proper cleanup and responsiveness to termination signals. - Added documentation for signal handling fixes and usage instructions.
This commit is contained in:
716
docs/ride-ranking-implementation.md
Normal file
716
docs/ride-ranking-implementation.md
Normal file
@@ -0,0 +1,716 @@
|
||||
# Ride Ranking System - Complete Implementation Documentation
|
||||
|
||||
## Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [Backend Implementation](#backend-implementation)
|
||||
3. [Frontend Implementation](#frontend-implementation)
|
||||
4. [API Reference](#api-reference)
|
||||
5. [Usage Examples](#usage-examples)
|
||||
6. [Deployment & Maintenance](#deployment--maintenance)
|
||||
|
||||
## Overview
|
||||
|
||||
The ThrillWiki Ride Ranking System implements the Internet Roller Coaster Poll (IRCP) algorithm to provide fair, data-driven rankings of theme park rides based on user ratings. This document covers the complete implementation across both backend (Django) and frontend (Vue.js/TypeScript) components.
|
||||
|
||||
### Key Features
|
||||
- **Pairwise Comparison Algorithm**: Compares every ride against every other ride based on mutual riders
|
||||
- **Web Interface**: Browse rankings with filtering and detailed views
|
||||
- **REST API**: Comprehensive API for programmatic access
|
||||
- **Historical Tracking**: Track ranking changes over time
|
||||
- **Statistical Analysis**: Head-to-head comparisons and win/loss records
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Database Models
|
||||
|
||||
#### Location: `apps/rides/models/rankings.py`
|
||||
|
||||
```python
|
||||
# Core ranking models
|
||||
- RideRanking: Current ranking data for each ride
|
||||
- RidePairComparison: Cached pairwise comparison results
|
||||
- RankingSnapshot: Historical ranking data
|
||||
```
|
||||
|
||||
#### Key Fields
|
||||
|
||||
**RideRanking Model**:
|
||||
- `rank` (Integer): Overall ranking position
|
||||
- `wins` (Integer): Number of head-to-head wins
|
||||
- `losses` (Integer): Number of head-to-head losses
|
||||
- `ties` (Integer): Number of tied comparisons
|
||||
- `winning_percentage` (Decimal): Win percentage (ties count as 0.5)
|
||||
- `mutual_riders_count` (Integer): Total users who rated this ride
|
||||
- `average_rating` (Decimal): Average user rating
|
||||
- `last_calculated` (DateTime): Timestamp of last calculation
|
||||
|
||||
### Service Layer
|
||||
|
||||
#### Location: `apps/rides/services/ranking_service.py`
|
||||
|
||||
The `RideRankingService` class implements the core ranking algorithm:
|
||||
|
||||
```python
|
||||
class RideRankingService:
|
||||
def update_all_rankings(category=None):
|
||||
"""Main entry point for ranking calculation"""
|
||||
|
||||
def _calculate_pairwise_comparison(ride_a, ride_b):
|
||||
"""Compare two rides based on mutual riders"""
|
||||
|
||||
def _calculate_rankings_from_comparisons():
|
||||
"""Convert comparisons to rankings"""
|
||||
|
||||
def _apply_tiebreakers():
|
||||
"""Resolve ties using head-to-head comparisons"""
|
||||
```
|
||||
|
||||
### Django Views
|
||||
|
||||
#### Location: `apps/rides/views.py`
|
||||
|
||||
**Web Views**:
|
||||
```python
|
||||
class RideRankingsView(ListView):
|
||||
"""Main rankings list page with filtering"""
|
||||
template_name = 'rides/rankings.html'
|
||||
paginate_by = 50
|
||||
|
||||
class RideRankingDetailView(DetailView):
|
||||
"""Detailed ranking view for a specific ride"""
|
||||
template_name = 'rides/ranking_detail.html'
|
||||
```
|
||||
|
||||
**HTMX Endpoints**:
|
||||
- `ranking_history_chart`: Returns chart data for ranking history
|
||||
- `ranking_comparisons`: Returns head-to-head comparison data
|
||||
|
||||
### URL Configuration
|
||||
|
||||
#### Location: `apps/rides/urls.py`
|
||||
|
||||
```python
|
||||
urlpatterns = [
|
||||
path('rankings/', RideRankingsView.as_view(), name='ride-rankings'),
|
||||
path('rankings/<slug:ride_slug>/', RideRankingDetailView.as_view(), name='ride-ranking-detail'),
|
||||
path('rankings/<slug:ride_slug>/history-chart/', ranking_history_chart, name='ranking-history-chart'),
|
||||
path('rankings/<slug:ride_slug>/comparisons/', ranking_comparisons, name='ranking-comparisons'),
|
||||
]
|
||||
```
|
||||
|
||||
### API Implementation
|
||||
|
||||
#### Serializers
|
||||
**Location**: `apps/api/v1/serializers_rankings.py`
|
||||
|
||||
```python
|
||||
class RideRankingSerializer(serializers.ModelSerializer):
|
||||
"""Basic ranking data serialization"""
|
||||
|
||||
class RideRankingDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed ranking with relationships"""
|
||||
|
||||
class RankingSnapshotSerializer(serializers.ModelSerializer):
|
||||
"""Historical ranking data"""
|
||||
|
||||
class RankingStatsSerializer(serializers.Serializer):
|
||||
"""System-wide statistics"""
|
||||
```
|
||||
|
||||
#### ViewSets
|
||||
**Location**: `apps/api/v1/viewsets_rankings.py`
|
||||
|
||||
```python
|
||||
class RideRankingViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
REST API endpoint for ride rankings
|
||||
Supports filtering, ordering, and custom actions
|
||||
"""
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def history(self, request, pk=None):
|
||||
"""Get historical ranking data"""
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def comparisons(self, request, pk=None):
|
||||
"""Get head-to-head comparisons"""
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def statistics(self, request):
|
||||
"""Get system-wide statistics"""
|
||||
|
||||
class TriggerRankingCalculationView(APIView):
|
||||
"""Admin endpoint to trigger manual calculation"""
|
||||
```
|
||||
|
||||
#### API URLs
|
||||
**Location**: `apps/api/v1/urls.py`
|
||||
|
||||
```python
|
||||
router.register(r'rankings', RideRankingViewSet, basename='ranking')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('rankings/calculate/', TriggerRankingCalculationView.as_view()),
|
||||
]
|
||||
```
|
||||
|
||||
### Management Commands
|
||||
|
||||
#### Location: `apps/rides/management/commands/update_ride_rankings.py`
|
||||
|
||||
```bash
|
||||
# Update all rankings
|
||||
python manage.py update_ride_rankings
|
||||
|
||||
# Update specific category
|
||||
python manage.py update_ride_rankings --category RC
|
||||
```
|
||||
|
||||
### Admin Interface
|
||||
|
||||
#### Location: `apps/rides/admin.py`
|
||||
|
||||
```python
|
||||
@admin.register(RideRanking)
|
||||
class RideRankingAdmin(admin.ModelAdmin):
|
||||
list_display = ['rank', 'ride', 'winning_percentage', 'wins', 'losses']
|
||||
list_filter = ['ride__category', 'last_calculated']
|
||||
search_fields = ['ride__name']
|
||||
ordering = ['rank']
|
||||
```
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### TypeScript Type Definitions
|
||||
|
||||
#### Location: `frontend/src/types/index.ts`
|
||||
|
||||
```typescript
|
||||
// Core ranking types
|
||||
export interface RideRanking {
|
||||
id: number
|
||||
rank: number
|
||||
ride: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
park: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
category: 'RC' | 'DR' | 'FR' | 'WR' | 'TR' | 'OT'
|
||||
}
|
||||
wins: number
|
||||
losses: number
|
||||
ties: number
|
||||
winning_percentage: number
|
||||
mutual_riders_count: number
|
||||
comparison_count: number
|
||||
average_rating: number
|
||||
last_calculated: string
|
||||
rank_change?: number
|
||||
previous_rank?: number | null
|
||||
}
|
||||
|
||||
export interface RideRankingDetail extends RideRanking {
|
||||
ride: {
|
||||
// Extended ride information
|
||||
description?: string
|
||||
manufacturer?: { id: number; name: string }
|
||||
opening_date?: string
|
||||
status: string
|
||||
}
|
||||
calculation_version?: string
|
||||
head_to_head_comparisons?: HeadToHeadComparison[]
|
||||
ranking_history?: RankingSnapshot[]
|
||||
}
|
||||
|
||||
export interface HeadToHeadComparison {
|
||||
opponent: {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
park: string
|
||||
}
|
||||
wins: number
|
||||
losses: number
|
||||
ties: number
|
||||
result: 'win' | 'loss' | 'tie'
|
||||
mutual_riders: number
|
||||
}
|
||||
|
||||
export interface RankingSnapshot {
|
||||
date: string
|
||||
rank: number
|
||||
winning_percentage: number
|
||||
}
|
||||
|
||||
export interface RankingStatistics {
|
||||
total_ranked_rides: number
|
||||
total_comparisons: number
|
||||
last_calculation_time: string
|
||||
calculation_duration: number
|
||||
top_rated_ride?: RideInfo
|
||||
most_compared_ride?: RideInfo
|
||||
biggest_rank_change?: RankChangeInfo
|
||||
}
|
||||
```
|
||||
|
||||
### API Service Class
|
||||
|
||||
#### Location: `frontend/src/services/api.ts`
|
||||
|
||||
```typescript
|
||||
export class RankingsApi {
|
||||
// Core API methods
|
||||
async getRankings(params?: RankingParams): Promise<ApiResponse<RideRanking>>
|
||||
async getRankingDetail(rideSlug: string): Promise<RideRankingDetail>
|
||||
async getRankingHistory(rideSlug: string): Promise<RankingSnapshot[]>
|
||||
async getHeadToHeadComparisons(rideSlug: string): Promise<HeadToHeadComparison[]>
|
||||
async getRankingStatistics(): Promise<RankingStatistics>
|
||||
async calculateRankings(category?: string): Promise<CalculationResult>
|
||||
|
||||
// Convenience methods
|
||||
async getTopRankings(limit: number, category?: string): Promise<RideRanking[]>
|
||||
async getParkRankings(parkSlug: string, params?: Params): Promise<ApiResponse<RideRanking>>
|
||||
async searchRankings(query: string): Promise<RideRanking[]>
|
||||
async getRankChange(rideSlug: string): Promise<RankChangeInfo>
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with Main API
|
||||
|
||||
```typescript
|
||||
// Singleton instance with all API services
|
||||
export const api = new ThrillWikiApi()
|
||||
|
||||
// Direct access to rankings API
|
||||
export const rankingsApi = api.rankings
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
#### Get Rankings List
|
||||
```http
|
||||
GET /api/v1/rankings/
|
||||
```
|
||||
|
||||
**Query Parameters**:
|
||||
- `page` (integer): Page number
|
||||
- `page_size` (integer): Results per page (default: 20)
|
||||
- `category` (string): Filter by category (RC, DR, FR, WR, TR, OT)
|
||||
- `min_riders` (integer): Minimum mutual riders
|
||||
- `park` (string): Filter by park slug
|
||||
- `ordering` (string): Sort order (rank, -rank, winning_percentage, -winning_percentage)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"count": 523,
|
||||
"next": "http://api.example.com/api/v1/rankings/?page=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"rank": 1,
|
||||
"ride": {
|
||||
"id": 123,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"park": {
|
||||
"id": 45,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point"
|
||||
},
|
||||
"category": "RC"
|
||||
},
|
||||
"wins": 487,
|
||||
"losses": 23,
|
||||
"ties": 13,
|
||||
"winning_percentage": 0.9405,
|
||||
"mutual_riders_count": 1543,
|
||||
"comparison_count": 523,
|
||||
"average_rating": 9.4,
|
||||
"last_calculated": "2024-01-15T02:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Ranking Details
|
||||
```http
|
||||
GET /api/v1/rankings/{ride-slug}/
|
||||
```
|
||||
|
||||
**Response**: Extended ranking data with full ride details, comparisons, and history
|
||||
|
||||
#### Get Ranking History
|
||||
```http
|
||||
GET /api/v1/rankings/{ride-slug}/history/
|
||||
```
|
||||
|
||||
**Response**: Array of ranking snapshots (last 90 days)
|
||||
|
||||
#### Get Head-to-Head Comparisons
|
||||
```http
|
||||
GET /api/v1/rankings/{ride-slug}/comparisons/
|
||||
```
|
||||
|
||||
**Response**: Array of comparison results with all other rides
|
||||
|
||||
#### Get Statistics
|
||||
```http
|
||||
GET /api/v1/rankings/statistics/
|
||||
```
|
||||
|
||||
**Response**: System-wide ranking statistics
|
||||
|
||||
#### Trigger Calculation (Admin)
|
||||
```http
|
||||
POST /api/v1/rankings/calculate/
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"category": "RC" // Optional
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"rides_ranked": 523,
|
||||
"comparisons_made": 136503,
|
||||
"duration": 45.23,
|
||||
"timestamp": "2024-01-15T02:00:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Frontend (Vue.js/TypeScript)
|
||||
|
||||
#### Display Top Rankings
|
||||
```typescript
|
||||
import { rankingsApi } from '@/services/api'
|
||||
|
||||
export default {
|
||||
async mounted() {
|
||||
try {
|
||||
// Get top 10 rankings
|
||||
const topRides = await rankingsApi.getTopRankings(10)
|
||||
this.rankings = topRides
|
||||
|
||||
// Get roller coasters only
|
||||
const response = await rankingsApi.getRankings({
|
||||
category: 'RC',
|
||||
page_size: 20,
|
||||
ordering: 'rank'
|
||||
})
|
||||
this.rollerCoasters = response.results
|
||||
} catch (error) {
|
||||
console.error('Failed to load rankings:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Display Ranking Details
|
||||
```typescript
|
||||
// In a Vue component
|
||||
async loadRankingDetails(rideSlug: string) {
|
||||
const [details, history, comparisons] = await Promise.all([
|
||||
rankingsApi.getRankingDetail(rideSlug),
|
||||
rankingsApi.getRankingHistory(rideSlug),
|
||||
rankingsApi.getHeadToHeadComparisons(rideSlug)
|
||||
])
|
||||
|
||||
this.rankingDetails = details
|
||||
this.chartData = this.prepareChartData(history)
|
||||
this.comparisons = comparisons
|
||||
}
|
||||
```
|
||||
|
||||
#### Search Rankings
|
||||
```typescript
|
||||
async searchRides(query: string) {
|
||||
const results = await rankingsApi.searchRankings(query)
|
||||
this.searchResults = results
|
||||
}
|
||||
```
|
||||
|
||||
### Backend (Python/Django)
|
||||
|
||||
#### Access Rankings in Views
|
||||
```python
|
||||
from apps.rides.models import RideRanking
|
||||
|
||||
# Get top 10 rides
|
||||
top_rides = RideRanking.objects.select_related('ride', 'ride__park').order_by('rank')[:10]
|
||||
|
||||
# Get rankings for a specific category
|
||||
coaster_rankings = RideRanking.objects.filter(
|
||||
ride__category='RC'
|
||||
).order_by('rank')
|
||||
|
||||
# Get ranking with change indicator
|
||||
ranking = RideRanking.objects.get(ride__slug='millennium-force')
|
||||
if ranking.previous_rank:
|
||||
change = ranking.previous_rank - ranking.rank
|
||||
direction = 'up' if change > 0 else 'down' if change < 0 else 'same'
|
||||
```
|
||||
|
||||
#### Trigger Ranking Update
|
||||
```python
|
||||
from apps.rides.services.ranking_service import RideRankingService
|
||||
|
||||
# Update all rankings
|
||||
service = RideRankingService()
|
||||
result = service.update_all_rankings()
|
||||
|
||||
# Update specific category
|
||||
result = service.update_all_rankings(category='RC')
|
||||
|
||||
print(f"Ranked {result['rides_ranked']} rides")
|
||||
print(f"Made {result['comparisons_made']} comparisons")
|
||||
print(f"Duration: {result['duration']:.2f} seconds")
|
||||
```
|
||||
|
||||
### Command Line
|
||||
|
||||
```bash
|
||||
# Update rankings via management command
|
||||
uv run python manage.py update_ride_rankings
|
||||
|
||||
# Update only roller coasters
|
||||
uv run python manage.py update_ride_rankings --category RC
|
||||
|
||||
# Schedule daily updates with cron
|
||||
0 2 * * * cd /path/to/project && uv run python manage.py update_ride_rankings
|
||||
```
|
||||
|
||||
## Deployment & Maintenance
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. **Run Migrations**:
|
||||
```bash
|
||||
uv run python manage.py migrate
|
||||
```
|
||||
|
||||
2. **Initial Ranking Calculation**:
|
||||
```bash
|
||||
uv run python manage.py update_ride_rankings
|
||||
```
|
||||
|
||||
3. **Verify in Admin**:
|
||||
- Navigate to `/admin/rides/rideranking/`
|
||||
- Verify rankings are populated
|
||||
|
||||
### Scheduled Updates
|
||||
|
||||
Add to crontab for daily updates:
|
||||
```bash
|
||||
# Update rankings daily at 2 AM
|
||||
0 2 * * * cd /path/to/thrillwiki && uv run python manage.py update_ride_rankings
|
||||
|
||||
# Optional: Update different categories at different times
|
||||
0 2 * * * cd /path/to/thrillwiki && uv run python manage.py update_ride_rankings --category RC
|
||||
0 3 * * * cd /path/to/thrillwiki && uv run python manage.py update_ride_rankings --category DR
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
1. **Check Logs**:
|
||||
```bash
|
||||
tail -f /path/to/logs/ranking_updates.log
|
||||
```
|
||||
|
||||
2. **Monitor Performance**:
|
||||
- Track calculation duration via API statistics endpoint
|
||||
- Monitor database query performance
|
||||
- Check comparison cache hit rates
|
||||
|
||||
3. **Data Validation**:
|
||||
```python
|
||||
# Check for ranking anomalies
|
||||
from apps.rides.models import RideRanking
|
||||
|
||||
# Verify all ranks are unique
|
||||
ranks = RideRanking.objects.values_list('rank', flat=True)
|
||||
assert len(ranks) == len(set(ranks))
|
||||
|
||||
# Check winning percentage calculation
|
||||
for ranking in RideRanking.objects.all():
|
||||
expected = (ranking.wins + 0.5 * ranking.ties) / ranking.comparison_count
|
||||
assert abs(ranking.winning_percentage - expected) < 0.001
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
1. **Database Indexes**:
|
||||
```sql
|
||||
-- Ensure these indexes exist
|
||||
CREATE INDEX idx_ranking_rank ON rides_rideranking(rank);
|
||||
CREATE INDEX idx_ranking_ride ON rides_rideranking(ride_id);
|
||||
CREATE INDEX idx_comparison_rides ON rides_ridepaircomparison(ride_a_id, ride_b_id);
|
||||
```
|
||||
|
||||
2. **Cache Configuration**:
|
||||
```python
|
||||
# settings.py
|
||||
CACHES = {
|
||||
'rankings': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': 'redis://127.0.0.1:6379/2',
|
||||
'TIMEOUT': 3600, # 1 hour
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Batch Processing**:
|
||||
- Process comparisons in batches of 1000
|
||||
- Use bulk_create for database inserts
|
||||
- Consider parallel processing for large datasets
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
1. **Rankings not updating**:
|
||||
- Check cron job is running
|
||||
- Verify database connectivity
|
||||
- Check for lock files preventing concurrent runs
|
||||
|
||||
2. **Incorrect rankings**:
|
||||
- Clear comparison cache and recalculate
|
||||
- Verify rating data integrity
|
||||
- Check for duplicate user ratings
|
||||
|
||||
3. **Performance issues**:
|
||||
- Analyze slow queries with Django Debug Toolbar
|
||||
- Consider increasing database resources
|
||||
- Implement incremental updates for large datasets
|
||||
|
||||
### API Rate Limiting
|
||||
|
||||
Configure in `settings.py`:
|
||||
```python
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'rest_framework.throttling.AnonRateThrottle',
|
||||
'rest_framework.throttling.UserRateThrottle'
|
||||
],
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'anon': '100/hour',
|
||||
'user': '1000/hour'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Why Pairwise Comparison?
|
||||
- **Fairness**: Only compares rides among users who've experienced both
|
||||
- **Reduces Bias**: Popular rides aren't advantaged over less-ridden ones
|
||||
- **Head-to-Head Logic**: Direct comparisons matter for tie-breaking
|
||||
- **Robust to Outliers**: One extreme rating doesn't skew results
|
||||
|
||||
### Caching Strategy
|
||||
- **Comparison Cache**: Store pairwise results to avoid recalculation
|
||||
- **Snapshot History**: Keep 365 days of historical data
|
||||
- **API Response Cache**: Cache ranking lists for 1 hour
|
||||
|
||||
### Scalability Considerations
|
||||
- **O(n²) Complexity**: Scales quadratically with number of rides
|
||||
- **Batch Processing**: Process in chunks to manage memory
|
||||
- **Incremental Updates**: Future enhancement for real-time updates
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
```python
|
||||
# apps/rides/tests/test_ranking_service.py
|
||||
class RankingServiceTestCase(TestCase):
|
||||
def test_pairwise_comparison(self):
|
||||
"""Test comparison logic between two rides"""
|
||||
|
||||
def test_ranking_calculation(self):
|
||||
"""Test overall ranking calculation"""
|
||||
|
||||
def test_tiebreaker_logic(self):
|
||||
"""Test head-to-head tiebreaker"""
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
```python
|
||||
# apps/api/tests/test_ranking_api.py
|
||||
class RankingAPITestCase(APITestCase):
|
||||
def test_get_rankings_list(self):
|
||||
"""Test ranking list endpoint"""
|
||||
|
||||
def test_filtering_and_ordering(self):
|
||||
"""Test query parameters"""
|
||||
|
||||
def test_calculation_trigger(self):
|
||||
"""Test admin calculation endpoint"""
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
```typescript
|
||||
// frontend/tests/api/rankings.test.ts
|
||||
describe('Rankings API', () => {
|
||||
it('should fetch top rankings', async () => {
|
||||
const rankings = await rankingsApi.getTopRankings(10)
|
||||
expect(rankings).toHaveLength(10)
|
||||
expect(rankings[0].rank).toBe(1)
|
||||
})
|
||||
|
||||
it('should filter by category', async () => {
|
||||
const response = await rankingsApi.getRankings({ category: 'RC' })
|
||||
expect(response.results.every(r => r.ride.category === 'RC')).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
1. **Real-time Updates**: Update rankings immediately after new reviews
|
||||
2. **Regional Rankings**: Rankings by geographic region
|
||||
3. **Time-Period Rankings**: Best new rides, best classic rides
|
||||
4. **User Preferences**: Personalized rankings based on user history
|
||||
5. **Confidence Intervals**: Statistical confidence for rankings
|
||||
6. **Mobile App API**: Optimized endpoints for mobile applications
|
||||
|
||||
### Potential Optimizations
|
||||
1. **Incremental Updates**: Only recalculate affected comparisons
|
||||
2. **Parallel Processing**: Distribute calculation across workers
|
||||
3. **Machine Learning**: Predict rankings for new rides
|
||||
4. **GraphQL API**: More flexible data fetching
|
||||
5. **WebSocket Updates**: Real-time ranking changes
|
||||
|
||||
## Support & Documentation
|
||||
|
||||
### Additional Resources
|
||||
- [Original IRCP Algorithm](https://ushsho.com/ridesurvey.py)
|
||||
- [Django REST Framework Documentation](https://www.django-rest-framework.org/)
|
||||
- [Vue.js Documentation](https://vuejs.org/)
|
||||
- [TypeScript Documentation](https://www.typescriptlang.org/)
|
||||
|
||||
### Contact
|
||||
For questions or issues related to the ranking system:
|
||||
- Create an issue in the project repository
|
||||
- Contact the development team
|
||||
- Check the troubleshooting section above
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: January 2025*
|
||||
*Version: 1.0*
|
||||
608
docs/ride-ranking-system.md
Normal file
608
docs/ride-ranking-system.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# Ride Ranking System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The ThrillWiki ride ranking system implements the **Internet Roller Coaster Poll (IRCP) algorithm** to calculate fair, accurate rankings for all rides based on user ratings. This system provides daily-updated rankings that reflect the collective preferences of the community.
|
||||
|
||||
## Algorithm Description
|
||||
|
||||
### Original Internet Roller Coaster Poll Algorithm
|
||||
|
||||
The system implements the exact methodology from the Internet Roller Coaster Poll:
|
||||
|
||||
> "Each coaster is compared one at a time to every other coaster to see whether more people who have ridden both of them preferred one or the other. A coaster is given a 'Win' for each coaster that more mutual riders ranked behind it, given a 'Loss' for each coaster that more mutual riders ranked ahead of it, and given a 'Tie' for each coaster that the same number of mutual riders ranked above it and below it. Coasters are ranked by their overall winning percentage (where ties count as half of a win and half of a loss). In the event that two coasters end up with identical winning percentages, the tie is broken (if possible) by determining which of the two won the mutual rider comparison between those two coasters."
|
||||
|
||||
### Our Implementation
|
||||
|
||||
The ThrillWiki implementation adapts this algorithm to work with our existing rating system:
|
||||
|
||||
1. **"Have You Ridden" Detection**: A user is considered to have ridden a ride if they have submitted a rating/review for it
|
||||
2. **Preference Determination**: Higher ratings indicate preference (e.g., a user rating Ride A as 8/10 and Ride B as 6/10 prefers Ride A)
|
||||
3. **Pairwise Comparisons**: Every ride is compared to every other ride based on mutual riders
|
||||
4. **Winning Percentage**: Calculated as `(wins + 0.5 * ties) / total_comparisons`
|
||||
5. **Tie Breaking**: Head-to-head comparisons resolve ties in winning percentage
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Database Models
|
||||
|
||||
#### RideRanking
|
||||
Stores the calculated ranking for each ride:
|
||||
- `rank`: Overall rank position (1 = best)
|
||||
- `wins`: Number of rides this ride beats in pairwise comparisons
|
||||
- `losses`: Number of rides that beat this ride
|
||||
- `ties`: Number of rides with equal preference
|
||||
- `winning_percentage`: Win percentage where ties count as 0.5
|
||||
- `mutual_riders_count`: Total users who have rated this ride
|
||||
- `average_rating`: Average rating from all users
|
||||
- `last_calculated`: Timestamp of last calculation
|
||||
|
||||
#### RidePairComparison
|
||||
Caches pairwise comparison results between two rides:
|
||||
- `ride_a`, `ride_b`: The two rides being compared
|
||||
- `ride_a_wins`: Number of mutual riders who rated ride_a higher
|
||||
- `ride_b_wins`: Number of mutual riders who rated ride_b higher
|
||||
- `ties`: Number of mutual riders who rated both equally
|
||||
- `mutual_riders_count`: Total users who rated both rides
|
||||
|
||||
#### RankingSnapshot
|
||||
Historical tracking of rankings:
|
||||
- `ride`: The ride being tracked
|
||||
- `rank`: Rank on the snapshot date
|
||||
- `winning_percentage`: Win percentage on the snapshot date
|
||||
- `snapshot_date`: Date of the snapshot
|
||||
|
||||
### Service Layer
|
||||
|
||||
The `RideRankingService` (`apps/rides/services/ranking_service.py`) implements the core algorithm:
|
||||
|
||||
```python
|
||||
service = RideRankingService()
|
||||
result = service.update_all_rankings(category='RC') # Optional category filter
|
||||
```
|
||||
|
||||
Key methods:
|
||||
- `update_all_rankings()`: Main entry point for ranking calculation
|
||||
- `_calculate_pairwise_comparison()`: Compares two rides
|
||||
- `_calculate_rankings_from_comparisons()`: Converts comparisons to rankings
|
||||
- `_apply_tiebreakers()`: Resolves ties using head-to-head comparisons
|
||||
|
||||
## Usage
|
||||
|
||||
### Manual Ranking Update
|
||||
|
||||
Run the management command to update rankings:
|
||||
|
||||
```bash
|
||||
# Update all ride rankings
|
||||
uv run python manage.py update_ride_rankings
|
||||
|
||||
# Update only roller coaster rankings
|
||||
uv run python manage.py update_ride_rankings --category RC
|
||||
```
|
||||
|
||||
### Scheduled Updates (Cron)
|
||||
|
||||
Add to crontab for daily updates at 2 AM:
|
||||
|
||||
```bash
|
||||
0 2 * * * cd /Users/talor/thrillwiki_django_no_react/backend && uv run python manage.py update_ride_rankings
|
||||
```
|
||||
|
||||
### Accessing Rankings
|
||||
|
||||
#### Django Admin
|
||||
View rankings at `/admin/rides/rideranking/`
|
||||
|
||||
#### In Code
|
||||
```python
|
||||
from apps.rides.models import RideRanking
|
||||
|
||||
# Get top 10 rides
|
||||
top_rides = RideRanking.objects.select_related('ride').order_by('rank')[:10]
|
||||
|
||||
# Get ranking for specific ride
|
||||
ranking = RideRanking.objects.get(ride__slug='millennium-force')
|
||||
print(f"Rank: #{ranking.rank}, Win%: {ranking.winning_percentage:.1%}")
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
### Views
|
||||
|
||||
The ranking system provides comprehensive web views for browsing and analyzing rankings.
|
||||
|
||||
#### Rankings List View
|
||||
**URL**: `/rides/rankings/`
|
||||
|
||||
**Features**:
|
||||
- Paginated list of all ranked rides (50 per page)
|
||||
- Filter by ride category (Roller Coasters, Dark Rides, etc.)
|
||||
- Filter by minimum mutual riders to ensure statistical significance
|
||||
- HTMX support for dynamic updates without page refresh
|
||||
- Shows key metrics: rank, ride name, park, wins/losses/ties, winning percentage
|
||||
|
||||
**Query Parameters**:
|
||||
- `category`: Filter by ride type (RC, DR, FR, WR, TR, OT)
|
||||
- `min_riders`: Minimum number of mutual riders required (e.g., 100)
|
||||
- `page`: Page number for pagination
|
||||
|
||||
**Example URLs**:
|
||||
```
|
||||
/rides/rankings/ # All rankings
|
||||
/rides/rankings/?category=RC # Only roller coasters
|
||||
/rides/rankings/?category=RC&min_riders=50 # Roller coasters with 50+ mutual riders
|
||||
```
|
||||
|
||||
#### Ranking Detail View
|
||||
**URL**: `/rides/rankings/<ride-slug>/`
|
||||
|
||||
**Features**:
|
||||
- Comprehensive ranking metrics for a specific ride
|
||||
- Head-to-head comparison results with other rides
|
||||
- Historical ranking chart showing trends over time
|
||||
- Rank movement indicators (up/down from previous calculation)
|
||||
- Detailed breakdown of wins, losses, and ties
|
||||
|
||||
**HTMX Endpoints**:
|
||||
- `/rides/rankings/<ride-slug>/history-chart/` - Returns ranking history chart data
|
||||
- `/rides/rankings/<ride-slug>/comparisons/` - Returns detailed comparison table
|
||||
|
||||
### Template Integration
|
||||
|
||||
The views use Django templates with HTMX for dynamic updates:
|
||||
|
||||
```django
|
||||
{# rankings.html - Main rankings page #}
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="rankings-container">
|
||||
<h1>Ride Rankings</h1>
|
||||
|
||||
{# Filters #}
|
||||
<form hx-get="/rides/rankings/" hx-target="#rankings-table">
|
||||
<select name="category">
|
||||
<option value="all">All Rides</option>
|
||||
<option value="RC">Roller Coasters</option>
|
||||
<option value="DR">Dark Rides</option>
|
||||
<!-- etc. -->
|
||||
</select>
|
||||
<input type="number" name="min_riders" placeholder="Min riders">
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
{# Rankings table #}
|
||||
<div id="rankings-table">
|
||||
{% include "rides/partials/rankings_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### RESTful API
|
||||
|
||||
The ranking system exposes a comprehensive REST API for programmatic access.
|
||||
|
||||
#### List Rankings
|
||||
**Endpoint**: `GET /api/v1/rankings/`
|
||||
|
||||
**Description**: Get paginated list of ride rankings
|
||||
|
||||
**Query Parameters**:
|
||||
- `category` (string): Filter by ride category (RC, DR, FR, WR, TR, OT)
|
||||
- `min_riders` (integer): Minimum mutual riders required
|
||||
- `park` (string): Filter by park slug
|
||||
- `ordering` (string): Sort order (rank, -rank, winning_percentage, -winning_percentage)
|
||||
- `page` (integer): Page number
|
||||
- `page_size` (integer): Results per page (default: 20)
|
||||
|
||||
**Response Example**:
|
||||
```json
|
||||
{
|
||||
"count": 523,
|
||||
"next": "http://api.example.com/api/v1/rankings/?page=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"rank": 1,
|
||||
"ride": {
|
||||
"id": 123,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"park": {
|
||||
"id": 45,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point"
|
||||
},
|
||||
"category": "RC"
|
||||
},
|
||||
"wins": 487,
|
||||
"losses": 23,
|
||||
"ties": 13,
|
||||
"winning_percentage": 0.9405,
|
||||
"mutual_riders_count": 1543,
|
||||
"comparison_count": 523,
|
||||
"average_rating": 9.4,
|
||||
"last_calculated": "2024-01-15T02:00:00Z",
|
||||
"rank_change": 0,
|
||||
"previous_rank": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Ranking Details
|
||||
**Endpoint**: `GET /api/v1/rankings/<ride-slug>/`
|
||||
|
||||
**Description**: Get detailed ranking information for a specific ride
|
||||
|
||||
**Response**: Includes full ride details, head-to-head comparisons, and ranking history
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"rank": 1,
|
||||
"ride": {
|
||||
"id": 123,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"description": "Hybrid roller coaster...",
|
||||
"park": {
|
||||
"id": 45,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States"
|
||||
}
|
||||
},
|
||||
"category": "RC",
|
||||
"manufacturer": {
|
||||
"id": 12,
|
||||
"name": "Rocky Mountain Construction"
|
||||
},
|
||||
"opening_date": "2018-05-05",
|
||||
"status": "OPERATING"
|
||||
},
|
||||
"wins": 487,
|
||||
"losses": 23,
|
||||
"ties": 13,
|
||||
"winning_percentage": 0.9405,
|
||||
"mutual_riders_count": 1543,
|
||||
"comparison_count": 523,
|
||||
"average_rating": 9.4,
|
||||
"last_calculated": "2024-01-15T02:00:00Z",
|
||||
"calculation_version": "1.0",
|
||||
"head_to_head_comparisons": [
|
||||
{
|
||||
"opponent": {
|
||||
"id": 124,
|
||||
"name": "Fury 325",
|
||||
"slug": "fury-325",
|
||||
"park": "Carowinds"
|
||||
},
|
||||
"wins": 234,
|
||||
"losses": 189,
|
||||
"ties": 23,
|
||||
"result": "win",
|
||||
"mutual_riders": 446
|
||||
}
|
||||
],
|
||||
"ranking_history": [
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"rank": 1,
|
||||
"winning_percentage": 0.9405
|
||||
},
|
||||
{
|
||||
"date": "2024-01-14",
|
||||
"rank": 1,
|
||||
"winning_percentage": 0.9398
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Ranking History
|
||||
**Endpoint**: `GET /api/v1/rankings/<ride-slug>/history/`
|
||||
|
||||
**Description**: Get historical ranking data for a specific ride (last 90 days)
|
||||
|
||||
**Response**: Array of ranking snapshots
|
||||
|
||||
#### Get Head-to-Head Comparisons
|
||||
**Endpoint**: `GET /api/v1/rankings/<ride-slug>/comparisons/`
|
||||
|
||||
**Description**: Get detailed head-to-head comparison data for a ride
|
||||
|
||||
**Response**: Array of comparison results with all rides
|
||||
|
||||
#### Get Ranking Statistics
|
||||
**Endpoint**: `GET /api/v1/rankings/statistics/`
|
||||
|
||||
**Description**: Get system-wide ranking statistics
|
||||
|
||||
**Response Example**:
|
||||
```json
|
||||
{
|
||||
"total_ranked_rides": 523,
|
||||
"total_comparisons": 136503,
|
||||
"last_calculation_time": "2024-01-15T02:00:00Z",
|
||||
"calculation_duration": 45.23,
|
||||
"top_rated_ride": {
|
||||
"id": 123,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"park": "Cedar Point",
|
||||
"rank": 1,
|
||||
"winning_percentage": 0.9405,
|
||||
"average_rating": 9.4
|
||||
},
|
||||
"most_compared_ride": {
|
||||
"id": 456,
|
||||
"name": "Millennium Force",
|
||||
"slug": "millennium-force",
|
||||
"park": "Cedar Point",
|
||||
"comparison_count": 521
|
||||
},
|
||||
"biggest_rank_change": {
|
||||
"ride": {
|
||||
"id": 789,
|
||||
"name": "Iron Gwazi",
|
||||
"slug": "iron-gwazi"
|
||||
},
|
||||
"current_rank": 3,
|
||||
"previous_rank": 8,
|
||||
"change": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Trigger Ranking Calculation (Admin)
|
||||
**Endpoint**: `POST /api/v1/rankings/calculate/`
|
||||
|
||||
**Description**: Manually trigger ranking calculation (requires admin authentication)
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"category": "RC" // Optional - filter to specific category
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"rides_ranked": 523,
|
||||
"comparisons_made": 136503,
|
||||
"duration": 45.23,
|
||||
"timestamp": "2024-01-15T02:00:45Z"
|
||||
}
|
||||
```
|
||||
|
||||
### API Authentication
|
||||
|
||||
- Read endpoints (GET): No authentication required
|
||||
- Calculation endpoint (POST): Requires admin authentication via token or session
|
||||
|
||||
### API Rate Limiting
|
||||
|
||||
- Anonymous users: 100 requests per hour
|
||||
- Authenticated users: 1000 requests per hour
|
||||
- Admin users: No rate limiting
|
||||
|
||||
### Python Client Example
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Get top 10 rankings
|
||||
response = requests.get(
|
||||
"https://api.thrillwiki.com/api/v1/rankings/",
|
||||
params={"page_size": 10}
|
||||
)
|
||||
rankings = response.json()["results"]
|
||||
|
||||
for ranking in rankings:
|
||||
print(f"#{ranking['rank']}: {ranking['ride']['name']} - {ranking['winning_percentage']:.1%}")
|
||||
|
||||
# Get specific ride ranking
|
||||
response = requests.get(
|
||||
"https://api.thrillwiki.com/api/v1/rankings/steel-vengeance/"
|
||||
)
|
||||
details = response.json()
|
||||
print(f"{details['ride']['name']} is ranked #{details['rank']}")
|
||||
print(f"Wins: {details['wins']}, Losses: {details['losses']}, Ties: {details['ties']}")
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript Client Example
|
||||
|
||||
```typescript
|
||||
// Using fetch API
|
||||
async function getTopRankings(limit: number = 10) {
|
||||
const response = await fetch(
|
||||
`https://api.thrillwiki.com/api/v1/rankings/?page_size=${limit}`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.results;
|
||||
}
|
||||
|
||||
// Using axios
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'https://api.thrillwiki.com/api/v1',
|
||||
});
|
||||
|
||||
async function getRideRanking(slug: string) {
|
||||
const { data } = await api.get(`/rankings/${slug}/`);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
## Calculation Example
|
||||
|
||||
### Scenario
|
||||
Three rides with the following ratings from users:
|
||||
|
||||
**Ride A** (Millennium Force):
|
||||
- User 1: 10/10
|
||||
- User 2: 9/10
|
||||
- User 3: 8/10
|
||||
|
||||
**Ride B** (Maverick):
|
||||
- User 1: 9/10
|
||||
- User 2: 10/10
|
||||
- User 4: 8/10
|
||||
|
||||
**Ride C** (Top Thrill 2):
|
||||
- User 1: 8/10
|
||||
- User 3: 9/10
|
||||
- User 4: 10/10
|
||||
|
||||
### Pairwise Comparisons
|
||||
|
||||
**A vs B** (mutual riders: Users 1, 2):
|
||||
- User 1: A(10) > B(9) → A wins
|
||||
- User 2: A(9) < B(10) → B wins
|
||||
- Result: 1 win each, 0 ties
|
||||
|
||||
**A vs C** (mutual riders: Users 1, 3):
|
||||
- User 1: A(10) > C(8) → A wins
|
||||
- User 3: A(8) < C(9) → C wins
|
||||
- Result: 1 win each, 0 ties
|
||||
|
||||
**B vs C** (mutual riders: Users 1, 4):
|
||||
- User 1: B(9) > C(8) → B wins
|
||||
- User 4: B(8) < C(10) → C wins
|
||||
- Result: 1 win each, 0 ties
|
||||
|
||||
### Final Rankings
|
||||
All three rides have:
|
||||
- Wins: 1
|
||||
- Losses: 1
|
||||
- Ties: 0
|
||||
- Winning Percentage: 50%
|
||||
|
||||
Since all have identical winning percentages, the system would use additional criteria (mutual rider count, average rating) to determine final order.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Caching**: Pairwise comparisons are cached in `RidePairComparison` table
|
||||
2. **Batch Processing**: Comparisons calculated in bulk to minimize database queries
|
||||
3. **Indexes**: Database indexes on frequently queried fields:
|
||||
- `rank` for ordering
|
||||
- `winning_percentage` for sorting
|
||||
- `ride_id` for lookups
|
||||
- Composite indexes for complex queries
|
||||
|
||||
### Scalability
|
||||
|
||||
The algorithm complexity is O(n²) where n is the number of rides, as each ride must be compared to every other ride. For 1,000 rides:
|
||||
- Comparisons needed: 499,500
|
||||
- Estimated processing time: 30-60 seconds
|
||||
- Database storage: ~20MB for comparison cache
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Admin Interface
|
||||
|
||||
Access Django admin to:
|
||||
- View current rankings
|
||||
- Inspect pairwise comparisons
|
||||
- Track historical changes
|
||||
- Monitor calculation performance
|
||||
|
||||
### Logging
|
||||
|
||||
The system logs:
|
||||
- Calculation start/end times
|
||||
- Number of rides ranked
|
||||
- Number of comparisons made
|
||||
- Any errors or warnings
|
||||
|
||||
Example log output:
|
||||
```
|
||||
INFO: Starting ranking calculation for category: RC
|
||||
INFO: Found 523 rides to rank
|
||||
DEBUG: Processed 100/136503 comparisons
|
||||
INFO: Ranking calculation completed in 45.23 seconds
|
||||
```
|
||||
|
||||
### Data Cleanup
|
||||
|
||||
Old ranking snapshots are automatically cleaned up after 365 days to manage database size.
|
||||
|
||||
## Algorithm Validity
|
||||
|
||||
### Mathematical Properties
|
||||
|
||||
1. **Transitivity**: Not enforced (A > B and B > C doesn't guarantee A > C)
|
||||
2. **Consistency**: Same input always produces same output
|
||||
3. **Fairness**: Every mutual rider's opinion counts equally
|
||||
4. **Completeness**: All possible comparisons are considered
|
||||
|
||||
### Advantages Over Simple Averaging
|
||||
|
||||
1. **Reduces Selection Bias**: Only compares rides among users who've experienced both
|
||||
2. **Fair Comparisons**: Popular rides aren't advantaged over less-ridden ones
|
||||
3. **Head-to-Head Logic**: Direct comparisons matter for tie-breaking
|
||||
4. **Robust to Outliers**: One extremely high/low rating doesn't skew results
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
|
||||
1. **Real-time Updates**: Update rankings immediately after new reviews
|
||||
2. **Category-Specific Rankings**: Separate rankings for different ride types
|
||||
3. **Regional Rankings**: Rankings by geographic region
|
||||
4. **Time-Period Rankings**: Best new rides, best classic rides
|
||||
5. **API Endpoints**: RESTful API for ranking data
|
||||
6. **Ranking Trends**: Visualizations of ranking changes over time
|
||||
|
||||
### Potential Optimizations
|
||||
|
||||
1. **Incremental Updates**: Only recalculate affected comparisons
|
||||
2. **Parallel Processing**: Distribute comparison calculations
|
||||
3. **Machine Learning**: Predict rankings for new rides
|
||||
4. **Weighted Ratings**: Consider recency or reviewer credibility
|
||||
|
||||
## Technical Details
|
||||
|
||||
### File Structure
|
||||
```
|
||||
apps/rides/
|
||||
├── models/
|
||||
│ └── rankings.py # Ranking data models
|
||||
├── services/
|
||||
│ └── ranking_service.py # Algorithm implementation
|
||||
├── management/
|
||||
│ └── commands/
|
||||
│ └── update_ride_rankings.py # CLI command
|
||||
└── admin.py # Admin interface configuration
|
||||
```
|
||||
|
||||
### Database Tables
|
||||
- `rides_rideranking`: Current rankings
|
||||
- `rides_ridepaircomparison`: Cached comparisons
|
||||
- `rides_rankingsnapshot`: Historical data
|
||||
- `rides_riderankingevent`: Audit trail (pghistory)
|
||||
- `rides_ridepaircomparisonevent`: Audit trail (pghistory)
|
||||
|
||||
### Dependencies
|
||||
- Django 5.2+
|
||||
- PostgreSQL with pghistory
|
||||
- Python 3.11+
|
||||
|
||||
## References
|
||||
|
||||
- [Internet Roller Coaster Poll](https://ushsho.com/ridesurvey.py) - Original algorithm source
|
||||
- [Condorcet Method](https://en.wikipedia.org/wiki/Condorcet_method) - Similar voting system theory
|
||||
- [Pairwise Comparison](https://en.wikipedia.org/wiki/Pairwise_comparison) - Mathematical foundation
|
||||
164
docs/system-architecture-diagram.md
Normal file
164
docs/system-architecture-diagram.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# ThrillWiki Trending System - Technical Architecture
|
||||
|
||||
## System Components Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Frontend Layer"
|
||||
A[Home.vue Component] --> B[API Service Layer]
|
||||
B --> C[Trending Content Display]
|
||||
B --> D[New Content Display]
|
||||
end
|
||||
|
||||
subgraph "API Layer"
|
||||
E[/api/v1/trending/] --> F[TrendingViewSet]
|
||||
G[/api/v1/new-content/] --> H[NewContentViewSet]
|
||||
F --> I[TrendingSerializer]
|
||||
H --> J[NewContentSerializer]
|
||||
end
|
||||
|
||||
subgraph "Business Logic"
|
||||
K[Trending Algorithm] --> L[Score Calculator]
|
||||
L --> M[Weight Processor]
|
||||
M --> N[Ranking Engine]
|
||||
end
|
||||
|
||||
subgraph "Data Layer"
|
||||
O[PageView Model] --> P[View Tracker]
|
||||
Q[Park Model] --> R[Content Source]
|
||||
S[Ride Model] --> R
|
||||
T[pghistory Events] --> U[Change Tracker]
|
||||
end
|
||||
|
||||
subgraph "Caching Layer"
|
||||
V[Redis Cache] --> W[Trending Cache]
|
||||
V --> X[New Content Cache]
|
||||
W --> Y[6hr TTL]
|
||||
X --> Z[24hr TTL]
|
||||
end
|
||||
|
||||
subgraph "Background Tasks"
|
||||
AA[Management Command] --> BB[Calculate Trending]
|
||||
CC[Celery/Cron Scheduler] --> AA
|
||||
BB --> K
|
||||
BB --> V
|
||||
end
|
||||
|
||||
subgraph "Middleware"
|
||||
DD[View Tracking Middleware] --> O
|
||||
EE[User Request] --> DD
|
||||
end
|
||||
|
||||
A --> E
|
||||
A --> G
|
||||
F --> K
|
||||
H --> U
|
||||
K --> O
|
||||
K --> Q
|
||||
K --> S
|
||||
F --> V
|
||||
H --> V
|
||||
EE --> A
|
||||
```
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant F as Frontend
|
||||
participant API as API Layer
|
||||
participant C as Cache
|
||||
participant BG as Background Job
|
||||
participant DB as Database
|
||||
participant M as Middleware
|
||||
|
||||
Note over U,M: Page View Tracking
|
||||
U->>F: Visit Park/Ride Page
|
||||
F->>M: HTTP Request
|
||||
M->>DB: Store PageView Record
|
||||
|
||||
Note over U,M: Trending Content Request
|
||||
U->>F: Load Home Page
|
||||
F->>API: GET /api/v1/trending/?tab=rides
|
||||
API->>C: Check Cache
|
||||
alt Cache Hit
|
||||
C->>API: Return Cached Data
|
||||
else Cache Miss
|
||||
API->>DB: Query Trending Data
|
||||
DB->>API: Raw Data
|
||||
API->>API: Apply Algorithm
|
||||
API->>C: Store in Cache
|
||||
end
|
||||
API->>F: Trending Response
|
||||
F->>U: Display Trending Content
|
||||
|
||||
Note over U,M: Background Processing
|
||||
BG->>DB: Aggregate PageViews
|
||||
BG->>DB: Calculate Scores
|
||||
BG->>C: Update Cache
|
||||
```
|
||||
|
||||
## Algorithm Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start Trending Calculation] --> B[Fetch Recent PageViews]
|
||||
B --> C[Group by Content Type/ID]
|
||||
C --> D[Calculate View Score]
|
||||
D --> E[Fetch Content Ratings]
|
||||
E --> F[Calculate Rating Score]
|
||||
F --> G[Calculate Recency Score]
|
||||
G --> H[Apply Weighted Formula]
|
||||
H --> I{Score > Threshold?}
|
||||
I -->|Yes| J[Add to Trending List]
|
||||
I -->|No| K[Skip Item]
|
||||
J --> L[Sort by Final Score]
|
||||
K --> L
|
||||
L --> M[Cache Results]
|
||||
M --> N[End]
|
||||
```
|
||||
|
||||
## Database Schema Relationships
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
PageView ||--o{ ContentType : references
|
||||
PageView {
|
||||
id bigint PK
|
||||
content_type_id int FK
|
||||
object_id int
|
||||
user_session varchar
|
||||
ip_address inet
|
||||
user_agent text
|
||||
timestamp datetime
|
||||
}
|
||||
|
||||
Park ||--o{ PageView : tracked_in
|
||||
Park {
|
||||
id int PK
|
||||
name varchar
|
||||
slug varchar
|
||||
average_rating decimal
|
||||
status varchar
|
||||
opening_date date
|
||||
closing_date date
|
||||
}
|
||||
|
||||
Ride ||--o{ PageView : tracked_in
|
||||
Ride {
|
||||
id int PK
|
||||
name varchar
|
||||
slug varchar
|
||||
park_id int FK
|
||||
average_rating decimal
|
||||
category varchar
|
||||
status varchar
|
||||
opening_date date
|
||||
}
|
||||
|
||||
TrendingCache {
|
||||
key varchar PK
|
||||
data json
|
||||
expires_at datetime
|
||||
}
|
||||
140
docs/trending-system-architecture.md
Normal file
140
docs/trending-system-architecture.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# ThrillWiki Trending & New Content System Architecture
|
||||
|
||||
## System Overview
|
||||
|
||||
This document outlines the architecture for implementing real trending and new content functionality to replace the current mock data implementation on the ThrillWiki home page.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Frontend Structure (Vue 3 + TypeScript)
|
||||
- **Home.vue** expects specific data formats:
|
||||
- **Trending Content**: `{id, name, location, category, rating, rank, views, views_change, slug}`
|
||||
- **New Content**: `{id, name, location, category, date_added, slug}`
|
||||
- **Tabs Supported**:
|
||||
- Trending: Rides, Parks, Reviews
|
||||
- New: Recently Added, Newly Opened, Upcoming
|
||||
|
||||
### Backend Infrastructure
|
||||
- **Django REST Framework** with comprehensive ViewSets
|
||||
- **pghistory** already tracking model changes
|
||||
- **Existing endpoints** for recent changes, openings, closures
|
||||
- **Models**: Park and Ride with ratings, status, dates
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### 1. Data Flow Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User Views Page] --> B[View Tracking Middleware]
|
||||
B --> C[PageView Model]
|
||||
|
||||
D[Trending Calculation Job] --> E[Trending Algorithm]
|
||||
E --> F[Cache Layer]
|
||||
|
||||
G[Frontend Request] --> H[API Endpoints]
|
||||
H --> F
|
||||
F --> I[Serialized Response]
|
||||
I --> J[Frontend Display]
|
||||
|
||||
K[Management Command] --> D
|
||||
L[Celery/Cron Schedule] --> K
|
||||
```
|
||||
|
||||
### 2. Database Schema Design
|
||||
|
||||
#### PageView Model
|
||||
```python
|
||||
class PageView(models.Model):
|
||||
content_type = models.ForeignKey(ContentType)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
user_session = models.CharField(max_length=40)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField()
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id', 'timestamp']),
|
||||
models.Index(fields=['timestamp']),
|
||||
]
|
||||
```
|
||||
|
||||
### 3. Trending Algorithm
|
||||
|
||||
#### Calculation Components
|
||||
- **View Count Weight**: Recent page views (configurable time window)
|
||||
- **Rating Weight**: Average rating from Park/Ride models
|
||||
- **Recency Boost**: Recently added/updated content bonus
|
||||
- **Category Balancing**: Ensure diverse content across categories
|
||||
|
||||
#### Formula
|
||||
```
|
||||
Trending Score = (View Score × 0.4) + (Rating Score × 0.3) + (Recency Score × 0.2) + (Engagement Score × 0.1)
|
||||
```
|
||||
|
||||
### 4. API Endpoints Design
|
||||
|
||||
#### Trending Endpoint
|
||||
```
|
||||
GET /api/v1/trending/?tab={rides|parks|reviews}&limit=6
|
||||
```
|
||||
|
||||
#### New Content Endpoint
|
||||
```
|
||||
GET /api/v1/new-content/?tab={recently-added|newly-opened|upcoming}&limit=4
|
||||
```
|
||||
|
||||
### 5. Caching Strategy
|
||||
|
||||
#### Cache Keys
|
||||
- `trending_rides_6h`: Trending rides cache (6 hour TTL)
|
||||
- `trending_parks_6h`: Trending parks cache (6 hour TTL)
|
||||
- `new_content_24h`: New content cache (24 hour TTL)
|
||||
|
||||
#### Cache Invalidation
|
||||
- Manual refresh via management command
|
||||
- Automatic refresh on schedule
|
||||
- Cache warming during low-traffic periods
|
||||
|
||||
### 6. Performance Considerations
|
||||
|
||||
#### View Tracking Optimization
|
||||
- Async middleware for non-blocking view tracking
|
||||
- Batch insert for high-volume periods
|
||||
- IP-based rate limiting to prevent spam
|
||||
|
||||
#### Database Optimization
|
||||
- Proper indexing on PageView model
|
||||
- Aggregate tables for trending calculations
|
||||
- Periodic cleanup of old PageView records
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
The implementation follows the todo list with these key phases:
|
||||
|
||||
1. **Database Layer**: PageView model and migrations
|
||||
2. **Algorithm Design**: Trending calculation logic
|
||||
3. **API Layer**: New endpoints and serializers
|
||||
4. **Tracking System**: Middleware for view capture
|
||||
5. **Caching Layer**: Performance optimization
|
||||
6. **Automation**: Management commands and scheduling
|
||||
7. **Frontend Integration**: Replace mock data
|
||||
8. **Testing & Monitoring**: Comprehensive coverage
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- Anonymous view tracking (no personal data)
|
||||
- Session-based rate limiting
|
||||
- User agent validation
|
||||
- IP address anonymization options
|
||||
|
||||
## Monitoring & Analytics
|
||||
|
||||
- View tracking success rates
|
||||
- Trending calculation performance
|
||||
- Cache hit/miss ratios
|
||||
- API response times
|
||||
- Algorithm effectiveness metrics
|
||||
Reference in New Issue
Block a user