mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:31:07 -05:00
remove apps/api
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
Centralized API package for ThrillWiki
|
||||
|
||||
All API endpoints MUST be defined here under the /api/v1/ structure.
|
||||
This enforces consistent API architecture and prevents rogue endpoint creation.
|
||||
"""
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
ThrillWiki API App Configuration
|
||||
|
||||
This module contains the Django app configuration for the centralized API application.
|
||||
All API endpoints are routed through this app following the pattern:
|
||||
- Frontend: /api/{endpoint}
|
||||
- Vite Proxy: /api/ -> /api/v1/
|
||||
- Django: backend/api/v1/{endpoint}
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
"""Configuration for the centralized API app."""
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
||||
verbose_name = "ThrillWiki API"
|
||||
|
||||
def ready(self):
|
||||
"""Import signals when the app is ready."""
|
||||
import apps.api.v1.signals # noqa: F401
|
||||
@@ -1 +0,0 @@
|
||||
# Management commands package
|
||||
@@ -1,158 +0,0 @@
|
||||
# ThrillWiki Data Seeding Script
|
||||
|
||||
## Overview
|
||||
|
||||
The `seed_data.py` management command provides comprehensive test data seeding for the ThrillWiki application. It creates realistic data across all models in the system for testing and development purposes.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
```bash
|
||||
# Seed with default counts
|
||||
uv run manage.py seed_data
|
||||
|
||||
# Clear existing data and seed fresh
|
||||
uv run manage.py seed_data --clear
|
||||
|
||||
# Custom counts
|
||||
uv run manage.py seed_data --users 50 --parks 20 --rides 100 --reviews 200
|
||||
```
|
||||
|
||||
### Command Options
|
||||
|
||||
- `--clear`: Clear existing data before seeding
|
||||
- `--users N`: Number of users to create (default: 25)
|
||||
- `--companies N`: Number of companies to create (default: 15)
|
||||
- `--parks N`: Number of parks to create (default: 10)
|
||||
- `--rides N`: Number of rides to create (default: 50)
|
||||
- `--ride-models N`: Number of ride models to create (default: 20)
|
||||
- `--reviews N`: Number of reviews to create (default: 100)
|
||||
|
||||
## What Gets Created
|
||||
|
||||
### Users & Accounts
|
||||
- **Admin User**: `admin` / `admin123` (superuser)
|
||||
- **Moderator User**: `moderator` / `mod123` (staff)
|
||||
- **Regular Users**: Random realistic users with profiles
|
||||
- **User Profiles**: Complete with ride credits, social links, preferences
|
||||
- **Notifications**: Sample notifications for users
|
||||
- **Top Lists**: User-created top lists for parks and rides
|
||||
|
||||
### Companies
|
||||
- **Park Operators**: Disney, Universal, Six Flags, Cedar Fair, etc.
|
||||
- **Ride Manufacturers**: B&M, Intamin, Vekoma, RMC, etc.
|
||||
- **Ride Designers**: Werner Stengel, Alan Schilke, John Wardley
|
||||
- **Company Headquarters**: Realistic address data
|
||||
|
||||
### Parks & Locations
|
||||
- **Famous Parks**: Magic Kingdom, Disneyland, Cedar Point, etc.
|
||||
- **Park Locations**: Geographic coordinates and addresses
|
||||
- **Park Areas**: Themed areas within parks
|
||||
- **Park Photos**: Sample photo records
|
||||
|
||||
### Rides & Models
|
||||
- **Famous Coasters**: Steel Vengeance, Millennium Force, etc.
|
||||
- **Ride Models**: B&M Dive Coaster, Intamin Accelerator, etc.
|
||||
- **Roller Coaster Stats**: Height, speed, inversions, etc.
|
||||
- **Ride Photos**: Sample photo records
|
||||
- **Technical Specs**: Detailed specifications for ride models
|
||||
|
||||
### Content & Reviews
|
||||
- **Park Reviews**: User reviews with ratings and visit dates
|
||||
- **Ride Reviews**: Detailed ride experiences
|
||||
- **Review Content**: Realistic review text and ratings
|
||||
|
||||
## Data Quality Features
|
||||
|
||||
### Realistic Data
|
||||
- **Names**: Diverse, realistic user names
|
||||
- **Locations**: Accurate geographic coordinates
|
||||
- **Relationships**: Proper company-park-ride relationships
|
||||
- **Statistics**: Realistic ride statistics and ratings
|
||||
|
||||
### Comprehensive Coverage
|
||||
- **All Models**: Seeds data for every model in the system
|
||||
- **Relationships**: Maintains proper foreign key relationships
|
||||
- **Optional Models**: Handles models that may not exist gracefully
|
||||
|
||||
### Data Integrity
|
||||
- **Unique Constraints**: Uses `get_or_create` to avoid duplicates
|
||||
- **Validation**: Respects model constraints and validation rules
|
||||
- **Dependencies**: Creates data in proper dependency order
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Architecture
|
||||
- **Modular Design**: Separate methods for each model type
|
||||
- **Transaction Safety**: All operations wrapped in database transaction
|
||||
- **Error Handling**: Graceful handling of missing optional models
|
||||
- **Progress Reporting**: Clear console output with emojis and counts
|
||||
|
||||
### Model Handling
|
||||
- **Dual Company Models**: Properly handles separate Park and Ride company models
|
||||
- **Optional Models**: Checks for existence before using optional models
|
||||
- **Type Safety**: Proper type hints and error handling
|
||||
|
||||
### Data Generation
|
||||
- **Random but Realistic**: Uses curated lists for realistic data
|
||||
- **Configurable Counts**: All counts are configurable via command line
|
||||
- **Relationship Integrity**: Maintains proper relationships between models
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Database Schema Mismatch**: If you see timezone constraint errors, run migrations first:
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
```
|
||||
|
||||
2. **Permission Errors**: Ensure database user has proper permissions for all operations
|
||||
|
||||
3. **Memory Issues**: For large datasets, consider running with smaller batches
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Database Schema Compatibility**: May encounter issues with database schemas that have additional required fields not present in the current models (e.g., timezone field)
|
||||
- **pghistory Compatibility**: May have issues with some pghistory configurations
|
||||
- **Cloudflare Images**: Creates placeholder records without actual images
|
||||
- **Geographic Data**: Requires PostGIS for location features
|
||||
- **Transaction Management**: Uses atomic transactions which may fail completely if any model creation fails
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Adding New Models
|
||||
1. Import the model at the top of the file
|
||||
2. Add to `models_to_clear` list if needed
|
||||
3. Create a new `create_*` method
|
||||
4. Call the method in `handle()` in proper dependency order
|
||||
5. Add count to `print_summary()`
|
||||
|
||||
### Customizing Data
|
||||
- Modify the data lists (e.g., `first_names`, `famous_parks`) to customize generated data
|
||||
- Adjust probability weights for different scenarios
|
||||
- Add new relationship patterns as needed
|
||||
|
||||
## Performance
|
||||
|
||||
### Optimization Tips
|
||||
- Use `--clear` sparingly in production-like environments
|
||||
- Consider smaller batch sizes for very large datasets
|
||||
- Monitor database performance during seeding
|
||||
|
||||
### Typical Performance
|
||||
- 25 users, 15 companies, 10 parks, 50 rides: ~30 seconds
|
||||
- 100 users, 50 companies, 25 parks, 200 rides: ~2-3 minutes
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Default Passwords**: All seeded users have simple passwords for development only
|
||||
- **Admin Access**: Creates admin user with known credentials
|
||||
- **Production Warning**: Never run with `--clear` in production environments
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Bulk Operations**: Use bulk_create for better performance
|
||||
- **Custom Scenarios**: Add preset scenarios (small, medium, large)
|
||||
- **Data Export**: Add ability to export seeded data
|
||||
- **Incremental Updates**: Support for updating existing data
|
||||
@@ -1,601 +0,0 @@
|
||||
# ThrillWiki Data Seeding - Implementation Guide
|
||||
|
||||
## Overview
|
||||
This document outlines the specific requirements and implementation steps needed to complete the data seeding script for ThrillWiki. Currently, three features are skipped during seeding due to missing or incomplete model implementations.
|
||||
|
||||
## 🛡️ Moderation Data Implementation
|
||||
|
||||
### Current Status
|
||||
```
|
||||
🛡️ Creating moderation data...
|
||||
✅ Comprehensive moderation system is implemented and ready for seeding
|
||||
```
|
||||
|
||||
### Available Models
|
||||
The moderation system is fully implemented in `apps.moderation.models` with the following models:
|
||||
|
||||
#### 1. ModerationReport Model
|
||||
```python
|
||||
class ModerationReport(TrackedModel):
|
||||
"""Model for tracking user reports about content, users, or behavior"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending Review'),
|
||||
('UNDER_REVIEW', 'Under Review'),
|
||||
('RESOLVED', 'Resolved'),
|
||||
('DISMISSED', 'Dismissed'),
|
||||
]
|
||||
|
||||
REPORT_TYPE_CHOICES = [
|
||||
('SPAM', 'Spam'),
|
||||
('HARASSMENT', 'Harassment'),
|
||||
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
|
||||
('MISINFORMATION', 'Misinformation'),
|
||||
('COPYRIGHT', 'Copyright Violation'),
|
||||
('PRIVACY', 'Privacy Violation'),
|
||||
('HATE_SPEECH', 'Hate Speech'),
|
||||
('VIOLENCE', 'Violence or Threats'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
reason = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
reported_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='moderation_reports_made')
|
||||
assigned_moderator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
# ... additional fields
|
||||
```
|
||||
|
||||
#### 2. ModerationQueue Model
|
||||
```python
|
||||
class ModerationQueue(TrackedModel):
|
||||
"""Model for managing moderation workflow and task assignment"""
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
('CONTENT_REVIEW', 'Content Review'),
|
||||
('USER_REVIEW', 'User Review'),
|
||||
('BULK_ACTION', 'Bulk Action'),
|
||||
('POLICY_VIOLATION', 'Policy Violation'),
|
||||
('APPEAL', 'Appeal'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
assigned_to = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
related_report = models.ForeignKey(ModerationReport, on_delete=models.CASCADE, null=True, blank=True)
|
||||
# ... additional fields
|
||||
```
|
||||
|
||||
#### 3. ModerationAction Model
|
||||
```python
|
||||
class ModerationAction(TrackedModel):
|
||||
"""Model for tracking actions taken against users or content"""
|
||||
|
||||
ACTION_TYPE_CHOICES = [
|
||||
('WARNING', 'Warning'),
|
||||
('USER_SUSPENSION', 'User Suspension'),
|
||||
('USER_BAN', 'User Ban'),
|
||||
('CONTENT_REMOVAL', 'Content Removal'),
|
||||
('CONTENT_EDIT', 'Content Edit'),
|
||||
('CONTENT_RESTRICTION', 'Content Restriction'),
|
||||
('ACCOUNT_RESTRICTION', 'Account Restriction'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
|
||||
reason = models.CharField(max_length=200)
|
||||
details = models.TextField()
|
||||
moderator = models.ForeignKey(User, on_delete=models.CASCADE, related_name='moderation_actions_taken')
|
||||
target_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='moderation_actions_received')
|
||||
related_report = models.ForeignKey(ModerationReport, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
# ... additional fields
|
||||
```
|
||||
|
||||
#### 4. Additional Models
|
||||
- **BulkOperation**: For tracking bulk administrative operations
|
||||
- **PhotoSubmission**: For photo moderation workflow
|
||||
- **EditSubmission**: For content edit submissions (legacy)
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Moderation app already exists** at `backend/apps/moderation/`
|
||||
|
||||
2. **Already added to INSTALLED_APPS** in `backend/config/django/base.py`
|
||||
|
||||
3. **Models are fully implemented** in `apps/moderation/models.py`
|
||||
|
||||
4. **Update the seeding script** - Replace the placeholder in `create_moderation_data()`:
|
||||
```python
|
||||
def create_moderation_data(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
|
||||
"""Create moderation reports, queue items, and actions"""
|
||||
self.stdout.write('🛡️ Creating moderation data...')
|
||||
|
||||
if not users or (not parks and not rides):
|
||||
self.stdout.write(' ⚠️ No users or content found, skipping moderation data')
|
||||
return
|
||||
|
||||
moderators = [u for u in users if u.role in ['MODERATOR', 'ADMIN']]
|
||||
if not moderators:
|
||||
self.stdout.write(' ⚠️ No moderators found, skipping moderation data')
|
||||
return
|
||||
|
||||
moderation_count = 0
|
||||
all_content = list(parks) + list(rides)
|
||||
|
||||
# Create moderation reports
|
||||
for _ in range(min(15, len(all_content))):
|
||||
content_item = random.choice(all_content)
|
||||
reporter = random.choice(users)
|
||||
moderator = random.choice(moderators) if random.random() < 0.7 else None
|
||||
|
||||
report = ModerationReport.objects.create(
|
||||
report_type=random.choice(['SPAM', 'INAPPROPRIATE_CONTENT', 'MISINFORMATION', 'OTHER']),
|
||||
status=random.choice(['PENDING', 'UNDER_REVIEW', 'RESOLVED', 'DISMISSED']),
|
||||
priority=random.choice(['LOW', 'MEDIUM', 'HIGH']),
|
||||
reason=f"Reported issue with {content_item.__class__.__name__}",
|
||||
description=random.choice([
|
||||
'Content contains inappropriate information',
|
||||
'Suspected spam or promotional content',
|
||||
'Information appears to be inaccurate',
|
||||
'Content violates community guidelines'
|
||||
]),
|
||||
reported_by=reporter,
|
||||
assigned_moderator=moderator,
|
||||
reported_entity_type=content_item.__class__.__name__.lower(),
|
||||
reported_entity_id=content_item.pk,
|
||||
)
|
||||
|
||||
# Create queue item for some reports
|
||||
if random.random() < 0.6:
|
||||
queue_item = ModerationQueue.objects.create(
|
||||
item_type=random.choice(['CONTENT_REVIEW', 'POLICY_VIOLATION']),
|
||||
status=random.choice(['PENDING', 'IN_PROGRESS', 'COMPLETED']),
|
||||
priority=report.priority,
|
||||
title=f"Review {content_item.__class__.__name__}: {content_item}",
|
||||
description=f"Review required for reported {content_item.__class__.__name__.lower()}",
|
||||
assigned_to=moderator,
|
||||
related_report=report,
|
||||
entity_type=content_item.__class__.__name__.lower(),
|
||||
entity_id=content_item.pk,
|
||||
)
|
||||
|
||||
# Create action if resolved
|
||||
if queue_item.status == 'COMPLETED' and moderator:
|
||||
ModerationAction.objects.create(
|
||||
action_type=random.choice(['WARNING', 'CONTENT_EDIT', 'CONTENT_RESTRICTION']),
|
||||
reason=f"Action taken on {content_item.__class__.__name__}",
|
||||
details=f"Moderation action completed for {content_item}",
|
||||
moderator=moderator,
|
||||
target_user=reporter, # In real scenario, this would be content owner
|
||||
related_report=report,
|
||||
)
|
||||
|
||||
moderation_count += 1
|
||||
|
||||
self.stdout.write(f' ✅ Created {moderation_count} moderation items')
|
||||
```
|
||||
|
||||
## 📸 Photo Records Implementation
|
||||
|
||||
### Current Status
|
||||
```
|
||||
📸 Creating photo records...
|
||||
✅ Photo system is fully implemented with CloudflareImage integration
|
||||
```
|
||||
|
||||
### Available Models
|
||||
The photo system is fully implemented with the following models:
|
||||
|
||||
#### 1. ParkPhoto Model
|
||||
```python
|
||||
class ParkPhoto(TrackedModel):
|
||||
"""Photo model specific to parks"""
|
||||
|
||||
park = models.ForeignKey("parks.Park", on_delete=models.CASCADE, related_name="photos")
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Park photo stored on Cloudflare Images"
|
||||
)
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
date_taken = models.DateTimeField(null=True, blank=True)
|
||||
# ... additional fields with MediaService integration
|
||||
```
|
||||
|
||||
#### 2. RidePhoto Model
|
||||
```python
|
||||
class RidePhoto(TrackedModel):
|
||||
"""Photo model specific to rides"""
|
||||
|
||||
ride = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="photos")
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Ride photo stored on Cloudflare Images"
|
||||
)
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
|
||||
# Ride-specific metadata
|
||||
photo_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
)
|
||||
# ... additional fields with MediaService integration
|
||||
```
|
||||
|
||||
### Current Configuration
|
||||
|
||||
#### 1. Cloudflare Images Already Configured
|
||||
The system is already configured in `backend/config/django/base.py`:
|
||||
```python
|
||||
# Cloudflare Images Settings
|
||||
CLOUDFLARE_IMAGES = {
|
||||
'ACCOUNT_ID': config("CLOUDFLARE_IMAGES_ACCOUNT_ID"),
|
||||
'API_TOKEN': config("CLOUDFLARE_IMAGES_API_TOKEN"),
|
||||
'ACCOUNT_HASH': config("CLOUDFLARE_IMAGES_ACCOUNT_HASH"),
|
||||
'DEFAULT_VARIANT': 'public',
|
||||
'UPLOAD_TIMEOUT': 300,
|
||||
'MAX_FILE_SIZE': 10 * 1024 * 1024, # 10MB
|
||||
'ALLOWED_FORMATS': ['jpeg', 'png', 'gif', 'webp'],
|
||||
# ... additional configuration
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. django-cloudflareimages-toolkit Integration
|
||||
- ✅ Package is installed and configured
|
||||
- ✅ Models use CloudflareImage foreign keys
|
||||
- ✅ Advanced MediaService integration exists
|
||||
- ✅ Custom upload path functions implemented
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Photo models already exist** in `apps/parks/models/media.py` and `apps/rides/models/media.py`
|
||||
|
||||
2. **CloudflareImage toolkit is installed** and configured
|
||||
|
||||
3. **Environment variables needed** (add to `.env`):
|
||||
```env
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_ID=your_account_id
|
||||
CLOUDFLARE_IMAGES_API_TOKEN=your_api_token
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your_account_hash
|
||||
```
|
||||
|
||||
4. **Update the seeding script** - Replace the placeholder in `create_photos()`:
|
||||
```python
|
||||
def create_photos(self, parks: List[Park], rides: List[Ride], users: List[User]) -> None:
|
||||
"""Create sample photo records using CloudflareImage"""
|
||||
self.stdout.write('📸 Creating photo records...')
|
||||
|
||||
# For development/testing, we can create placeholder CloudflareImage instances
|
||||
# In production, these would be actual uploaded images
|
||||
|
||||
photo_count = 0
|
||||
|
||||
# Create park photos
|
||||
for park in random.sample(parks, min(len(parks), 8)):
|
||||
for i in range(random.randint(1, 3)):
|
||||
try:
|
||||
# Create a placeholder CloudflareImage for seeding
|
||||
# In real usage, this would be an actual uploaded image
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
# Add minimal required fields for seeding
|
||||
# Actual implementation depends on CloudflareImage model structure
|
||||
)
|
||||
|
||||
ParkPhoto.objects.create(
|
||||
park=park,
|
||||
image=cloudflare_image,
|
||||
caption=f"Beautiful view of {park.name}",
|
||||
alt_text=f"Photo of {park.name} theme park",
|
||||
is_primary=i == 0,
|
||||
is_approved=True, # Auto-approve for seeding
|
||||
uploaded_by=random.choice(users),
|
||||
date_taken=timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||
)
|
||||
photo_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f' ⚠️ Failed to create park photo: {str(e)}')
|
||||
|
||||
# Create ride photos
|
||||
for ride in random.sample(rides, min(len(rides), 15)):
|
||||
for i in range(random.randint(1, 2)):
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
# Add minimal required fields for seeding
|
||||
)
|
||||
|
||||
RidePhoto.objects.create(
|
||||
ride=ride,
|
||||
image=cloudflare_image,
|
||||
caption=f"Exciting view of {ride.name}",
|
||||
alt_text=f"Photo of {ride.name} ride",
|
||||
photo_type=random.choice(['exterior', 'queue', 'station', 'onride']),
|
||||
is_primary=i == 0,
|
||||
is_approved=True, # Auto-approve for seeding
|
||||
uploaded_by=random.choice(users),
|
||||
date_taken=timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||
)
|
||||
photo_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f' ⚠️ Failed to create ride photo: {str(e)}')
|
||||
|
||||
self.stdout.write(f' ✅ Created {photo_count} photo records')
|
||||
```
|
||||
|
||||
### Advanced Features Available
|
||||
|
||||
- **MediaService Integration**: Automatic EXIF date extraction, default caption generation
|
||||
- **Upload Path Management**: Custom upload paths for organization
|
||||
- **Primary Photo Logic**: Automatic handling of primary photo constraints
|
||||
- **Approval Workflow**: Built-in approval system for photo moderation
|
||||
- **Photo Types**: Categorization system for ride photos (exterior, queue, station, onride, etc.)
|
||||
|
||||
## 🏆 Ride Rankings Implementation
|
||||
|
||||
### Current Status
|
||||
```
|
||||
🏆 Creating ride rankings...
|
||||
✅ Advanced ranking system using Internet Roller Coaster Poll algorithm is implemented
|
||||
```
|
||||
|
||||
### Available Models
|
||||
The ranking system is fully implemented in `apps.rides.models.rankings` with a sophisticated algorithm:
|
||||
|
||||
#### 1. RideRanking Model
|
||||
```python
|
||||
class RideRanking(models.Model):
|
||||
"""
|
||||
Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm.
|
||||
Rankings are recalculated daily based on user reviews/ratings.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField("rides.Ride", on_delete=models.CASCADE, related_name="ranking")
|
||||
|
||||
# Core ranking metrics
|
||||
rank = models.PositiveIntegerField(db_index=True, help_text="Overall rank position (1 = best)")
|
||||
wins = models.PositiveIntegerField(default=0, help_text="Number of rides this ride beats in pairwise comparisons")
|
||||
losses = models.PositiveIntegerField(default=0, help_text="Number of rides that beat this ride in pairwise comparisons")
|
||||
ties = models.PositiveIntegerField(default=0, help_text="Number of rides with equal preference in pairwise comparisons")
|
||||
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4, help_text="Win percentage where ties count as 0.5")
|
||||
|
||||
# Additional metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(default=0, help_text="Total number of users who have rated this ride")
|
||||
comparison_count = models.PositiveIntegerField(default=0, help_text="Number of other rides this was compared against")
|
||||
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
last_calculated = models.DateTimeField(default=timezone.now)
|
||||
calculation_version = models.CharField(max_length=10, default="1.0")
|
||||
```
|
||||
|
||||
#### 2. RidePairComparison Model
|
||||
```python
|
||||
class RidePairComparison(models.Model):
|
||||
"""
|
||||
Caches pairwise comparison results between two rides.
|
||||
Used to speed up ranking calculations by storing mutual rider preferences.
|
||||
"""
|
||||
|
||||
ride_a = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a")
|
||||
ride_b = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b")
|
||||
|
||||
# Comparison results
|
||||
ride_a_wins = models.PositiveIntegerField(default=0, help_text="Number of mutual riders who rated ride_a higher")
|
||||
ride_b_wins = models.PositiveIntegerField(default=0, help_text="Number of mutual riders who rated ride_b higher")
|
||||
ties = models.PositiveIntegerField(default=0, help_text="Number of mutual riders who rated both rides equally")
|
||||
|
||||
# Metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(default=0, help_text="Total number of users who have rated both rides")
|
||||
ride_a_avg_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||
ride_b_avg_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||
|
||||
last_calculated = models.DateTimeField(auto_now=True)
|
||||
```
|
||||
|
||||
#### 3. RankingSnapshot Model
|
||||
```python
|
||||
class RankingSnapshot(models.Model):
|
||||
"""
|
||||
Stores historical snapshots of rankings for tracking changes over time.
|
||||
Allows showing ranking trends and movements.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="ranking_history")
|
||||
rank = models.PositiveIntegerField()
|
||||
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4)
|
||||
snapshot_date = models.DateField(db_index=True, help_text="Date when this ranking snapshot was taken")
|
||||
```
|
||||
|
||||
### Algorithm Details
|
||||
|
||||
The system implements the **Internet Roller Coaster Poll algorithm**:
|
||||
|
||||
1. **Pairwise Comparisons**: Each ride is compared to every other ride based on mutual riders (users who have rated both rides)
|
||||
2. **Winning Percentage**: Calculated as `(wins + 0.5 * ties) / total_comparisons`
|
||||
3. **Ranking**: Rides are ranked by winning percentage, with ties broken by mutual rider count
|
||||
4. **Daily Recalculation**: Rankings are updated daily to reflect new reviews and ratings
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Ranking models already exist** in `apps/rides/models/rankings.py`
|
||||
|
||||
2. **Models are fully implemented** with sophisticated algorithm
|
||||
|
||||
3. **Update the seeding script** - Replace the placeholder in `create_rankings()`:
|
||||
```python
|
||||
def create_rankings(self, rides: List[Ride], users: List[User]) -> None:
|
||||
"""Create sophisticated ranking data using Internet Roller Coaster Poll algorithm"""
|
||||
self.stdout.write('🏆 Creating ride rankings...')
|
||||
|
||||
if not rides:
|
||||
self.stdout.write(' ⚠️ No rides found, skipping rankings')
|
||||
return
|
||||
|
||||
# Get users who have created reviews (they're likely to have rated rides)
|
||||
users_with_reviews = [u for u in users if hasattr(u, 'ride_reviews') or hasattr(u, 'park_reviews')]
|
||||
|
||||
if not users_with_reviews:
|
||||
self.stdout.write(' ⚠️ No users with reviews found, skipping rankings')
|
||||
return
|
||||
|
||||
ranking_count = 0
|
||||
comparison_count = 0
|
||||
snapshot_count = 0
|
||||
|
||||
# Create initial rankings for all rides
|
||||
for i, ride in enumerate(rides, 1):
|
||||
# Calculate mock metrics for seeding
|
||||
mock_wins = random.randint(0, len(rides) - 1)
|
||||
mock_losses = random.randint(0, len(rides) - 1 - mock_wins)
|
||||
mock_ties = len(rides) - 1 - mock_wins - mock_losses
|
||||
total_comparisons = mock_wins + mock_losses + mock_ties
|
||||
|
||||
winning_percentage = (mock_wins + 0.5 * mock_ties) / total_comparisons if total_comparisons > 0 else 0.5
|
||||
|
||||
RideRanking.objects.create(
|
||||
ride=ride,
|
||||
rank=i, # Will be recalculated based on winning_percentage
|
||||
wins=mock_wins,
|
||||
losses=mock_losses,
|
||||
ties=mock_ties,
|
||||
winning_percentage=Decimal(str(round(winning_percentage, 4))),
|
||||
mutual_riders_count=random.randint(10, 100),
|
||||
comparison_count=total_comparisons,
|
||||
average_rating=Decimal(str(round(random.uniform(6.0, 9.5), 2))),
|
||||
last_calculated=timezone.now(),
|
||||
calculation_version="1.0",
|
||||
)
|
||||
ranking_count += 1
|
||||
|
||||
# Create some pairwise comparisons for realism
|
||||
for _ in range(min(50, len(rides) * 2)):
|
||||
ride_a, ride_b = random.sample(rides, 2)
|
||||
|
||||
# Avoid duplicate comparisons
|
||||
if RidePairComparison.objects.filter(
|
||||
models.Q(ride_a=ride_a, ride_b=ride_b) |
|
||||
models.Q(ride_a=ride_b, ride_b=ride_a)
|
||||
).exists():
|
||||
continue
|
||||
|
||||
mutual_riders = random.randint(5, 30)
|
||||
ride_a_wins = random.randint(0, mutual_riders)
|
||||
ride_b_wins = random.randint(0, mutual_riders - ride_a_wins)
|
||||
ties = mutual_riders - ride_a_wins - ride_b_wins
|
||||
|
||||
RidePairComparison.objects.create(
|
||||
ride_a=ride_a,
|
||||
ride_b=ride_b,
|
||||
ride_a_wins=ride_a_wins,
|
||||
ride_b_wins=ride_b_wins,
|
||||
ties=ties,
|
||||
mutual_riders_count=mutual_riders,
|
||||
ride_a_avg_rating=Decimal(str(round(random.uniform(6.0, 9.5), 2))),
|
||||
ride_b_avg_rating=Decimal(str(round(random.uniform(6.0, 9.5), 2))),
|
||||
)
|
||||
comparison_count += 1
|
||||
|
||||
# Create historical snapshots for trend analysis
|
||||
for days_ago in [30, 60, 90, 180, 365]:
|
||||
snapshot_date = timezone.now().date() - timedelta(days=days_ago)
|
||||
|
||||
for ride in random.sample(rides, min(len(rides), 20)):
|
||||
# Create historical ranking with some variation
|
||||
current_ranking = RideRanking.objects.get(ride=ride)
|
||||
historical_rank = max(1, current_ranking.rank + random.randint(-5, 5))
|
||||
historical_percentage = max(0.0, min(1.0,
|
||||
float(current_ranking.winning_percentage) + random.uniform(-0.1, 0.1)
|
||||
))
|
||||
|
||||
RankingSnapshot.objects.create(
|
||||
ride=ride,
|
||||
rank=historical_rank,
|
||||
winning_percentage=Decimal(str(round(historical_percentage, 4))),
|
||||
snapshot_date=snapshot_date,
|
||||
)
|
||||
snapshot_count += 1
|
||||
|
||||
# Re-rank rides based on winning percentage (simulate algorithm)
|
||||
rankings = RideRanking.objects.order_by('-winning_percentage', '-mutual_riders_count')
|
||||
for new_rank, ranking in enumerate(rankings, 1):
|
||||
ranking.rank = new_rank
|
||||
ranking.save(update_fields=['rank'])
|
||||
|
||||
self.stdout.write(f' ✅ Created {ranking_count} ride rankings')
|
||||
self.stdout.write(f' ✅ Created {comparison_count} pairwise comparisons')
|
||||
self.stdout.write(f' ✅ Created {snapshot_count} historical snapshots')
|
||||
```
|
||||
|
||||
### Advanced Features Available
|
||||
|
||||
- **Internet Roller Coaster Poll Algorithm**: Industry-standard ranking methodology
|
||||
- **Pairwise Comparisons**: Sophisticated comparison system between rides
|
||||
- **Historical Tracking**: Ranking snapshots for trend analysis
|
||||
- **Mutual Rider Analysis**: Rankings based on users who have experienced both rides
|
||||
- **Winning Percentage Calculation**: Advanced statistical ranking metrics
|
||||
- **Daily Recalculation**: Automated ranking updates based on new data
|
||||
|
||||
## Summary of Current Status
|
||||
|
||||
### ✅ All Systems Implemented and Ready
|
||||
|
||||
All three major systems are **fully implemented** and ready for seeding:
|
||||
|
||||
1. **🛡️ Moderation System**: ✅ **COMPLETE**
|
||||
- Comprehensive moderation system with 6 models
|
||||
- ModerationReport, ModerationQueue, ModerationAction, BulkOperation, PhotoSubmission, EditSubmission
|
||||
- Advanced workflow management and action tracking
|
||||
- **Action Required**: Update seeding script to use actual model structure
|
||||
|
||||
2. **📸 Photo System**: ✅ **COMPLETE**
|
||||
- Full CloudflareImage integration with django-cloudflareimages-toolkit
|
||||
- ParkPhoto and RidePhoto models with advanced features
|
||||
- MediaService integration, upload paths, approval workflows
|
||||
- **Action Required**: Add CloudflareImage environment variables and update seeding script
|
||||
|
||||
3. **🏆 Rankings System**: ✅ **COMPLETE**
|
||||
- Sophisticated Internet Roller Coaster Poll algorithm
|
||||
- RideRanking, RidePairComparison, RankingSnapshot models
|
||||
- Advanced pairwise comparison system with historical tracking
|
||||
- **Action Required**: Update seeding script to create realistic ranking data
|
||||
|
||||
### Implementation Priority
|
||||
|
||||
| System | Status | Priority | Effort Required |
|
||||
|--------|--------|----------|----------------|
|
||||
| Moderation | ✅ Implemented | HIGH | 1-2 hours (script updates) |
|
||||
| Photo | ✅ Implemented | MEDIUM | 1 hour (env vars + script) |
|
||||
| Rankings | ✅ Implemented | LOW | 30 mins (script updates) |
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Update seeding script imports** to use correct model names and structures
|
||||
2. **Add environment variables** for CloudflareImage integration
|
||||
3. **Modify seeding methods** to work with sophisticated existing models
|
||||
4. **Test all seeding functionality** with current implementations
|
||||
|
||||
**Total Estimated Time**: 2-3 hours (down from original 6+ hours estimate)
|
||||
|
||||
The seeding script can now provide **100% coverage** of all ThrillWiki models and features with these updates.
|
||||
@@ -1,212 +0,0 @@
|
||||
# SEEDING_IMPLEMENTATION_GUIDE.md Accuracy Report
|
||||
|
||||
**Date:** January 15, 2025
|
||||
**Reviewer:** Cline
|
||||
**Status:** COMPREHENSIVE ANALYSIS COMPLETE
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The SEEDING_IMPLEMENTATION_GUIDE.md file contains **significant inaccuracies** and outdated information. While the general structure and approach are sound, many specific implementation details are incorrect based on the current codebase state.
|
||||
|
||||
**Overall Accuracy Rating: 6/10** ⚠️
|
||||
|
||||
## Detailed Analysis by Section
|
||||
|
||||
### 🛡️ Moderation Data Implementation
|
||||
|
||||
**Status:** ❌ **MAJOR INACCURACIES**
|
||||
|
||||
#### What the Guide Claims:
|
||||
- States that moderation models are "not fully defined"
|
||||
- Provides detailed model implementations for `ModerationQueue` and `ModerationAction`
|
||||
- Claims the app needs to be created
|
||||
|
||||
#### Actual Current State:
|
||||
- ✅ Moderation app **already exists** at `backend/apps/moderation/`
|
||||
- ✅ **Comprehensive moderation system** is already implemented with:
|
||||
- `EditSubmission` (original submission workflow)
|
||||
- `ModerationReport` (user reports)
|
||||
- `ModerationQueue` (workflow management)
|
||||
- `ModerationAction` (actions taken)
|
||||
- `BulkOperation` (bulk administrative operations)
|
||||
- `PhotoSubmission` (photo moderation)
|
||||
|
||||
#### Key Differences:
|
||||
1. **Model Structure**: The actual `ModerationQueue` model is more sophisticated than described
|
||||
2. **Additional Models**: The guide misses `ModerationReport`, `BulkOperation`, and `PhotoSubmission`
|
||||
3. **Field Names**: Some field names differ (e.g., `submitted_by` vs `reported_by`)
|
||||
4. **Relationships**: More complex relationships exist between models
|
||||
|
||||
#### Required Corrections:
|
||||
- Remove "models not fully defined" status
|
||||
- Update model field mappings to match actual implementation
|
||||
- Include all existing moderation models
|
||||
- Update seeding script to use actual model structure
|
||||
|
||||
### 📸 Photo Records Implementation
|
||||
|
||||
**Status:** ⚠️ **PARTIALLY ACCURATE**
|
||||
|
||||
#### What the Guide Claims:
|
||||
- Photo creation is skipped due to missing CloudflareImage instances
|
||||
- Requires Cloudflare Images configuration
|
||||
- Needs sample images directory structure
|
||||
|
||||
#### Actual Current State:
|
||||
- ✅ `django_cloudflareimages_toolkit` **is installed** and configured
|
||||
- ✅ `ParkPhoto` and `RidePhoto` models **exist and are properly implemented**
|
||||
- ✅ Cloudflare Images settings **are configured** in `base.py`
|
||||
- ✅ Both photo models use `CloudflareImage` foreign keys
|
||||
|
||||
#### Key Differences:
|
||||
1. **Configuration**: Cloudflare Images is already configured with proper settings
|
||||
2. **Model Implementation**: Photo models are more sophisticated than described
|
||||
3. **Upload Paths**: Custom upload path functions exist
|
||||
4. **Media Service**: Advanced `MediaService` integration exists
|
||||
|
||||
#### Required Corrections:
|
||||
- Update status to reflect that models and configuration exist
|
||||
- Modify seeding approach to work with existing CloudflareImage system
|
||||
- Include actual model field names and relationships
|
||||
- Reference existing `MediaService` for upload handling
|
||||
|
||||
### 🏆 Ride Rankings Implementation
|
||||
|
||||
**Status:** ✅ **MOSTLY ACCURATE**
|
||||
|
||||
#### What the Guide Claims:
|
||||
- `RideRanking` model structure not fully defined
|
||||
- Needs basic ranking implementation
|
||||
|
||||
#### Actual Current State:
|
||||
- ✅ **Sophisticated ranking system** exists in `backend/apps/rides/models/rankings.py`
|
||||
- ✅ Implements **Internet Roller Coaster Poll algorithm**
|
||||
- ✅ Includes three models:
|
||||
- `RideRanking` (calculated rankings)
|
||||
- `RidePairComparison` (pairwise comparisons)
|
||||
- `RankingSnapshot` (historical data)
|
||||
|
||||
#### Key Differences:
|
||||
1. **Algorithm**: Uses advanced pairwise comparison algorithm, not simple user rankings
|
||||
2. **Complexity**: Much more sophisticated than guide suggests
|
||||
3. **Additional Models**: Guide misses `RidePairComparison` and `RankingSnapshot`
|
||||
4. **Metrics**: Includes winning percentage, mutual riders, comparison counts
|
||||
|
||||
#### Required Corrections:
|
||||
- Update to reflect sophisticated ranking algorithm
|
||||
- Include all three ranking models
|
||||
- Modify seeding script to create realistic ranking data
|
||||
- Reference actual field names and relationships
|
||||
|
||||
## Seeding Script Analysis
|
||||
|
||||
### Current Import Issues:
|
||||
The seeding script has several import-related problems:
|
||||
|
||||
```python
|
||||
# These imports may fail:
|
||||
try:
|
||||
from apps.moderation.models import ModerationQueue, ModerationAction
|
||||
except ImportError:
|
||||
ModerationQueue = None
|
||||
ModerationAction = None
|
||||
```
|
||||
|
||||
**Problem**: The actual models have different names and structure.
|
||||
|
||||
### Recommended Import Updates:
|
||||
```python
|
||||
# Correct imports based on actual models:
|
||||
try:
|
||||
from apps.moderation.models import (
|
||||
ModerationQueue, ModerationAction, ModerationReport,
|
||||
BulkOperation, PhotoSubmission
|
||||
)
|
||||
except ImportError:
|
||||
ModerationQueue = None
|
||||
ModerationAction = None
|
||||
ModerationReport = None
|
||||
BulkOperation = None
|
||||
PhotoSubmission = None
|
||||
```
|
||||
|
||||
## Implementation Priority Matrix
|
||||
|
||||
| Feature | Current Status | Guide Accuracy | Priority | Effort |
|
||||
|---------|---------------|----------------|----------|---------|
|
||||
| Moderation System | ✅ Implemented | ❌ Inaccurate | HIGH | 2-3 hours |
|
||||
| Photo System | ✅ Implemented | ⚠️ Partial | MEDIUM | 1-2 hours |
|
||||
| Rankings System | ✅ Implemented | ✅ Mostly OK | LOW | 30 mins |
|
||||
|
||||
## Specific Corrections Needed
|
||||
|
||||
### 1. Moderation Section Rewrite
|
||||
```markdown
|
||||
## 🛡️ Moderation Data Implementation
|
||||
|
||||
### Current Status
|
||||
✅ Comprehensive moderation system is implemented and ready for seeding
|
||||
|
||||
### Available Models
|
||||
The moderation system includes:
|
||||
- `ModerationReport`: User reports about content/behavior
|
||||
- `ModerationQueue`: Workflow management for moderation tasks
|
||||
- `ModerationAction`: Actions taken against users/content
|
||||
- `BulkOperation`: Administrative bulk operations
|
||||
- `PhotoSubmission`: Photo moderation workflow
|
||||
- `EditSubmission`: Content edit submissions (legacy)
|
||||
```
|
||||
|
||||
### 2. Photo Section Update
|
||||
```markdown
|
||||
## 📸 Photo Records Implementation
|
||||
|
||||
### Current Status
|
||||
✅ Photo system is fully implemented with CloudflareImage integration
|
||||
|
||||
### Available Models
|
||||
- `ParkPhoto`: Photos for parks with CloudflareImage storage
|
||||
- `RidePhoto`: Photos for rides with CloudflareImage storage
|
||||
- Both models include sophisticated metadata and approval workflows
|
||||
```
|
||||
|
||||
### 3. Rankings Section Enhancement
|
||||
```markdown
|
||||
## 🏆 Ride Rankings Implementation
|
||||
|
||||
### Current Status
|
||||
✅ Advanced ranking system using Internet Roller Coaster Poll algorithm
|
||||
|
||||
### Available Models
|
||||
- `RideRanking`: Calculated rankings with winning percentages
|
||||
- `RidePairComparison`: Cached pairwise comparison results
|
||||
- `RankingSnapshot`: Historical ranking data for trend analysis
|
||||
```
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### Immediate (High Priority)
|
||||
1. **Rewrite moderation section** to reflect actual implementation
|
||||
2. **Update seeding script imports** to use correct model names
|
||||
3. **Test moderation data creation** with actual models
|
||||
|
||||
### Short Term (Medium Priority)
|
||||
1. **Update photo section** to reflect CloudflareImage integration
|
||||
2. **Create sample photo seeding** using existing infrastructure
|
||||
3. **Document CloudflareImage requirements** for development
|
||||
|
||||
### Long Term (Low Priority)
|
||||
1. **Enhance rankings seeding** to use sophisticated algorithm
|
||||
2. **Add historical ranking snapshots** to seeding
|
||||
3. **Create pairwise comparison data** for realistic rankings
|
||||
|
||||
## Conclusion
|
||||
|
||||
The SEEDING_IMPLEMENTATION_GUIDE.md requires significant updates to match the current codebase. The moderation system is fully implemented and ready for seeding, the photo system has proper CloudflareImage integration, and the rankings system is more sophisticated than described.
|
||||
|
||||
**Next Steps:**
|
||||
1. Update the guide with accurate information
|
||||
2. Modify the seeding script to work with actual models
|
||||
3. Test all seeding functionality with current implementations
|
||||
|
||||
**Estimated Time to Fix:** 4-6 hours total
|
||||
@@ -1 +0,0 @@
|
||||
# Management commands
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/", include("apps.api.v1.urls")),
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
ThrillWiki API v1.
|
||||
|
||||
This module provides the version 1 REST API for ThrillWiki, consolidating
|
||||
all endpoints under a unified, well-documented API structure.
|
||||
"""
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Accounts API module for user profile and top list management.
|
||||
"""
|
||||
@@ -1,86 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
||||
|
||||
|
||||
class UserProfileCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
extra_kwargs = {"user": {"read_only": True}}
|
||||
|
||||
|
||||
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL"""
|
||||
# Safely try to return an avatar url if present
|
||||
avatar = getattr(obj, "avatar", None)
|
||||
if avatar:
|
||||
return getattr(avatar, "url", None)
|
||||
user_profile = getattr(obj, "user", None)
|
||||
if user_profile and getattr(user_profile, "profile", None):
|
||||
avatar = getattr(user_profile.profile, "avatar", None)
|
||||
if avatar:
|
||||
return getattr(avatar, "url", None)
|
||||
return None
|
||||
|
||||
|
||||
class TopListItemCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListItemUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
# allow updates, adjust as needed
|
||||
extra_kwargs = {"top_list": {"read_only": False}}
|
||||
|
||||
|
||||
class TopListItemOutputSerializer(serializers.ModelSerializer):
|
||||
# Remove the ride field since it doesn't exist on the model
|
||||
# The model likely uses a generic foreign key or different field name
|
||||
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
# user is set by view's perform_create
|
||||
extra_kwargs = {"user": {"read_only": True}}
|
||||
|
||||
|
||||
class TopListOutputSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
items = TopListItemOutputSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
URL configuration for user account management API endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Admin endpoints for user management
|
||||
path(
|
||||
"users/<str:user_id>/delete/",
|
||||
views.delete_user_preserve_submissions,
|
||||
name="delete_user_preserve_submissions",
|
||||
),
|
||||
path(
|
||||
"users/<str:user_id>/deletion-check/",
|
||||
views.check_user_deletion_eligibility,
|
||||
name="check_user_deletion_eligibility",
|
||||
),
|
||||
# Self-service account deletion endpoints
|
||||
path(
|
||||
"delete-account/request/",
|
||||
views.request_account_deletion,
|
||||
name="request_account_deletion",
|
||||
),
|
||||
path(
|
||||
"delete-account/verify/",
|
||||
views.verify_account_deletion,
|
||||
name="verify_account_deletion",
|
||||
),
|
||||
path(
|
||||
"delete-account/cancel/",
|
||||
views.cancel_account_deletion,
|
||||
name="cancel_account_deletion",
|
||||
),
|
||||
# User profile endpoints
|
||||
path("profile/", views.get_user_profile, name="get_user_profile"),
|
||||
path("profile/account/", views.update_user_account, name="update_user_account"),
|
||||
path("profile/update/", views.update_user_profile, name="update_user_profile"),
|
||||
# User preferences endpoints
|
||||
path("preferences/", views.get_user_preferences, name="get_user_preferences"),
|
||||
path(
|
||||
"preferences/update/",
|
||||
views.update_user_preferences,
|
||||
name="update_user_preferences",
|
||||
),
|
||||
path(
|
||||
"preferences/theme/",
|
||||
views.update_theme_preference,
|
||||
name="update_theme_preference",
|
||||
),
|
||||
# Notification settings endpoints
|
||||
path(
|
||||
"settings/notifications/",
|
||||
views.get_notification_settings,
|
||||
name="get_notification_settings",
|
||||
),
|
||||
path(
|
||||
"settings/notifications/update/",
|
||||
views.update_notification_settings,
|
||||
name="update_notification_settings",
|
||||
),
|
||||
# Privacy settings endpoints
|
||||
path("settings/privacy/", views.get_privacy_settings, name="get_privacy_settings"),
|
||||
path(
|
||||
"settings/privacy/update/",
|
||||
views.update_privacy_settings,
|
||||
name="update_privacy_settings",
|
||||
),
|
||||
# Security settings endpoints
|
||||
path(
|
||||
"settings/security/", views.get_security_settings, name="get_security_settings"
|
||||
),
|
||||
path(
|
||||
"settings/security/update/",
|
||||
views.update_security_settings,
|
||||
name="update_security_settings",
|
||||
),
|
||||
# User statistics endpoints
|
||||
path("statistics/", views.get_user_statistics, name="get_user_statistics"),
|
||||
# Top lists endpoints
|
||||
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
|
||||
path("top-lists/create/", views.create_top_list, name="create_top_list"),
|
||||
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
|
||||
path(
|
||||
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
|
||||
),
|
||||
# Notification endpoints
|
||||
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
|
||||
path(
|
||||
"notifications/mark-read/",
|
||||
views.mark_notifications_read,
|
||||
name="mark_notifications_read",
|
||||
),
|
||||
path(
|
||||
"notification-preferences/",
|
||||
views.get_notification_preferences,
|
||||
name="get_notification_preferences",
|
||||
),
|
||||
path(
|
||||
"notification-preferences/update/",
|
||||
views.update_notification_preferences,
|
||||
name="update_notification_preferences",
|
||||
),
|
||||
# Avatar endpoints
|
||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
Authentication API endpoints for ThrillWiki v1.
|
||||
|
||||
This package contains all authentication and authorization-related
|
||||
API functionality including login, logout, user management, and permissions.
|
||||
"""
|
||||
@@ -1,3 +0,0 @@
|
||||
# This file is intentionally empty.
|
||||
# All models are now in their appropriate apps to avoid conflicts.
|
||||
# PasswordReset model is available in apps.accounts.models
|
||||
@@ -1,608 +0,0 @@
|
||||
"""
|
||||
Auth domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to authentication, user accounts,
|
||||
profiles, top lists, and user statistics.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from apps.accounts.models import PasswordReset
|
||||
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
def _normalize_email(value: str) -> str:
|
||||
"""Normalize email for consistent lookups (strip + lowercase)."""
|
||||
if value is None:
|
||||
return value
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
# Import shared utilities
|
||||
|
||||
|
||||
class ModelChoices:
|
||||
"""Model choices utility class."""
|
||||
|
||||
@staticmethod
|
||||
def get_top_list_categories():
|
||||
"""Get top list category choices."""
|
||||
return [
|
||||
("RC", "Roller Coasters"),
|
||||
("DR", "Dark Rides"),
|
||||
("FR", "Flat Rides"),
|
||||
("WR", "Water Rides"),
|
||||
("PK", "Parks"),
|
||||
]
|
||||
|
||||
|
||||
# === AUTHENTICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Example",
|
||||
summary="Example user response",
|
||||
description="A typical user object",
|
||||
value={
|
||||
"id": 1,
|
||||
"username": "john_doe",
|
||||
"email": "john@example.com",
|
||||
"display_name": "John Doe",
|
||||
"date_joined": "2024-01-01T12:00:00Z",
|
||||
"is_active": True,
|
||||
"avatar_url": "https://example.com/avatars/john.jpg",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"""User serializer for API responses."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"display_name",
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar_url()
|
||||
return None
|
||||
|
||||
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=254, help_text="Username or email address"
|
||||
)
|
||||
password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
return attrs
|
||||
|
||||
raise serializers.ValidationError("Must include username/email and password.")
|
||||
|
||||
|
||||
class LoginOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for successful login."""
|
||||
|
||||
access = serializers.CharField()
|
||||
refresh = serializers.CharField()
|
||||
user = UserOutputSerializer()
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
class SignupInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for user registration."""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"display_name",
|
||||
"password",
|
||||
"password_confirm",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"password": {"write_only": True},
|
||||
"email": {"required": True},
|
||||
"display_name": {"required": True},
|
||||
}
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email is unique (case-insensitive) and return normalized email."""
|
||||
normalized = _normalize_email(value)
|
||||
if UserModel.objects.filter(email__iexact=normalized).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return normalized
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique."""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match."""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data and send verification email."""
|
||||
validated_data.pop("password_confirm", None)
|
||||
password = validated_data.pop("password")
|
||||
|
||||
# Create inactive user - they need to verify email first
|
||||
user = UserModel.objects.create_user( # type: ignore[attr-defined]
|
||||
password=password, is_active=False, **validated_data
|
||||
)
|
||||
|
||||
# Create email verification record and send email
|
||||
self._send_verification_email(user)
|
||||
|
||||
return user
|
||||
|
||||
def _send_verification_email(self, user):
|
||||
"""Send email verification to the user."""
|
||||
from apps.accounts.models import EmailVerification
|
||||
from django.utils.crypto import get_random_string
|
||||
from django_forwardemail.services import EmailService
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create or update email verification record
|
||||
verification, created = EmailVerification.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={'token': get_random_string(64)}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing token and timestamp
|
||||
verification.token = get_random_string(64)
|
||||
verification.save()
|
||||
|
||||
# Get current site from request context
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
site = get_current_site(request._request)
|
||||
|
||||
# Build verification URL
|
||||
verification_url = request.build_absolute_uri(
|
||||
f"/api/v1/auth/verify-email/{verification.token}/"
|
||||
)
|
||||
|
||||
# Send verification email
|
||||
try:
|
||||
response = EmailService.send_email(
|
||||
to=user.email,
|
||||
subject="Verify your ThrillWiki account",
|
||||
text=f"""
|
||||
Welcome to ThrillWiki!
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
{verification_url}
|
||||
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
|
||||
Thanks,
|
||||
The ThrillWiki Team
|
||||
""".strip(),
|
||||
site=site,
|
||||
)
|
||||
|
||||
# Log the ForwardEmail email ID from the response
|
||||
email_id = response.get('id') if response else None
|
||||
if email_id:
|
||||
logger.info(
|
||||
f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
|
||||
else:
|
||||
logger.info(
|
||||
f"Verification email sent successfully to {user.email}. No email ID in response.")
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail registration
|
||||
logger.error(f"Failed to send verification email to {user.email}: {e}")
|
||||
|
||||
|
||||
class SignupOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for successful signup."""
|
||||
|
||||
access = serializers.CharField(allow_null=True)
|
||||
refresh = serializers.CharField(allow_null=True)
|
||||
user = UserOutputSerializer()
|
||||
message = serializers.CharField()
|
||||
email_verification_required = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
class PasswordResetInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password reset request."""
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Normalize email and attach user to the serializer when found (case-insensitive).
|
||||
|
||||
Returns the normalized email. Does not reveal whether the email exists.
|
||||
"""
|
||||
normalized = _normalize_email(value)
|
||||
try:
|
||||
user = UserModel.objects.get(email__iexact=normalized)
|
||||
self.user = user
|
||||
except UserModel.DoesNotExist:
|
||||
# Do not reveal whether the email exists; keep behavior unchanged.
|
||||
pass
|
||||
return normalized
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists."""
|
||||
if hasattr(self, "user"):
|
||||
# generate a secure random token and persist it with expiry
|
||||
now = timezone.now()
|
||||
expires = now + timedelta(hours=24) # token valid for 24 hours
|
||||
|
||||
# Persist password reset with generated token (avoid creating an unused local variable).
|
||||
PasswordReset.objects.create(
|
||||
user=self.user,
|
||||
token=get_random_string(64),
|
||||
expires_at=expires,
|
||||
)
|
||||
|
||||
# Optionally: enqueue/send an email with the token-based reset link here.
|
||||
# Keep token out of API responses to avoid leaking it.
|
||||
|
||||
|
||||
class PasswordResetOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password reset request."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
|
||||
|
||||
class PasswordChangeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
max_length=128,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct."""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Old password is incorrect.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate new passwords match."""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Change user password."""
|
||||
user = self.context["request"].user
|
||||
# validated_data is guaranteed to exist after is_valid() is called
|
||||
new_password = self.validated_data["new_password"] # type: ignore[index]
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PasswordChangeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password change."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
|
||||
|
||||
class LogoutOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for logout."""
|
||||
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
class SocialProviderOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for social authentication providers."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
authUrl = serializers.URLField()
|
||||
|
||||
|
||||
class AuthStatusOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for authentication status check."""
|
||||
|
||||
authenticated = serializers.BooleanField()
|
||||
user = UserOutputSerializer(allow_null=True)
|
||||
|
||||
|
||||
# === USER PROFILE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Profile Example",
|
||||
summary="Example user profile response",
|
||||
description="A user's profile information",
|
||||
value={
|
||||
"id": 1,
|
||||
"profile_id": "1234",
|
||||
"display_name": "Coaster Enthusiast",
|
||||
"bio": "Love visiting theme parks around the world!",
|
||||
"pronouns": "they/them",
|
||||
"avatar_url": "/media/avatars/user1.jpg",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 80,
|
||||
"water_ride_credits": 25,
|
||||
"user": {
|
||||
"username": "coaster_fan",
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserProfileOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for user profiles."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
profile_id = serializers.CharField()
|
||||
display_name = serializers.CharField()
|
||||
bio = serializers.CharField()
|
||||
pronouns = serializers.CharField()
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
twitter = serializers.URLField()
|
||||
instagram = serializers.URLField()
|
||||
youtube = serializers.URLField()
|
||||
discord = serializers.CharField()
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = serializers.IntegerField()
|
||||
dark_ride_credits = serializers.IntegerField()
|
||||
flat_ride_credits = serializers.IntegerField()
|
||||
water_ride_credits = serializers.IntegerField()
|
||||
|
||||
# User info (limited)
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
return obj.get_avatar_url()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> Dict[str, Any]:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"date_joined": obj.user.date_joined,
|
||||
}
|
||||
|
||||
|
||||
class UserProfileCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating user profiles."""
|
||||
|
||||
display_name = serializers.CharField(max_length=50)
|
||||
bio = serializers.CharField(max_length=500, allow_blank=True, default="")
|
||||
pronouns = serializers.CharField(max_length=50, allow_blank=True, default="")
|
||||
twitter = serializers.URLField(required=False, allow_blank=True)
|
||||
instagram = serializers.URLField(required=False, allow_blank=True)
|
||||
youtube = serializers.URLField(required=False, allow_blank=True)
|
||||
discord = serializers.CharField(max_length=100, allow_blank=True, default="")
|
||||
|
||||
|
||||
class UserProfileUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating user profiles."""
|
||||
|
||||
display_name = serializers.CharField(max_length=50, required=False)
|
||||
bio = serializers.CharField(max_length=500, allow_blank=True, required=False)
|
||||
pronouns = serializers.CharField(max_length=50, allow_blank=True, required=False)
|
||||
twitter = serializers.URLField(required=False, allow_blank=True)
|
||||
instagram = serializers.URLField(required=False, allow_blank=True)
|
||||
youtube = serializers.URLField(required=False, allow_blank=True)
|
||||
discord = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||
coaster_credits = serializers.IntegerField(required=False)
|
||||
dark_ride_credits = serializers.IntegerField(required=False)
|
||||
flat_ride_credits = serializers.IntegerField(required=False)
|
||||
water_ride_credits = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
# === TOP LIST SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Example",
|
||||
summary="Example top list response",
|
||||
description="A user's top list of rides or parks",
|
||||
value={
|
||||
"id": 1,
|
||||
"title": "My Top 10 Roller Coasters",
|
||||
"category": "RC",
|
||||
"description": "My favorite roller coasters ranked",
|
||||
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-08-15T12:00:00Z",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for top lists."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
# User info
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> Dict[str, Any]:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"display_name": obj.user.get_display_name(),
|
||||
}
|
||||
|
||||
|
||||
class TopListCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating top lists."""
|
||||
|
||||
title = serializers.CharField(max_length=100)
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories())
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
|
||||
class TopListUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating top lists."""
|
||||
|
||||
title = serializers.CharField(max_length=100, required=False)
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_top_list_categories(), required=False
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
|
||||
|
||||
# === TOP LIST ITEM SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Item Example",
|
||||
summary="Example top list item response",
|
||||
description="An item in a user's top list",
|
||||
value={
|
||||
"id": 1,
|
||||
"rank": 1,
|
||||
"notes": "Amazing airtime and smooth ride",
|
||||
"object_name": "Steel Vengeance",
|
||||
"object_type": "Ride",
|
||||
"top_list": {"id": 1, "title": "My Top 10 Roller Coasters"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListItemOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for top list items."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
rank = serializers.IntegerField()
|
||||
notes = serializers.CharField()
|
||||
object_name = serializers.SerializerMethodField()
|
||||
object_type = serializers.SerializerMethodField()
|
||||
|
||||
# Top list info
|
||||
top_list = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_object_name(self, obj) -> str:
|
||||
"""Get the name of the referenced object."""
|
||||
# This would need to be implemented based on the generic foreign key
|
||||
return "Object Name" # Placeholder
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_object_type(self, obj) -> str:
|
||||
"""Get the type of the referenced object."""
|
||||
return obj.content_type.model_class().__name__
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_top_list(self, obj) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": obj.top_list.id,
|
||||
"title": obj.top_list.title,
|
||||
}
|
||||
|
||||
|
||||
class TopListItemCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating top list items."""
|
||||
|
||||
top_list_id = serializers.IntegerField()
|
||||
content_type_id = serializers.IntegerField()
|
||||
object_id = serializers.IntegerField()
|
||||
rank = serializers.IntegerField(min_value=1)
|
||||
notes = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
|
||||
class TopListItemUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating top list items."""
|
||||
|
||||
rank = serializers.IntegerField(min_value=1, required=False)
|
||||
notes = serializers.CharField(allow_blank=True, required=False)
|
||||
@@ -1,31 +0,0 @@
|
||||
"""
|
||||
Auth Serializers Package
|
||||
|
||||
This package contains social authentication-related serializers.
|
||||
Main authentication serializers are imported directly from the parent serializers.py file.
|
||||
"""
|
||||
|
||||
from .social import (
|
||||
ConnectedProviderSerializer,
|
||||
AvailableProviderSerializer,
|
||||
SocialAuthStatusSerializer,
|
||||
ConnectProviderInputSerializer,
|
||||
ConnectProviderOutputSerializer,
|
||||
DisconnectProviderOutputSerializer,
|
||||
SocialProviderListOutputSerializer,
|
||||
ConnectedProvidersListOutputSerializer,
|
||||
SocialProviderErrorSerializer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Social authentication serializers
|
||||
'ConnectedProviderSerializer',
|
||||
'AvailableProviderSerializer',
|
||||
'SocialAuthStatusSerializer',
|
||||
'ConnectProviderInputSerializer',
|
||||
'ConnectProviderOutputSerializer',
|
||||
'DisconnectProviderOutputSerializer',
|
||||
'SocialProviderListOutputSerializer',
|
||||
'ConnectedProvidersListOutputSerializer',
|
||||
'SocialProviderErrorSerializer',
|
||||
]
|
||||
@@ -1,198 +0,0 @@
|
||||
"""
|
||||
Social Provider Management Serializers
|
||||
|
||||
Serializers for handling social provider connection/disconnection requests
|
||||
and responses in the ThrillWiki API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
class ConnectedProviderSerializer(serializers.Serializer):
|
||||
"""Serializer for connected social provider information."""
|
||||
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider ID (e.g., 'google', 'discord')"
|
||||
)
|
||||
provider_name = serializers.CharField(
|
||||
help_text="Human-readable provider name"
|
||||
)
|
||||
uid = serializers.CharField(
|
||||
help_text="User ID on the social provider"
|
||||
)
|
||||
date_joined = serializers.DateTimeField(
|
||||
help_text="When this provider was connected"
|
||||
)
|
||||
can_disconnect = serializers.BooleanField(
|
||||
help_text="Whether this provider can be safely disconnected"
|
||||
)
|
||||
disconnect_reason = serializers.CharField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="Reason why provider cannot be disconnected (if applicable)"
|
||||
)
|
||||
extra_data = serializers.JSONField(
|
||||
required=False,
|
||||
help_text="Additional data from the social provider"
|
||||
)
|
||||
|
||||
|
||||
class AvailableProviderSerializer(serializers.Serializer):
|
||||
"""Serializer for available social provider information."""
|
||||
|
||||
id = serializers.CharField(
|
||||
help_text="Provider ID (e.g., 'google', 'discord')"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Human-readable provider name"
|
||||
)
|
||||
auth_url = serializers.URLField(
|
||||
help_text="URL to initiate authentication with this provider"
|
||||
)
|
||||
connect_url = serializers.URLField(
|
||||
help_text="API URL to connect this provider"
|
||||
)
|
||||
|
||||
|
||||
class SocialAuthStatusSerializer(serializers.Serializer):
|
||||
"""Serializer for comprehensive social authentication status."""
|
||||
|
||||
user_id = serializers.IntegerField(
|
||||
help_text="User's ID"
|
||||
)
|
||||
username = serializers.CharField(
|
||||
help_text="User's username"
|
||||
)
|
||||
email = serializers.EmailField(
|
||||
help_text="User's email address"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(
|
||||
help_text="Whether user has email/password authentication set up"
|
||||
)
|
||||
connected_providers = ConnectedProviderSerializer(
|
||||
many=True,
|
||||
help_text="List of connected social providers"
|
||||
)
|
||||
total_auth_methods = serializers.IntegerField(
|
||||
help_text="Total number of authentication methods available"
|
||||
)
|
||||
can_disconnect_any = serializers.BooleanField(
|
||||
help_text="Whether user can safely disconnect any provider"
|
||||
)
|
||||
requires_password_setup = serializers.BooleanField(
|
||||
help_text="Whether user needs to set up password before disconnecting"
|
||||
)
|
||||
|
||||
|
||||
class ConnectProviderInputSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider connection requests."""
|
||||
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider ID to connect (e.g., 'google', 'discord')"
|
||||
)
|
||||
|
||||
def validate_provider(self, value):
|
||||
"""Validate that the provider is supported and configured."""
|
||||
from apps.accounts.services.social_provider_service import SocialProviderService
|
||||
|
||||
is_valid, message = SocialProviderService.validate_provider_exists(value)
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError(message)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ConnectProviderOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider connection responses."""
|
||||
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the connection was successful"
|
||||
)
|
||||
message = serializers.CharField(
|
||||
help_text="Success or error message"
|
||||
)
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider that was connected"
|
||||
)
|
||||
auth_url = serializers.URLField(
|
||||
required=False,
|
||||
help_text="URL to complete the connection process"
|
||||
)
|
||||
|
||||
|
||||
class DisconnectProviderOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider disconnection responses."""
|
||||
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the disconnection was successful"
|
||||
)
|
||||
message = serializers.CharField(
|
||||
help_text="Success or error message"
|
||||
)
|
||||
provider = serializers.CharField(
|
||||
help_text="Provider that was disconnected"
|
||||
)
|
||||
remaining_providers = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
help_text="List of remaining connected providers"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(
|
||||
help_text="Whether user still has password authentication"
|
||||
)
|
||||
suggestions = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Suggestions for maintaining account access (if applicable)"
|
||||
)
|
||||
|
||||
|
||||
class SocialProviderListOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for listing available social providers."""
|
||||
|
||||
available_providers = AvailableProviderSerializer(
|
||||
many=True,
|
||||
help_text="List of available social providers"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
help_text="Number of available providers"
|
||||
)
|
||||
|
||||
|
||||
class ConnectedProvidersListOutputSerializer(serializers.Serializer):
|
||||
"""Serializer for listing connected social providers."""
|
||||
|
||||
connected_providers = ConnectedProviderSerializer(
|
||||
many=True,
|
||||
help_text="List of connected social providers"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
help_text="Number of connected providers"
|
||||
)
|
||||
has_password_auth = serializers.BooleanField(
|
||||
help_text="Whether user has password authentication"
|
||||
)
|
||||
can_disconnect_any = serializers.BooleanField(
|
||||
help_text="Whether user can safely disconnect any provider"
|
||||
)
|
||||
|
||||
|
||||
class SocialProviderErrorSerializer(serializers.Serializer):
|
||||
"""Serializer for social provider error responses."""
|
||||
|
||||
error = serializers.CharField(
|
||||
help_text="Error message"
|
||||
)
|
||||
code = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Error code for programmatic handling"
|
||||
)
|
||||
suggestions = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Suggestions for resolving the error"
|
||||
)
|
||||
provider = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Provider related to the error (if applicable)"
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Auth domain URL Configuration for ThrillWiki API v1.
|
||||
|
||||
This module contains URL patterns for core authentication functionality only.
|
||||
User profiles and top lists are handled by the dedicated accounts app.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from .views import (
|
||||
# Main auth views
|
||||
LoginAPIView,
|
||||
SignupAPIView,
|
||||
LogoutAPIView,
|
||||
CurrentUserAPIView,
|
||||
PasswordResetAPIView,
|
||||
PasswordChangeAPIView,
|
||||
AuthStatusAPIView,
|
||||
# Email verification views
|
||||
EmailVerificationAPIView,
|
||||
ResendVerificationAPIView,
|
||||
)
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# Core authentication endpoints
|
||||
path("login/", LoginAPIView.as_view(), name="auth-login"),
|
||||
path("signup/", SignupAPIView.as_view(), name="auth-signup"),
|
||||
path("logout/", LogoutAPIView.as_view(), name="auth-logout"),
|
||||
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||
|
||||
# JWT token management
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
|
||||
|
||||
# Social authentication endpoints (dj-rest-auth)
|
||||
path("social/", include("dj_rest_auth.registration.urls")),
|
||||
|
||||
path(
|
||||
"password/reset/",
|
||||
PasswordResetAPIView.as_view(),
|
||||
name="auth-password-reset",
|
||||
),
|
||||
path(
|
||||
"password/change/",
|
||||
PasswordChangeAPIView.as_view(),
|
||||
name="auth-password-change",
|
||||
),
|
||||
|
||||
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
|
||||
|
||||
# Email verification endpoints
|
||||
path(
|
||||
"verify-email/<str:token>/",
|
||||
EmailVerificationAPIView.as_view(),
|
||||
name="auth-verify-email",
|
||||
),
|
||||
path(
|
||||
"resend-verification/",
|
||||
ResendVerificationAPIView.as_view(),
|
||||
name="auth-resend-verification",
|
||||
),
|
||||
]
|
||||
|
||||
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||
# to maintain clean separation of concerns and avoid duplicate API endpoints.
|
||||
@@ -1,589 +0,0 @@
|
||||
"""
|
||||
Auth domain views for ThrillWiki API v1.
|
||||
|
||||
This module contains all authentication-related API endpoints including
|
||||
login, signup, logout, password management, social authentication,
|
||||
user profiles, and top lists.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import authenticate, login, logout, get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from typing import Optional, cast # added 'cast'
|
||||
from django.http import HttpRequest # new import
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
# Import directly from the auth serializers.py file (not the serializers package)
|
||||
from .serializers import (
|
||||
# Authentication serializers
|
||||
LoginInputSerializer,
|
||||
LoginOutputSerializer,
|
||||
SignupInputSerializer,
|
||||
SignupOutputSerializer,
|
||||
LogoutOutputSerializer,
|
||||
UserOutputSerializer,
|
||||
PasswordResetInputSerializer,
|
||||
PasswordResetOutputSerializer,
|
||||
PasswordChangeInputSerializer,
|
||||
PasswordChangeOutputSerializer,
|
||||
AuthStatusOutputSerializer,
|
||||
)
|
||||
|
||||
# Handle optional dependencies with fallback classes
|
||||
|
||||
|
||||
class FallbackTurnstileMixin:
|
||||
"""Fallback mixin if TurnstileMixin is not available."""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
pass
|
||||
|
||||
|
||||
# Try to import the real class, use fallback if not available and ensure it's a class/type
|
||||
try:
|
||||
from apps.accounts.mixins import TurnstileMixin as _ImportedTurnstileMixin
|
||||
|
||||
# Ensure the imported object is a class/type that can be used as a base class.
|
||||
# If it's not a type for any reason, fall back to the safe mixin.
|
||||
if isinstance(_ImportedTurnstileMixin, type):
|
||||
TurnstileMixin = _ImportedTurnstileMixin
|
||||
else:
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
except Exception:
|
||||
# Catch any import errors or unexpected exceptions and use the fallback mixin.
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
# Helper: safely obtain underlying HttpRequest (used by Django auth)
|
||||
|
||||
|
||||
def _get_underlying_request(request: Request) -> HttpRequest:
|
||||
"""
|
||||
Return a django HttpRequest for use with Django auth and site utilities.
|
||||
|
||||
DRF's Request wraps the underlying HttpRequest in ._request; cast() tells the
|
||||
typechecker that the returned object is indeed an HttpRequest.
|
||||
"""
|
||||
return cast(HttpRequest, getattr(request, "_request", request))
|
||||
|
||||
|
||||
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
|
||||
def _authenticate_user_by_lookup(
|
||||
email_or_username: str, password: str, request: Request
|
||||
) -> Optional[UserModel]:
|
||||
"""
|
||||
Try a single optimized query to find a user by email OR username then authenticate.
|
||||
Returns authenticated user or None.
|
||||
"""
|
||||
try:
|
||||
# Single query to find user by email OR username
|
||||
if "@" in (email_or_username or ""):
|
||||
user_obj = (
|
||||
UserModel.objects.select_related()
|
||||
.filter(Q(email=email_or_username) | Q(username=email_or_username))
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
user_obj = (
|
||||
UserModel.objects.select_related()
|
||||
.filter(Q(username=email_or_username) | Q(email=email_or_username))
|
||||
.first()
|
||||
)
|
||||
|
||||
if user_obj:
|
||||
username_val = getattr(user_obj, "username", None)
|
||||
return authenticate(
|
||||
# type: ignore[arg-type]
|
||||
_get_underlying_request(request),
|
||||
username=username_val,
|
||||
password=password,
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to authenticate directly with provided identifier
|
||||
return authenticate(
|
||||
# type: ignore[arg-type]
|
||||
_get_underlying_request(request),
|
||||
username=email_or_username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# === AUTHENTICATION API VIEWS ===
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User login",
|
||||
description="Authenticate user with username/email and password.",
|
||||
request=LoginInputSerializer,
|
||||
responses={
|
||||
200: LoginOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class LoginAPIView(APIView):
|
||||
"""API endpoint for user login."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = LoginInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# instantiate mixin before calling to avoid type-mismatch in static analysis
|
||||
TurnstileMixin().validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
# If mixin doesn't do anything, continue
|
||||
pass
|
||||
|
||||
serializer = LoginInputSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
validated = serializer.validated_data
|
||||
# Use .get to satisfy static analyzers
|
||||
email_or_username = validated.get("username") # type: ignore[assignment]
|
||||
password = validated.get("password") # type: ignore[assignment]
|
||||
|
||||
if not email_or_username or not password:
|
||||
return Response(
|
||||
{"error": "username and password are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = _authenticate_user_by_lookup(email_or_username, password, request)
|
||||
|
||||
if user:
|
||||
if getattr(user, "is_active", False):
|
||||
# pass a real HttpRequest to Django login with backend specified
|
||||
login(_get_underlying_request(request), user,
|
||||
backend='django.contrib.auth.backends.ModelBackend')
|
||||
|
||||
# Generate JWT tokens
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
access_token = refresh.access_token
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"access": str(access_token),
|
||||
"refresh": str(refresh),
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Email verification required",
|
||||
"message": "Please verify your email address before logging in. Check your email for a verification link.",
|
||||
"email_verification_required": True
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Invalid credentials"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User registration",
|
||||
description="Register a new user account. Email verification required.",
|
||||
request=SignupInputSerializer,
|
||||
responses={
|
||||
201: SignupOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SignupAPIView(APIView):
|
||||
"""API endpoint for user registration."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = SignupInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# instantiate mixin before calling to avoid type-mismatch in static analysis
|
||||
TurnstileMixin().validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
# If mixin doesn't do anything, continue
|
||||
pass
|
||||
|
||||
serializer = SignupInputSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
|
||||
# Don't log in the user immediately - they need to verify their email first
|
||||
response_serializer = SignupOutputSerializer(
|
||||
{
|
||||
"access": None,
|
||||
"refresh": None,
|
||||
"user": user,
|
||||
"message": "Registration successful. Please check your email to verify your account.",
|
||||
"email_verification_required": True,
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User logout",
|
||||
description="Logout the current user and blacklist their refresh token.",
|
||||
responses={
|
||||
200: LogoutOutputSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class LogoutAPIView(APIView):
|
||||
"""API endpoint for user logout."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = LogoutOutputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Get refresh token from request data with proper type handling
|
||||
refresh_token = None
|
||||
if hasattr(request, 'data') and request.data is not None:
|
||||
data = getattr(request, 'data', {})
|
||||
if hasattr(data, 'get'):
|
||||
refresh_token = data.get("refresh")
|
||||
|
||||
if refresh_token and isinstance(refresh_token, str):
|
||||
# Blacklist the refresh token
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
try:
|
||||
# Create RefreshToken from string and blacklist it
|
||||
refresh_token_obj = RefreshToken(
|
||||
refresh_token) # type: ignore[arg-type]
|
||||
refresh_token_obj.blacklist()
|
||||
except Exception:
|
||||
# Token might be invalid or already blacklisted
|
||||
pass
|
||||
|
||||
# Also delete the old token for backward compatibility
|
||||
if hasattr(request.user, "auth_token"):
|
||||
request.user.auth_token.delete()
|
||||
|
||||
# Logout from session using the underlying HttpRequest
|
||||
logout(_get_underlying_request(request))
|
||||
|
||||
response_serializer = LogoutOutputSerializer(
|
||||
{"message": "Logout successful"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get current user",
|
||||
description="Retrieve information about the currently authenticated user.",
|
||||
responses={
|
||||
200: UserOutputSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class CurrentUserAPIView(APIView):
|
||||
"""API endpoint to get current user information."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = UserOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
serializer = UserOutputSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Request password reset",
|
||||
description="Send a password reset email to the user.",
|
||||
request=PasswordResetInputSerializer,
|
||||
responses={
|
||||
200: PasswordResetOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class PasswordResetAPIView(APIView):
|
||||
"""API endpoint to request password reset."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = PasswordResetInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordResetInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordResetOutputSerializer(
|
||||
{"detail": "Password reset email sent"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Change password",
|
||||
description="Change the current user's password.",
|
||||
request=PasswordChangeInputSerializer,
|
||||
responses={
|
||||
200: PasswordChangeOutputSerializer,
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class PasswordChangeAPIView(APIView):
|
||||
"""API endpoint to change password."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = PasswordChangeInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordChangeInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordChangeOutputSerializer(
|
||||
{"detail": "Password changed successfully"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Check authentication status",
|
||||
description="Check if user is authenticated and return user data.",
|
||||
responses={200: AuthStatusOutputSerializer},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class AuthStatusAPIView(APIView):
|
||||
"""API endpoint to check authentication status."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = AuthStatusOutputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
if request.user.is_authenticated:
|
||||
response_data = {
|
||||
"authenticated": True,
|
||||
"user": request.user,
|
||||
}
|
||||
else:
|
||||
response_data = {
|
||||
"authenticated": False,
|
||||
"user": None,
|
||||
}
|
||||
|
||||
serializer = AuthStatusOutputSerializer(response_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# === EMAIL VERIFICATION API VIEWS ===
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Verify email address",
|
||||
description="Verify user's email address using verification token.",
|
||||
responses={
|
||||
200: {"type": "object", "properties": {"message": {"type": "string"}}},
|
||||
400: "Bad Request",
|
||||
404: "Token not found",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class EmailVerificationAPIView(APIView):
|
||||
"""API endpoint for email verification."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def get(self, request: Request, token: str) -> Response:
|
||||
from apps.accounts.models import EmailVerification
|
||||
|
||||
try:
|
||||
verification = EmailVerification.objects.select_related('user').get(token=token)
|
||||
user = verification.user
|
||||
|
||||
# Activate the user
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
# Delete the verification record
|
||||
verification.delete()
|
||||
|
||||
return Response({
|
||||
"message": "Email verified successfully. You can now log in.",
|
||||
"success": True
|
||||
})
|
||||
|
||||
except EmailVerification.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Invalid or expired verification token"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Resend verification email",
|
||||
description="Resend email verification to user's email address.",
|
||||
request={"type": "object", "properties": {"email": {"type": "string", "format": "email"}}},
|
||||
responses={
|
||||
200: {"type": "object", "properties": {"message": {"type": "string"}}},
|
||||
400: "Bad Request",
|
||||
404: "User not found",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class ResendVerificationAPIView(APIView):
|
||||
"""API endpoint to resend email verification."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
from apps.accounts.models import EmailVerification
|
||||
from django.utils.crypto import get_random_string
|
||||
from django_forwardemail.services import EmailService
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
email = request.data.get('email')
|
||||
if not email:
|
||||
return Response(
|
||||
{"error": "Email address is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(email__iexact=email.strip().lower())
|
||||
|
||||
# Don't resend if user is already active
|
||||
if user.is_active:
|
||||
return Response(
|
||||
{"error": "Email is already verified"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create or update verification record
|
||||
verification, created = EmailVerification.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={'token': get_random_string(64)}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing token and timestamp
|
||||
verification.token = get_random_string(64)
|
||||
verification.save()
|
||||
|
||||
# Send verification email
|
||||
site = get_current_site(_get_underlying_request(request))
|
||||
verification_url = request.build_absolute_uri(
|
||||
f"/api/v1/auth/verify-email/{verification.token}/"
|
||||
)
|
||||
|
||||
try:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject="Verify your ThrillWiki account",
|
||||
text=f"""
|
||||
Welcome to ThrillWiki!
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
{verification_url}
|
||||
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
|
||||
Thanks,
|
||||
The ThrillWiki Team
|
||||
""".strip(),
|
||||
site=site,
|
||||
)
|
||||
|
||||
return Response({
|
||||
"message": "Verification email sent successfully",
|
||||
"success": True
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send verification email to {user.email}: {e}")
|
||||
|
||||
return Response(
|
||||
{"error": "Failed to send verification email"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal whether email exists
|
||||
return Response({
|
||||
"message": "If the email exists, a verification email has been sent",
|
||||
"success": True
|
||||
})
|
||||
|
||||
|
||||
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
|
||||
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
|
||||
# to avoid duplication and maintain clean separation of concerns.
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
Core API URL configuration.
|
||||
Centralized from apps.core.urls
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Entity search endpoints - migrated from apps.core.urls
|
||||
urlpatterns = [
|
||||
path(
|
||||
"entities/search/",
|
||||
views.EntityFuzzySearchView.as_view(),
|
||||
name="entity_fuzzy_search",
|
||||
),
|
||||
path(
|
||||
"entities/not-found/",
|
||||
views.EntityNotFoundView.as_view(),
|
||||
name="entity_not_found",
|
||||
),
|
||||
path(
|
||||
"entities/suggestions/",
|
||||
views.QuickEntitySuggestionView.as_view(),
|
||||
name="entity_suggestions",
|
||||
),
|
||||
]
|
||||
@@ -1,370 +0,0 @@
|
||||
"""
|
||||
Centralized core API views.
|
||||
Migrated from apps.core.views.entity_search
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from typing import Optional, List
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from apps.core.services.entity_fuzzy_matching import (
|
||||
entity_fuzzy_matcher,
|
||||
EntityType,
|
||||
)
|
||||
|
||||
|
||||
class EntityFuzzySearchView(APIView):
|
||||
"""
|
||||
API endpoint for fuzzy entity search with authentication prompts.
|
||||
|
||||
Handles entity lookup failures by providing intelligent suggestions and
|
||||
authentication prompts for entity creation.
|
||||
|
||||
Migrated from apps.core.views.entity_search.EntityFuzzySearchView
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny] # Allow both authenticated and anonymous users
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Fuzzy entity search",
|
||||
description="Perform fuzzy entity search with authentication prompts for entity creation",
|
||||
)
|
||||
def post(self, request):
|
||||
"""
|
||||
Perform fuzzy entity search.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "entity name to search",
|
||||
"entity_types": ["park", "ride", "company"], // optional
|
||||
"include_suggestions": true // optional, default true
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"query": "original query",
|
||||
"matches": [
|
||||
{
|
||||
"entity_type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"score": 0.95,
|
||||
"confidence": "high",
|
||||
"match_reason": "Text similarity with 'Cedar Point'",
|
||||
"url": "/parks/cedar-point/",
|
||||
"entity_id": 123
|
||||
}
|
||||
],
|
||||
"suggestion": {
|
||||
"suggested_name": "New Entity Name",
|
||||
"entity_type": "park",
|
||||
"requires_authentication": true,
|
||||
"login_prompt": "Log in to suggest adding...",
|
||||
"signup_prompt": "Sign up to contribute...",
|
||||
"creation_hint": "Help expand ThrillWiki..."
|
||||
},
|
||||
"user_authenticated": false
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse request data
|
||||
query = request.data.get("query", "").strip()
|
||||
entity_types_raw = request.data.get(
|
||||
"entity_types", ["park", "ride", "company"]
|
||||
)
|
||||
include_suggestions = request.data.get("include_suggestions", True)
|
||||
|
||||
# Validate query
|
||||
if not query or len(query) < 2:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Query must be at least 2 characters long",
|
||||
"code": "INVALID_QUERY",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Parse and validate entity types
|
||||
entity_types = []
|
||||
valid_types = {"park", "ride", "company"}
|
||||
|
||||
for entity_type in entity_types_raw:
|
||||
if entity_type in valid_types:
|
||||
entity_types.append(EntityType(entity_type))
|
||||
|
||||
if not entity_types:
|
||||
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
# Perform fuzzy matching
|
||||
matches, suggestion = entity_fuzzy_matcher.find_entity(
|
||||
query=query, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# Format response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"matches": [match.to_dict() for match in matches],
|
||||
"user_authenticated": (
|
||||
request.user.is_authenticated
|
||||
if hasattr(request.user, "is_authenticated")
|
||||
else False
|
||||
),
|
||||
}
|
||||
|
||||
# Include suggestion if requested and available
|
||||
if include_suggestions and suggestion:
|
||||
response_data["suggestion"] = {
|
||||
"suggested_name": suggestion.suggested_name,
|
||||
"entity_type": suggestion.entity_type.value,
|
||||
"requires_authentication": suggestion.requires_authentication,
|
||||
"login_prompt": suggestion.login_prompt,
|
||||
"signup_prompt": suggestion.signup_prompt,
|
||||
"creation_hint": suggestion.creation_hint,
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Internal server error: {str(e)}",
|
||||
"code": "INTERNAL_ERROR",
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class EntityNotFoundView(APIView):
|
||||
"""
|
||||
Endpoint specifically for handling entity not found scenarios.
|
||||
|
||||
This view is called when normal entity lookup fails and provides
|
||||
fuzzy matching suggestions along with authentication prompts.
|
||||
|
||||
Migrated from apps.core.views.entity_search.EntityNotFoundView
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Handle entity not found",
|
||||
description="Handle entity not found scenarios with fuzzy matching suggestions and authentication prompts",
|
||||
)
|
||||
def post(self, request):
|
||||
"""
|
||||
Handle entity not found with suggestions.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"original_query": "what user searched for",
|
||||
"attempted_slug": "slug-that-failed", // optional
|
||||
"entity_type": "park", // optional, inferred from context
|
||||
"context": { // optional context information
|
||||
"park_slug": "park-slug-if-searching-for-ride",
|
||||
"source_page": "page where search originated"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
original_query = request.data.get("original_query", "").strip()
|
||||
attempted_slug = request.data.get("attempted_slug", "")
|
||||
entity_type_hint = request.data.get("entity_type")
|
||||
context = request.data.get("context", {})
|
||||
|
||||
if not original_query:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "original_query is required",
|
||||
"code": "MISSING_QUERY",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Determine entity types to search based on context
|
||||
entity_types = []
|
||||
if entity_type_hint:
|
||||
try:
|
||||
entity_types = [EntityType(entity_type_hint)]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If we have park context, prioritize ride searches
|
||||
if context.get("park_slug") and not entity_types:
|
||||
entity_types = [EntityType.RIDE, EntityType.PARK]
|
||||
|
||||
# Default to all types if not specified
|
||||
if not entity_types:
|
||||
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
# Try fuzzy matching on the original query
|
||||
matches, suggestion = entity_fuzzy_matcher.find_entity(
|
||||
query=original_query, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# If no matches on original query, try the attempted slug
|
||||
if not matches and attempted_slug:
|
||||
# Convert slug back to readable name for fuzzy matching
|
||||
slug_as_name = attempted_slug.replace("-", " ").title()
|
||||
matches, suggestion = entity_fuzzy_matcher.find_entity(
|
||||
query=slug_as_name, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# Prepare response with detailed context
|
||||
response_data = {
|
||||
"success": True,
|
||||
"original_query": original_query,
|
||||
"attempted_slug": attempted_slug,
|
||||
"context": context,
|
||||
"matches": [match.to_dict() for match in matches],
|
||||
"user_authenticated": (
|
||||
request.user.is_authenticated
|
||||
if hasattr(request.user, "is_authenticated")
|
||||
else False
|
||||
),
|
||||
"has_matches": len(matches) > 0,
|
||||
}
|
||||
|
||||
# Always include suggestion for entity not found scenarios
|
||||
if suggestion:
|
||||
response_data["suggestion"] = {
|
||||
"suggested_name": suggestion.suggested_name,
|
||||
"entity_type": suggestion.entity_type.value,
|
||||
"requires_authentication": suggestion.requires_authentication,
|
||||
"login_prompt": suggestion.login_prompt,
|
||||
"signup_prompt": suggestion.signup_prompt,
|
||||
"creation_hint": suggestion.creation_hint,
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Internal server error: {str(e)}",
|
||||
"code": "INTERNAL_ERROR",
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class QuickEntitySuggestionView(APIView):
|
||||
"""
|
||||
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
|
||||
|
||||
Migrated from apps.core.views.entity_search.QuickEntitySuggestionView
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Quick entity suggestions",
|
||||
description="Lightweight endpoint for quick entity suggestions (e.g., autocomplete)",
|
||||
)
|
||||
def get(self, request):
|
||||
"""
|
||||
Get quick entity suggestions.
|
||||
|
||||
Query parameters:
|
||||
- q: query string
|
||||
- types: comma-separated entity types (park,ride,company)
|
||||
- limit: max results (default 5)
|
||||
"""
|
||||
try:
|
||||
query = request.GET.get("q", "").strip()
|
||||
types_param = request.GET.get("types", "park,ride,company")
|
||||
limit = min(int(request.GET.get("limit", 5)), 10) # Cap at 10
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return Response(
|
||||
{"suggestions": [], "query": query}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Parse entity types
|
||||
entity_types = []
|
||||
for type_str in types_param.split(","):
|
||||
type_str = type_str.strip()
|
||||
if type_str in ["park", "ride", "company"]:
|
||||
entity_types.append(EntityType(type_str))
|
||||
|
||||
if not entity_types:
|
||||
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
# Get fuzzy matches
|
||||
matches, _ = entity_fuzzy_matcher.find_entity(
|
||||
query=query, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# Format as simple suggestions
|
||||
suggestions = []
|
||||
for match in matches[:limit]:
|
||||
suggestions.append(
|
||||
{
|
||||
"name": match.name,
|
||||
"type": match.entity_type.value,
|
||||
"slug": match.slug,
|
||||
"url": match.url,
|
||||
"score": match.score,
|
||||
"confidence": match.confidence,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"suggestions": suggestions, "query": query, "count": len(suggestions)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"suggestions": [], "query": request.GET.get("q", ""), "error": str(e)},
|
||||
status=status.HTTP_200_OK,
|
||||
) # Return 200 even on errors for autocomplete
|
||||
|
||||
|
||||
# Utility function for other views to use
|
||||
def get_entity_suggestions(
|
||||
query: str, entity_types: Optional[List[str]] = None, user=None
|
||||
):
|
||||
"""
|
||||
Utility function for other Django views to get entity suggestions.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
entity_types: List of entity type strings
|
||||
user: Django user object
|
||||
|
||||
Returns:
|
||||
Tuple of (matches, suggestion)
|
||||
"""
|
||||
try:
|
||||
# Convert string types to EntityType enums
|
||||
parsed_types = []
|
||||
if entity_types:
|
||||
for entity_type in entity_types:
|
||||
try:
|
||||
parsed_types.append(EntityType(entity_type))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not parsed_types:
|
||||
parsed_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
return entity_fuzzy_matcher.find_entity(
|
||||
query=query, entity_types=parsed_types, user=user
|
||||
)
|
||||
except Exception:
|
||||
return [], None
|
||||
@@ -1,11 +0,0 @@
|
||||
"""
|
||||
Email service API URL configuration.
|
||||
Centralized from apps.email_service.urls
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("send/", views.SendEmailView.as_view(), name="send_email"),
|
||||
]
|
||||
@@ -1,106 +0,0 @@
|
||||
"""
|
||||
Centralized email service API views.
|
||||
Migrated from apps.email_service.views
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="Send email",
|
||||
description="Send an email via the email service.",
|
||||
request={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Recipient email address",
|
||||
},
|
||||
"subject": {"type": "string", "description": "Email subject"},
|
||||
"text": {"type": "string", "description": "Email body text"},
|
||||
"from_email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Sender email address (optional)",
|
||||
},
|
||||
},
|
||||
"required": ["to", "subject", "text"],
|
||||
},
|
||||
responses={
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"response": {"type": "object"},
|
||||
},
|
||||
},
|
||||
400: "Bad Request",
|
||||
500: "Internal Server Error",
|
||||
},
|
||||
tags=["Email"],
|
||||
)
|
||||
class SendEmailView(APIView):
|
||||
"""
|
||||
API endpoint for sending emails.
|
||||
|
||||
Migrated from apps.email_service.views.SendEmailView to centralized API structure.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny] # Allow unauthenticated access
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Send an email via the email service.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Email subject",
|
||||
"text": "Email body text",
|
||||
"from_email": "sender@example.com" // optional
|
||||
}
|
||||
"""
|
||||
data = request.data
|
||||
to = data.get("to")
|
||||
subject = data.get("subject")
|
||||
text = data.get("text")
|
||||
from_email = data.get("from_email") # Optional
|
||||
|
||||
if not all([to, subject, text]):
|
||||
return Response(
|
||||
{
|
||||
"error": "Missing required fields",
|
||||
"required_fields": ["to", "subject", "text"],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the current site
|
||||
site = get_current_site(request)
|
||||
|
||||
# Send email using the site's configuration
|
||||
response = EmailService.send_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
text=text,
|
||||
from_email=from_email, # Will use site's default if None
|
||||
site=site,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Email sent successfully", "response": response},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
History API Module
|
||||
|
||||
This module provides API endpoints for accessing historical data and change tracking
|
||||
across all models in the ThrillWiki system.
|
||||
"""
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
History API URLs
|
||||
|
||||
URL patterns for history-related API endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
ParkHistoryViewSet,
|
||||
RideHistoryViewSet,
|
||||
UnifiedHistoryViewSet,
|
||||
)
|
||||
|
||||
# Create router for history ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"timeline", UnifiedHistoryViewSet, basename="unified-history")
|
||||
|
||||
urlpatterns = [
|
||||
# Park history endpoints
|
||||
path(
|
||||
"parks/<str:park_slug>/",
|
||||
ParkHistoryViewSet.as_view({"get": "list"}),
|
||||
name="park-history-list",
|
||||
),
|
||||
path(
|
||||
"parks/<str:park_slug>/detail/",
|
||||
ParkHistoryViewSet.as_view({"get": "retrieve"}),
|
||||
name="park-history-detail",
|
||||
),
|
||||
# Ride history endpoints
|
||||
path(
|
||||
"parks/<str:park_slug>/rides/<str:ride_slug>/",
|
||||
RideHistoryViewSet.as_view({"get": "list"}),
|
||||
name="ride-history-list",
|
||||
),
|
||||
path(
|
||||
"parks/<str:park_slug>/rides/<str:ride_slug>/detail/",
|
||||
RideHistoryViewSet.as_view({"get": "retrieve"}),
|
||||
name="ride-history-detail",
|
||||
),
|
||||
# Include router URLs for unified timeline
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -1,513 +0,0 @@
|
||||
"""
|
||||
History API Views
|
||||
|
||||
This module provides ViewSets for accessing historical data and change tracking
|
||||
across all models in the ThrillWiki system using django-pghistory.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rest_framework.request import Request
|
||||
from typing import Optional, cast, Sequence
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Count, QuerySet
|
||||
import pghistory.models
|
||||
from datetime import datetime
|
||||
|
||||
# Import models
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
# Import serializers
|
||||
from .. import serializers as history_serializers
|
||||
from rest_framework import serializers as drf_serializers
|
||||
|
||||
# Minimal fallback serializer used when a specific serializer symbol is missing.
|
||||
|
||||
|
||||
class _FallbackSerializer(drf_serializers.Serializer):
|
||||
def to_representation(self, instance):
|
||||
# return minimal safe representation so responses serialize without errors
|
||||
return {}
|
||||
|
||||
|
||||
ParkHistoryEventSerializer = getattr(
|
||||
history_serializers, "ParkHistoryEventSerializer", _FallbackSerializer
|
||||
)
|
||||
RideHistoryEventSerializer = getattr(
|
||||
history_serializers, "RideHistoryEventSerializer", _FallbackSerializer
|
||||
)
|
||||
ParkHistoryOutputSerializer = getattr(
|
||||
history_serializers, "ParkHistoryOutputSerializer", _FallbackSerializer
|
||||
)
|
||||
RideHistoryOutputSerializer = getattr(
|
||||
history_serializers, "RideHistoryOutputSerializer", _FallbackSerializer
|
||||
)
|
||||
UnifiedHistoryTimelineSerializer = getattr(
|
||||
history_serializers, "UnifiedHistoryTimelineSerializer", _FallbackSerializer
|
||||
)
|
||||
|
||||
# --- Constants for model strings to avoid duplication ---
|
||||
PARK_MODEL = "parks.park"
|
||||
|
||||
RIDE_MODELS: Sequence[str] = [
|
||||
"rides.ride",
|
||||
"rides.ridemodel",
|
||||
"rides.rollercoasterstats",
|
||||
]
|
||||
|
||||
COMPANY_MODELS: Sequence[str] = [
|
||||
"companies.operator",
|
||||
"companies.propertyowner",
|
||||
"companies.manufacturer",
|
||||
"companies.designer",
|
||||
]
|
||||
|
||||
ACCOUNT_MODEL = "accounts.user"
|
||||
|
||||
ALL_TRACKED_MODELS: Sequence[str] = [
|
||||
PARK_MODEL,
|
||||
*RIDE_MODELS,
|
||||
*COMPANY_MODELS,
|
||||
ACCOUNT_MODEL,
|
||||
]
|
||||
|
||||
# --- Helper utilities to reduce duplicated logic / cognitive complexity ---
|
||||
|
||||
|
||||
def _parse_date(date_str: Optional[str]) -> Optional[datetime]:
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_list_filters(
|
||||
queryset: QuerySet,
|
||||
request: Request,
|
||||
*,
|
||||
default_limit: int = 50,
|
||||
max_limit: int = 500,
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Apply common 'list' filters: event_type, start/end date, and limit.
|
||||
Expects request to be a rest_framework.request.Request (cast by caller).
|
||||
"""
|
||||
# event_type
|
||||
event_type = request.query_params.get("event_type")
|
||||
if event_type == "created":
|
||||
queryset = queryset.filter(pgh_label="created")
|
||||
elif event_type == "updated":
|
||||
queryset = queryset.filter(pgh_label="updated")
|
||||
elif event_type == "deleted":
|
||||
queryset = queryset.filter(pgh_label="deleted")
|
||||
|
||||
# date range
|
||||
start_date = _parse_date(request.query_params.get("start_date"))
|
||||
if start_date:
|
||||
queryset = queryset.filter(pgh_created_at__gte=start_date)
|
||||
|
||||
end_date = _parse_date(request.query_params.get("end_date"))
|
||||
if end_date:
|
||||
queryset = queryset.filter(pgh_created_at__lte=end_date)
|
||||
|
||||
# limit (slice the queryset)
|
||||
limit_raw = request.query_params.get("limit", str(default_limit))
|
||||
try:
|
||||
limit_val = min(int(limit_raw), max_limit)
|
||||
queryset = queryset[:limit_val]
|
||||
except (ValueError, TypeError):
|
||||
queryset = queryset[:default_limit]
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Get park history",
|
||||
description="Retrieve history timeline for a specific park including all changes over time.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of history events to return (default: 50, max: 500)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Offset for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="event_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by event type (created, updated, deleted)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="start_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events after this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="end_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events before this date (YYYY-MM-DD)",
|
||||
),
|
||||
],
|
||||
responses={200: ParkHistoryEventSerializer(many=True)},
|
||||
tags=["History", "Parks"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get complete park history",
|
||||
description="Retrieve complete history for a park including current state and timeline.",
|
||||
responses={200: ParkHistoryOutputSerializer},
|
||||
tags=["History", "Parks"],
|
||||
),
|
||||
)
|
||||
class ParkHistoryViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for accessing park history data.
|
||||
|
||||
Provides read-only access to historical changes for parks,
|
||||
including version history and real-world changes.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "park_slug"
|
||||
filter_backends = [OrderingFilter]
|
||||
ordering_fields = ["pgh_created_at"]
|
||||
ordering = ["-pgh_created_at"]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get history events for the specified park."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
if not park_slug:
|
||||
return pghistory.models.Events.objects.none()
|
||||
|
||||
# Get the park to ensure it exists
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
|
||||
# Base queryset for park events
|
||||
queryset = (
|
||||
pghistory.models.Events.objects.filter(
|
||||
pgh_model__in=[PARK_MODEL], pgh_obj_id=getattr(park, "id", None)
|
||||
)
|
||||
.select_related()
|
||||
.order_by("-pgh_created_at")
|
||||
)
|
||||
|
||||
# Apply list filters via helper to reduce complexity
|
||||
if self.action == "list":
|
||||
queryset = _apply_list_filters(
|
||||
queryset, cast(Request, self.request), default_limit=50, max_limit=500
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "retrieve":
|
||||
return ParkHistoryOutputSerializer
|
||||
return ParkHistoryEventSerializer
|
||||
|
||||
def retrieve(self, request, park_slug=None):
|
||||
"""Get complete park history including current state."""
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
|
||||
# Get history events
|
||||
history_events = self.get_queryset()[:100] # Latest 100 events
|
||||
|
||||
# safe attribute access using getattr to avoid static-checker complaints
|
||||
first_recorded = getattr(history_events.last(), "pgh_created_at", None)
|
||||
last_modified = getattr(history_events.first(), "pgh_created_at", None)
|
||||
|
||||
# Prepare data for serializer
|
||||
history_data = {
|
||||
"park": park,
|
||||
"current_state": park,
|
||||
"summary": {
|
||||
"total_events": self.get_queryset().count(),
|
||||
"first_recorded": first_recorded,
|
||||
"last_modified": last_modified,
|
||||
},
|
||||
"events": history_events,
|
||||
}
|
||||
|
||||
serializer = ParkHistoryOutputSerializer(history_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Get ride history",
|
||||
description="Retrieve history timeline for a specific ride including all changes over time.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of history events to return (default: 50, max: 500)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Offset for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="event_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by event type (created, updated, deleted)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="start_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events after this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="end_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events before this date (YYYY-MM-DD)",
|
||||
),
|
||||
],
|
||||
responses={200: RideHistoryEventSerializer(many=True)},
|
||||
tags=["History", "Rides"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get complete ride history",
|
||||
description="Retrieve complete history for a ride including current state and timeline.",
|
||||
responses={200: RideHistoryOutputSerializer},
|
||||
tags=["History", "Rides"],
|
||||
),
|
||||
)
|
||||
class RideHistoryViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for accessing ride history data.
|
||||
|
||||
Provides read-only access to historical changes for rides,
|
||||
including version history and real-world changes.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "ride_slug"
|
||||
filter_backends = [OrderingFilter]
|
||||
ordering_fields = ["pgh_created_at"]
|
||||
ordering = ["-pgh_created_at"]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get history events for the specified ride."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return pghistory.models.Events.objects.none()
|
||||
|
||||
# Get the ride to ensure it exists
|
||||
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
|
||||
|
||||
# Base queryset for ride events
|
||||
queryset = (
|
||||
pghistory.models.Events.objects.filter(
|
||||
pgh_model__in=RIDE_MODELS, pgh_obj_id=getattr(ride, "id", None)
|
||||
)
|
||||
.select_related()
|
||||
.order_by("-pgh_created_at")
|
||||
)
|
||||
|
||||
# Apply list filters via helper
|
||||
if self.action == "list":
|
||||
queryset = _apply_list_filters(
|
||||
queryset, cast(Request, self.request), default_limit=50, max_limit=500
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "retrieve":
|
||||
return RideHistoryOutputSerializer
|
||||
return RideHistoryEventSerializer
|
||||
|
||||
def retrieve(self, request, park_slug=None, ride_slug=None):
|
||||
"""Get complete ride history including current state."""
|
||||
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
|
||||
|
||||
# Get history events
|
||||
history_events = self.get_queryset()[:100] # Latest 100 events
|
||||
|
||||
# safe attribute access
|
||||
first_recorded = getattr(history_events.last(), "pgh_created_at", None)
|
||||
last_modified = getattr(history_events.first(), "pgh_created_at", None)
|
||||
|
||||
# Prepare data for serializer
|
||||
history_data = {
|
||||
"ride": ride,
|
||||
"current_state": ride,
|
||||
"summary": {
|
||||
"total_events": self.get_queryset().count(),
|
||||
"first_recorded": first_recorded,
|
||||
"last_modified": last_modified,
|
||||
},
|
||||
"events": history_events,
|
||||
}
|
||||
|
||||
serializer = RideHistoryOutputSerializer(history_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Unified history timeline",
|
||||
description="Retrieve a unified timeline of all changes across parks, rides, and companies.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of history events to return (default: 100, max: 1000)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Offset for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="model_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by model type (park, ride, company)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="event_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by event type (created, updated, deleted)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="start_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events after this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="end_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events before this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="significance",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by change significance (major, minor, routine)",
|
||||
),
|
||||
],
|
||||
responses={200: UnifiedHistoryTimelineSerializer},
|
||||
tags=["History"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get unified history timeline item",
|
||||
description="Retrieve a specific item from the unified history timeline.",
|
||||
responses={200: UnifiedHistoryTimelineSerializer},
|
||||
tags=["History"],
|
||||
),
|
||||
)
|
||||
class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for unified history timeline across all models.
|
||||
|
||||
Provides a comprehensive view of all changes across
|
||||
parks, rides, and companies in chronological order.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
filter_backends = [OrderingFilter]
|
||||
ordering_fields = ["pgh_created_at"]
|
||||
ordering = ["-pgh_created_at"]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get unified history events across all tracked models."""
|
||||
queryset = (
|
||||
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
|
||||
.select_related()
|
||||
.order_by("-pgh_created_at")
|
||||
)
|
||||
|
||||
# Filter by requested model_type (if provided)
|
||||
model_type = cast(Request, self.request).query_params.get("model_type")
|
||||
if model_type == "park":
|
||||
queryset = queryset.filter(pgh_model=PARK_MODEL)
|
||||
elif model_type == "ride":
|
||||
queryset = queryset.filter(pgh_model__in=RIDE_MODELS)
|
||||
elif model_type == "company":
|
||||
queryset = queryset.filter(pgh_model__in=COMPANY_MODELS)
|
||||
elif model_type == "user":
|
||||
queryset = queryset.filter(pgh_model=ACCOUNT_MODEL)
|
||||
|
||||
# Apply shared list filters when serving the list action
|
||||
if self.action == "list":
|
||||
queryset = _apply_list_filters(
|
||||
queryset, cast(Request, self.request), default_limit=100, max_limit=1000
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return unified history timeline serializer."""
|
||||
return UnifiedHistoryTimelineSerializer
|
||||
|
||||
def list(self, request):
|
||||
"""Get unified history timeline with summary statistics."""
|
||||
events = list(self.get_queryset()) # evaluate for counts / earliest/latest use
|
||||
|
||||
# Summary statistics across all tracked models
|
||||
total_events = pghistory.models.Events.objects.filter(
|
||||
pgh_model__in=ALL_TRACKED_MODELS
|
||||
).count()
|
||||
|
||||
event_type_counts = (
|
||||
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
|
||||
.values("pgh_label")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
model_type_counts = (
|
||||
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
|
||||
.values("pgh_model")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
timeline_data = {
|
||||
"summary": {
|
||||
"total_events": total_events,
|
||||
"events_returned": len(events),
|
||||
"event_type_breakdown": {
|
||||
item["pgh_label"]: item["count"] for item in event_type_counts
|
||||
},
|
||||
"model_type_breakdown": {
|
||||
item["pgh_model"]: item["count"] for item in model_type_counts
|
||||
},
|
||||
"time_range": {
|
||||
"earliest": events[-1].pgh_created_at if events else None,
|
||||
"latest": events[0].pgh_created_at if events else None,
|
||||
},
|
||||
},
|
||||
"events": events,
|
||||
}
|
||||
|
||||
serializer = UnifiedHistoryTimelineSerializer(timeline_data)
|
||||
return Response(serializer.data)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""
|
||||
Maps API module for centralized API structure.
|
||||
Migrated from apps.core.views.map_views
|
||||
"""
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
URL patterns for the unified map service API.
|
||||
Migrated from apps.core.urls.map_urls to centralized API structure.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Map API endpoints - migrated from apps.core.urls.map_urls
|
||||
urlpatterns = [
|
||||
# Main map data endpoint
|
||||
path("locations/", views.MapLocationsAPIView.as_view(), name="map_locations"),
|
||||
# Location detail endpoint
|
||||
path(
|
||||
"locations/<str:location_type>/<int:location_id>/",
|
||||
views.MapLocationDetailAPIView.as_view(),
|
||||
name="map_location_detail",
|
||||
),
|
||||
# Search endpoint
|
||||
path("search/", views.MapSearchAPIView.as_view(), name="map_search"),
|
||||
# Bounds-based query endpoint
|
||||
path("bounds/", views.MapBoundsAPIView.as_view(), name="map_bounds"),
|
||||
# Service statistics endpoint
|
||||
path("stats/", views.MapStatsAPIView.as_view(), name="map_stats"),
|
||||
# Cache management endpoints
|
||||
path("cache/", views.MapCacheAPIView.as_view(), name="map_cache"),
|
||||
path(
|
||||
"cache/invalidate/",
|
||||
views.MapCacheAPIView.as_view(),
|
||||
name="map_cache_invalidate",
|
||||
),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,339 +0,0 @@
|
||||
"""
|
||||
Contract Validation Middleware for ThrillWiki API
|
||||
|
||||
This middleware catches contract violations between the Django backend and frontend
|
||||
TypeScript interfaces, providing immediate feedback during development.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContractValidationMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Development-only middleware that validates API responses against expected contracts.
|
||||
|
||||
This middleware:
|
||||
1. Checks all API responses for contract compliance
|
||||
2. Logs warnings when responses don't match expected TypeScript interfaces
|
||||
3. Specifically validates filter metadata structure
|
||||
4. Alerts when categorical filters are strings instead of objects
|
||||
|
||||
Only active when DEBUG=True to avoid performance impact in production.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
super().__init__(get_response)
|
||||
self.get_response = get_response
|
||||
self.enabled = getattr(settings, 'DEBUG', False)
|
||||
|
||||
if self.enabled:
|
||||
logger.info("Contract validation middleware enabled (DEBUG mode)")
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Process API responses to check for contract violations."""
|
||||
|
||||
if not self.enabled:
|
||||
return response
|
||||
|
||||
# Only validate API endpoints
|
||||
if not request.path.startswith('/api/'):
|
||||
return response
|
||||
|
||||
# Only validate JSON responses
|
||||
if not isinstance(response, (JsonResponse, Response)):
|
||||
return response
|
||||
|
||||
# Only validate successful responses (2xx status codes)
|
||||
if not (200 <= response.status_code < 300):
|
||||
return response
|
||||
|
||||
try:
|
||||
# Get response data
|
||||
if isinstance(response, Response):
|
||||
data = response.data
|
||||
else:
|
||||
data = json.loads(response.content.decode('utf-8'))
|
||||
|
||||
# Validate the response
|
||||
self._validate_response_contract(request.path, data)
|
||||
|
||||
except Exception as e:
|
||||
# Log validation errors but don't break the response
|
||||
logger.warning(
|
||||
f"Contract validation error for {request.path}: {str(e)}",
|
||||
extra={
|
||||
'path': request.path,
|
||||
'method': request.method,
|
||||
'status_code': response.status_code,
|
||||
'validation_error': str(e)
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _validate_response_contract(self, path: str, data: Any) -> None:
|
||||
"""Validate response data against expected contracts."""
|
||||
|
||||
# Check for filter metadata endpoints
|
||||
if 'filter-options' in path or 'filter_options' in path:
|
||||
self._validate_filter_metadata(path, data)
|
||||
|
||||
# Check for hybrid filtering endpoints
|
||||
if 'hybrid' in path:
|
||||
self._validate_hybrid_response(path, data)
|
||||
|
||||
# Check for pagination responses
|
||||
if isinstance(data, dict) and 'results' in data:
|
||||
self._validate_pagination_response(path, data)
|
||||
|
||||
# Check for common contract violations
|
||||
self._validate_common_patterns(path, data)
|
||||
|
||||
def _validate_filter_metadata(self, path: str, data: Any) -> None:
|
||||
"""Validate filter metadata structure."""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"FILTER_METADATA_NOT_DICT",
|
||||
f"Filter metadata should be a dictionary, got {type(data).__name__}"
|
||||
)
|
||||
return
|
||||
|
||||
# Check for categorical filters
|
||||
if 'categorical' in data:
|
||||
categorical = data['categorical']
|
||||
if isinstance(categorical, dict):
|
||||
for filter_name, filter_options in categorical.items():
|
||||
self._validate_categorical_filter(path, filter_name, filter_options)
|
||||
|
||||
# Check for ranges
|
||||
if 'ranges' in data:
|
||||
ranges = data['ranges']
|
||||
if isinstance(ranges, dict):
|
||||
for range_name, range_data in ranges.items():
|
||||
self._validate_range_filter(path, range_name, range_data)
|
||||
|
||||
def _validate_categorical_filter(self, path: str, filter_name: str, filter_options: Any) -> None:
|
||||
"""Validate categorical filter options format."""
|
||||
|
||||
if not isinstance(filter_options, list):
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"CATEGORICAL_FILTER_NOT_ARRAY",
|
||||
f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}"
|
||||
)
|
||||
return
|
||||
|
||||
for i, option in enumerate(filter_options):
|
||||
if isinstance(option, str):
|
||||
# CRITICAL: This is the main contract violation we're trying to catch
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"CATEGORICAL_OPTION_IS_STRING",
|
||||
f"Categorical filter '{filter_name}' option {i} is a string '{option}' but should be an object with value/label/count properties",
|
||||
severity="ERROR"
|
||||
)
|
||||
elif isinstance(option, dict):
|
||||
# Validate object structure
|
||||
if 'value' not in option:
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"MISSING_VALUE_PROPERTY",
|
||||
f"Categorical filter '{filter_name}' option {i} missing 'value' property"
|
||||
)
|
||||
if 'label' not in option:
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"MISSING_LABEL_PROPERTY",
|
||||
f"Categorical filter '{filter_name}' option {i} missing 'label' property"
|
||||
)
|
||||
# Count is optional but should be number if present
|
||||
if 'count' in option and option['count'] is not None and not isinstance(option['count'], (int, float)):
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"INVALID_COUNT_TYPE",
|
||||
f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}"
|
||||
)
|
||||
|
||||
def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None:
|
||||
"""Validate range filter format."""
|
||||
|
||||
if not isinstance(range_data, dict):
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"RANGE_FILTER_NOT_OBJECT",
|
||||
f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}"
|
||||
)
|
||||
return
|
||||
|
||||
# Check required properties
|
||||
required_props = ['min', 'max']
|
||||
for prop in required_props:
|
||||
if prop not in range_data:
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"MISSING_RANGE_PROPERTY",
|
||||
f"Range filter '{range_name}' missing required property '{prop}'"
|
||||
)
|
||||
|
||||
# Check step property
|
||||
if 'step' in range_data and not isinstance(range_data['step'], (int, float)):
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"INVALID_STEP_TYPE",
|
||||
f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}"
|
||||
)
|
||||
|
||||
def _validate_hybrid_response(self, path: str, data: Any) -> None:
|
||||
"""Validate hybrid filtering response structure."""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
# Check for strategy field
|
||||
if 'strategy' in data:
|
||||
strategy = data['strategy']
|
||||
if strategy not in ['client_side', 'server_side']:
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"INVALID_STRATEGY_VALUE",
|
||||
f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'"
|
||||
)
|
||||
|
||||
# Check filter_metadata structure
|
||||
if 'filter_metadata' in data:
|
||||
self._validate_filter_metadata(path, data['filter_metadata'])
|
||||
|
||||
def _validate_pagination_response(self, path: str, data: Dict[str, Any]) -> None:
|
||||
"""Validate pagination response structure."""
|
||||
|
||||
# Check for required pagination fields
|
||||
required_fields = ['count', 'results']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"MISSING_PAGINATION_FIELD",
|
||||
f"Pagination response missing required field '{field}'"
|
||||
)
|
||||
|
||||
# Check results is array
|
||||
if 'results' in data and not isinstance(data['results'], list):
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"RESULTS_NOT_ARRAY",
|
||||
f"Pagination 'results' should be an array, got {type(data['results']).__name__}"
|
||||
)
|
||||
|
||||
def _validate_common_patterns(self, path: str, data: Any) -> None:
|
||||
"""Validate common API response patterns."""
|
||||
|
||||
if isinstance(data, dict):
|
||||
# Check for null vs undefined issues
|
||||
for key, value in data.items():
|
||||
if value is None and key.endswith('_id'):
|
||||
# ID fields should probably be null, not undefined
|
||||
continue
|
||||
|
||||
# Check for numeric fields that might be strings
|
||||
if key.endswith('_count') and isinstance(value, str):
|
||||
try:
|
||||
int(value)
|
||||
self._log_contract_violation(
|
||||
path,
|
||||
"NUMERIC_FIELD_AS_STRING",
|
||||
f"Field '{key}' appears to be numeric but is a string: '{value}'"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _log_contract_violation(
|
||||
self,
|
||||
path: str,
|
||||
violation_type: str,
|
||||
message: str,
|
||||
severity: str = "WARNING"
|
||||
) -> None:
|
||||
"""Log a contract violation with structured data."""
|
||||
|
||||
log_data = {
|
||||
'contract_violation': True,
|
||||
'violation_type': violation_type,
|
||||
'api_path': path,
|
||||
'severity': severity,
|
||||
'message': message,
|
||||
'suggestion': self._get_violation_suggestion(violation_type)
|
||||
}
|
||||
|
||||
if severity == "ERROR":
|
||||
logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
|
||||
else:
|
||||
logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
|
||||
|
||||
def _get_violation_suggestion(self, violation_type: str) -> str:
|
||||
"""Get suggestion for fixing a contract violation."""
|
||||
|
||||
suggestions = {
|
||||
"CATEGORICAL_OPTION_IS_STRING": (
|
||||
"Convert string arrays to object arrays with {value, label, count} structure. "
|
||||
"Use the ensure_filter_option_format() utility function from apps.api.v1.serializers.shared"
|
||||
),
|
||||
"MISSING_VALUE_PROPERTY": (
|
||||
"Add 'value' property to filter option objects. "
|
||||
"Use FilterOptionSerializer from apps.api.v1.serializers.shared"
|
||||
),
|
||||
"MISSING_LABEL_PROPERTY": (
|
||||
"Add 'label' property to filter option objects. "
|
||||
"Use FilterOptionSerializer from apps.api.v1.serializers.shared"
|
||||
),
|
||||
"RANGE_FILTER_NOT_OBJECT": (
|
||||
"Convert range data to object with min/max/step/unit properties. "
|
||||
"Use FilterRangeSerializer from apps.api.v1.serializers.shared"
|
||||
),
|
||||
"NUMERIC_FIELD_AS_STRING": (
|
||||
"Ensure numeric fields are returned as numbers, not strings. "
|
||||
"Check serializer field types and database field types."
|
||||
),
|
||||
"RESULTS_NOT_ARRAY": (
|
||||
"Ensure pagination 'results' field is always an array. "
|
||||
"Check serializer implementation."
|
||||
)
|
||||
}
|
||||
|
||||
return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.")
|
||||
|
||||
|
||||
class ContractValidationSettings:
|
||||
"""Settings for contract validation middleware."""
|
||||
|
||||
# Enable/disable specific validation checks
|
||||
VALIDATE_FILTER_METADATA = True
|
||||
VALIDATE_PAGINATION = True
|
||||
VALIDATE_HYBRID_RESPONSES = True
|
||||
VALIDATE_COMMON_PATTERNS = True
|
||||
|
||||
# Severity levels for different violations
|
||||
CATEGORICAL_STRING_SEVERITY = "ERROR" # This is the critical issue
|
||||
MISSING_PROPERTY_SEVERITY = "WARNING"
|
||||
TYPE_MISMATCH_SEVERITY = "WARNING"
|
||||
|
||||
# Paths to exclude from validation
|
||||
EXCLUDED_PATHS = [
|
||||
'/api/docs/',
|
||||
'/api/schema/',
|
||||
'/api/v1/auth/', # Auth endpoints might have different structures
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def should_validate_path(cls, path: str) -> bool:
|
||||
"""Check if a path should be validated."""
|
||||
return not any(excluded in path for excluded in cls.EXCLUDED_PATHS)
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
Parks API module for ThrillWiki API v1.
|
||||
|
||||
This module provides API endpoints for park-related functionality including
|
||||
search suggestions, location services, and roadtrip planning.
|
||||
"""
|
||||
@@ -1,306 +0,0 @@
|
||||
"""
|
||||
Park Rides API views for ThrillWiki API v1.
|
||||
|
||||
This module implements endpoints for accessing rides within specific parks:
|
||||
- GET /parks/{park_slug}/rides/ - List rides at a park with pagination and filtering
|
||||
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
# Import models
|
||||
try:
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Park = None # type: ignore
|
||||
Ride = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
# Import serializers
|
||||
try:
|
||||
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
|
||||
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
|
||||
SERIALIZERS_AVAILABLE = True
|
||||
except Exception:
|
||||
SERIALIZERS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class ParkRidesListAPIView(APIView):
|
||||
"""List rides at a specific park with pagination and filtering."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List rides at a specific park",
|
||||
description="Get paginated list of rides at a specific park with filtering options",
|
||||
parameters=[
|
||||
# Pagination
|
||||
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT, description="Page number"),
|
||||
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT, description="Number of results per page (max 100)"),
|
||||
|
||||
# Filtering
|
||||
OpenApiParameter(name="category", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Filter by ride category"),
|
||||
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Filter by operational status"),
|
||||
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Search rides by name"),
|
||||
|
||||
# Ordering
|
||||
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Order results by field"),
|
||||
],
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks", "Rides"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""List rides at a specific park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park and ride models not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get rides for this park
|
||||
qs = Ride.objects.filter(park=park).select_related(
|
||||
"manufacturer", "designer", "ride_model", "park_area"
|
||||
).prefetch_related("photos")
|
||||
|
||||
# Apply filtering
|
||||
qs = self._apply_filters(qs, request.query_params)
|
||||
|
||||
# Apply ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
if ordering:
|
||||
qs = qs.order_by(ordering)
|
||||
|
||||
# Paginate results
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = RideListOutputSerializer(
|
||||
page, many=True, context={"request": request, "park": park}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
serializer_data = [
|
||||
{
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"category": getattr(ride, "category", ""),
|
||||
"status": getattr(ride, "status", ""),
|
||||
"manufacturer": {
|
||||
"name": ride.manufacturer.name if ride.manufacturer else "",
|
||||
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
|
||||
},
|
||||
}
|
||||
for ride in page
|
||||
]
|
||||
return paginator.get_paginated_response(serializer_data)
|
||||
|
||||
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply filtering to the rides queryset."""
|
||||
# Category filter
|
||||
category = params.get("category")
|
||||
if category:
|
||||
qs = qs.filter(category=category)
|
||||
|
||||
# Status filter
|
||||
status_filter = params.get("status")
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
|
||||
# Search filter
|
||||
search = params.get("search")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=search) |
|
||||
Q(description__icontains=search) |
|
||||
Q(manufacturer__name__icontains=search)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class ParkRideDetailAPIView(APIView):
|
||||
"""Get specific ride details within park context."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride details within park context",
|
||||
description="Get comprehensive details for a specific ride at a specific park",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks", "Rides"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str, ride_slug: str) -> Response:
|
||||
"""Get ride details within park context."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park and ride models not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get the ride
|
||||
try:
|
||||
ride, is_historical = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Ride.DoesNotExist:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
# Ensure ride belongs to this park
|
||||
if ride.park_id != park.id:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = RideDetailOutputSerializer(
|
||||
ride, context={"request": request, "park": park}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
return Response({
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"description": getattr(ride, "description", ""),
|
||||
"category": getattr(ride, "category", ""),
|
||||
"status": getattr(ride, "status", ""),
|
||||
"park": {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
},
|
||||
"manufacturer": {
|
||||
"name": ride.manufacturer.name if ride.manufacturer else "",
|
||||
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
|
||||
} if ride.manufacturer else None,
|
||||
})
|
||||
|
||||
|
||||
class ParkComprehensiveDetailAPIView(APIView):
|
||||
"""Get comprehensive park details including summary of rides."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get comprehensive park details with rides summary",
|
||||
description="Get complete park details including a summary of rides (first 10) and link to full rides list",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""Get comprehensive park details with rides summary."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park and ride models not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get park with full related data
|
||||
park = Park.objects.select_related(
|
||||
"operator", "property_owner", "location"
|
||||
).prefetch_related(
|
||||
"areas", "rides", "photos"
|
||||
).get(pk=park.pk)
|
||||
|
||||
# Get a sample of rides (first 10) for preview
|
||||
rides_sample = Ride.objects.filter(park=park).select_related(
|
||||
"manufacturer", "designer", "ride_model"
|
||||
)[:10]
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
# Get full park details
|
||||
park_serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request}
|
||||
)
|
||||
park_data = park_serializer.data
|
||||
|
||||
# Add rides summary
|
||||
rides_serializer = RideListOutputSerializer(
|
||||
rides_sample, many=True, context={"request": request, "park": park}
|
||||
)
|
||||
|
||||
# Enhance response with rides data
|
||||
park_data["rides_summary"] = {
|
||||
"total_count": park.ride_count or 0,
|
||||
"sample": rides_serializer.data,
|
||||
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
|
||||
}
|
||||
|
||||
return Response(park_data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
return Response({
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"description": getattr(park, "description", ""),
|
||||
"location": str(getattr(park, "location", "")),
|
||||
"operator": getattr(park.operator, "name", "") if hasattr(park, "operator") else "",
|
||||
"ride_count": getattr(park, "ride_count", 0),
|
||||
"rides_summary": {
|
||||
"total_count": getattr(park, "ride_count", 0),
|
||||
"sample": [
|
||||
{
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"category": getattr(ride, "category", ""),
|
||||
}
|
||||
for ride in rides_sample
|
||||
],
|
||||
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,552 +0,0 @@
|
||||
"""
|
||||
Ride photo API views for ThrillWiki API v1 (nested under parks).
|
||||
|
||||
This module contains ride photo ViewSet following the parks pattern for domain consistency.
|
||||
Provides CRUD operations for ride photos nested under parks/{park_slug}/rides/{ride_slug}/photos/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError, NotFound
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.rides.models.media import RidePhoto
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
from apps.api.v1.rides.serializers import (
|
||||
RidePhotoOutputSerializer,
|
||||
RidePhotoCreateInputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
RidePhotoListOutputSerializer,
|
||||
RidePhotoApprovalInputSerializer,
|
||||
RidePhotoStatsOutputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride photos",
|
||||
description="Retrieve a paginated list of ride photos with filtering capabilities.",
|
||||
responses={200: RidePhotoListOutputSerializer(many=True)},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Upload ride photo",
|
||||
description="Upload a new photo for a ride. Requires authentication.",
|
||||
request=RidePhotoCreateInputSerializer,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ride photo details",
|
||||
description="Retrieve detailed information about a specific ride photo.",
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update ride photo",
|
||||
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update ride photo",
|
||||
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete ride photo",
|
||||
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
)
|
||||
class RidePhotoViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing ride photos with full CRUD operations (nested under parks).
|
||||
|
||||
Provides CRUD operations for ride photos with proper permission checking.
|
||||
Uses RideMediaService for business logic operations.
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get photos for the current ride with optimized queries."""
|
||||
queryset = RidePhoto.objects.select_related(
|
||||
"ride", "ride__park", "ride__park__operator", "uploaded_by"
|
||||
)
|
||||
|
||||
# Filter by park and ride from URL kwargs
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if park_slug and ride_slug:
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
queryset = queryset.filter(ride=ride)
|
||||
except (Park.DoesNotExist, Ride.DoesNotExist):
|
||||
# Return empty queryset if park or ride not found
|
||||
return queryset.none()
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return RidePhotoListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return RidePhotoCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return RidePhotoUpdateInputSerializer
|
||||
else:
|
||||
return RidePhotoOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new ride photo using RideMediaService."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
raise ValidationError("Park and ride slugs are required")
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
except Ride.DoesNotExist:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
try:
|
||||
# Use the service to create the photo with proper business logic
|
||||
photo = RideMediaService.upload_photo(
|
||||
ride=ride,
|
||||
image_file=serializer.validated_data["image"],
|
||||
user=self.request.user,
|
||||
caption=serializer.validated_data.get("caption", ""),
|
||||
alt_text=serializer.validated_data.get("alt_text", ""),
|
||||
photo_type=serializer.validated_data.get("photo_type", "exterior"),
|
||||
is_primary=serializer.validated_data.get("is_primary", False),
|
||||
auto_approve=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update ride photo with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||
|
||||
# Handle primary photo logic using service
|
||||
if serializer.validated_data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=instance.ride, photo=instance)
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
try:
|
||||
serializer.save()
|
||||
logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride photo: {e}")
|
||||
raise ValidationError(f"Failed to update photo: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete ride photo with permission checking."""
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete from Cloudflare first if image exists
|
||||
if instance.image:
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(instance.image)
|
||||
logger.info(
|
||||
f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete ride photo from Cloudflare: {str(e)}")
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
RideMediaService.delete_photo(
|
||||
instance, deleted_by=self.request.user
|
||||
)
|
||||
|
||||
logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
summary="Set photo as primary",
|
||||
description="Set this photo as the primary photo for the ride",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary(self, request, **kwargs):
|
||||
"""Set this photo as the primary photo for the ride."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
request.user == photo.uploaded_by
|
||||
or getattr(request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only modify your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
|
||||
|
||||
if success:
|
||||
# Refresh the photo instance
|
||||
photo.refresh_from_db()
|
||||
serializer = self.get_serializer(photo)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo": serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Failed to set primary photo"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Bulk approve/reject photos",
|
||||
description="Bulk approve or reject multiple ride photos (admin only)",
|
||||
request=RidePhotoApprovalInputSerializer,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def bulk_approve(self, request, **kwargs):
|
||||
"""Bulk approve or reject multiple photos (admin only)."""
|
||||
if not getattr(request.user, "is_staff", False):
|
||||
raise PermissionDenied("Only administrators can approve photos.")
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = getattr(serializer, "validated_data", {})
|
||||
photo_ids = validated_data.get("photo_ids")
|
||||
approve = validated_data.get("approve")
|
||||
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if photo_ids is None or approve is None:
|
||||
return Response(
|
||||
{"error": "Missing required fields: photo_ids and/or approve."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Filter photos to only those belonging to this ride
|
||||
photos_queryset = RidePhoto.objects.filter(id__in=photo_ids)
|
||||
if park_slug and ride_slug:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
photos_queryset = photos_queryset.filter(ride=ride)
|
||||
|
||||
updated_count = photos_queryset.update(is_approved=approve)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride photo statistics",
|
||||
description="Get photo statistics for the ride",
|
||||
responses={
|
||||
200: RidePhotoStatsOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get photo statistics for the ride."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return Response(
|
||||
{"error": "Park and ride slugs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found at this park"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
stats = RideMediaService.get_photo_stats(ride)
|
||||
serializer = RidePhotoStatsOutputSerializer(stats)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Save Cloudflare image as ride photo",
|
||||
description="Save a Cloudflare image as a ride photo after direct upload to Cloudflare",
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=False, methods=["post"])
|
||||
def save_image(self, request, **kwargs):
|
||||
"""Save a Cloudflare image as a ride photo after direct upload to Cloudflare."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return Response(
|
||||
{"error": "Park and ride slugs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found at this park"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
cloudflare_image_id = request.data.get("cloudflare_image_id")
|
||||
if not cloudflare_image_id:
|
||||
return Response(
|
||||
{"error": "cloudflare_image_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import CloudflareImage model and service
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
|
||||
# Always fetch the latest image data from Cloudflare API
|
||||
try:
|
||||
# Get image details from Cloudflare API
|
||||
service = CloudflareImagesService()
|
||||
image_data = service.get_image(cloudflare_image_id)
|
||||
|
||||
if not image_data:
|
||||
return Response(
|
||||
{"error": "Image not found in Cloudflare"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Try to find existing CloudflareImage record by cloudflare_id
|
||||
cloudflare_image = None
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.get(
|
||||
cloudflare_id=cloudflare_image_id)
|
||||
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = 'uploaded'
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get('meta', {})
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get(
|
||||
'result', {}).get('variants', [])
|
||||
cloudflare_image.cloudflare_metadata = image_data
|
||||
cloudflare_image.width = image_data.get('width')
|
||||
cloudflare_image.height = image_data.get('height')
|
||||
cloudflare_image.format = image_data.get('format', '')
|
||||
cloudflare_image.save()
|
||||
|
||||
except CloudflareImage.DoesNotExist:
|
||||
# Create new CloudflareImage record from API response
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=request.user,
|
||||
status='uploaded',
|
||||
upload_url='', # Not needed for uploaded images
|
||||
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
|
||||
uploaded_at=timezone.now(),
|
||||
metadata=image_data.get('meta', {}),
|
||||
# Extract variants from nested result structure
|
||||
variants=image_data.get('result', {}).get('variants', []),
|
||||
cloudflare_metadata=image_data,
|
||||
width=image_data.get('width'),
|
||||
height=image_data.get('height'),
|
||||
format=image_data.get('format', ''),
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create the ride photo with the CloudflareImage reference
|
||||
photo = RidePhoto.objects.create(
|
||||
ride=ride,
|
||||
image=cloudflare_image,
|
||||
uploaded_by=request.user,
|
||||
caption=request.data.get("caption", ""),
|
||||
alt_text=request.data.get("alt_text", ""),
|
||||
photo_type=request.data.get("photo_type", "exterior"),
|
||||
is_primary=request.data.get("is_primary", False),
|
||||
is_approved=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Handle primary photo logic if requested
|
||||
if request.data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=ride, photo=photo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving ride photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1,380 +0,0 @@
|
||||
"""
|
||||
Ride review API views for ThrillWiki API v1 (nested under parks).
|
||||
|
||||
This module contains ride review ViewSet following the parks pattern for domain consistency.
|
||||
Provides CRUD operations for ride reviews nested under parks/{park_slug}/rides/{ride_slug}/reviews/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError, NotFound
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
from apps.api.v1.serializers.ride_reviews import (
|
||||
RideReviewOutputSerializer,
|
||||
RideReviewCreateInputSerializer,
|
||||
RideReviewUpdateInputSerializer,
|
||||
RideReviewListOutputSerializer,
|
||||
RideReviewStatsOutputSerializer,
|
||||
RideReviewModerationInputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride reviews",
|
||||
description="Retrieve a paginated list of ride reviews with filtering capabilities.",
|
||||
responses={200: RideReviewListOutputSerializer(many=True)},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create ride review",
|
||||
description="Create a new review for a ride. Requires authentication.",
|
||||
request=RideReviewCreateInputSerializer,
|
||||
responses={
|
||||
201: RideReviewOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ride review details",
|
||||
description="Retrieve detailed information about a specific ride review.",
|
||||
responses={
|
||||
200: RideReviewOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update ride review",
|
||||
description="Update ride review information. Requires authentication and ownership or admin privileges.",
|
||||
request=RideReviewUpdateInputSerializer,
|
||||
responses={
|
||||
200: RideReviewOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update ride review",
|
||||
description="Partially update ride review information. Requires authentication and ownership or admin privileges.",
|
||||
request=RideReviewUpdateInputSerializer,
|
||||
responses={
|
||||
200: RideReviewOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete ride review",
|
||||
description="Delete a ride review. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
)
|
||||
class RideReviewViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing ride reviews with full CRUD operations.
|
||||
|
||||
Provides CRUD operations for ride reviews with proper permission checking.
|
||||
Includes advanced features like bulk moderation and statistics.
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get reviews for the current ride with optimized queries."""
|
||||
queryset = RideReview.objects.select_related(
|
||||
"ride", "ride__park", "user", "user__profile"
|
||||
)
|
||||
|
||||
# Filter by park and ride from URL kwargs
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if park_slug and ride_slug:
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
queryset = queryset.filter(ride=ride)
|
||||
except (Park.DoesNotExist, Ride.DoesNotExist):
|
||||
# Return empty queryset if park or ride not found
|
||||
return queryset.none()
|
||||
|
||||
# Filter published reviews for non-staff users
|
||||
if not (hasattr(self.request, 'user') and
|
||||
getattr(self.request.user, 'is_staff', False)):
|
||||
queryset = queryset.filter(is_published=True)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return RideReviewListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return RideReviewCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return RideReviewUpdateInputSerializer
|
||||
else:
|
||||
return RideReviewOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new ride review."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
raise ValidationError("Park and ride slugs are required")
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
except Ride.DoesNotExist:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
# Check if user already has a review for this ride
|
||||
if RideReview.objects.filter(ride=ride, user=self.request.user).exists():
|
||||
raise ValidationError("You have already reviewed this ride")
|
||||
|
||||
try:
|
||||
# Save the review
|
||||
review = serializer.save(
|
||||
ride=ride,
|
||||
user=self.request.user,
|
||||
is_published=True # Auto-publish for now, can add moderation later
|
||||
)
|
||||
|
||||
logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride review: {e}")
|
||||
raise ValidationError(f"Failed to create review: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update ride review with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.user
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own reviews or be an admin.")
|
||||
|
||||
try:
|
||||
serializer.save()
|
||||
logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride review: {e}")
|
||||
raise ValidationError(f"Failed to update review: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete ride review with permission checking."""
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.user
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only delete your own reviews or be an admin.")
|
||||
|
||||
try:
|
||||
logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}")
|
||||
instance.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride review: {e}")
|
||||
raise ValidationError(f"Failed to delete review: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride review statistics",
|
||||
description="Get review statistics for the ride",
|
||||
responses={
|
||||
200: RideReviewStatsOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get review statistics for the ride."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return Response(
|
||||
{"error": "Park and ride slugs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found at this park"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get review statistics
|
||||
reviews = RideReview.objects.filter(ride=ride, is_published=True)
|
||||
|
||||
total_reviews = reviews.count()
|
||||
published_reviews = total_reviews # Since we're filtering published
|
||||
pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count()
|
||||
|
||||
# Calculate average rating
|
||||
avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating']
|
||||
|
||||
# Get rating distribution
|
||||
rating_distribution = {}
|
||||
for i in range(1, 11):
|
||||
rating_distribution[str(i)] = reviews.filter(rating=i).count()
|
||||
|
||||
# Get recent reviews count (last 30 days)
|
||||
from datetime import timedelta
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
recent_reviews = reviews.filter(created_at__gte=thirty_days_ago).count()
|
||||
|
||||
stats = {
|
||||
"total_reviews": total_reviews,
|
||||
"published_reviews": published_reviews,
|
||||
"pending_reviews": pending_reviews,
|
||||
"average_rating": round(avg_rating, 2) if avg_rating else None,
|
||||
"rating_distribution": rating_distribution,
|
||||
"recent_reviews": recent_reviews,
|
||||
}
|
||||
|
||||
serializer = RideReviewStatsOutputSerializer(stats)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride review stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get review statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Bulk moderate reviews",
|
||||
description="Bulk moderate multiple ride reviews (admin only)",
|
||||
request=RideReviewModerationInputSerializer,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
)
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def moderate(self, request, **kwargs):
|
||||
"""Bulk moderate multiple reviews (admin only)."""
|
||||
if not getattr(request.user, "is_staff", False):
|
||||
raise PermissionDenied("Only administrators can moderate reviews.")
|
||||
|
||||
serializer = RideReviewModerationInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
review_ids = validated_data.get("review_ids")
|
||||
action_type = validated_data.get("action")
|
||||
moderation_notes = validated_data.get("moderation_notes", "")
|
||||
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
try:
|
||||
# Filter reviews to only those belonging to this ride
|
||||
reviews_queryset = RideReview.objects.filter(id__in=review_ids)
|
||||
if park_slug and ride_slug:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
reviews_queryset = reviews_queryset.filter(ride=ride)
|
||||
|
||||
if action_type == "publish":
|
||||
updated_count = reviews_queryset.update(
|
||||
is_published=True,
|
||||
moderated_by=request.user,
|
||||
moderated_at=timezone.now(),
|
||||
moderation_notes=moderation_notes
|
||||
)
|
||||
message = f"Successfully published {updated_count} reviews"
|
||||
elif action_type == "unpublish":
|
||||
updated_count = reviews_queryset.update(
|
||||
is_published=False,
|
||||
moderated_by=request.user,
|
||||
moderated_at=timezone.now(),
|
||||
moderation_notes=moderation_notes
|
||||
)
|
||||
message = f"Successfully unpublished {updated_count} reviews"
|
||||
elif action_type == "delete":
|
||||
updated_count = reviews_queryset.count()
|
||||
reviews_queryset.delete()
|
||||
message = f"Successfully deleted {updated_count} reviews"
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Invalid action type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": message,
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk review moderation: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to moderate reviews: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1,393 +0,0 @@
|
||||
"""
|
||||
Park media serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for park-specific media functionality.
|
||||
Enhanced from rogue implementation to maintain full feature parity.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Park Photo with Cloudflare Images",
|
||||
summary="Complete park photo response",
|
||||
description="Example response showing all fields including Cloudflare Images URLs and variants",
|
||||
value={
|
||||
"id": 456,
|
||||
"image": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
"image_url": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def456ghi789/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def456ghi789/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def456ghi789/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def456ghi789/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance",
|
||||
"alt_text": "Main entrance gate with decorative archway",
|
||||
"is_primary": True,
|
||||
"is_approved": True,
|
||||
"created_at": "2023-01-01T12:00:00Z",
|
||||
"updated_at": "2023-01-01T12:00:00Z",
|
||||
"date_taken": "2023-01-01T11:00:00Z",
|
||||
"uploaded_by_username": "parkfan456",
|
||||
"file_size": 1536000,
|
||||
"dimensions": [1600, 900],
|
||||
"park_slug": "cedar-point",
|
||||
"park_name": "Cedar Point",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Enhanced output serializer for park photos with Cloudflare Images support."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
file_size = serializers.SerializerMethodField()
|
||||
dimensions = serializers.SerializerMethodField()
|
||||
image_url = serializers.SerializerMethodField()
|
||||
image_variants = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
|
||||
)
|
||||
def get_file_size(self, obj):
|
||||
"""Get file size in bytes."""
|
||||
return obj.file_size
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
min_length=2,
|
||||
max_length=2,
|
||||
allow_null=True,
|
||||
help_text="Image dimensions as [width, height] in pixels",
|
||||
)
|
||||
)
|
||||
def get_dimensions(self, obj):
|
||||
"""Get image dimensions as [width, height]."""
|
||||
return obj.dimensions
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.URLField(
|
||||
help_text="Full URL to the Cloudflare Images asset", allow_null=True
|
||||
)
|
||||
)
|
||||
def get_image_url(self, obj):
|
||||
"""Get the full Cloudflare Images URL."""
|
||||
if obj.image:
|
||||
return obj.image.url
|
||||
return None
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.DictField(
|
||||
child=serializers.URLField(),
|
||||
help_text="Available Cloudflare Images variants with their URLs",
|
||||
)
|
||||
)
|
||||
def get_image_variants(self, obj):
|
||||
"""Get available image variants from Cloudflare Images."""
|
||||
if not obj.image:
|
||||
return {}
|
||||
|
||||
# Common variants for park photos
|
||||
variants = {
|
||||
"thumbnail": f"{obj.image.url}/thumbnail",
|
||||
"medium": f"{obj.image.url}/medium",
|
||||
"large": f"{obj.image.url}/large",
|
||||
"public": f"{obj.image.url}/public",
|
||||
}
|
||||
return variants
|
||||
|
||||
park_slug = serializers.CharField(source="park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"image_url",
|
||||
"image_variants",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"image_url",
|
||||
"image_variants",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Optimized output serializer for park photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for bulk photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
|
||||
|
||||
# Legacy serializers for backwards compatibility
|
||||
class ParkPhotoSerializer(serializers.ModelSerializer):
|
||||
"""Legacy serializer for the ParkPhoto model - maintained for compatibility."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = (
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"uploaded_at",
|
||||
"uploaded_by",
|
||||
)
|
||||
|
||||
|
||||
class HybridParkSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Enhanced serializer for hybrid filtering strategy.
|
||||
Includes all filterable fields for client-side filtering.
|
||||
"""
|
||||
|
||||
# Location fields from related ParkLocation
|
||||
city = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
country = serializers.SerializerMethodField()
|
||||
continent = serializers.SerializerMethodField()
|
||||
latitude = serializers.SerializerMethodField()
|
||||
longitude = serializers.SerializerMethodField()
|
||||
|
||||
# Company fields
|
||||
operator_name = serializers.CharField(source="operator.name", read_only=True)
|
||||
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
|
||||
|
||||
# Image URLs for display
|
||||
banner_image_url = serializers.SerializerMethodField()
|
||||
card_image_url = serializers.SerializerMethodField()
|
||||
|
||||
# Computed fields for filtering
|
||||
opening_year = serializers.IntegerField(read_only=True)
|
||||
search_text = serializers.CharField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_city(self, obj):
|
||||
"""Get city from related location."""
|
||||
try:
|
||||
return obj.location.city if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_state(self, obj):
|
||||
"""Get state from related location."""
|
||||
try:
|
||||
return obj.location.state if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_country(self, obj):
|
||||
"""Get country from related location."""
|
||||
try:
|
||||
return obj.location.country if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_continent(self, obj):
|
||||
"""Get continent from related location."""
|
||||
try:
|
||||
return obj.location.continent if hasattr(obj, 'location') and obj.location else None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_latitude(self, obj):
|
||||
"""Get latitude from related location."""
|
||||
try:
|
||||
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
|
||||
return obj.location.coordinates[1] # PostGIS returns [lon, lat]
|
||||
return None
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_longitude(self, obj):
|
||||
"""Get longitude from related location."""
|
||||
try:
|
||||
if hasattr(obj, 'location') and obj.location and obj.location.coordinates:
|
||||
return obj.location.coordinates[0] # PostGIS returns [lon, lat]
|
||||
return None
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_banner_image_url(self, obj):
|
||||
"""Get banner image URL."""
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return obj.banner_image.image.url
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_card_image_url(self, obj):
|
||||
"""Get card image URL."""
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return obj.card_image.image.url
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = [
|
||||
# Basic park info
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"status",
|
||||
"park_type",
|
||||
|
||||
# Dates and computed fields
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"opening_year",
|
||||
"operating_season",
|
||||
|
||||
# Location fields
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"continent",
|
||||
"latitude",
|
||||
"longitude",
|
||||
|
||||
# Company relationships
|
||||
"operator_name",
|
||||
"property_owner_name",
|
||||
|
||||
# Statistics
|
||||
"size_acres",
|
||||
"average_rating",
|
||||
"ride_count",
|
||||
"coaster_count",
|
||||
|
||||
# Images
|
||||
"banner_image_url",
|
||||
"card_image_url",
|
||||
|
||||
# URLs
|
||||
"website",
|
||||
"url",
|
||||
|
||||
# Computed fields for filtering
|
||||
"search_text",
|
||||
|
||||
# Metadata
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for the Park model."""
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"country",
|
||||
"continent",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"website",
|
||||
"status",
|
||||
)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Comprehensive URL routes for Parks domain (API v1).
|
||||
|
||||
This file exposes a maximal set of "full-fat" endpoints implemented in
|
||||
`apps.api.v1.parks.park_views` and `apps.api.v1.parks.views`. Endpoints are
|
||||
intentionally expansive to match the rides API functionality and provide
|
||||
complete feature parity for parks management.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .park_views import (
|
||||
ParkListCreateAPIView,
|
||||
ParkDetailAPIView,
|
||||
FilterOptionsAPIView,
|
||||
CompanySearchAPIView,
|
||||
ParkSearchSuggestionsAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
)
|
||||
from .park_rides_views import (
|
||||
ParkRidesListAPIView,
|
||||
ParkRideDetailAPIView,
|
||||
ParkComprehensiveDetailAPIView,
|
||||
)
|
||||
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
|
||||
from .ride_photos_views import RidePhotoViewSet
|
||||
from .ride_reviews_views import RideReviewViewSet
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
router = DefaultRouter()
|
||||
router.register(r"", ParkPhotoViewSet, basename="park-photo")
|
||||
|
||||
# Create routers for nested ride endpoints
|
||||
ride_photos_router = DefaultRouter()
|
||||
ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
|
||||
|
||||
ride_reviews_router = DefaultRouter()
|
||||
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
|
||||
|
||||
app_name = "api_v1_parks"
|
||||
|
||||
urlpatterns = [
|
||||
# Core list/create endpoints
|
||||
path("", ParkListCreateAPIView.as_view(), name="park-list-create"),
|
||||
|
||||
# Hybrid filtering endpoints
|
||||
path("hybrid/", HybridParkAPIView.as_view(), name="park-hybrid-list"),
|
||||
path("hybrid/filter-metadata/", ParkFilterMetadataAPIView.as_view(), name="park-hybrid-filter-metadata"),
|
||||
|
||||
# Filter options
|
||||
path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"),
|
||||
# Autocomplete / suggestion endpoints
|
||||
path(
|
||||
"search/companies/",
|
||||
CompanySearchAPIView.as_view(),
|
||||
name="park-search-companies",
|
||||
),
|
||||
path(
|
||||
"search-suggestions/",
|
||||
ParkSearchSuggestionsAPIView.as_view(),
|
||||
name="park-search-suggestions",
|
||||
),
|
||||
# Detail and action endpoints - supports both ID and slug
|
||||
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
|
||||
# Park rides endpoints
|
||||
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/", ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
|
||||
|
||||
# Comprehensive park detail endpoint with rides summary
|
||||
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"),
|
||||
|
||||
# Park image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
ParkImageSettingsAPIView.as_view(),
|
||||
name="park-image-settings",
|
||||
),
|
||||
# Park photo endpoints - domain-specific photo management
|
||||
path("<int:park_pk>/photos/", include(router.urls)),
|
||||
|
||||
# Nested ride photo endpoints - photos for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
|
||||
|
||||
# Nested ride review endpoints - reviews for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||
]
|
||||
@@ -1,824 +0,0 @@
|
||||
"""
|
||||
Park API views for ThrillWiki API v1.
|
||||
|
||||
This module contains consolidated park photo viewset for the centralized API structure.
|
||||
Enhanced from rogue implementation to maintain full feature parity.
|
||||
"""
|
||||
|
||||
from .serializers import (
|
||||
ParkPhotoOutputSerializer,
|
||||
ParkPhotoCreateInputSerializer,
|
||||
ParkPhotoUpdateInputSerializer,
|
||||
ParkPhotoListOutputSerializer,
|
||||
ParkPhotoApprovalInputSerializer,
|
||||
ParkPhotoStatsOutputSerializer,
|
||||
)
|
||||
from typing import Any, cast
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.parks.models import ParkPhoto, Park
|
||||
from apps.parks.services import ParkMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List park photos",
|
||||
description="Retrieve a paginated list of park photos with filtering capabilities.",
|
||||
responses={200: ParkPhotoListOutputSerializer(many=True)},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Upload park photo",
|
||||
description="Upload a new photo for a park. Requires authentication.",
|
||||
request=ParkPhotoCreateInputSerializer,
|
||||
responses={
|
||||
201: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get park photo details",
|
||||
description="Retrieve detailed information about a specific park photo.",
|
||||
responses={
|
||||
200: ParkPhotoOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update park photo",
|
||||
description="Update park photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=ParkPhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update park photo",
|
||||
description="Partially update park photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=ParkPhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete park photo",
|
||||
description="Delete a park photo. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
)
|
||||
class ParkPhotoViewSet(ModelViewSet):
|
||||
"""
|
||||
Enhanced ViewSet for managing park photos with full feature parity.
|
||||
|
||||
Provides CRUD operations for park photos with proper permission checking.
|
||||
Uses ParkMediaService for business logic operations.
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get photos for the current park with optimized queries."""
|
||||
queryset = ParkPhoto.objects.select_related(
|
||||
"park", "park__operator", "uploaded_by"
|
||||
)
|
||||
|
||||
# If park_pk is provided in URL kwargs, filter by park
|
||||
park_pk = self.kwargs.get("park_pk")
|
||||
if park_pk:
|
||||
queryset = queryset.filter(park_id=park_pk)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return ParkPhotoListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return ParkPhotoCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return ParkPhotoUpdateInputSerializer
|
||||
else:
|
||||
return ParkPhotoOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new park photo using ParkMediaService."""
|
||||
park_id = self.kwargs.get("park_pk")
|
||||
if not park_id:
|
||||
raise ValidationError("Park ID is required")
|
||||
|
||||
try:
|
||||
Park.objects.get(pk=park_id)
|
||||
except Park.DoesNotExist:
|
||||
raise ValidationError("Park not found")
|
||||
|
||||
try:
|
||||
# Use the service to create the photo with proper business logic
|
||||
service = cast(Any, ParkMediaService())
|
||||
photo = service.create_photo(
|
||||
park_id=park_id,
|
||||
uploaded_by=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating park photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update park photo with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or cast(Any, self.request.user).is_staff
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||
|
||||
# Handle primary photo logic using service
|
||||
if serializer.validated_data.get("is_primary", False):
|
||||
try:
|
||||
ParkMediaService().set_primary_photo(
|
||||
park_id=instance.park_id, photo_id=instance.id
|
||||
)
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete park photo with permission checking."""
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or cast(Any, self.request.user).is_staff
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete from Cloudflare first if image exists
|
||||
if instance.image:
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(instance.image)
|
||||
logger.info(
|
||||
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete park photo from Cloudflare: {str(e)}")
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
ParkMediaService().delete_photo(
|
||||
instance.id, deleted_by=cast(UserModel, self.request.user)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting park photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
summary="Set photo as primary",
|
||||
description="Set this photo as the primary photo for the park",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary(self, request, **kwargs):
|
||||
"""Set this photo as the primary photo for the park."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (request.user == photo.uploaded_by or cast(Any, request.user).is_staff):
|
||||
raise PermissionDenied(
|
||||
"You can only modify your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
ParkMediaService().set_primary_photo(
|
||||
park_id=photo.park_id, photo_id=photo.id
|
||||
)
|
||||
|
||||
# Refresh the photo instance
|
||||
photo.refresh_from_db()
|
||||
serializer = self.get_serializer(photo)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo": serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Bulk approve/reject photos",
|
||||
description="Bulk approve or reject multiple park photos (admin only)",
|
||||
request=ParkPhotoApprovalInputSerializer,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
)
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def bulk_approve(self, request, **kwargs):
|
||||
"""Bulk approve or reject multiple photos (admin only)."""
|
||||
if not cast(Any, request.user).is_staff:
|
||||
raise PermissionDenied("Only administrators can approve photos.")
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = cast(dict, getattr(serializer, "validated_data", {}))
|
||||
photo_ids = validated_data.get("photo_ids")
|
||||
approve = validated_data.get("approve")
|
||||
park_id = self.kwargs.get("park_pk")
|
||||
|
||||
if photo_ids is None or approve is None:
|
||||
return Response(
|
||||
{"error": "Missing required fields: photo_ids and/or approve."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Filter photos to only those belonging to this park (if park_pk provided)
|
||||
photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids)
|
||||
if park_id:
|
||||
photos_queryset = photos_queryset.filter(park_id=park_id)
|
||||
|
||||
updated_count = photos_queryset.update(is_approved=approve)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get park photo statistics",
|
||||
description="Get photo statistics for the park",
|
||||
responses={
|
||||
200: ParkPhotoStatsOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get photo statistics for the park."""
|
||||
park_pk = self.kwargs.get("park_pk")
|
||||
park = None
|
||||
if park_pk:
|
||||
try:
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
if park is not None:
|
||||
stats = ParkMediaService().get_photo_stats(park=park)
|
||||
else:
|
||||
stats = ParkMediaService().get_photo_stats(park=cast(Park, None))
|
||||
serializer = ParkPhotoStatsOutputSerializer(stats)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting park photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Legacy compatibility action using the legacy set_primary logic
|
||||
@extend_schema(
|
||||
summary="Set photo as primary (legacy)",
|
||||
description="Legacy set primary action for backwards compatibility",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary_legacy(self, request, id=None):
|
||||
"""Legacy set primary action for backwards compatibility."""
|
||||
photo = self.get_object()
|
||||
if not (
|
||||
request.user == photo.uploaded_by
|
||||
or request.user.has_perm("parks.change_parkphoto")
|
||||
):
|
||||
return Response(
|
||||
{"error": "You do not have permission to edit photos for this park."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
ParkMediaService().set_primary_photo(
|
||||
park_id=photo.park_id, photo_id=photo.id
|
||||
)
|
||||
return Response({"message": "Photo set as primary successfully."})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Save Cloudflare image as park photo",
|
||||
description="Save a Cloudflare image as a park photo after direct upload to Cloudflare",
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={
|
||||
201: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
)
|
||||
@action(detail=False, methods=["post"])
|
||||
def save_image(self, request, **kwargs):
|
||||
"""Save a Cloudflare image as a park photo after direct upload to Cloudflare."""
|
||||
park_pk = self.kwargs.get("park_pk")
|
||||
if not park_pk:
|
||||
return Response(
|
||||
{"error": "Park ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park = Park.objects.get(pk=park_pk)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
cloudflare_image_id = request.data.get("cloudflare_image_id")
|
||||
if not cloudflare_image_id:
|
||||
return Response(
|
||||
{"error": "cloudflare_image_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import CloudflareImage model and service
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
from django.utils import timezone
|
||||
|
||||
# Always fetch the latest image data from Cloudflare API
|
||||
try:
|
||||
# Get image details from Cloudflare API
|
||||
service = CloudflareImagesService()
|
||||
image_data = service.get_image(cloudflare_image_id)
|
||||
|
||||
if not image_data:
|
||||
return Response(
|
||||
{"error": "Image not found in Cloudflare"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Try to find existing CloudflareImage record by cloudflare_id
|
||||
cloudflare_image = None
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.get(
|
||||
cloudflare_id=cloudflare_image_id)
|
||||
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = 'uploaded'
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get('meta', {})
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get(
|
||||
'result', {}).get('variants', [])
|
||||
cloudflare_image.cloudflare_metadata = image_data
|
||||
cloudflare_image.width = image_data.get('width')
|
||||
cloudflare_image.height = image_data.get('height')
|
||||
cloudflare_image.format = image_data.get('format', '')
|
||||
cloudflare_image.save()
|
||||
|
||||
except CloudflareImage.DoesNotExist:
|
||||
# Create new CloudflareImage record from API response
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=request.user,
|
||||
status='uploaded',
|
||||
upload_url='', # Not needed for uploaded images
|
||||
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
|
||||
uploaded_at=timezone.now(),
|
||||
metadata=image_data.get('meta', {}),
|
||||
# Extract variants from nested result structure
|
||||
variants=image_data.get('result', {}).get('variants', []),
|
||||
cloudflare_metadata=image_data,
|
||||
width=image_data.get('width'),
|
||||
height=image_data.get('height'),
|
||||
format=image_data.get('format', ''),
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create the park photo with the CloudflareImage reference
|
||||
photo = ParkPhoto.objects.create(
|
||||
park=park,
|
||||
image=cloudflare_image,
|
||||
uploaded_by=request.user,
|
||||
caption=request.data.get("caption", ""),
|
||||
alt_text=request.data.get("alt_text", ""),
|
||||
photo_type=request.data.get("photo_type", "exterior"),
|
||||
is_primary=request.data.get("is_primary", False),
|
||||
is_approved=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Handle primary photo logic if requested
|
||||
if request.data.get("is_primary", False):
|
||||
try:
|
||||
ParkMediaService().set_primary_photo(
|
||||
park_id=park.id, photo_id=photo.id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving park photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from .serializers import HybridParkSerializer
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get parks with hybrid filtering",
|
||||
description="Retrieve parks with intelligent hybrid filtering strategy. Automatically chooses between client-side and server-side filtering based on data size.",
|
||||
parameters=[
|
||||
OpenApiParameter("status", OpenApiTypes.STR, description="Filter by park status (comma-separated for multiple)"),
|
||||
OpenApiParameter("park_type", OpenApiTypes.STR, description="Filter by park type (comma-separated for multiple)"),
|
||||
OpenApiParameter("country", OpenApiTypes.STR, description="Filter by country (comma-separated for multiple)"),
|
||||
OpenApiParameter("state", OpenApiTypes.STR, description="Filter by state (comma-separated for multiple)"),
|
||||
OpenApiParameter("opening_year_min", OpenApiTypes.INT, description="Minimum opening year"),
|
||||
OpenApiParameter("opening_year_max", OpenApiTypes.INT, description="Maximum opening year"),
|
||||
OpenApiParameter("size_min", OpenApiTypes.NUMBER, description="Minimum park size in acres"),
|
||||
OpenApiParameter("size_max", OpenApiTypes.NUMBER, description="Maximum park size in acres"),
|
||||
OpenApiParameter("rating_min", OpenApiTypes.NUMBER, description="Minimum average rating"),
|
||||
OpenApiParameter("rating_max", OpenApiTypes.NUMBER, description="Maximum average rating"),
|
||||
OpenApiParameter("ride_count_min", OpenApiTypes.INT, description="Minimum ride count"),
|
||||
OpenApiParameter("ride_count_max", OpenApiTypes.INT, description="Maximum ride count"),
|
||||
OpenApiParameter("coaster_count_min", OpenApiTypes.INT, description="Minimum coaster count"),
|
||||
OpenApiParameter("coaster_count_max", OpenApiTypes.INT, description="Maximum coaster count"),
|
||||
OpenApiParameter("operator", OpenApiTypes.STR, description="Filter by operator slug (comma-separated for multiple)"),
|
||||
OpenApiParameter("search", OpenApiTypes.STR, description="Search query for park names, descriptions, locations, and operators"),
|
||||
OpenApiParameter("offset", OpenApiTypes.INT, description="Offset for progressive loading (server-side pagination)"),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Parks data with hybrid filtering metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parks": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/HybridParkSerializer"}
|
||||
},
|
||||
"total_count": {"type": "integer"},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["client_side", "server_side"],
|
||||
"description": "Filtering strategy used"
|
||||
},
|
||||
"has_more": {
|
||||
"type": "boolean",
|
||||
"description": "Whether more data is available for progressive loading"
|
||||
},
|
||||
"next_offset": {
|
||||
"type": "integer",
|
||||
"nullable": True,
|
||||
"description": "Next offset for progressive loading"
|
||||
},
|
||||
"filter_metadata": {
|
||||
"type": "object",
|
||||
"description": "Available filter options and ranges"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
)
|
||||
class HybridParkAPIView(APIView):
|
||||
"""
|
||||
Hybrid Park API View with intelligent filtering strategy.
|
||||
|
||||
Automatically chooses between client-side and server-side filtering
|
||||
based on data size and complexity. Provides progressive loading
|
||||
for large datasets and complete data for smaller sets.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""Get parks with hybrid filtering strategy."""
|
||||
try:
|
||||
# Extract filters from query parameters
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Check if this is a progressive load request
|
||||
offset = request.query_params.get('offset')
|
||||
if offset is not None:
|
||||
try:
|
||||
offset = int(offset)
|
||||
# Get progressive load data
|
||||
data = smart_park_loader.get_progressive_load(offset, filters)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"error": "Invalid offset parameter"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
# Get initial load data
|
||||
data = smart_park_loader.get_initial_load(filters)
|
||||
|
||||
# Serialize the parks data
|
||||
serializer = HybridParkSerializer(data['parks'], many=True)
|
||||
|
||||
# Prepare response
|
||||
response_data = {
|
||||
'parks': serializer.data,
|
||||
'total_count': data['total_count'],
|
||||
'strategy': data.get('strategy', 'server_side'),
|
||||
'has_more': data.get('has_more', False),
|
||||
'next_offset': data.get('next_offset'),
|
||||
}
|
||||
|
||||
# Include filter metadata for initial loads
|
||||
if 'filter_metadata' in data:
|
||||
response_data['filter_metadata'] = data['filter_metadata']
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in HybridParkAPIView: {e}")
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def _extract_filters(self, query_params):
|
||||
"""Extract and parse filters from query parameters."""
|
||||
filters = {}
|
||||
|
||||
# Handle comma-separated list parameters
|
||||
list_params = ['status', 'park_type', 'country', 'state', 'operator']
|
||||
for param in list_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
filters[param] = [v.strip() for v in value.split(',') if v.strip()]
|
||||
|
||||
# Handle integer parameters
|
||||
int_params = [
|
||||
'opening_year_min', 'opening_year_max',
|
||||
'ride_count_min', 'ride_count_max',
|
||||
'coaster_count_min', 'coaster_count_max'
|
||||
]
|
||||
for param in int_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
try:
|
||||
filters[param] = int(value)
|
||||
except ValueError:
|
||||
pass # Skip invalid integer values
|
||||
|
||||
# Handle float parameters
|
||||
float_params = ['size_min', 'size_max', 'rating_min', 'rating_max']
|
||||
for param in float_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
try:
|
||||
filters[param] = float(value)
|
||||
except ValueError:
|
||||
pass # Skip invalid float values
|
||||
|
||||
# Handle search parameter
|
||||
search = query_params.get('search')
|
||||
if search:
|
||||
filters['search'] = search.strip()
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get park filter metadata",
|
||||
description="Get available filter options and ranges for parks filtering.",
|
||||
parameters=[
|
||||
OpenApiParameter("scoped", OpenApiTypes.BOOL, description="Whether to scope metadata to current filters"),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Filter metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"categorical": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countries": {"type": "array", "items": {"type": "string"}},
|
||||
"states": {"type": "array", "items": {"type": "string"}},
|
||||
"park_types": {"type": "array", "items": {"type": "string"}},
|
||||
"statuses": {"type": "array", "items": {"type": "string"}},
|
||||
"operators": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"slug": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ranges": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"opening_year": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
},
|
||||
"size_acres": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "number", "nullable": True},
|
||||
"max": {"type": "number", "nullable": True}
|
||||
}
|
||||
},
|
||||
"average_rating": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "number", "nullable": True},
|
||||
"max": {"type": "number", "nullable": True}
|
||||
}
|
||||
},
|
||||
"ride_count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
},
|
||||
"coaster_count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min": {"type": "integer", "nullable": True},
|
||||
"max": {"type": "integer", "nullable": True}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"total_count": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
)
|
||||
class ParkFilterMetadataAPIView(APIView):
|
||||
"""
|
||||
API view for getting park filter metadata.
|
||||
|
||||
Provides information about available filter options and ranges
|
||||
to help build dynamic filter interfaces.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
"""Get park filter metadata."""
|
||||
try:
|
||||
# Check if metadata should be scoped to current filters
|
||||
scoped = request.query_params.get('scoped', '').lower() == 'true'
|
||||
filters = None
|
||||
|
||||
if scoped:
|
||||
filters = self._extract_filters(request.query_params)
|
||||
|
||||
# Get filter metadata
|
||||
metadata = smart_park_loader.get_filter_metadata(filters)
|
||||
|
||||
return Response(metadata, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ParkFilterMetadataAPIView: {e}")
|
||||
return Response(
|
||||
{"error": "Internal server error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def _extract_filters(self, query_params):
|
||||
"""Extract and parse filters from query parameters."""
|
||||
# Reuse the same filter extraction logic
|
||||
view = HybridParkAPIView()
|
||||
return view._extract_filters(query_params)
|
||||
@@ -1,6 +0,0 @@
|
||||
"""
|
||||
RideModel API package for ThrillWiki API v1.
|
||||
|
||||
This package provides comprehensive API endpoints for ride model management,
|
||||
including CRUD operations, search, filtering, and nested resources.
|
||||
"""
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
URL routes for RideModel domain (API v1).
|
||||
|
||||
This file exposes comprehensive endpoints for ride model management:
|
||||
- Core CRUD operations for ride models
|
||||
- Search and filtering capabilities
|
||||
- Statistics and analytics
|
||||
- Nested resources (variants, technical specs, photos)
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
RideModelListCreateAPIView,
|
||||
RideModelDetailAPIView,
|
||||
RideModelSearchAPIView,
|
||||
RideModelFilterOptionsAPIView,
|
||||
RideModelStatsAPIView,
|
||||
RideModelVariantListCreateAPIView,
|
||||
RideModelVariantDetailAPIView,
|
||||
RideModelTechnicalSpecListCreateAPIView,
|
||||
RideModelTechnicalSpecDetailAPIView,
|
||||
RideModelPhotoListCreateAPIView,
|
||||
RideModelPhotoDetailAPIView,
|
||||
)
|
||||
|
||||
app_name = "api_v1_ride_models"
|
||||
|
||||
urlpatterns = [
|
||||
# Core ride model endpoints - nested under manufacturer
|
||||
path("", RideModelListCreateAPIView.as_view(), name="ride-model-list-create"),
|
||||
path(
|
||||
"<slug:ride_model_slug>/",
|
||||
RideModelDetailAPIView.as_view(),
|
||||
name="ride-model-detail",
|
||||
),
|
||||
# Search and filtering (global, not manufacturer-specific)
|
||||
path("search/", RideModelSearchAPIView.as_view(), name="ride-model-search"),
|
||||
path(
|
||||
"filter-options/",
|
||||
RideModelFilterOptionsAPIView.as_view(),
|
||||
name="ride-model-filter-options",
|
||||
),
|
||||
# Statistics (global, not manufacturer-specific)
|
||||
path("stats/", RideModelStatsAPIView.as_view(), name="ride-model-stats"),
|
||||
# Ride model variants - using slug-based lookup
|
||||
path(
|
||||
"<slug:ride_model_slug>/variants/",
|
||||
RideModelVariantListCreateAPIView.as_view(),
|
||||
name="ride-model-variant-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/variants/<int:pk>/",
|
||||
RideModelVariantDetailAPIView.as_view(),
|
||||
name="ride-model-variant-detail",
|
||||
),
|
||||
# Technical specifications - using slug-based lookup
|
||||
path(
|
||||
"<slug:ride_model_slug>/technical-specs/",
|
||||
RideModelTechnicalSpecListCreateAPIView.as_view(),
|
||||
name="ride-model-technical-spec-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/technical-specs/<int:pk>/",
|
||||
RideModelTechnicalSpecDetailAPIView.as_view(),
|
||||
name="ride-model-technical-spec-detail",
|
||||
),
|
||||
# Photos - using slug-based lookup
|
||||
path(
|
||||
"<slug:ride_model_slug>/photos/",
|
||||
RideModelPhotoListCreateAPIView.as_view(),
|
||||
name="ride-model-photo-list-create",
|
||||
),
|
||||
path(
|
||||
"<slug:ride_model_slug>/photos/<int:pk>/",
|
||||
RideModelPhotoDetailAPIView.as_view(),
|
||||
name="ride-model-photo-detail",
|
||||
),
|
||||
]
|
||||
@@ -1,862 +0,0 @@
|
||||
"""
|
||||
RideModel API views for ThrillWiki API v1.
|
||||
|
||||
This module implements comprehensive endpoints for ride model management:
|
||||
- List / Create: GET /ride-models/ POST /ride-models/
|
||||
- Retrieve / Update / Delete: GET /ride-models/{pk}/ PATCH/PUT/DELETE
|
||||
- Filter options: GET /ride-models/filter-options/
|
||||
- Search: GET /ride-models/search/?q=...
|
||||
- Statistics: GET /ride-models/stats/
|
||||
- Variants: CRUD operations for ride model variants
|
||||
- Technical specs: CRUD operations for technical specifications
|
||||
- Photos: CRUD operations for ride model photos
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from datetime import timedelta
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.db.models import Q, Count
|
||||
from django.utils import timezone
|
||||
|
||||
# Import serializers
|
||||
from apps.api.v1.serializers.ride_models import (
|
||||
RideModelListOutputSerializer,
|
||||
RideModelDetailOutputSerializer,
|
||||
RideModelCreateInputSerializer,
|
||||
RideModelUpdateInputSerializer,
|
||||
RideModelFilterInputSerializer,
|
||||
RideModelVariantOutputSerializer,
|
||||
RideModelVariantCreateInputSerializer,
|
||||
RideModelVariantUpdateInputSerializer,
|
||||
RideModelStatsOutputSerializer,
|
||||
)
|
||||
|
||||
# Attempt to import models; fall back gracefully if not present
|
||||
try:
|
||||
from apps.rides.models import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
from apps.rides.models.company import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
# Try alternative import path
|
||||
from apps.rides.models.rides import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
from apps.rides.models.rides import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
RideModel = None
|
||||
RideModelVariant = None
|
||||
RideModelPhoto = None
|
||||
RideModelTechnicalSpec = None
|
||||
Company = None
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
# === RIDE MODEL VIEWS ===
|
||||
|
||||
|
||||
class RideModelListCreateAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List ride models with filtering and pagination",
|
||||
description="List ride models with comprehensive filtering and pagination.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="category", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="target_market",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="is_discontinued",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
),
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request, manufacturer_slug: str) -> Response:
|
||||
"""List ride models for a specific manufacturer with filtering and pagination."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Ride model listing is not available because domain models are not imported. "
|
||||
"Implement apps.rides.models.RideModel to enable listing."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get manufacturer or 404
|
||||
try:
|
||||
manufacturer = Company.objects.get(slug=manufacturer_slug)
|
||||
except Company.DoesNotExist:
|
||||
raise NotFound("Manufacturer not found")
|
||||
|
||||
qs = (
|
||||
RideModel.objects.filter(manufacturer=manufacturer)
|
||||
.select_related("manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filter_serializer = RideModelFilterInputSerializer(data=request.query_params)
|
||||
if filter_serializer.is_valid():
|
||||
filters = filter_serializer.validated_data
|
||||
|
||||
# Search filter
|
||||
if filters.get("search"):
|
||||
search_term = filters["search"]
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=search_term)
|
||||
| Q(description__icontains=search_term)
|
||||
| Q(manufacturer__name__icontains=search_term)
|
||||
)
|
||||
|
||||
# Category filter
|
||||
if filters.get("category"):
|
||||
qs = qs.filter(category__in=filters["category"])
|
||||
|
||||
# Manufacturer filters
|
||||
if filters.get("manufacturer_id"):
|
||||
qs = qs.filter(manufacturer_id=filters["manufacturer_id"])
|
||||
if filters.get("manufacturer_slug"):
|
||||
qs = qs.filter(manufacturer__slug=filters["manufacturer_slug"])
|
||||
|
||||
# Target market filter
|
||||
if filters.get("target_market"):
|
||||
qs = qs.filter(target_market__in=filters["target_market"])
|
||||
|
||||
# Discontinued filter
|
||||
if filters.get("is_discontinued") is not None:
|
||||
qs = qs.filter(is_discontinued=filters["is_discontinued"])
|
||||
|
||||
# Year filters
|
||||
if filters.get("first_installation_year_min"):
|
||||
qs = qs.filter(
|
||||
first_installation_year__gte=filters["first_installation_year_min"]
|
||||
)
|
||||
if filters.get("first_installation_year_max"):
|
||||
qs = qs.filter(
|
||||
first_installation_year__lte=filters["first_installation_year_max"]
|
||||
)
|
||||
|
||||
# Installation count filter
|
||||
if filters.get("min_installations"):
|
||||
qs = qs.filter(total_installations__gte=filters["min_installations"])
|
||||
|
||||
# Height filters
|
||||
if filters.get("min_height_ft"):
|
||||
qs = qs.filter(
|
||||
typical_height_range_max_ft__gte=filters["min_height_ft"]
|
||||
)
|
||||
if filters.get("max_height_ft"):
|
||||
qs = qs.filter(
|
||||
typical_height_range_min_ft__lte=filters["max_height_ft"]
|
||||
)
|
||||
|
||||
# Speed filters
|
||||
if filters.get("min_speed_mph"):
|
||||
qs = qs.filter(
|
||||
typical_speed_range_max_mph__gte=filters["min_speed_mph"]
|
||||
)
|
||||
if filters.get("max_speed_mph"):
|
||||
qs = qs.filter(
|
||||
typical_speed_range_min_mph__lte=filters["max_speed_mph"]
|
||||
)
|
||||
|
||||
# Ordering
|
||||
ordering = filters.get("ordering", "manufacturer__name,name")
|
||||
if ordering:
|
||||
order_fields = ordering.split(",")
|
||||
qs = qs.order_by(*order_fields)
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
serializer = RideModelListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Create a new ride model",
|
||||
description="Create a new ride model for a specific manufacturer.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
request=RideModelCreateInputSerializer,
|
||||
responses={201: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def post(self, request: Request, manufacturer_slug: str) -> Response:
|
||||
"""Create a new ride model for a specific manufacturer."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Ride model creation is not available because domain models are not imported."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get manufacturer or 404
|
||||
try:
|
||||
manufacturer = Company.objects.get(slug=manufacturer_slug)
|
||||
except Company.DoesNotExist:
|
||||
raise NotFound("Manufacturer not found")
|
||||
|
||||
serializer_in = RideModelCreateInputSerializer(data=request.data)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
validated = serializer_in.validated_data
|
||||
|
||||
# Create ride model (use manufacturer from URL, not from request data)
|
||||
ride_model = RideModel.objects.create(
|
||||
name=validated["name"],
|
||||
description=validated.get("description", ""),
|
||||
category=validated.get("category", ""),
|
||||
manufacturer=manufacturer,
|
||||
typical_height_range_min_ft=validated.get("typical_height_range_min_ft"),
|
||||
typical_height_range_max_ft=validated.get("typical_height_range_max_ft"),
|
||||
typical_speed_range_min_mph=validated.get("typical_speed_range_min_mph"),
|
||||
typical_speed_range_max_mph=validated.get("typical_speed_range_max_mph"),
|
||||
typical_capacity_range_min=validated.get("typical_capacity_range_min"),
|
||||
typical_capacity_range_max=validated.get("typical_capacity_range_max"),
|
||||
track_type=validated.get("track_type", ""),
|
||||
support_structure=validated.get("support_structure", ""),
|
||||
train_configuration=validated.get("train_configuration", ""),
|
||||
restraint_system=validated.get("restraint_system", ""),
|
||||
first_installation_year=validated.get("first_installation_year"),
|
||||
last_installation_year=validated.get("last_installation_year"),
|
||||
is_discontinued=validated.get("is_discontinued", False),
|
||||
notable_features=validated.get("notable_features", ""),
|
||||
target_market=validated.get("target_market", ""),
|
||||
)
|
||||
|
||||
out_serializer = RideModelDetailOutputSerializer(
|
||||
ride_model, context={"request": request}
|
||||
)
|
||||
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class RideModelDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_ride_model_or_404(
|
||||
self, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound("Ride model models not available")
|
||||
try:
|
||||
return (
|
||||
RideModel.objects.select_related("manufacturer")
|
||||
.prefetch_related("photos", "variants", "technical_specs")
|
||||
.get(manufacturer__slug=manufacturer_slug, slug=ride_model_slug)
|
||||
)
|
||||
except RideModel.DoesNotExist:
|
||||
raise NotFound("Ride model not found")
|
||||
|
||||
@extend_schema(
|
||||
summary="Retrieve a ride model",
|
||||
description="Get detailed information about a specific ride model.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
serializer = RideModelDetailOutputSerializer(
|
||||
ride_model, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Update a ride model",
|
||||
description="Update a ride model (partial update supported).",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
request=RideModelUpdateInputSerializer,
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def patch(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
serializer_in = RideModelUpdateInputSerializer(data=request.data, partial=True)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
|
||||
# Update fields
|
||||
for field, value in serializer_in.validated_data.items():
|
||||
if field == "manufacturer_id":
|
||||
try:
|
||||
manufacturer = Company.objects.get(id=value)
|
||||
ride_model.manufacturer = manufacturer
|
||||
except Company.DoesNotExist:
|
||||
raise ValidationError({"manufacturer_id": "Manufacturer not found"})
|
||||
else:
|
||||
setattr(ride_model, field, value)
|
||||
|
||||
ride_model.save()
|
||||
|
||||
serializer = RideModelDetailOutputSerializer(
|
||||
ride_model, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
def put(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
# Full replace - reuse patch behavior for simplicity
|
||||
return self.patch(request, manufacturer_slug, ride_model_slug)
|
||||
|
||||
@extend_schema(
|
||||
summary="Delete a ride model",
|
||||
description="Delete a ride model.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="manufacturer_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ride_model_slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={204: None},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def delete(
|
||||
self, request: Request, manufacturer_slug: str, ride_model_slug: str
|
||||
) -> Response:
|
||||
ride_model = self._get_ride_model_or_404(manufacturer_slug, ride_model_slug)
|
||||
ride_model.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# === RIDE MODEL SEARCH AND FILTER OPTIONS ===
|
||||
|
||||
|
||||
class RideModelSearchAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Search ride models",
|
||||
description="Search ride models by name, description, or manufacturer.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="q",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
q = request.query_params.get("q", "")
|
||||
if not q:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Hyper Coaster",
|
||||
"manufacturer": {"name": "Bolliger & Mabillard"},
|
||||
"category": "RC",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
qs = RideModel.objects.filter(
|
||||
Q(name__icontains=q)
|
||||
| Q(description__icontains=q)
|
||||
| Q(manufacturer__name__icontains=q)
|
||||
).select_related("manufacturer")[:20]
|
||||
|
||||
results = [
|
||||
{
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"slug": model.slug,
|
||||
"manufacturer": {
|
||||
"id": model.manufacturer.id if model.manufacturer else None,
|
||||
"name": model.manufacturer.name if model.manufacturer else None,
|
||||
"slug": model.manufacturer.slug if model.manufacturer else None,
|
||||
},
|
||||
"category": model.category,
|
||||
"target_market": model.target_market,
|
||||
"is_discontinued": model.is_discontinued,
|
||||
}
|
||||
for model in qs
|
||||
]
|
||||
return Response(results)
|
||||
|
||||
|
||||
class RideModelFilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get filter options for ride models",
|
||||
description="Get available filter options for ride model filtering.",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return filter options for ride models with Rich Choice Objects metadata."""
|
||||
# Import Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
if not MODELS_AVAILABLE:
|
||||
# Use Rich Choice Objects for fallback options
|
||||
try:
|
||||
# Get rich choice objects from registry
|
||||
categories = get_choices('categories', 'rides')
|
||||
target_markets = get_choices('target_markets', 'rides')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
categories_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in categories
|
||||
]
|
||||
|
||||
target_markets_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in target_markets
|
||||
]
|
||||
|
||||
except Exception:
|
||||
# Ultimate fallback with basic structure
|
||||
categories_data = [
|
||||
{"value": "RC", "label": "Roller Coaster", "description": "High-speed thrill rides with tracks", "color": "red", "icon": "roller-coaster", "css_class": "bg-red-100 text-red-800", "sort_order": 1},
|
||||
{"value": "DR", "label": "Dark Ride", "description": "Indoor themed experiences", "color": "purple", "icon": "dark-ride", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2},
|
||||
{"value": "FR", "label": "Flat Ride", "description": "Spinning and rotating attractions", "color": "blue", "icon": "flat-ride", "css_class": "bg-blue-100 text-blue-800", "sort_order": 3},
|
||||
{"value": "WR", "label": "Water Ride", "description": "Water-based attractions and slides", "color": "cyan", "icon": "water-ride", "css_class": "bg-cyan-100 text-cyan-800", "sort_order": 4},
|
||||
{"value": "TR", "label": "Transport", "description": "Transportation systems within parks", "color": "green", "icon": "transport", "css_class": "bg-green-100 text-green-800", "sort_order": 5},
|
||||
{"value": "OT", "label": "Other", "description": "Miscellaneous attractions", "color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
|
||||
]
|
||||
target_markets_data = [
|
||||
{"value": "FAMILY", "label": "Family", "description": "Suitable for all family members", "color": "green", "icon": "family", "css_class": "bg-green-100 text-green-800", "sort_order": 1},
|
||||
{"value": "THRILL", "label": "Thrill", "description": "High-intensity thrill experience", "color": "orange", "icon": "thrill", "css_class": "bg-orange-100 text-orange-800", "sort_order": 2},
|
||||
{"value": "EXTREME", "label": "Extreme", "description": "Maximum intensity experience", "color": "red", "icon": "extreme", "css_class": "bg-red-100 text-red-800", "sort_order": 3},
|
||||
{"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4},
|
||||
{"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
|
||||
]
|
||||
|
||||
return Response({
|
||||
"categories": categories_data,
|
||||
"target_markets": target_markets_data,
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name A-Z"},
|
||||
{"value": "-name", "label": "Name Z-A"},
|
||||
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
|
||||
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
|
||||
{"value": "first_installation_year", "label": "Oldest First"},
|
||||
{"value": "-first_installation_year", "label": "Newest First"},
|
||||
{"value": "total_installations", "label": "Fewest Installations"},
|
||||
{"value": "-total_installations", "label": "Most Installations"},
|
||||
],
|
||||
})
|
||||
|
||||
# Get static choice definitions from Rich Choice Objects (primary source)
|
||||
# Get dynamic data from database queries
|
||||
|
||||
# Get rich choice objects from registry
|
||||
categories = get_choices('categories', 'rides')
|
||||
target_markets = get_choices('target_markets', 'rides')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
categories_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in categories
|
||||
]
|
||||
|
||||
target_markets_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in target_markets
|
||||
]
|
||||
|
||||
# Get actual data from database
|
||||
manufacturers = (
|
||||
Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"], ride_models__isnull=False
|
||||
)
|
||||
.distinct()
|
||||
.values("id", "name", "slug")
|
||||
)
|
||||
|
||||
return Response({
|
||||
"categories": categories_data,
|
||||
"target_markets": target_markets_data,
|
||||
"manufacturers": list(manufacturers),
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name A-Z"},
|
||||
{"value": "-name", "label": "Name Z-A"},
|
||||
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
|
||||
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
|
||||
{"value": "first_installation_year", "label": "Oldest First"},
|
||||
{"value": "-first_installation_year", "label": "Newest First"},
|
||||
{"value": "total_installations", "label": "Fewest Installations"},
|
||||
{"value": "-total_installations", "label": "Most Installations"},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
|
||||
# === RIDE MODEL STATISTICS ===
|
||||
|
||||
|
||||
class RideModelStatsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride model statistics",
|
||||
description="Get comprehensive statistics about ride models.",
|
||||
responses={200: RideModelStatsOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get ride model statistics."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"total_models": 50,
|
||||
"total_installations": 500,
|
||||
"active_manufacturers": 15,
|
||||
"discontinued_models": 10,
|
||||
"by_category": {"RC": 30, "FR": 15, "WR": 5},
|
||||
"by_target_market": {"THRILL": 25, "FAMILY": 20, "EXTREME": 5},
|
||||
"by_manufacturer": {"Bolliger & Mabillard": 8, "Intamin": 6},
|
||||
"recent_models": 3,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate statistics
|
||||
total_models = RideModel.objects.count()
|
||||
total_installations = (
|
||||
RideModel.objects.aggregate(total=Count("rides"))["total"] or 0
|
||||
)
|
||||
|
||||
active_manufacturers = (
|
||||
Company.objects.filter(
|
||||
roles__contains=["MANUFACTURER"], ride_models__isnull=False
|
||||
)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
discontinued_models = RideModel.objects.filter(is_discontinued=True).count()
|
||||
|
||||
# Category breakdown
|
||||
by_category = {}
|
||||
category_counts = (
|
||||
RideModel.objects.exclude(category="")
|
||||
.values("category")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
for item in category_counts:
|
||||
by_category[item["category"]] = item["count"]
|
||||
|
||||
# Target market breakdown
|
||||
by_target_market = {}
|
||||
market_counts = (
|
||||
RideModel.objects.exclude(target_market="")
|
||||
.values("target_market")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
for item in market_counts:
|
||||
by_target_market[item["target_market"]] = item["count"]
|
||||
|
||||
# Manufacturer breakdown (top 10)
|
||||
by_manufacturer = {}
|
||||
manufacturer_counts = (
|
||||
RideModel.objects.filter(manufacturer__isnull=False)
|
||||
.values("manufacturer__name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:10]
|
||||
)
|
||||
for item in manufacturer_counts:
|
||||
by_manufacturer[item["manufacturer__name"]] = item["count"]
|
||||
|
||||
# Recent models (last 30 days)
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
recent_models = RideModel.objects.filter(
|
||||
created_at__gte=thirty_days_ago
|
||||
).count()
|
||||
|
||||
return Response(
|
||||
{
|
||||
"total_models": total_models,
|
||||
"total_installations": total_installations,
|
||||
"active_manufacturers": active_manufacturers,
|
||||
"discontinued_models": discontinued_models,
|
||||
"by_category": by_category,
|
||||
"by_target_market": by_target_market,
|
||||
"by_manufacturer": by_manufacturer,
|
||||
"recent_models": recent_models,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL VARIANTS ===
|
||||
|
||||
|
||||
class RideModelVariantListCreateAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List variants for a ride model",
|
||||
description="Get all variants for a specific ride model.",
|
||||
responses={200: RideModelVariantOutputSerializer(many=True)},
|
||||
tags=["Ride Model Variants"],
|
||||
)
|
||||
def get(self, request: Request, ride_model_pk: int) -> Response:
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response([])
|
||||
|
||||
try:
|
||||
ride_model = RideModel.objects.get(pk=ride_model_pk)
|
||||
except RideModel.DoesNotExist:
|
||||
raise NotFound("Ride model not found")
|
||||
|
||||
variants = RideModelVariant.objects.filter(ride_model=ride_model)
|
||||
serializer = RideModelVariantOutputSerializer(variants, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Create a variant for a ride model",
|
||||
description="Create a new variant for a specific ride model.",
|
||||
request=RideModelVariantCreateInputSerializer,
|
||||
responses={201: RideModelVariantOutputSerializer()},
|
||||
tags=["Ride Model Variants"],
|
||||
)
|
||||
def post(self, request: Request, ride_model_pk: int) -> Response:
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Variants not available"},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
try:
|
||||
ride_model = RideModel.objects.get(pk=ride_model_pk)
|
||||
except RideModel.DoesNotExist:
|
||||
raise NotFound("Ride model not found")
|
||||
|
||||
# Override ride_model_id in the data
|
||||
data = request.data.copy()
|
||||
data["ride_model_id"] = ride_model_pk
|
||||
|
||||
serializer_in = RideModelVariantCreateInputSerializer(data=data)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
validated = serializer_in.validated_data
|
||||
|
||||
variant = RideModelVariant.objects.create(
|
||||
ride_model=ride_model,
|
||||
name=validated["name"],
|
||||
description=validated.get("description", ""),
|
||||
min_height_ft=validated.get("min_height_ft"),
|
||||
max_height_ft=validated.get("max_height_ft"),
|
||||
min_speed_mph=validated.get("min_speed_mph"),
|
||||
max_speed_mph=validated.get("max_speed_mph"),
|
||||
distinguishing_features=validated.get("distinguishing_features", ""),
|
||||
)
|
||||
|
||||
serializer = RideModelVariantOutputSerializer(variant)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class RideModelVariantDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_variant_or_404(self, ride_model_pk: int, pk: int) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound("Variants not available")
|
||||
try:
|
||||
return RideModelVariant.objects.get(ride_model_id=ride_model_pk, pk=pk)
|
||||
except RideModelVariant.DoesNotExist:
|
||||
raise NotFound("Variant not found")
|
||||
|
||||
@extend_schema(
|
||||
summary="Get a ride model variant",
|
||||
responses={200: RideModelVariantOutputSerializer()},
|
||||
tags=["Ride Model Variants"],
|
||||
)
|
||||
def get(self, request: Request, ride_model_pk: int, pk: int) -> Response:
|
||||
variant = self._get_variant_or_404(ride_model_pk, pk)
|
||||
serializer = RideModelVariantOutputSerializer(variant)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Update a ride model variant",
|
||||
request=RideModelVariantUpdateInputSerializer,
|
||||
responses={200: RideModelVariantOutputSerializer()},
|
||||
tags=["Ride Model Variants"],
|
||||
)
|
||||
def patch(self, request: Request, ride_model_pk: int, pk: int) -> Response:
|
||||
variant = self._get_variant_or_404(ride_model_pk, pk)
|
||||
serializer_in = RideModelVariantUpdateInputSerializer(
|
||||
data=request.data, partial=True
|
||||
)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
|
||||
for field, value in serializer_in.validated_data.items():
|
||||
setattr(variant, field, value)
|
||||
variant.save()
|
||||
|
||||
serializer = RideModelVariantOutputSerializer(variant)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Delete a ride model variant",
|
||||
responses={204: None},
|
||||
tags=["Ride Model Variants"],
|
||||
)
|
||||
def delete(self, request: Request, ride_model_pk: int, pk: int) -> Response:
|
||||
variant = self._get_variant_or_404(ride_model_pk, pk)
|
||||
variant.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# Note: Similar patterns would be implemented for RideModelTechnicalSpec and RideModelPhoto
|
||||
# For brevity, I'm including the class definitions but not the full implementations
|
||||
|
||||
|
||||
class RideModelTechnicalSpecListCreateAPIView(APIView):
|
||||
"""CRUD operations for ride model technical specifications."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variants...
|
||||
|
||||
|
||||
class RideModelTechnicalSpecDetailAPIView(APIView):
|
||||
"""CRUD operations for individual technical specifications."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variant detail...
|
||||
|
||||
|
||||
class RideModelPhotoListCreateAPIView(APIView):
|
||||
"""CRUD operations for ride model photos."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variants...
|
||||
|
||||
|
||||
class RideModelPhotoDetailAPIView(APIView):
|
||||
"""CRUD operations for individual ride model photos."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
# Implementation similar to variant detail...
|
||||
@@ -1,552 +0,0 @@
|
||||
"""
|
||||
Ride photo API views for ThrillWiki API v1.
|
||||
|
||||
This module contains ride photo ViewSet following the parks pattern for domain consistency.
|
||||
Enhanced from centralized media API to provide domain-specific ride photo management.
|
||||
"""
|
||||
|
||||
from .serializers import (
|
||||
RidePhotoOutputSerializer,
|
||||
RidePhotoCreateInputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
RidePhotoListOutputSerializer,
|
||||
RidePhotoApprovalInputSerializer,
|
||||
RidePhotoStatsOutputSerializer,
|
||||
)
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.rides.models import RidePhoto, Ride
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride photos",
|
||||
description="Retrieve a paginated list of ride photos with filtering capabilities.",
|
||||
responses={200: RidePhotoListOutputSerializer(many=True)},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Upload ride photo",
|
||||
description="Upload a new photo for a ride. Requires authentication.",
|
||||
request=RidePhotoCreateInputSerializer,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ride photo details",
|
||||
description="Retrieve detailed information about a specific ride photo.",
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update ride photo",
|
||||
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update ride photo",
|
||||
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete ride photo",
|
||||
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
)
|
||||
class RidePhotoViewSet(ModelViewSet):
|
||||
"""
|
||||
Enhanced ViewSet for managing ride photos with full feature parity.
|
||||
|
||||
Provides CRUD operations for ride photos with proper permission checking.
|
||||
Uses RideMediaService for business logic operations.
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get photos for the current ride with optimized queries."""
|
||||
queryset = RidePhoto.objects.select_related(
|
||||
"ride", "ride__park", "ride__park__operator", "uploaded_by"
|
||||
)
|
||||
|
||||
# If ride_pk is provided in URL kwargs, filter by ride
|
||||
ride_pk = self.kwargs.get("ride_pk")
|
||||
if ride_pk:
|
||||
queryset = queryset.filter(ride_id=ride_pk)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return RidePhotoListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return RidePhotoCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return RidePhotoUpdateInputSerializer
|
||||
else:
|
||||
return RidePhotoOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new ride photo using RideMediaService."""
|
||||
ride_id = self.kwargs.get("ride_pk")
|
||||
if not ride_id:
|
||||
raise ValidationError("Ride ID is required")
|
||||
|
||||
try:
|
||||
ride = Ride.objects.get(pk=ride_id)
|
||||
except Ride.DoesNotExist:
|
||||
raise ValidationError("Ride not found")
|
||||
|
||||
try:
|
||||
# Use the service to create the photo with proper business logic
|
||||
photo = RideMediaService.upload_photo(
|
||||
ride=ride,
|
||||
image_file=serializer.validated_data["image"],
|
||||
user=self.request.user, # type: ignore
|
||||
caption=serializer.validated_data.get("caption", ""),
|
||||
alt_text=serializer.validated_data.get("alt_text", ""),
|
||||
photo_type=serializer.validated_data.get("photo_type", "exterior"),
|
||||
is_primary=serializer.validated_data.get("is_primary", False),
|
||||
auto_approve=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update ride photo with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||
|
||||
# Handle primary photo logic using service
|
||||
if serializer.validated_data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=instance.ride, photo=instance)
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete ride photo with permission checking."""
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete from Cloudflare first if image exists
|
||||
if instance.image:
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(instance.image)
|
||||
logger.info(
|
||||
f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete ride photo from Cloudflare: {str(e)}")
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
RideMediaService.delete_photo(
|
||||
instance, deleted_by=self.request.user # type: ignore
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
summary="Set photo as primary",
|
||||
description="Set this photo as the primary photo for the ride",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary(self, request, **kwargs):
|
||||
"""Set this photo as the primary photo for the ride."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
request.user == photo.uploaded_by
|
||||
or getattr(request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only modify your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
|
||||
|
||||
if success:
|
||||
# Refresh the photo instance
|
||||
photo.refresh_from_db()
|
||||
serializer = self.get_serializer(photo)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo": serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Failed to set primary photo"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Bulk approve/reject photos",
|
||||
description="Bulk approve or reject multiple ride photos (admin only)",
|
||||
request=RidePhotoApprovalInputSerializer,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
)
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def bulk_approve(self, request, **kwargs):
|
||||
"""Bulk approve or reject multiple photos (admin only)."""
|
||||
if not getattr(request.user, "is_staff", False):
|
||||
raise PermissionDenied("Only administrators can approve photos.")
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = getattr(serializer, "validated_data", {})
|
||||
photo_ids = validated_data.get("photo_ids")
|
||||
approve = validated_data.get("approve")
|
||||
ride_id = self.kwargs.get("ride_pk")
|
||||
|
||||
if photo_ids is None or approve is None:
|
||||
return Response(
|
||||
{"error": "Missing required fields: photo_ids and/or approve."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Filter photos to only those belonging to this ride (if ride_pk provided)
|
||||
photos_queryset = RidePhoto.objects.filter(id__in=photo_ids)
|
||||
if ride_id:
|
||||
photos_queryset = photos_queryset.filter(ride_id=ride_id)
|
||||
|
||||
updated_count = photos_queryset.update(is_approved=approve)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride photo statistics",
|
||||
description="Get photo statistics for the ride",
|
||||
responses={
|
||||
200: RidePhotoStatsOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get photo statistics for the ride."""
|
||||
ride_pk = self.kwargs.get("ride_pk")
|
||||
ride = None
|
||||
if ride_pk:
|
||||
try:
|
||||
ride = Ride.objects.get(pk=ride_pk)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
if ride is not None:
|
||||
stats = RideMediaService.get_photo_stats(ride)
|
||||
else:
|
||||
# Global stats across all rides
|
||||
stats = {
|
||||
"total_photos": RidePhoto.objects.count(),
|
||||
"approved_photos": RidePhoto.objects.filter(
|
||||
is_approved=True
|
||||
).count(),
|
||||
"pending_photos": RidePhoto.objects.filter(
|
||||
is_approved=False
|
||||
).count(),
|
||||
"has_primary": False, # Not applicable for global stats
|
||||
"recent_uploads": RidePhoto.objects.order_by("-created_at")[
|
||||
:5
|
||||
].count(),
|
||||
"by_type": {},
|
||||
}
|
||||
|
||||
serializer = RidePhotoStatsOutputSerializer(stats)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Legacy compatibility action using the legacy set_primary logic
|
||||
@extend_schema(
|
||||
summary="Set photo as primary (legacy)",
|
||||
description="Legacy set primary action for backwards compatibility",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary_legacy(self, request, id=None):
|
||||
"""Legacy set primary action for backwards compatibility."""
|
||||
photo = self.get_object()
|
||||
if not (
|
||||
request.user == photo.uploaded_by
|
||||
or request.user.has_perm("rides.change_ridephoto")
|
||||
):
|
||||
return Response(
|
||||
{"error": "You do not have permission to edit photos for this ride."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
|
||||
if success:
|
||||
return Response({"message": "Photo set as primary successfully."})
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Failed to set primary photo"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Save Cloudflare image as ride photo",
|
||||
description="Save a Cloudflare image as a ride photo after direct upload to Cloudflare",
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
)
|
||||
@action(detail=False, methods=["post"])
|
||||
def save_image(self, request, **kwargs):
|
||||
"""Save a Cloudflare image as a ride photo after direct upload to Cloudflare."""
|
||||
ride_pk = self.kwargs.get("ride_pk")
|
||||
if not ride_pk:
|
||||
return Response(
|
||||
{"error": "Ride ID is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
ride = Ride.objects.get(pk=ride_pk)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
cloudflare_image_id = request.data.get("cloudflare_image_id")
|
||||
if not cloudflare_image_id:
|
||||
return Response(
|
||||
{"error": "cloudflare_image_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import CloudflareImage model and service
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
from django.utils import timezone
|
||||
|
||||
# Always fetch the latest image data from Cloudflare API
|
||||
try:
|
||||
# Get image details from Cloudflare API
|
||||
service = CloudflareImagesService()
|
||||
image_data = service.get_image(cloudflare_image_id)
|
||||
|
||||
if not image_data:
|
||||
return Response(
|
||||
{"error": "Image not found in Cloudflare"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Try to find existing CloudflareImage record by cloudflare_id
|
||||
cloudflare_image = None
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.get(
|
||||
cloudflare_id=cloudflare_image_id)
|
||||
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = 'uploaded'
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get('meta', {})
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get(
|
||||
'result', {}).get('variants', [])
|
||||
cloudflare_image.cloudflare_metadata = image_data
|
||||
cloudflare_image.width = image_data.get('width')
|
||||
cloudflare_image.height = image_data.get('height')
|
||||
cloudflare_image.format = image_data.get('format', '')
|
||||
cloudflare_image.save()
|
||||
|
||||
except CloudflareImage.DoesNotExist:
|
||||
# Create new CloudflareImage record from API response
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=request.user,
|
||||
status='uploaded',
|
||||
upload_url='', # Not needed for uploaded images
|
||||
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
|
||||
uploaded_at=timezone.now(),
|
||||
metadata=image_data.get('meta', {}),
|
||||
# Extract variants from nested result structure
|
||||
variants=image_data.get('result', {}).get('variants', []),
|
||||
cloudflare_metadata=image_data,
|
||||
width=image_data.get('width'),
|
||||
height=image_data.get('height'),
|
||||
format=image_data.get('format', ''),
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create the ride photo with the CloudflareImage reference
|
||||
photo = RidePhoto.objects.create(
|
||||
ride=ride,
|
||||
image=cloudflare_image,
|
||||
uploaded_by=request.user,
|
||||
caption=request.data.get("caption", ""),
|
||||
alt_text=request.data.get("alt_text", ""),
|
||||
photo_type=request.data.get("photo_type", "exterior"),
|
||||
is_primary=request.data.get("is_primary", False),
|
||||
is_approved=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Handle primary photo logic if requested
|
||||
if request.data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=ride, photo=photo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving ride photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1,604 +0,0 @@
|
||||
"""
|
||||
Ride media serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Ride Photo with Cloudflare Images",
|
||||
summary="Complete ride photo response",
|
||||
description="Example response showing all fields including Cloudflare Images URLs and variants",
|
||||
value={
|
||||
"id": 123,
|
||||
"image": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"alt_text": "Steel roller coaster with multiple inversions",
|
||||
"is_primary": True,
|
||||
"is_approved": True,
|
||||
"photo_type": "exterior",
|
||||
"created_at": "2023-01-01T12:00:00Z",
|
||||
"updated_at": "2023-01-01T12:00:00Z",
|
||||
"date_taken": "2023-01-01T10:00:00Z",
|
||||
"uploaded_by_username": "photographer123",
|
||||
"file_size": 2048576,
|
||||
"dimensions": [1920, 1080],
|
||||
"ride_slug": "steel-vengeance",
|
||||
"ride_name": "Steel Vengeance",
|
||||
"park_slug": "cedar-point",
|
||||
"park_name": "Cedar Point",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride photos with Cloudflare Images support."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
file_size = serializers.SerializerMethodField()
|
||||
dimensions = serializers.SerializerMethodField()
|
||||
image_url = serializers.SerializerMethodField()
|
||||
image_variants = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.IntegerField(allow_null=True, help_text="File size in bytes")
|
||||
)
|
||||
def get_file_size(self, obj):
|
||||
"""Get file size in bytes."""
|
||||
return obj.file_size
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
min_length=2,
|
||||
max_length=2,
|
||||
allow_null=True,
|
||||
help_text="Image dimensions as [width, height] in pixels",
|
||||
)
|
||||
)
|
||||
def get_dimensions(self, obj):
|
||||
"""Get image dimensions as [width, height]."""
|
||||
return obj.dimensions
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.URLField(
|
||||
help_text="Full URL to the Cloudflare Images asset", allow_null=True
|
||||
)
|
||||
)
|
||||
def get_image_url(self, obj):
|
||||
"""Get the full Cloudflare Images URL."""
|
||||
if obj.image:
|
||||
return obj.image.url
|
||||
return None
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.DictField(
|
||||
child=serializers.URLField(),
|
||||
help_text="Available Cloudflare Images variants with their URLs",
|
||||
)
|
||||
)
|
||||
def get_image_variants(self, obj):
|
||||
"""Get available image variants from Cloudflare Images."""
|
||||
if not obj.image:
|
||||
return {}
|
||||
|
||||
# Common variants for ride photos
|
||||
variants = {
|
||||
"thumbnail": f"{obj.image.url}/thumbnail",
|
||||
"medium": f"{obj.image.url}/medium",
|
||||
"large": f"{obj.image.url}/large",
|
||||
"public": f"{obj.image.url}/public",
|
||||
}
|
||||
return variants
|
||||
|
||||
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"image_url",
|
||||
"image_variants",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"photo_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"image_url",
|
||||
"image_variants",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RidePhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
by_type = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Photo counts by type"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoTypeFilterSerializer(serializers.Serializer):
|
||||
"""Serializer for filtering photos by type."""
|
||||
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
required=False,
|
||||
help_text="Filter photos by type",
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoSerializer(serializers.ModelSerializer):
|
||||
"""Legacy serializer for backward compatibility."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"photo_type",
|
||||
"uploaded_at",
|
||||
"uploaded_by",
|
||||
]
|
||||
|
||||
|
||||
class HybridRideSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Enhanced serializer for hybrid filtering strategy.
|
||||
Includes all filterable fields for client-side filtering.
|
||||
"""
|
||||
|
||||
# Park fields
|
||||
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||
park_slug = serializers.CharField(source="park.slug", read_only=True)
|
||||
|
||||
# Park location fields
|
||||
park_city = serializers.SerializerMethodField()
|
||||
park_state = serializers.SerializerMethodField()
|
||||
park_country = serializers.SerializerMethodField()
|
||||
|
||||
# Park area fields
|
||||
park_area_name = serializers.CharField(source="park_area.name", read_only=True, allow_null=True)
|
||||
park_area_slug = serializers.CharField(source="park_area.slug", read_only=True, allow_null=True)
|
||||
|
||||
# Company fields
|
||||
manufacturer_name = serializers.CharField(source="manufacturer.name", read_only=True, allow_null=True)
|
||||
manufacturer_slug = serializers.CharField(source="manufacturer.slug", read_only=True, allow_null=True)
|
||||
designer_name = serializers.CharField(source="designer.name", read_only=True, allow_null=True)
|
||||
designer_slug = serializers.CharField(source="designer.slug", read_only=True, allow_null=True)
|
||||
|
||||
# Ride model fields
|
||||
ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True)
|
||||
ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True)
|
||||
ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True)
|
||||
ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True)
|
||||
ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True)
|
||||
|
||||
# Roller coaster stats fields
|
||||
coaster_height_ft = serializers.SerializerMethodField()
|
||||
coaster_length_ft = serializers.SerializerMethodField()
|
||||
coaster_speed_mph = serializers.SerializerMethodField()
|
||||
coaster_inversions = serializers.SerializerMethodField()
|
||||
coaster_ride_time_seconds = serializers.SerializerMethodField()
|
||||
coaster_track_type = serializers.SerializerMethodField()
|
||||
coaster_track_material = serializers.SerializerMethodField()
|
||||
coaster_roller_coaster_type = serializers.SerializerMethodField()
|
||||
coaster_max_drop_height_ft = serializers.SerializerMethodField()
|
||||
coaster_propulsion_system = serializers.SerializerMethodField()
|
||||
coaster_train_style = serializers.SerializerMethodField()
|
||||
coaster_trains_count = serializers.SerializerMethodField()
|
||||
coaster_cars_per_train = serializers.SerializerMethodField()
|
||||
coaster_seats_per_car = serializers.SerializerMethodField()
|
||||
|
||||
# Image URLs for display
|
||||
banner_image_url = serializers.SerializerMethodField()
|
||||
card_image_url = serializers.SerializerMethodField()
|
||||
|
||||
# Computed fields for filtering
|
||||
opening_year = serializers.IntegerField(read_only=True)
|
||||
search_text = serializers.CharField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_park_city(self, obj):
|
||||
"""Get city from park location."""
|
||||
try:
|
||||
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
|
||||
return obj.park.location.city
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_park_state(self, obj):
|
||||
"""Get state from park location."""
|
||||
try:
|
||||
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
|
||||
return obj.park.location.state
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_park_country(self, obj):
|
||||
"""Get country from park location."""
|
||||
try:
|
||||
if obj.park and hasattr(obj.park, 'location') and obj.park.location:
|
||||
return obj.park.location.country
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_height_ft(self, obj):
|
||||
"""Get roller coaster height."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return float(obj.coaster_stats.height_ft) if obj.coaster_stats.height_ft else None
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_length_ft(self, obj):
|
||||
"""Get roller coaster length."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return float(obj.coaster_stats.length_ft) if obj.coaster_stats.length_ft else None
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_speed_mph(self, obj):
|
||||
"""Get roller coaster speed."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return float(obj.coaster_stats.speed_mph) if obj.coaster_stats.speed_mph else None
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_coaster_inversions(self, obj):
|
||||
"""Get roller coaster inversions."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.inversions
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_coaster_ride_time_seconds(self, obj):
|
||||
"""Get roller coaster ride time."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.ride_time_seconds
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_coaster_track_type(self, obj):
|
||||
"""Get roller coaster track type."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.track_type
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_coaster_track_material(self, obj):
|
||||
"""Get roller coaster track material."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.track_material
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_coaster_roller_coaster_type(self, obj):
|
||||
"""Get roller coaster type."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.roller_coaster_type
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_max_drop_height_ft(self, obj):
|
||||
"""Get roller coaster max drop height."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return float(obj.coaster_stats.max_drop_height_ft) if obj.coaster_stats.max_drop_height_ft else None
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_coaster_propulsion_system(self, obj):
|
||||
"""Get roller coaster propulsion system."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.propulsion_system
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_coaster_train_style(self, obj):
|
||||
"""Get roller coaster train style."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.train_style
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_coaster_trains_count(self, obj):
|
||||
"""Get roller coaster trains count."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.trains_count
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_coaster_cars_per_train(self, obj):
|
||||
"""Get roller coaster cars per train."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.cars_per_train
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_coaster_seats_per_car(self, obj):
|
||||
"""Get roller coaster seats per car."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.seats_per_car
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_banner_image_url(self, obj):
|
||||
"""Get banner image URL."""
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return obj.banner_image.image.url
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_card_image_url(self, obj):
|
||||
"""Get card image URL."""
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return obj.card_image.image.url
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
# Basic ride info
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"description",
|
||||
"category",
|
||||
"status",
|
||||
"post_closing_status",
|
||||
|
||||
# Dates and computed fields
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"status_since",
|
||||
"opening_year",
|
||||
|
||||
# Park fields
|
||||
"park_name",
|
||||
"park_slug",
|
||||
"park_city",
|
||||
"park_state",
|
||||
"park_country",
|
||||
|
||||
# Park area fields
|
||||
"park_area_name",
|
||||
"park_area_slug",
|
||||
|
||||
# Company fields
|
||||
"manufacturer_name",
|
||||
"manufacturer_slug",
|
||||
"designer_name",
|
||||
"designer_slug",
|
||||
|
||||
# Ride model fields
|
||||
"ride_model_name",
|
||||
"ride_model_slug",
|
||||
"ride_model_category",
|
||||
"ride_model_manufacturer_name",
|
||||
"ride_model_manufacturer_slug",
|
||||
|
||||
# Ride specifications
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"average_rating",
|
||||
|
||||
# Roller coaster stats
|
||||
"coaster_height_ft",
|
||||
"coaster_length_ft",
|
||||
"coaster_speed_mph",
|
||||
"coaster_inversions",
|
||||
"coaster_ride_time_seconds",
|
||||
"coaster_track_type",
|
||||
"coaster_track_material",
|
||||
"coaster_roller_coaster_type",
|
||||
"coaster_max_drop_height_ft",
|
||||
"coaster_propulsion_system",
|
||||
"coaster_train_style",
|
||||
"coaster_trains_count",
|
||||
"coaster_cars_per_train",
|
||||
"coaster_seats_per_car",
|
||||
|
||||
# Images
|
||||
"banner_image_url",
|
||||
"card_image_url",
|
||||
|
||||
# URLs
|
||||
"url",
|
||||
"park_url",
|
||||
|
||||
# Computed fields for filtering
|
||||
"search_text",
|
||||
|
||||
# Metadata
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RideSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for the Ride model."""
|
||||
|
||||
class Meta:
|
||||
model = Ride
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"park",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"category",
|
||||
"status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
]
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Comprehensive URL routes for Rides domain (API v1).
|
||||
|
||||
This file exposes a maximal set of "full-fat" endpoints implemented in
|
||||
`apps.api.v1.rides.views`. Endpoints are intentionally expansive (aliases,
|
||||
bulk operations, action endpoints, analytics, import/export) so the backend
|
||||
surface matches the frontend's expectations. Implementations for specific
|
||||
actions (bulk, publish, export, import, recommendations) should be added
|
||||
to the views module when business logic is available.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
RideListCreateAPIView,
|
||||
RideDetailAPIView,
|
||||
FilterOptionsAPIView,
|
||||
CompanySearchAPIView,
|
||||
RideModelSearchAPIView,
|
||||
RideSearchSuggestionsAPIView,
|
||||
RideImageSettingsAPIView,
|
||||
HybridRideAPIView,
|
||||
RideFilterMetadataAPIView,
|
||||
)
|
||||
from .photo_views import RidePhotoViewSet
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
router = DefaultRouter()
|
||||
router.register(r"", RidePhotoViewSet, basename="ridephoto")
|
||||
|
||||
app_name = "api_v1_rides"
|
||||
|
||||
urlpatterns = [
|
||||
# Core list/create endpoints
|
||||
path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
|
||||
|
||||
# Hybrid filtering endpoints
|
||||
path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"),
|
||||
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
|
||||
|
||||
# Filter options
|
||||
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
|
||||
# Autocomplete / suggestion endpoints
|
||||
path(
|
||||
"search/companies/",
|
||||
CompanySearchAPIView.as_view(),
|
||||
name="ride-search-companies",
|
||||
),
|
||||
path(
|
||||
"search/ride-models/",
|
||||
RideModelSearchAPIView.as_view(),
|
||||
name="ride-search-ride-models",
|
||||
),
|
||||
path(
|
||||
"search-suggestions/",
|
||||
RideSearchSuggestionsAPIView.as_view(),
|
||||
name="ride-search-suggestions",
|
||||
),
|
||||
# Ride model management endpoints - nested under rides/manufacturers
|
||||
path(
|
||||
"manufacturers/<slug:manufacturer_slug>/",
|
||||
include("apps.api.v1.rides.manufacturers.urls"),
|
||||
),
|
||||
# Detail and action endpoints
|
||||
path("<int:pk>/", RideDetailAPIView.as_view(), name="ride-detail"),
|
||||
# Ride image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
RideImageSettingsAPIView.as_view(),
|
||||
name="ride-image-settings",
|
||||
),
|
||||
# Ride photo endpoints - domain-specific photo management
|
||||
path("<int:ride_pk>/photos/", include(router.urls)),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
Custom schema hooks for drf-spectacular
|
||||
"""
|
||||
|
||||
|
||||
def custom_preprocessing_hook(endpoints):
|
||||
"""
|
||||
Custom preprocessing hook for drf-spectacular.
|
||||
Currently disabled - returns all endpoints for full schema generation.
|
||||
"""
|
||||
# Return all endpoints without filtering
|
||||
return endpoints
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
ThrillWiki API v1 serializers module.
|
||||
|
||||
This module re-exports the explicit serializer names defined in the
|
||||
package-level 'serializers' package (backend/apps/api/v1/serializers/__init__.py).
|
||||
It avoids dynamic importlib usage and provides a stable, statically analyzable
|
||||
re-export surface for linters.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Instead of trying to import from .serializers (which causes a self-import
|
||||
# / circular-import problem in this module), declare stable placeholders.
|
||||
# Importers (e.g. views) can still do `from .serializers import LoginInputSerializer`
|
||||
# and static analysis will see the symbol. At runtime, these may be replaced
|
||||
# by the real serializers by the package-level serializers package, or left
|
||||
# as None in environments where the package isn't available.
|
||||
LoginInputSerializer: Any = None
|
||||
LoginOutputSerializer: Any = None
|
||||
SignupInputSerializer: Any = None
|
||||
SignupOutputSerializer: Any = None
|
||||
LogoutOutputSerializer: Any = None
|
||||
UserOutputSerializer: Any = None
|
||||
PasswordResetInputSerializer: Any = None
|
||||
PasswordResetOutputSerializer: Any = None
|
||||
PasswordChangeInputSerializer: Any = None
|
||||
PasswordChangeOutputSerializer: Any = None
|
||||
SocialProviderOutputSerializer: Any = None
|
||||
AuthStatusOutputSerializer: Any = None
|
||||
UserProfileCreateInputSerializer: Any = None
|
||||
UserProfileUpdateInputSerializer: Any = None
|
||||
UserProfileOutputSerializer: Any = None
|
||||
TopListCreateInputSerializer: Any = None
|
||||
TopListUpdateInputSerializer: Any = None
|
||||
TopListOutputSerializer: Any = None
|
||||
TopListItemCreateInputSerializer: Any = None
|
||||
TopListItemUpdateInputSerializer: Any = None
|
||||
TopListItemOutputSerializer: Any = None
|
||||
|
||||
# Explicit __all__ for static analysis — update this list if new serializers are added.
|
||||
__all__ = (
|
||||
"LoginInputSerializer",
|
||||
"LoginOutputSerializer",
|
||||
"SignupInputSerializer",
|
||||
"SignupOutputSerializer",
|
||||
"LogoutOutputSerializer",
|
||||
"UserOutputSerializer",
|
||||
"PasswordResetInputSerializer",
|
||||
"PasswordResetOutputSerializer",
|
||||
"PasswordChangeInputSerializer",
|
||||
"PasswordChangeOutputSerializer",
|
||||
"SocialProviderOutputSerializer",
|
||||
"AuthStatusOutputSerializer",
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
"UserProfileOutputSerializer",
|
||||
"TopListCreateInputSerializer",
|
||||
"TopListUpdateInputSerializer",
|
||||
"TopListOutputSerializer",
|
||||
"TopListItemCreateInputSerializer",
|
||||
"TopListItemUpdateInputSerializer",
|
||||
"TopListItemOutputSerializer",
|
||||
)
|
||||
@@ -1,330 +0,0 @@
|
||||
"""
|
||||
ThrillWiki API v1 serializers module.
|
||||
|
||||
This module provides a unified interface to all serializers across different domains
|
||||
while maintaining the modular structure for better organization and maintainability.
|
||||
"""
|
||||
|
||||
from .services import (
|
||||
HealthCheckOutputSerializer,
|
||||
PerformanceMetricsOutputSerializer,
|
||||
SimpleHealthOutputSerializer,
|
||||
EmailSendInputSerializer,
|
||||
EmailTemplateOutputSerializer,
|
||||
MapDataOutputSerializer,
|
||||
CoordinateInputSerializer,
|
||||
HistoryEventSerializer,
|
||||
HistoryEntryOutputSerializer,
|
||||
HistoryCreateInputSerializer,
|
||||
ModerationSubmissionSerializer,
|
||||
ModerationSubmissionOutputSerializer,
|
||||
RoadtripParkSerializer,
|
||||
RoadtripCreateInputSerializer,
|
||||
RoadtripOutputSerializer,
|
||||
GeocodeInputSerializer,
|
||||
GeocodeOutputSerializer,
|
||||
DistanceCalculationInputSerializer,
|
||||
DistanceCalculationOutputSerializer,
|
||||
) # noqa: F401
|
||||
from typing import Any, Dict, List
|
||||
import importlib
|
||||
|
||||
# --- Shared utilities and base classes ---
|
||||
from .shared import (
|
||||
FilterOptionSerializer,
|
||||
FilterRangeSerializer,
|
||||
StandardizedFilterMetadataSerializer,
|
||||
validate_filter_metadata_contract,
|
||||
ensure_filter_option_format,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Parks domain ---
|
||||
from .parks import (
|
||||
ParkListOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
ParkCreateInputSerializer,
|
||||
ParkUpdateInputSerializer,
|
||||
ParkFilterInputSerializer,
|
||||
ParkAreaDetailOutputSerializer,
|
||||
ParkAreaCreateInputSerializer,
|
||||
ParkAreaUpdateInputSerializer,
|
||||
ParkLocationOutputSerializer,
|
||||
ParkLocationCreateInputSerializer,
|
||||
ParkLocationUpdateInputSerializer,
|
||||
ParkSuggestionSerializer,
|
||||
ParkSuggestionOutputSerializer,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Companies and ride models domain ---
|
||||
from .companies import (
|
||||
CompanyDetailOutputSerializer,
|
||||
CompanyCreateInputSerializer,
|
||||
CompanyUpdateInputSerializer,
|
||||
RideModelDetailOutputSerializer,
|
||||
RideModelCreateInputSerializer,
|
||||
RideModelUpdateInputSerializer,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Rides domain ---
|
||||
from .rides import (
|
||||
RideParkOutputSerializer,
|
||||
RideModelOutputSerializer,
|
||||
RideListOutputSerializer,
|
||||
RideDetailOutputSerializer,
|
||||
RideCreateInputSerializer,
|
||||
RideUpdateInputSerializer,
|
||||
RideFilterInputSerializer,
|
||||
RollerCoasterStatsOutputSerializer,
|
||||
RollerCoasterStatsCreateInputSerializer,
|
||||
RollerCoasterStatsUpdateInputSerializer,
|
||||
RideLocationOutputSerializer,
|
||||
RideLocationCreateInputSerializer,
|
||||
RideLocationUpdateInputSerializer,
|
||||
RideReviewOutputSerializer,
|
||||
RideReviewCreateInputSerializer,
|
||||
RideReviewUpdateInputSerializer,
|
||||
) # noqa: F401
|
||||
|
||||
# --- Accounts domain: try multiple likely locations, fall back to placeholders ---
|
||||
_ACCOUNTS_SYMBOLS: List[str] = [
|
||||
"UserProfileOutputSerializer",
|
||||
"UserProfileCreateInputSerializer",
|
||||
"UserProfileUpdateInputSerializer",
|
||||
"TopListOutputSerializer",
|
||||
"TopListCreateInputSerializer",
|
||||
"TopListUpdateInputSerializer",
|
||||
"TopListItemOutputSerializer",
|
||||
"TopListItemCreateInputSerializer",
|
||||
"TopListItemUpdateInputSerializer",
|
||||
"UserOutputSerializer",
|
||||
"LoginInputSerializer",
|
||||
"LoginOutputSerializer",
|
||||
"SignupInputSerializer",
|
||||
"SignupOutputSerializer",
|
||||
"PasswordResetInputSerializer",
|
||||
"PasswordResetOutputSerializer",
|
||||
"PasswordChangeInputSerializer",
|
||||
"PasswordChangeOutputSerializer",
|
||||
"LogoutOutputSerializer",
|
||||
"SocialProviderOutputSerializer",
|
||||
"AuthStatusOutputSerializer",
|
||||
]
|
||||
|
||||
|
||||
def _import_accounts_symbols() -> Dict[str, Any]:
|
||||
"""
|
||||
Try a list of candidate module paths and return a dict mapping expected symbol
|
||||
names to the objects found. If no candidate provides a symbol, the symbol maps to None.
|
||||
"""
|
||||
candidates = [
|
||||
f"{__package__}.accounts",
|
||||
f"{__package__}.auth",
|
||||
"apps.accounts.serializers",
|
||||
"apps.api.v1.auth.serializers",
|
||||
]
|
||||
|
||||
# Prepare default placeholders
|
||||
result: Dict[str, Any] = {name: None for name in _ACCOUNTS_SYMBOLS}
|
||||
|
||||
for modname in candidates:
|
||||
try:
|
||||
module = importlib.import_module(modname)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Fill in any symbols that exist on this module (don't require all)
|
||||
for name in _ACCOUNTS_SYMBOLS:
|
||||
if hasattr(module, name):
|
||||
result[name] = getattr(module, name)
|
||||
|
||||
# If we've found at least one real object (not all None), stop trying further candidates.
|
||||
if any(result[name] is not None for name in _ACCOUNTS_SYMBOLS):
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
_accounts = _import_accounts_symbols()
|
||||
|
||||
# Bind account symbols into the module namespace (only if they exist)
|
||||
for _name in _ACCOUNTS_SYMBOLS:
|
||||
if _accounts.get(_name) is not None:
|
||||
globals()[_name] = _accounts[_name]
|
||||
|
||||
# --- Services domain ---
|
||||
|
||||
# --- Optionally try importing other domain modules and inject serializer-like names ---
|
||||
_optional_domains = [
|
||||
"other",
|
||||
"media",
|
||||
"parks_media",
|
||||
"rides_media",
|
||||
"search",
|
||||
"history",
|
||||
]
|
||||
for domain in _optional_domains:
|
||||
modname = f"{__package__}.{domain}"
|
||||
try:
|
||||
module = importlib.import_module(modname)
|
||||
except Exception:
|
||||
continue
|
||||
# Inject any attribute that looks like a serializer or matches uppercase naming used by exported symbols
|
||||
for attr in dir(module):
|
||||
if attr.startswith("_"):
|
||||
continue
|
||||
# Heuristic: export classes/constants that end with 'Serializer' or are uppercase constants
|
||||
if (
|
||||
attr.endswith("Serializer")
|
||||
or attr.isupper()
|
||||
or attr.endswith("OutputSerializer")
|
||||
or attr.endswith("InputSerializer")
|
||||
):
|
||||
globals()[attr] = getattr(module, attr)
|
||||
|
||||
# --- Construct a conservative __all__ based on explicit lists and discovered serializer names ---
|
||||
_SHARED_EXPORTS = [
|
||||
"FilterOptionSerializer",
|
||||
"FilterRangeSerializer",
|
||||
"StandardizedFilterMetadataSerializer",
|
||||
"validate_filter_metadata_contract",
|
||||
"ensure_filter_option_format",
|
||||
]
|
||||
|
||||
_PARKS_EXPORTS = [
|
||||
"ParkListOutputSerializer",
|
||||
"ParkDetailOutputSerializer",
|
||||
"ParkCreateInputSerializer",
|
||||
"ParkUpdateInputSerializer",
|
||||
"ParkFilterInputSerializer",
|
||||
"ParkAreaDetailOutputSerializer",
|
||||
"ParkAreaCreateInputSerializer",
|
||||
"ParkAreaUpdateInputSerializer",
|
||||
"ParkLocationOutputSerializer",
|
||||
"ParkLocationCreateInputSerializer",
|
||||
"ParkLocationUpdateInputSerializer",
|
||||
"ParkSuggestionSerializer",
|
||||
"ParkSuggestionOutputSerializer",
|
||||
]
|
||||
|
||||
_COMPANIES_EXPORTS = [
|
||||
"CompanyDetailOutputSerializer",
|
||||
"CompanyCreateInputSerializer",
|
||||
"CompanyUpdateInputSerializer",
|
||||
"RideModelDetailOutputSerializer",
|
||||
"RideModelCreateInputSerializer",
|
||||
"RideModelUpdateInputSerializer",
|
||||
]
|
||||
|
||||
_RIDES_EXPORTS = [
|
||||
"RideParkOutputSerializer",
|
||||
"RideModelOutputSerializer",
|
||||
"RideListOutputSerializer",
|
||||
"RideDetailOutputSerializer",
|
||||
"RideCreateInputSerializer",
|
||||
"RideUpdateInputSerializer",
|
||||
"RideFilterInputSerializer",
|
||||
"RollerCoasterStatsOutputSerializer",
|
||||
"RollerCoasterStatsCreateInputSerializer",
|
||||
"RollerCoasterStatsUpdateInputSerializer",
|
||||
"RideLocationOutputSerializer",
|
||||
"RideLocationCreateInputSerializer",
|
||||
"RideLocationUpdateInputSerializer",
|
||||
"RideReviewOutputSerializer",
|
||||
"RideReviewCreateInputSerializer",
|
||||
"RideReviewUpdateInputSerializer",
|
||||
]
|
||||
|
||||
_SERVICES_EXPORTS = [
|
||||
"HealthCheckOutputSerializer",
|
||||
"PerformanceMetricsOutputSerializer",
|
||||
"SimpleHealthOutputSerializer",
|
||||
"EmailSendInputSerializer",
|
||||
"EmailTemplateOutputSerializer",
|
||||
"MapDataOutputSerializer",
|
||||
"CoordinateInputSerializer",
|
||||
"HistoryEventSerializer",
|
||||
"HistoryEntryOutputSerializer",
|
||||
"HistoryCreateInputSerializer",
|
||||
"ModerationSubmissionSerializer",
|
||||
"ModerationSubmissionOutputSerializer",
|
||||
"RoadtripParkSerializer",
|
||||
"RoadtripCreateInputSerializer",
|
||||
"RoadtripOutputSerializer",
|
||||
"GeocodeInputSerializer",
|
||||
"GeocodeOutputSerializer",
|
||||
"DistanceCalculationInputSerializer",
|
||||
"DistanceCalculationOutputSerializer",
|
||||
]
|
||||
|
||||
# Build a static __all__ list with only the serializers we know exist
|
||||
__all__ = [
|
||||
# Shared exports
|
||||
"FilterOptionSerializer",
|
||||
"FilterRangeSerializer",
|
||||
"StandardizedFilterMetadataSerializer",
|
||||
"validate_filter_metadata_contract",
|
||||
"ensure_filter_option_format",
|
||||
# Parks exports
|
||||
"ParkListOutputSerializer",
|
||||
"ParkDetailOutputSerializer",
|
||||
"ParkCreateInputSerializer",
|
||||
"ParkUpdateInputSerializer",
|
||||
"ParkFilterInputSerializer",
|
||||
"ParkAreaDetailOutputSerializer",
|
||||
"ParkAreaCreateInputSerializer",
|
||||
"ParkAreaUpdateInputSerializer",
|
||||
"ParkLocationOutputSerializer",
|
||||
"ParkLocationCreateInputSerializer",
|
||||
"ParkLocationUpdateInputSerializer",
|
||||
"ParkSuggestionSerializer",
|
||||
"ParkSuggestionOutputSerializer",
|
||||
# Companies exports
|
||||
"CompanyDetailOutputSerializer",
|
||||
"CompanyCreateInputSerializer",
|
||||
"CompanyUpdateInputSerializer",
|
||||
"RideModelDetailOutputSerializer",
|
||||
"RideModelCreateInputSerializer",
|
||||
"RideModelUpdateInputSerializer",
|
||||
# Rides exports
|
||||
"RideParkOutputSerializer",
|
||||
"RideModelOutputSerializer",
|
||||
"RideListOutputSerializer",
|
||||
"RideDetailOutputSerializer",
|
||||
"RideCreateInputSerializer",
|
||||
"RideUpdateInputSerializer",
|
||||
"RideFilterInputSerializer",
|
||||
"RollerCoasterStatsOutputSerializer",
|
||||
"RollerCoasterStatsCreateInputSerializer",
|
||||
"RollerCoasterStatsUpdateInputSerializer",
|
||||
"RideLocationOutputSerializer",
|
||||
"RideLocationCreateInputSerializer",
|
||||
"RideLocationUpdateInputSerializer",
|
||||
"RideReviewOutputSerializer",
|
||||
"RideReviewCreateInputSerializer",
|
||||
"RideReviewUpdateInputSerializer",
|
||||
# Services exports
|
||||
"HealthCheckOutputSerializer",
|
||||
"PerformanceMetricsOutputSerializer",
|
||||
"SimpleHealthOutputSerializer",
|
||||
"EmailSendInputSerializer",
|
||||
"EmailTemplateOutputSerializer",
|
||||
"MapDataOutputSerializer",
|
||||
"CoordinateInputSerializer",
|
||||
"HistoryEventSerializer",
|
||||
"HistoryEntryOutputSerializer",
|
||||
"HistoryCreateInputSerializer",
|
||||
"ModerationSubmissionSerializer",
|
||||
"ModerationSubmissionOutputSerializer",
|
||||
"RoadtripParkSerializer",
|
||||
"RoadtripCreateInputSerializer",
|
||||
"RoadtripOutputSerializer",
|
||||
"GeocodeInputSerializer",
|
||||
"GeocodeOutputSerializer",
|
||||
"DistanceCalculationInputSerializer",
|
||||
"DistanceCalculationOutputSerializer",
|
||||
]
|
||||
|
||||
# Add any accounts serializers that actually exist
|
||||
for name in _ACCOUNTS_SYMBOLS:
|
||||
if name in globals():
|
||||
__all__.append(name)
|
||||
@@ -1,904 +0,0 @@
|
||||
"""
|
||||
User accounts and settings serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to user account management,
|
||||
profile settings, preferences, privacy, notifications, and security.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.accounts.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
TopList,
|
||||
UserNotification,
|
||||
NotificationPreference,
|
||||
)
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === USER PROFILE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Profile Example",
|
||||
summary="Complete user profile",
|
||||
description="Full user profile with all fields",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user profile data."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
avatar_variants = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"profile_id",
|
||||
"display_name",
|
||||
"avatar",
|
||||
"avatar_url",
|
||||
"avatar_variants",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
]
|
||||
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the avatar URL with fallback to default letter-based avatar."""
|
||||
return obj.get_avatar_url()
|
||||
|
||||
def get_avatar_variants(self, obj):
|
||||
"""Get avatar variants for different use cases."""
|
||||
return obj.get_avatar_variants()
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Complete User Example",
|
||||
summary="Complete user with profile",
|
||||
description="Full user object with embedded profile",
|
||||
value={
|
||||
"user_id": "1234",
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"theme_preference": "dark",
|
||||
"profile": {
|
||||
"profile_id": "5678",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar": "https://example.com/avatars/user.jpg",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Love roller coasters and theme parks!",
|
||||
"twitter": "https://twitter.com/thrillseeker",
|
||||
"instagram": "https://instagram.com/thrillseeker",
|
||||
"youtube": "https://youtube.com/thrillseeker",
|
||||
"discord": "thrillseeker#1234",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class CompleteUserSerializer(serializers.ModelSerializer):
|
||||
"""Complete user serializer with profile data."""
|
||||
|
||||
profile = UserProfileSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"user_id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
"role",
|
||||
"theme_preference",
|
||||
"profile",
|
||||
]
|
||||
read_only_fields = ["user_id", "date_joined", "role"]
|
||||
|
||||
|
||||
# === USER SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Preferences Example",
|
||||
summary="User preferences and settings",
|
||||
description="User's preference settings",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
"email_notifications": True,
|
||||
"push_notifications": False,
|
||||
"privacy_level": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_statistics": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserPreferencesSerializer(serializers.Serializer):
|
||||
"""Serializer for user preferences and settings."""
|
||||
|
||||
theme_preference = RichChoiceFieldSerializer(
|
||||
choice_group="theme_preferences",
|
||||
domain="accounts",
|
||||
help_text="User's theme preference"
|
||||
)
|
||||
email_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Whether to receive email notifications"
|
||||
)
|
||||
push_notifications = serializers.BooleanField(
|
||||
default=False, help_text="Whether to receive push notifications"
|
||||
)
|
||||
privacy_level = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="public",
|
||||
help_text="Profile visibility level",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Whether to show email on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show real name on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Whether to show ride statistics on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Whether to allow direct messages"
|
||||
)
|
||||
|
||||
|
||||
# === NOTIFICATION SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Settings Example",
|
||||
summary="User notification preferences",
|
||||
description="Detailed notification settings",
|
||||
value={
|
||||
"email_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"weekly_digest": False,
|
||||
"new_features": True,
|
||||
"security_alerts": True,
|
||||
},
|
||||
"push_notifications": {
|
||||
"new_reviews": False,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
},
|
||||
"in_app_notifications": {
|
||||
"new_reviews": True,
|
||||
"review_replies": True,
|
||||
"friend_requests": True,
|
||||
"messages": True,
|
||||
"system_announcements": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationSettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for detailed notification settings."""
|
||||
|
||||
class EmailNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
weekly_digest = serializers.BooleanField(default=False)
|
||||
new_features = serializers.BooleanField(default=True)
|
||||
security_alerts = serializers.BooleanField(default=True)
|
||||
|
||||
class PushNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=False)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
|
||||
class InAppNotificationsSerializer(serializers.Serializer):
|
||||
new_reviews = serializers.BooleanField(default=True)
|
||||
review_replies = serializers.BooleanField(default=True)
|
||||
friend_requests = serializers.BooleanField(default=True)
|
||||
messages = serializers.BooleanField(default=True)
|
||||
system_announcements = serializers.BooleanField(default=True)
|
||||
|
||||
email_notifications = EmailNotificationsSerializer()
|
||||
push_notifications = PushNotificationsSerializer()
|
||||
in_app_notifications = InAppNotificationsSerializer()
|
||||
|
||||
|
||||
# === PRIVACY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Privacy Settings Example",
|
||||
summary="User privacy settings",
|
||||
description="Detailed privacy and visibility settings",
|
||||
value={
|
||||
"profile_visibility": "public",
|
||||
"show_email": False,
|
||||
"show_real_name": True,
|
||||
"show_join_date": True,
|
||||
"show_statistics": True,
|
||||
"show_reviews": True,
|
||||
"show_photos": True,
|
||||
"show_top_lists": True,
|
||||
"allow_friend_requests": True,
|
||||
"allow_messages": True,
|
||||
"allow_profile_comments": False,
|
||||
"search_visibility": True,
|
||||
"activity_visibility": "friends",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PrivacySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for privacy and visibility settings."""
|
||||
|
||||
profile_visibility = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="public",
|
||||
help_text="Overall profile visibility",
|
||||
)
|
||||
show_email = serializers.BooleanField(
|
||||
default=False, help_text="Show email address on profile"
|
||||
)
|
||||
show_real_name = serializers.BooleanField(
|
||||
default=True, help_text="Show real name on profile"
|
||||
)
|
||||
show_join_date = serializers.BooleanField(
|
||||
default=True, help_text="Show join date on profile"
|
||||
)
|
||||
show_statistics = serializers.BooleanField(
|
||||
default=True, help_text="Show ride statistics on profile"
|
||||
)
|
||||
show_reviews = serializers.BooleanField(
|
||||
default=True, help_text="Show reviews on profile"
|
||||
)
|
||||
show_photos = serializers.BooleanField(
|
||||
default=True, help_text="Show uploaded photos on profile"
|
||||
)
|
||||
show_top_lists = serializers.BooleanField(
|
||||
default=True, help_text="Show top lists on profile"
|
||||
)
|
||||
allow_friend_requests = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send friend requests"
|
||||
)
|
||||
allow_messages = serializers.BooleanField(
|
||||
default=True, help_text="Allow others to send direct messages"
|
||||
)
|
||||
allow_profile_comments = serializers.BooleanField(
|
||||
default=False, help_text="Allow others to comment on profile"
|
||||
)
|
||||
search_visibility = serializers.BooleanField(
|
||||
default=True, help_text="Allow profile to appear in search results"
|
||||
)
|
||||
activity_visibility = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="friends",
|
||||
help_text="Who can see your activity feed",
|
||||
)
|
||||
|
||||
|
||||
# === SECURITY SETTINGS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Security Settings Example",
|
||||
summary="User security settings",
|
||||
description="Account security and authentication settings",
|
||||
value={
|
||||
"two_factor_enabled": False,
|
||||
"login_notifications": True,
|
||||
"session_timeout": 30,
|
||||
"require_password_change": False,
|
||||
"last_password_change": "2024-01-01T00:00:00Z",
|
||||
"active_sessions": 2,
|
||||
"login_history_retention": 90,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SecuritySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for security settings."""
|
||||
|
||||
two_factor_enabled = serializers.BooleanField(
|
||||
default=False, help_text="Whether two-factor authentication is enabled"
|
||||
)
|
||||
login_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Send notifications for new logins"
|
||||
)
|
||||
session_timeout = serializers.IntegerField(
|
||||
default=30, min_value=5, max_value=180, help_text="Session timeout in days"
|
||||
)
|
||||
require_password_change = serializers.BooleanField(
|
||||
default=False, help_text="Whether password change is required"
|
||||
)
|
||||
last_password_change = serializers.DateTimeField(
|
||||
read_only=True, help_text="When password was last changed"
|
||||
)
|
||||
active_sessions = serializers.IntegerField(
|
||||
read_only=True, help_text="Number of active sessions"
|
||||
)
|
||||
login_history_retention = serializers.IntegerField(
|
||||
default=90,
|
||||
min_value=30,
|
||||
max_value=365,
|
||||
help_text="How long to keep login history (days)",
|
||||
)
|
||||
|
||||
|
||||
# === USER STATISTICS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Statistics Example",
|
||||
summary="User activity statistics",
|
||||
description="Comprehensive user activity and contribution statistics",
|
||||
value={
|
||||
"ride_credits": {
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 89,
|
||||
"water_ride_credits": 23,
|
||||
"total_credits": 307,
|
||||
},
|
||||
"contributions": {
|
||||
"park_reviews": 25,
|
||||
"ride_reviews": 87,
|
||||
"photos_uploaded": 156,
|
||||
"top_lists_created": 8,
|
||||
"helpful_votes_received": 342,
|
||||
},
|
||||
"activity": {
|
||||
"days_active": 45,
|
||||
"last_active": "2024-01-15T10:30:00Z",
|
||||
"average_review_rating": 4.2,
|
||||
"most_reviewed_park": "Cedar Point",
|
||||
"favorite_ride_type": "Roller Coaster",
|
||||
},
|
||||
"achievements": {
|
||||
"first_review": True,
|
||||
"photo_contributor": True,
|
||||
"top_reviewer": False,
|
||||
"park_explorer": True,
|
||||
"coaster_enthusiast": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserStatisticsSerializer(serializers.Serializer):
|
||||
"""Serializer for user statistics and achievements."""
|
||||
|
||||
class RideCreditsSerializer(serializers.Serializer):
|
||||
coaster_credits = serializers.IntegerField()
|
||||
dark_ride_credits = serializers.IntegerField()
|
||||
flat_ride_credits = serializers.IntegerField()
|
||||
water_ride_credits = serializers.IntegerField()
|
||||
total_credits = serializers.IntegerField()
|
||||
|
||||
class ContributionsSerializer(serializers.Serializer):
|
||||
park_reviews = serializers.IntegerField()
|
||||
ride_reviews = serializers.IntegerField()
|
||||
photos_uploaded = serializers.IntegerField()
|
||||
top_lists_created = serializers.IntegerField()
|
||||
helpful_votes_received = serializers.IntegerField()
|
||||
|
||||
class ActivitySerializer(serializers.Serializer):
|
||||
days_active = serializers.IntegerField()
|
||||
last_active = serializers.DateTimeField()
|
||||
average_review_rating = serializers.FloatField()
|
||||
most_reviewed_park = serializers.CharField()
|
||||
favorite_ride_type = serializers.CharField()
|
||||
|
||||
class AchievementsSerializer(serializers.Serializer):
|
||||
first_review = serializers.BooleanField()
|
||||
photo_contributor = serializers.BooleanField()
|
||||
top_reviewer = serializers.BooleanField()
|
||||
park_explorer = serializers.BooleanField()
|
||||
coaster_enthusiast = serializers.BooleanField()
|
||||
|
||||
ride_credits = RideCreditsSerializer()
|
||||
contributions = ContributionsSerializer()
|
||||
activity = ActivitySerializer()
|
||||
achievements = AchievementsSerializer()
|
||||
|
||||
|
||||
# === TOP LISTS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Example",
|
||||
summary="User's top list",
|
||||
description="A user's ranked list of rides or parks",
|
||||
value={
|
||||
"id": 1,
|
||||
"title": "My Top 10 Roller Coasters",
|
||||
"category": "RC",
|
||||
"description": "My favorite roller coasters from around the world",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"items_count": 10,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user's top lists."""
|
||||
|
||||
items_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"items_count",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_items_count(self, obj):
|
||||
"""Get the number of items in the list."""
|
||||
return obj.items.count()
|
||||
|
||||
|
||||
# === ACCOUNT UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Account Update Example",
|
||||
summary="Update account information",
|
||||
description="Update basic account information",
|
||||
value={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "newemail@example.com",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AccountUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating account information."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
]
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email uniqueness."""
|
||||
user = self.context["request"].user
|
||||
if User.objects.filter(email=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Email already in use")
|
||||
return value
|
||||
|
||||
|
||||
# === PROFILE UPDATE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Profile Update Example",
|
||||
summary="Update profile information",
|
||||
description="Update profile information and social links",
|
||||
value={
|
||||
"display_name": "New Display Name",
|
||||
"pronouns": "they/them",
|
||||
"bio": "Updated bio text",
|
||||
"twitter": "https://twitter.com/newhandle",
|
||||
"instagram": "",
|
||||
"youtube": "https://youtube.com/newchannel",
|
||||
"discord": "newhandle#5678",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating profile information."""
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = [
|
||||
"display_name",
|
||||
"pronouns",
|
||||
"bio",
|
||||
"twitter",
|
||||
"instagram",
|
||||
"youtube",
|
||||
"discord",
|
||||
]
|
||||
|
||||
def validate_display_name(self, value):
|
||||
"""Validate display name uniqueness - now checks User model first."""
|
||||
user = self.context["request"].user
|
||||
# Check User model for display_name uniqueness (primary location)
|
||||
if User.objects.filter(display_name=value).exclude(id=user.id).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
# Also check UserProfile for backward compatibility during transition
|
||||
if UserProfile.objects.filter(display_name=value).exclude(user=user).exists():
|
||||
raise serializers.ValidationError("Display name already taken")
|
||||
return value
|
||||
|
||||
|
||||
# === THEME PREFERENCE SERIALIZER ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Theme Update Example",
|
||||
summary="Update theme preference",
|
||||
description="Update user's theme preference",
|
||||
value={
|
||||
"theme_preference": "dark",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ThemePreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating theme preference."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["theme_preference"]
|
||||
|
||||
|
||||
# === NOTIFICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Notification Example",
|
||||
summary="User notification",
|
||||
description="A notification sent to a user",
|
||||
value={
|
||||
"id": 1,
|
||||
"notification_type": "submission_approved",
|
||||
"title": "Your submission has been approved!",
|
||||
"message": "Your photo submission for Cedar Point has been approved and is now live on the site.",
|
||||
"priority": "normal",
|
||||
"is_read": False,
|
||||
"read_at": None,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"expires_at": None,
|
||||
"extra_data": {"submission_id": 123, "park_name": "Cedar Point"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserNotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user notifications."""
|
||||
|
||||
class Meta:
|
||||
model = UserNotification
|
||||
fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"is_read",
|
||||
"read_at",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"notification_type",
|
||||
"title",
|
||||
"message",
|
||||
"priority",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"extra_data",
|
||||
]
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Notification Preferences Example",
|
||||
summary="User notification preferences",
|
||||
description="Comprehensive notification preferences for all channels",
|
||||
value={
|
||||
"submission_approved_email": True,
|
||||
"submission_approved_push": True,
|
||||
"submission_approved_inapp": True,
|
||||
"submission_rejected_email": True,
|
||||
"submission_rejected_push": True,
|
||||
"submission_rejected_inapp": True,
|
||||
"submission_pending_email": False,
|
||||
"submission_pending_push": False,
|
||||
"submission_pending_inapp": True,
|
||||
"review_reply_email": True,
|
||||
"review_reply_push": True,
|
||||
"review_reply_inapp": True,
|
||||
"review_helpful_email": False,
|
||||
"review_helpful_push": True,
|
||||
"review_helpful_inapp": True,
|
||||
"friend_request_email": True,
|
||||
"friend_request_push": True,
|
||||
"friend_request_inapp": True,
|
||||
"friend_accepted_email": False,
|
||||
"friend_accepted_push": True,
|
||||
"friend_accepted_inapp": True,
|
||||
"message_received_email": True,
|
||||
"message_received_push": True,
|
||||
"message_received_inapp": True,
|
||||
"system_announcement_email": True,
|
||||
"system_announcement_push": False,
|
||||
"system_announcement_inapp": True,
|
||||
"account_security_email": True,
|
||||
"account_security_push": True,
|
||||
"account_security_inapp": True,
|
||||
"feature_update_email": True,
|
||||
"feature_update_push": False,
|
||||
"feature_update_inapp": True,
|
||||
"achievement_unlocked_email": False,
|
||||
"achievement_unlocked_push": True,
|
||||
"achievement_unlocked_inapp": True,
|
||||
"milestone_reached_email": False,
|
||||
"milestone_reached_push": True,
|
||||
"milestone_reached_inapp": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class NotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification preferences."""
|
||||
|
||||
class Meta:
|
||||
model = NotificationPreference
|
||||
fields = [
|
||||
# Submission notifications
|
||||
"submission_approved_email",
|
||||
"submission_approved_push",
|
||||
"submission_approved_inapp",
|
||||
"submission_rejected_email",
|
||||
"submission_rejected_push",
|
||||
"submission_rejected_inapp",
|
||||
"submission_pending_email",
|
||||
"submission_pending_push",
|
||||
"submission_pending_inapp",
|
||||
# Review notifications
|
||||
"review_reply_email",
|
||||
"review_reply_push",
|
||||
"review_reply_inapp",
|
||||
"review_helpful_email",
|
||||
"review_helpful_push",
|
||||
"review_helpful_inapp",
|
||||
# Social notifications
|
||||
"friend_request_email",
|
||||
"friend_request_push",
|
||||
"friend_request_inapp",
|
||||
"friend_accepted_email",
|
||||
"friend_accepted_push",
|
||||
"friend_accepted_inapp",
|
||||
"message_received_email",
|
||||
"message_received_push",
|
||||
"message_received_inapp",
|
||||
# System notifications
|
||||
"system_announcement_email",
|
||||
"system_announcement_push",
|
||||
"system_announcement_inapp",
|
||||
"account_security_email",
|
||||
"account_security_push",
|
||||
"account_security_inapp",
|
||||
"feature_update_email",
|
||||
"feature_update_push",
|
||||
"feature_update_inapp",
|
||||
# Achievement notifications
|
||||
"achievement_unlocked_email",
|
||||
"achievement_unlocked_push",
|
||||
"achievement_unlocked_inapp",
|
||||
"milestone_reached_email",
|
||||
"milestone_reached_push",
|
||||
"milestone_reached_inapp",
|
||||
]
|
||||
|
||||
|
||||
# === NOTIFICATION ACTIONS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Mark Notifications Read Example",
|
||||
summary="Mark notifications as read",
|
||||
description="Mark specific notifications as read",
|
||||
value={"notification_ids": [1, 2, 3, 4, 5]},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MarkNotificationsReadSerializer(serializers.Serializer):
|
||||
"""Serializer for marking notifications as read."""
|
||||
|
||||
notification_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of notification IDs to mark as read",
|
||||
)
|
||||
|
||||
def validate_notification_ids(self, value):
|
||||
"""Validate that all notification IDs belong to the requesting user."""
|
||||
user = self.context["request"].user
|
||||
valid_ids = UserNotification.objects.filter(
|
||||
id__in=value, user=user
|
||||
).values_list("id", flat=True)
|
||||
|
||||
invalid_ids = set(value) - set(valid_ids)
|
||||
if invalid_ids:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid notification IDs: {list(invalid_ids)}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Avatar Upload Example",
|
||||
summary="Upload user avatar",
|
||||
description="Upload a new avatar image",
|
||||
value={"avatar": "base64_encoded_image_data_or_file_upload"},
|
||||
)
|
||||
]
|
||||
)
|
||||
class AvatarUploadSerializer(serializers.Serializer):
|
||||
"""Serializer for uploading user avatar."""
|
||||
|
||||
# Use FileField instead of ImageField to bypass Django's image validation
|
||||
avatar = serializers.FileField()
|
||||
|
||||
def validate_avatar(self, value):
|
||||
"""Validate avatar file."""
|
||||
if not value:
|
||||
raise serializers.ValidationError("No file provided")
|
||||
|
||||
# Check file size constraints (max 10MB for Cloudflare Images)
|
||||
if hasattr(value, 'size') and value.size > 10 * 1024 * 1024:
|
||||
raise serializers.ValidationError(
|
||||
"Image file too large. Maximum size is 10MB.")
|
||||
|
||||
# Try to validate with PIL
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
value.seek(0)
|
||||
image_data = value.read()
|
||||
value.seek(0) # Reset for later use
|
||||
|
||||
if len(image_data) == 0:
|
||||
raise serializers.ValidationError("File appears to be empty")
|
||||
|
||||
# Try to open with PIL
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Verify it's a valid image
|
||||
image.verify()
|
||||
|
||||
# Check image dimensions (max 12,000x12,000 for Cloudflare Images)
|
||||
if image.size[0] > 12000 or image.size[1] > 12000:
|
||||
raise serializers.ValidationError(
|
||||
"Image dimensions too large. Maximum is 12,000x12,000 pixels.")
|
||||
|
||||
# Check if it's a supported format
|
||||
if image.format not in ['JPEG', 'PNG', 'GIF', 'WEBP']:
|
||||
raise serializers.ValidationError(
|
||||
f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP.")
|
||||
|
||||
except serializers.ValidationError:
|
||||
raise # Re-raise validation errors
|
||||
except Exception:
|
||||
# PIL validation failed, but let Cloudflare Images try to process it
|
||||
pass
|
||||
|
||||
return value
|
||||
@@ -1,497 +0,0 @@
|
||||
"""
|
||||
Authentication domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to user authentication,
|
||||
registration, password management, and social authentication.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model, authenticate
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === USER SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Output Example",
|
||||
summary="Example user response",
|
||||
description="A typical user object in API responses",
|
||||
value={
|
||||
"id": 1,
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"is_active": True,
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for user data."""
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"date_joined",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined"]
|
||||
|
||||
|
||||
# === LOGIN SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Login Input Example",
|
||||
summary="Example login request",
|
||||
description="Login with username or email and password",
|
||||
value={
|
||||
"username": "thrillseeker",
|
||||
"password": "securepassword123",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=150,
|
||||
help_text="Username or email address",
|
||||
)
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="User password",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate login credentials."""
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
# Try to authenticate with the provided credentials
|
||||
user = authenticate(
|
||||
request=self.context.get("request"),
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if not user:
|
||||
# Try email-based authentication if username failed
|
||||
if "@" in username:
|
||||
try:
|
||||
user_obj = UserModel.objects.get(email=username)
|
||||
user = authenticate(
|
||||
request=self.context.get("request"),
|
||||
username=user_obj.username, # type: ignore[attr-defined]
|
||||
password=password,
|
||||
)
|
||||
except UserModel.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not user:
|
||||
raise serializers.ValidationError("Invalid credentials")
|
||||
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError("Account is disabled")
|
||||
|
||||
attrs["user"] = user
|
||||
else:
|
||||
raise serializers.ValidationError("Must include username and password")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Login Output Example",
|
||||
summary="Example login response",
|
||||
description="Successful login response with token and user data",
|
||||
value={
|
||||
"token": "abc123def456ghi789",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
},
|
||||
"message": "Login successful",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class LoginOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for login response."""
|
||||
|
||||
token = serializers.CharField(help_text="Authentication token")
|
||||
user = UserOutputSerializer(help_text="User information")
|
||||
message = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === SIGNUP SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Signup Input Example",
|
||||
summary="Example registration request",
|
||||
description="Register a new user account",
|
||||
value={
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"password": "securepassword123",
|
||||
"password_confirm": "securepassword123",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SignupInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for user registration."""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="User password",
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="Password confirmation",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"password",
|
||||
"password_confirm",
|
||||
"first_name",
|
||||
"last_name",
|
||||
]
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email uniqueness."""
|
||||
if UserModel.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError("Email already registered")
|
||||
return value
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username uniqueness."""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError("Username already taken")
|
||||
return value
|
||||
|
||||
def validate_password(self, value):
|
||||
"""Validate password strength."""
|
||||
try:
|
||||
validate_password(value)
|
||||
except DjangoValidationError as e:
|
||||
raise serializers.ValidationError(list(e.messages))
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError("Passwords do not match")
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create new user."""
|
||||
validated_data.pop("password_confirm")
|
||||
password = validated_data.pop("password")
|
||||
|
||||
user = UserModel.objects.create_user( # type: ignore[attr-defined]
|
||||
password=password,
|
||||
**validated_data,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Signup Output Example",
|
||||
summary="Example registration response",
|
||||
description="Successful registration response with token and user data",
|
||||
value={
|
||||
"token": "abc123def456ghi789",
|
||||
"user": {
|
||||
"id": 2,
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Smith",
|
||||
},
|
||||
"message": "Registration successful",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SignupOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for registration response."""
|
||||
|
||||
token = serializers.CharField(help_text="Authentication token")
|
||||
user = UserOutputSerializer(help_text="User information")
|
||||
message = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === LOGOUT SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Logout Output Example",
|
||||
summary="Example logout response",
|
||||
description="Successful logout response",
|
||||
value={
|
||||
"message": "Logout successful",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class LogoutOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for logout response."""
|
||||
|
||||
message = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === PASSWORD RESET SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Reset Input Example",
|
||||
summary="Example password reset request",
|
||||
description="Request password reset email",
|
||||
value={
|
||||
"email": "user@example.com",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordResetInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password reset request."""
|
||||
|
||||
email = serializers.EmailField(help_text="Email address for password reset")
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email exists."""
|
||||
if not UserModel.objects.filter(email=value).exists():
|
||||
# Don't reveal if email exists for security
|
||||
pass
|
||||
return value
|
||||
|
||||
def save(self, **kwargs): # type: ignore[override]
|
||||
"""Send password reset email."""
|
||||
email = self.validated_data["email"] # type: ignore[index]
|
||||
try:
|
||||
_user = UserModel.objects.get(email=email)
|
||||
# Here you would typically send a password reset email
|
||||
# For now, we'll just pass
|
||||
pass
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal if email exists for security
|
||||
pass
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Reset Output Example",
|
||||
summary="Example password reset response",
|
||||
description="Password reset email sent response",
|
||||
value={
|
||||
"detail": "Password reset email sent",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordResetOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password reset response."""
|
||||
|
||||
detail = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === PASSWORD CHANGE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Change Input Example",
|
||||
summary="Example password change request",
|
||||
description="Change current user's password",
|
||||
value={
|
||||
"old_password": "oldpassword123",
|
||||
"new_password": "newpassword456",
|
||||
"new_password_confirm": "newpassword456",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordChangeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="Current password",
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="New password",
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
style={"input_type": "password"},
|
||||
help_text="New password confirmation",
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate current password."""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Current password is incorrect")
|
||||
return value
|
||||
|
||||
def validate_new_password(self, value):
|
||||
"""Validate new password strength."""
|
||||
try:
|
||||
validate_password(value, user=self.context["request"].user)
|
||||
except DjangoValidationError as e:
|
||||
raise serializers.ValidationError(list(e.messages))
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError("New passwords do not match")
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs): # type: ignore[override]
|
||||
"""Change user password."""
|
||||
user = self.context["request"].user
|
||||
user.set_password(self.validated_data["new_password"]) # type: ignore[index]
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Password Change Output Example",
|
||||
summary="Example password change response",
|
||||
description="Password changed successfully response",
|
||||
value={
|
||||
"detail": "Password changed successfully",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PasswordChangeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password change response."""
|
||||
|
||||
detail = serializers.CharField(help_text="Success message")
|
||||
|
||||
|
||||
# === SOCIAL PROVIDER SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Social Provider Example",
|
||||
summary="Example social provider",
|
||||
description="Available social authentication provider",
|
||||
value={
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"authUrl": "https://example.com/accounts/google/login/",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class SocialProviderOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for social authentication providers."""
|
||||
|
||||
id = serializers.CharField(help_text="Provider ID")
|
||||
name = serializers.CharField(help_text="Provider display name")
|
||||
authUrl = serializers.URLField(help_text="Authentication URL")
|
||||
|
||||
|
||||
# === AUTH STATUS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Auth Status Authenticated Example",
|
||||
summary="Example authenticated status",
|
||||
description="Response when user is authenticated",
|
||||
value={
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "thrillseeker",
|
||||
"email": "user@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
},
|
||||
},
|
||||
),
|
||||
OpenApiExample(
|
||||
"Auth Status Unauthenticated Example",
|
||||
summary="Example unauthenticated status",
|
||||
description="Response when user is not authenticated",
|
||||
value={
|
||||
"authenticated": False,
|
||||
"user": None,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
class AuthStatusOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for authentication status."""
|
||||
|
||||
authenticated = serializers.BooleanField(help_text="Whether user is authenticated")
|
||||
user = UserOutputSerializer(
|
||||
allow_null=True, help_text="User information if authenticated"
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
Companies and ride models domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to companies that operate parks
|
||||
or manufacture rides, as well as ride model serializers.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === COMPANY SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Company Example",
|
||||
summary="Example company response",
|
||||
description="A company that operates parks or manufactures rides",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Cedar Fair",
|
||||
"slug": "cedar-fair",
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "Theme park operator based in Ohio",
|
||||
"website": "https://cedarfair.com",
|
||||
"founded_date": "1983-01-01",
|
||||
"rides_count": 0,
|
||||
"coasters_count": 0,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class CompanyDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for company details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
roles = serializers.ListField(child=serializers.CharField())
|
||||
description = serializers.CharField()
|
||||
website = serializers.URLField()
|
||||
founded_date = serializers.DateField(allow_null=True)
|
||||
rides_count = serializers.IntegerField()
|
||||
coasters_count = serializers.IntegerField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class CompanyCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating companies."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=ModelChoices.get_company_role_choices()),
|
||||
allow_empty=False,
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating companies."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=ModelChoices.get_company_role_choices()),
|
||||
required=False,
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# === RIDE MODEL SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Model Example",
|
||||
summary="Example ride model response",
|
||||
description="A specific model/type of ride manufactured by a company",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Dive Coaster",
|
||||
"description": "A roller coaster featuring a near-vertical drop",
|
||||
"category": "RC",
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideModelDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_manufacturer(self, obj) -> dict | None:
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
"id": obj.manufacturer.id,
|
||||
"name": obj.manufacturer.name,
|
||||
"slug": obj.manufacturer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride models."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride models."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
@@ -1,186 +0,0 @@
|
||||
"""
|
||||
History domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for history tracking and timeline functionality
|
||||
using django-pghistory.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
|
||||
class ParkHistoryEventSerializer(serializers.Serializer):
|
||||
"""Serializer for park history events."""
|
||||
|
||||
pgh_id = serializers.IntegerField(read_only=True)
|
||||
pgh_created_at = serializers.DateTimeField(read_only=True)
|
||||
pgh_label = serializers.CharField(read_only=True)
|
||||
pgh_obj_id = serializers.IntegerField(read_only=True)
|
||||
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
|
||||
pgh_data = serializers.JSONField(read_only=True)
|
||||
event_type = serializers.SerializerMethodField()
|
||||
changes = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_event_type(self, obj) -> str:
|
||||
"""Get human-readable event type."""
|
||||
return obj.pgh_label.replace("_", " ").title()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_changes(self, obj) -> dict:
|
||||
"""Get changes made in this event."""
|
||||
if hasattr(obj, "pgh_diff") and obj.pgh_diff:
|
||||
return obj.pgh_diff
|
||||
return {}
|
||||
|
||||
|
||||
class RideHistoryEventSerializer(serializers.Serializer):
|
||||
"""Serializer for ride history events."""
|
||||
|
||||
pgh_id = serializers.IntegerField(read_only=True)
|
||||
pgh_created_at = serializers.DateTimeField(read_only=True)
|
||||
pgh_label = serializers.CharField(read_only=True)
|
||||
pgh_obj_id = serializers.IntegerField(read_only=True)
|
||||
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
|
||||
pgh_data = serializers.JSONField(read_only=True)
|
||||
event_type = serializers.SerializerMethodField()
|
||||
changes = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_event_type(self, obj) -> str:
|
||||
"""Get human-readable event type."""
|
||||
return obj.pgh_label.replace("_", " ").title()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_changes(self, obj) -> dict:
|
||||
"""Get changes made in this event."""
|
||||
if hasattr(obj, "pgh_diff") and obj.pgh_diff:
|
||||
return obj.pgh_diff
|
||||
return {}
|
||||
|
||||
|
||||
class HistorySummarySerializer(serializers.Serializer):
|
||||
"""Serializer for history summary information."""
|
||||
|
||||
total_events = serializers.IntegerField()
|
||||
first_recorded = serializers.DateTimeField(allow_null=True)
|
||||
last_modified = serializers.DateTimeField(allow_null=True)
|
||||
|
||||
|
||||
class ParkHistoryOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for complete park history."""
|
||||
|
||||
park = serializers.SerializerMethodField()
|
||||
current_state = serializers.SerializerMethodField()
|
||||
summary = HistorySummarySerializer()
|
||||
events = ParkHistoryEventSerializer(many=True)
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj) -> dict:
|
||||
"""Get basic park information."""
|
||||
park = obj.get("park")
|
||||
if park:
|
||||
return {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"status": park.status,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_current_state(self, obj) -> dict:
|
||||
"""Get current park state."""
|
||||
park = obj.get("current_state")
|
||||
if park:
|
||||
return {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"status": park.status,
|
||||
"opening_date": (
|
||||
park.opening_date.isoformat()
|
||||
if hasattr(park, "opening_date") and park.opening_date
|
||||
else None
|
||||
),
|
||||
"coaster_count": getattr(park, "coaster_count", 0),
|
||||
"ride_count": getattr(park, "ride_count", 0),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
class RideHistoryOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for complete ride history."""
|
||||
|
||||
ride = serializers.SerializerMethodField()
|
||||
current_state = serializers.SerializerMethodField()
|
||||
summary = HistorySummarySerializer()
|
||||
events = RideHistoryEventSerializer(many=True)
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj) -> dict:
|
||||
"""Get basic ride information."""
|
||||
ride = obj.get("ride")
|
||||
if ride:
|
||||
return {
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park_name": ride.park.name if hasattr(ride, "park") else None,
|
||||
"status": getattr(ride, "status", "UNKNOWN"),
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_current_state(self, obj) -> dict:
|
||||
"""Get current ride state."""
|
||||
ride = obj.get("current_state")
|
||||
if ride:
|
||||
return {
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park_name": ride.park.name if hasattr(ride, "park") else None,
|
||||
"status": getattr(ride, "status", "UNKNOWN"),
|
||||
"opening_date": (
|
||||
ride.opening_date.isoformat()
|
||||
if hasattr(ride, "opening_date") and ride.opening_date
|
||||
else None
|
||||
),
|
||||
"ride_type": getattr(ride, "ride_type", "Unknown"),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
class UnifiedHistoryTimelineSerializer(serializers.Serializer):
|
||||
"""Serializer for unified history timeline."""
|
||||
|
||||
summary = serializers.SerializerMethodField()
|
||||
events = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_summary(self, obj) -> dict:
|
||||
"""Get timeline summary."""
|
||||
return obj.get("summary", {})
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_events(self, obj) -> list:
|
||||
"""Get timeline events."""
|
||||
events = obj.get("events", [])
|
||||
event_data = []
|
||||
|
||||
for event in events:
|
||||
event_data.append(
|
||||
{
|
||||
"pgh_id": event.pgh_id,
|
||||
"pgh_created_at": event.pgh_created_at,
|
||||
"pgh_label": event.pgh_label,
|
||||
"pgh_model": event.pgh_model,
|
||||
"pgh_obj_id": event.pgh_obj_id,
|
||||
"pgh_context": event.pgh_context,
|
||||
"event_type": event.pgh_label.replace("_", " ").title(),
|
||||
"model_type": event.pgh_model.split(".")[-1].title(),
|
||||
}
|
||||
)
|
||||
|
||||
return event_data
|
||||
@@ -1,421 +0,0 @@
|
||||
"""
|
||||
Maps domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to map functionality,
|
||||
including location data, search results, and clustering.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
|
||||
# === MAP LOCATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Location Example",
|
||||
summary="Example map location response",
|
||||
description="A location point on the map",
|
||||
value={
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"status": "OPERATING",
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"average_rating": 4.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapLocationSerializer(serializers.Serializer):
|
||||
"""Serializer for individual map locations (parks and rides)."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField() # 'park' or 'ride'
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
status = serializers.CharField()
|
||||
|
||||
# Location details
|
||||
location = serializers.SerializerMethodField()
|
||||
|
||||
# Statistics
|
||||
stats = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
"country": obj.location.country,
|
||||
"formatted_address": obj.location.formatted_address,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get relevant statistics based on object type."""
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Cluster Example",
|
||||
summary="Example map cluster response",
|
||||
description="A cluster of locations on the map",
|
||||
value={
|
||||
"id": "cluster_1",
|
||||
"type": "cluster",
|
||||
"latitude": 41.5,
|
||||
"longitude": -82.7,
|
||||
"count": 5,
|
||||
"bounds": {
|
||||
"north": 41.6,
|
||||
"south": 41.4,
|
||||
"east": -82.6,
|
||||
"west": -82.8,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapClusterSerializer(serializers.Serializer):
|
||||
"""Serializer for map clusters."""
|
||||
|
||||
id = serializers.CharField()
|
||||
type = serializers.CharField(default="cluster")
|
||||
latitude = serializers.FloatField()
|
||||
longitude = serializers.FloatField()
|
||||
count = serializers.IntegerField()
|
||||
bounds = serializers.DictField()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Locations Response Example",
|
||||
summary="Example map locations response",
|
||||
description="Response containing locations and optional clusters",
|
||||
value={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"locations": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"status": "OPERATING",
|
||||
}
|
||||
],
|
||||
"clusters": [],
|
||||
"bounds": {
|
||||
"north": 41.5,
|
||||
"south": 41.4,
|
||||
"east": -82.6,
|
||||
"west": -82.8,
|
||||
},
|
||||
"total_count": 1,
|
||||
"clustered": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapLocationsResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for map locations endpoint."""
|
||||
|
||||
status = serializers.CharField(default="success")
|
||||
locations = serializers.ListField(child=serializers.DictField())
|
||||
clusters = serializers.ListField(child=serializers.DictField(), default=list)
|
||||
bounds = serializers.DictField(default=dict)
|
||||
total_count = serializers.IntegerField(default=0)
|
||||
clustered = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
# === MAP SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Search Result Example",
|
||||
summary="Example map search result",
|
||||
description="A search result for map locations",
|
||||
value={
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"relevance_score": 0.95,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapSearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for map search results."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
location = serializers.SerializerMethodField()
|
||||
relevance_score = serializers.FloatField(required=False)
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get location information."""
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
"country": obj.location.country,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Search Response Example",
|
||||
summary="Example map search response",
|
||||
description="Response containing search results",
|
||||
value={
|
||||
"status": "success",
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
}
|
||||
],
|
||||
"query": "cedar point",
|
||||
"total_count": 1,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapSearchResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for map search endpoint."""
|
||||
|
||||
status = serializers.CharField(default="success")
|
||||
results = serializers.ListField(child=serializers.DictField())
|
||||
query = serializers.CharField()
|
||||
total_count = serializers.IntegerField(default=0)
|
||||
page = serializers.IntegerField(default=1)
|
||||
page_size = serializers.IntegerField(default=20)
|
||||
|
||||
|
||||
# === MAP DETAIL SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Map Location Detail Example",
|
||||
summary="Example map location detail response",
|
||||
description="Detailed information about a specific location",
|
||||
value={
|
||||
"id": 1,
|
||||
"type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"description": "America's Roller Coast",
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"status": "OPERATING",
|
||||
"location": {
|
||||
"street_address": "1 Cedar Point Dr",
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
"postal_code": "44870",
|
||||
"formatted_address": "1 Cedar Point Dr, Sandusky, Ohio, 44870, United States",
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"average_rating": 4.5,
|
||||
},
|
||||
"nearby_locations": [],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class MapLocationDetailSerializer(serializers.Serializer):
|
||||
"""Serializer for detailed map location information."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
status = serializers.CharField()
|
||||
|
||||
# Detailed location information
|
||||
location = serializers.SerializerMethodField()
|
||||
|
||||
# Statistics
|
||||
stats = serializers.SerializerMethodField()
|
||||
|
||||
# Nearby locations
|
||||
nearby_locations = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_location(self, obj) -> dict:
|
||||
"""Get detailed location information."""
|
||||
if hasattr(obj, "location") and obj.location:
|
||||
return {
|
||||
"street_address": obj.location.street_address,
|
||||
"city": obj.location.city,
|
||||
"state": obj.location.state,
|
||||
"country": obj.location.country,
|
||||
"postal_code": obj.location.postal_code,
|
||||
"formatted_address": obj.location.formatted_address,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_stats(self, obj) -> dict:
|
||||
"""Get detailed statistics based on object type."""
|
||||
if obj._meta.model_name == "park":
|
||||
return {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
}
|
||||
elif obj._meta.model_name == "ride":
|
||||
return {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": (
|
||||
float(obj.average_rating) if obj.average_rating else None
|
||||
),
|
||||
"park_name": obj.park.name if obj.park else None,
|
||||
"opening_date": (
|
||||
obj.opening_date.isoformat() if obj.opening_date else None
|
||||
),
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_nearby_locations(self, obj) -> list:
|
||||
"""Get nearby locations (placeholder for now)."""
|
||||
# TODO: Implement nearby location logic
|
||||
return []
|
||||
|
||||
|
||||
# === INPUT SERIALIZERS ===
|
||||
|
||||
|
||||
class MapBoundsInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for map bounds queries."""
|
||||
|
||||
north = serializers.FloatField(min_value=-90, max_value=90)
|
||||
south = serializers.FloatField(min_value=-90, max_value=90)
|
||||
east = serializers.FloatField(min_value=-180, max_value=180)
|
||||
west = serializers.FloatField(min_value=-180, max_value=180)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that bounds make geographic sense."""
|
||||
if attrs["north"] <= attrs["south"]:
|
||||
raise serializers.ValidationError(
|
||||
"North bound must be greater than south bound"
|
||||
)
|
||||
|
||||
# Handle longitude wraparound (e.g., crossing the international date line)
|
||||
# For now, we'll require west < east for simplicity
|
||||
if attrs["west"] >= attrs["east"]:
|
||||
raise serializers.ValidationError("West bound must be less than east bound")
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class MapSearchInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for map search queries."""
|
||||
|
||||
q = serializers.CharField(min_length=1, max_length=255)
|
||||
types = serializers.CharField(required=False, allow_blank=True)
|
||||
bounds = MapBoundsInputSerializer(required=False)
|
||||
page = serializers.IntegerField(min_value=1, default=1)
|
||||
page_size = serializers.IntegerField(min_value=1, max_value=100, default=20)
|
||||
|
||||
def validate_types(self, value):
|
||||
"""Validate location types."""
|
||||
if not value:
|
||||
return []
|
||||
|
||||
valid_types = ["park", "ride"]
|
||||
types = [t.strip().lower() for t in value.split(",")]
|
||||
|
||||
for location_type in types:
|
||||
if location_type not in valid_types:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid location type: {location_type}. Valid types: {', '.join(valid_types)}"
|
||||
)
|
||||
|
||||
return types
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Media domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for photo uploads, media management,
|
||||
and related media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
|
||||
# === MEDIA SERIALIZERS ===
|
||||
|
||||
|
||||
class PhotoUploadInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo uploads."""
|
||||
|
||||
file = serializers.ImageField()
|
||||
caption = serializers.CharField(
|
||||
max_length=500,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional caption for the photo",
|
||||
)
|
||||
alt_text = serializers.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Alt text for accessibility",
|
||||
)
|
||||
is_primary = serializers.BooleanField(
|
||||
default=False, help_text="Whether this should be the primary photo"
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Photo Detail Example",
|
||||
summary="Example photo detail response",
|
||||
description="A photo with full details",
|
||||
value={
|
||||
"id": 1,
|
||||
"url": "https://example.com/media/photos/ride123.jpg",
|
||||
"thumbnail_url": "https://example.com/media/thumbnails/ride123_thumb.jpg",
|
||||
"caption": "Amazing view of Steel Vengeance",
|
||||
"alt_text": "Steel Vengeance roller coaster with blue sky",
|
||||
"is_primary": True,
|
||||
"uploaded_at": "2024-08-15T10:30:00Z",
|
||||
"uploaded_by": {
|
||||
"id": 1,
|
||||
"username": "coaster_photographer",
|
||||
"display_name": "Coaster Photographer",
|
||||
},
|
||||
"content_type": "Ride",
|
||||
"object_id": 123,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PhotoDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.URLField()
|
||||
thumbnail_url = serializers.URLField(required=False)
|
||||
caption = serializers.CharField()
|
||||
alt_text = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
uploaded_at = serializers.DateTimeField()
|
||||
content_type = serializers.CharField()
|
||||
object_id = serializers.IntegerField()
|
||||
|
||||
# File metadata
|
||||
file_size = serializers.IntegerField()
|
||||
width = serializers.IntegerField()
|
||||
height = serializers.IntegerField()
|
||||
format = serializers.CharField()
|
||||
|
||||
# Uploader info
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_uploaded_by(self, obj) -> dict:
|
||||
"""Get uploader information."""
|
||||
return {
|
||||
"id": obj.uploaded_by.id,
|
||||
"username": obj.uploaded_by.username,
|
||||
"display_name": getattr(
|
||||
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
|
||||
)(),
|
||||
}
|
||||
|
||||
|
||||
class PhotoListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.URLField()
|
||||
thumbnail_url = serializers.URLField(required=False)
|
||||
caption = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
uploaded_at = serializers.DateTimeField()
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_uploaded_by(self, obj) -> dict:
|
||||
"""Get uploader information."""
|
||||
return {
|
||||
"id": obj.uploaded_by.id,
|
||||
"username": obj.uploaded_by.username,
|
||||
}
|
||||
|
||||
|
||||
class PhotoUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating photos."""
|
||||
|
||||
caption = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
||||
alt_text = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Statistics, health check, and miscellaneous domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for statistics, health checks, and other
|
||||
miscellaneous functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === STATISTICS SERIALIZERS ===
|
||||
|
||||
|
||||
class ParkStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park statistics."""
|
||||
|
||||
total_parks = serializers.IntegerField()
|
||||
operating_parks = serializers.IntegerField()
|
||||
closed_parks = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
average_coaster_count = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Top countries
|
||||
top_countries = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class RideStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride statistics."""
|
||||
|
||||
total_rides = serializers.IntegerField()
|
||||
operating_rides = serializers.IntegerField()
|
||||
closed_rides = serializers.IntegerField()
|
||||
under_construction = serializers.IntegerField()
|
||||
|
||||
# By category
|
||||
rides_by_category = serializers.DictField()
|
||||
|
||||
# Averages
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
average_capacity = serializers.DecimalField(
|
||||
max_digits=8, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Top manufacturers
|
||||
top_manufacturers = serializers.ListField(child=serializers.DictField())
|
||||
|
||||
# Recently added
|
||||
recently_added_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ParkReviewOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
rating = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
content = serializers.CharField()
|
||||
visit_date = serializers.DateField()
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
# User info (limited for privacy)
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> dict:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"display_name": obj.user.get_full_name() or obj.user.username,
|
||||
}
|
||||
|
||||
|
||||
# === HEALTH CHECK SERIALIZERS ===
|
||||
|
||||
|
||||
class HealthCheckOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for health check responses."""
|
||||
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="health_statuses",
|
||||
domain="core"
|
||||
)
|
||||
timestamp = serializers.DateTimeField()
|
||||
version = serializers.CharField()
|
||||
environment = serializers.CharField()
|
||||
response_time_ms = serializers.FloatField()
|
||||
checks = serializers.DictField()
|
||||
metrics = serializers.DictField()
|
||||
|
||||
|
||||
class PerformanceMetricsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for performance metrics."""
|
||||
|
||||
timestamp = serializers.DateTimeField()
|
||||
database_analysis = serializers.DictField()
|
||||
cache_performance = serializers.DictField()
|
||||
recent_slow_queries = serializers.ListField()
|
||||
|
||||
|
||||
class SimpleHealthOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for simple health check."""
|
||||
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="simple_health_statuses",
|
||||
domain="core"
|
||||
)
|
||||
timestamp = serializers.DateTimeField()
|
||||
error = serializers.CharField(required=False)
|
||||
@@ -1,728 +0,0 @@
|
||||
"""
|
||||
Parks domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to parks, park areas, park locations,
|
||||
and park search functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
|
||||
from apps.core.services.media_url_service import MediaURLService
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === PARK SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Park List Example",
|
||||
summary="Example park list response",
|
||||
description="A typical park in the list view",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"status": "OPERATING",
|
||||
"description": "America's Roller Coast",
|
||||
"average_rating": 4.5,
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"location": {
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"operator": {"id": 1, "name": "Cedar Fair", "slug": "cedar-fair"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="parks"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
coaster_count = serializers.IntegerField(allow_null=True)
|
||||
ride_count = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Location (simplified for list view)
|
||||
location = LocationOutputSerializer(allow_null=True)
|
||||
|
||||
# Operator info
|
||||
operator = CompanyOutputSerializer()
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this park."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Park Detail Example",
|
||||
summary="Example park detail response",
|
||||
description="A complete park detail response",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"status": "OPERATING",
|
||||
"description": "America's Roller Coast",
|
||||
"opening_date": "1870-01-01",
|
||||
"website": "https://cedarpoint.com",
|
||||
"size_acres": 364.0,
|
||||
"average_rating": 4.5,
|
||||
"coaster_count": 17,
|
||||
"ride_count": 70,
|
||||
"location": {
|
||||
"latitude": 41.4793,
|
||||
"longitude": -82.6833,
|
||||
"city": "Sandusky",
|
||||
"state": "Ohio",
|
||||
"country": "United States",
|
||||
},
|
||||
"operator": {"id": 1, "name": "Cedar Fair", "slug": "cedar-fair"},
|
||||
"photos": [
|
||||
{
|
||||
"id": 456,
|
||||
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance",
|
||||
"is_primary": True,
|
||||
}
|
||||
],
|
||||
"primary_photo": {
|
||||
"id": 456,
|
||||
"image_url": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/def789ghi012/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/def789ghi012/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/def789ghi012/large",
|
||||
"public": "https://imagedelivery.net/account-hash/def789ghi012/public",
|
||||
},
|
||||
"caption": "Beautiful park entrance",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park detail view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="parks"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Details
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
operating_season = serializers.CharField()
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, allow_null=True
|
||||
)
|
||||
website = serializers.URLField()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
coaster_count = serializers.IntegerField(allow_null=True)
|
||||
ride_count = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Location (full details)
|
||||
location = LocationOutputSerializer(allow_null=True)
|
||||
|
||||
# Companies
|
||||
operator = CompanyOutputSerializer()
|
||||
property_owner = CompanyOutputSerializer(allow_null=True)
|
||||
|
||||
# Areas
|
||||
areas = serializers.SerializerMethodField()
|
||||
|
||||
# Photos
|
||||
photos = serializers.SerializerMethodField()
|
||||
primary_photo = serializers.SerializerMethodField()
|
||||
banner_image = serializers.SerializerMethodField()
|
||||
card_image = serializers.SerializerMethodField()
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this park."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_areas(self, obj):
|
||||
"""Get simplified area information."""
|
||||
if hasattr(obj, "areas"):
|
||||
return [
|
||||
{
|
||||
"id": area.id,
|
||||
"name": area.name,
|
||||
"slug": area.slug,
|
||||
"description": area.description,
|
||||
}
|
||||
for area in obj.areas.all()
|
||||
]
|
||||
return []
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_photos(self, obj):
|
||||
"""Get all approved photos for this park."""
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
photos = ParkPhoto.objects.filter(park=obj, is_approved=True).order_by(
|
||||
"-is_primary", "-created_at"
|
||||
)[
|
||||
:10
|
||||
] # Limit to 10 photos
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
|
||||
},
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
}
|
||||
for photo in photos
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_primary_photo(self, obj):
|
||||
"""Get the primary photo for this park."""
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
photo = ParkPhoto.objects.filter(
|
||||
park=obj, is_primary=True, is_approved=True
|
||||
).first()
|
||||
|
||||
if photo and photo.image:
|
||||
return {
|
||||
"id": photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, photo.caption, photo.pk, "public"),
|
||||
},
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_banner_image(self, obj):
|
||||
"""Get the banner image for this park with fallback to latest photo."""
|
||||
# First try the explicitly set banner image
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return {
|
||||
"id": obj.banner_image.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.banner_image.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.banner_image.caption, obj.banner_image.pk, "public"),
|
||||
},
|
||||
"caption": obj.banner_image.caption,
|
||||
"alt_text": obj.banner_image.alt_text,
|
||||
}
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
latest_photo = (
|
||||
ParkPhoto.objects.filter(
|
||||
park=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
|
||||
},
|
||||
"caption": latest_photo.caption,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
"is_fallback": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_card_image(self, obj):
|
||||
"""Get the card image for this park with fallback to latest photo."""
|
||||
# First try the explicitly set card image
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return {
|
||||
"id": obj.card_image.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(obj.card_image.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, obj.card_image.caption, obj.card_image.pk, "public"),
|
||||
},
|
||||
"caption": obj.card_image.caption,
|
||||
"alt_text": obj.card_image.alt_text,
|
||||
}
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
latest_photo = (
|
||||
ParkPhoto.objects.filter(
|
||||
park=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.pk,
|
||||
"image_url": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
"image_variants": {
|
||||
"thumbnail": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "thumbnail"),
|
||||
"medium": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "medium"),
|
||||
"large": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "large"),
|
||||
"public": MediaURLService.get_cloudflare_url_with_fallback(latest_photo.image, "public"),
|
||||
},
|
||||
"friendly_urls": {
|
||||
"thumbnail": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "thumbnail"),
|
||||
"medium": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "medium"),
|
||||
"large": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "large"),
|
||||
"public": MediaURLService.generate_park_photo_url(obj.slug, latest_photo.caption, latest_photo.pk, "public"),
|
||||
},
|
||||
"caption": latest_photo.caption,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
"is_fallback": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ParkImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for setting park banner and card images."""
|
||||
|
||||
banner_image_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
card_image_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate_banner_image_id(self, value):
|
||||
"""Validate that the banner image belongs to the same park."""
|
||||
if value is not None:
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
ParkPhoto.objects.get(id=value)
|
||||
# The park will be validated in the view
|
||||
return value
|
||||
except ParkPhoto.DoesNotExist:
|
||||
raise serializers.ValidationError("Photo not found")
|
||||
return value
|
||||
|
||||
def validate_card_image_id(self, value):
|
||||
"""Validate that the card image belongs to the same park."""
|
||||
if value is not None:
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
try:
|
||||
ParkPhoto.objects.get(id=value)
|
||||
# The park will be validated in the view
|
||||
return value
|
||||
except ParkPhoto.DoesNotExist:
|
||||
raise serializers.ValidationError("Photo not found")
|
||||
return value
|
||||
|
||||
|
||||
class ParkCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating parks."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_park_status_choices(), default="OPERATING"
|
||||
)
|
||||
|
||||
# Optional details
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
operating_season = serializers.CharField(
|
||||
max_length=255, required=False, allow_blank=True
|
||||
)
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Required operator
|
||||
operator_id = serializers.IntegerField()
|
||||
|
||||
# Optional property owner
|
||||
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
opening_date = attrs.get("opening_date")
|
||||
closing_date = attrs.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ParkUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating parks."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_park_status_choices(), required=False
|
||||
)
|
||||
|
||||
# Optional details
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
operating_season = serializers.CharField(
|
||||
max_length=255, required=False, allow_blank=True
|
||||
)
|
||||
size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Companies
|
||||
operator_id = serializers.IntegerField(required=False)
|
||||
property_owner_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
opening_date = attrs.get("opening_date")
|
||||
closing_date = attrs.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ParkFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for park filtering and search."""
|
||||
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=[],
|
||||
required=False, # Choices set dynamically
|
||||
)
|
||||
|
||||
# Location filters
|
||||
country = serializers.CharField(required=False, allow_blank=True)
|
||||
state = serializers.CharField(required=False, allow_blank=True)
|
||||
city = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Rating filter
|
||||
min_rating = serializers.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
)
|
||||
|
||||
# Size filter
|
||||
min_size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, min_value=0
|
||||
)
|
||||
max_size_acres = serializers.DecimalField(
|
||||
max_digits=10, decimal_places=2, required=False, min_value=0
|
||||
)
|
||||
|
||||
# Company filters
|
||||
operator_id = serializers.IntegerField(required=False)
|
||||
property_owner_id = serializers.IntegerField(required=False)
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"coaster_count",
|
||||
"-coaster_count",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
],
|
||||
required=False,
|
||||
default="name",
|
||||
)
|
||||
|
||||
|
||||
# === PARK AREA SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Park Area Example",
|
||||
summary="Example park area response",
|
||||
description="A themed area within a park",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Tomorrowland",
|
||||
"slug": "tomorrowland",
|
||||
"description": "A futuristic themed area",
|
||||
"park": {"id": 1, "name": "Magic Kingdom", "slug": "magic-kingdom"},
|
||||
"opening_date": "1971-10-01",
|
||||
"closing_date": None,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class ParkAreaDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park areas."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
|
||||
# Park info
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.park.id,
|
||||
"name": obj.park.name,
|
||||
"slug": obj.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class ParkAreaCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating park areas."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
park_id = serializers.IntegerField()
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
opening_date = attrs.get("opening_date")
|
||||
closing_date = attrs.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ParkAreaUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating park areas."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
opening_date = attrs.get("opening_date")
|
||||
closing_date = attrs.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# === PARK LOCATION SERIALIZERS ===
|
||||
|
||||
|
||||
class ParkLocationOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park locations."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
address = serializers.CharField()
|
||||
city = serializers.CharField()
|
||||
state = serializers.CharField()
|
||||
country = serializers.CharField()
|
||||
postal_code = serializers.CharField()
|
||||
formatted_address = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.park.id,
|
||||
"name": obj.park.name,
|
||||
"slug": obj.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class ParkLocationCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating park locations."""
|
||||
|
||||
park_id = serializers.IntegerField()
|
||||
latitude = serializers.FloatField(required=False, allow_null=True)
|
||||
longitude = serializers.FloatField(required=False, allow_null=True)
|
||||
address = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
city = serializers.CharField(max_length=100)
|
||||
state = serializers.CharField(max_length=100)
|
||||
country = serializers.CharField(max_length=100)
|
||||
postal_code = serializers.CharField(max_length=20, allow_blank=True, default="")
|
||||
|
||||
|
||||
class ParkLocationUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating park locations."""
|
||||
|
||||
latitude = serializers.FloatField(required=False, allow_null=True)
|
||||
longitude = serializers.FloatField(required=False, allow_null=True)
|
||||
address = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
city = serializers.CharField(max_length=100, required=False)
|
||||
state = serializers.CharField(max_length=100, required=False)
|
||||
country = serializers.CharField(max_length=100, required=False)
|
||||
postal_code = serializers.CharField(max_length=20, allow_blank=True, required=False)
|
||||
|
||||
|
||||
# === PARKS SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
class ParkSuggestionSerializer(serializers.Serializer):
|
||||
"""Serializer for park search suggestions."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
location = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
coaster_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ParkSuggestionOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park suggestions."""
|
||||
|
||||
results = ParkSuggestionSerializer(many=True)
|
||||
query = serializers.CharField()
|
||||
count = serializers.IntegerField()
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
Park media serializers for ThrillWiki API.
|
||||
|
||||
This module contains serializers for park-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
|
||||
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for park photos."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
file_size = serializers.ReadOnlyField()
|
||||
dimensions = serializers.ReadOnlyField()
|
||||
park_slug = serializers.CharField(source="park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for park photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
Serializers for review-related API endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.parks.models.reviews import ParkReview
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["username", "display_name", "avatar_url"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar_url()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class LatestReviewSerializer(serializers.Serializer):
|
||||
"""Serializer for latest reviews combining park and ride reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
type = serializers.CharField() # 'park' or 'ride'
|
||||
title = serializers.CharField()
|
||||
content_snippet = serializers.CharField()
|
||||
rating = serializers.IntegerField()
|
||||
created_at = serializers.DateTimeField()
|
||||
user = ReviewUserSerializer()
|
||||
|
||||
# Subject information (park or ride)
|
||||
subject_name = serializers.CharField()
|
||||
subject_slug = serializers.CharField()
|
||||
subject_url = serializers.CharField()
|
||||
|
||||
# Park information (for ride reviews)
|
||||
park_name = serializers.CharField(allow_null=True)
|
||||
park_slug = serializers.CharField(allow_null=True)
|
||||
park_url = serializers.CharField(allow_null=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Convert review instance to serialized representation."""
|
||||
if isinstance(instance, ParkReview):
|
||||
return {
|
||||
"id": instance.pk,
|
||||
"type": "park",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.park.name,
|
||||
"subject_slug": instance.park.slug,
|
||||
"subject_url": f"/parks/{instance.park.slug}/",
|
||||
"park_name": None,
|
||||
"park_slug": None,
|
||||
"park_url": None,
|
||||
}
|
||||
elif isinstance(instance, RideReview):
|
||||
return {
|
||||
"id": instance.pk,
|
||||
"type": "ride",
|
||||
"title": instance.title,
|
||||
"content_snippet": self._get_content_snippet(instance.content),
|
||||
"rating": instance.rating,
|
||||
"created_at": instance.created_at,
|
||||
"user": ReviewUserSerializer(instance.user).data,
|
||||
"subject_name": instance.ride.name,
|
||||
"subject_slug": instance.ride.slug,
|
||||
"subject_url": f"/parks/{instance.ride.park.slug}/rides/{instance.ride.slug}/",
|
||||
"park_name": instance.ride.park.name,
|
||||
"park_slug": instance.ride.park.slug,
|
||||
"park_url": f"/parks/{instance.ride.park.slug}/",
|
||||
}
|
||||
return {}
|
||||
|
||||
def _get_content_snippet(self, content, max_length=150):
|
||||
"""Get a snippet of the review content."""
|
||||
if len(content) <= max_length:
|
||||
return content
|
||||
|
||||
# Find the last complete word within the limit
|
||||
snippet = content[:max_length]
|
||||
last_space = snippet.rfind(" ")
|
||||
if last_space > 0:
|
||||
snippet = snippet[:last_space]
|
||||
|
||||
return snippet + "..."
|
||||
@@ -1,797 +0,0 @@
|
||||
"""
|
||||
RideModel serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to ride models, variants,
|
||||
technical specifications, and related functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
# Use dynamic imports to avoid circular import issues
|
||||
|
||||
|
||||
def get_ride_model_classes():
|
||||
"""Get ride model classes dynamically to avoid import issues."""
|
||||
from apps.rides.models import (
|
||||
RideModel,
|
||||
RideModelVariant,
|
||||
RideModelPhoto,
|
||||
RideModelTechnicalSpec,
|
||||
)
|
||||
|
||||
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec
|
||||
|
||||
|
||||
# === RIDE MODEL SERIALIZERS ===
|
||||
|
||||
|
||||
class RideModelManufacturerOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model's manufacturer data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
|
||||
|
||||
class RideModelPhotoOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model photos."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
image_url = serializers.SerializerMethodField()
|
||||
caption = serializers.CharField()
|
||||
alt_text = serializers.CharField()
|
||||
photo_type = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
photographer = serializers.CharField()
|
||||
source = serializers.CharField()
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_image_url(self, obj):
|
||||
"""Get the image URL."""
|
||||
if obj.image:
|
||||
return obj.image.url
|
||||
return None
|
||||
|
||||
|
||||
class RideModelTechnicalSpecOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model technical specifications."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
spec_category = serializers.CharField()
|
||||
spec_name = serializers.CharField()
|
||||
spec_value = serializers.CharField()
|
||||
spec_unit = serializers.CharField()
|
||||
notes = serializers.CharField()
|
||||
|
||||
|
||||
class RideModelVariantOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model variants."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
min_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
max_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
min_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
max_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
distinguishing_features = serializers.CharField()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Model List Example",
|
||||
summary="Example ride model list response",
|
||||
description="A typical ride model in the list view",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Hyper Coaster",
|
||||
"slug": "bolliger-mabillard-hyper-coaster",
|
||||
"category": "RC",
|
||||
"description": "High-speed steel roller coaster with airtime hills",
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
"target_market": "THRILL",
|
||||
"is_discontinued": False,
|
||||
"total_installations": 15,
|
||||
"first_installation_year": 1999,
|
||||
"height_range_display": "200-325 ft",
|
||||
"speed_range_display": "70-95 mph",
|
||||
"primary_image": {
|
||||
"id": 123,
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"caption": "B&M Hyper Coaster",
|
||||
"photo_type": "PROMOTIONAL",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideModelListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
|
||||
|
||||
# Market info
|
||||
target_market = RichChoiceFieldSerializer(
|
||||
choice_group="target_markets",
|
||||
domain="rides"
|
||||
)
|
||||
is_discontinued = serializers.BooleanField()
|
||||
total_installations = serializers.IntegerField()
|
||||
first_installation_year = serializers.IntegerField(allow_null=True)
|
||||
last_installation_year = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Display properties
|
||||
height_range_display = serializers.CharField()
|
||||
speed_range_display = serializers.CharField()
|
||||
installation_years_range = serializers.CharField()
|
||||
|
||||
# Primary image
|
||||
primary_image = RideModelPhotoOutputSerializer(allow_null=True)
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this ride model."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Model Detail Example",
|
||||
summary="Example ride model detail response",
|
||||
description="A complete ride model detail response",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Hyper Coaster",
|
||||
"slug": "bolliger-mabillard-hyper-coaster",
|
||||
"category": "RC",
|
||||
"description": "High-speed steel roller coaster featuring airtime hills and smooth ride experience",
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Bolliger & Mabillard",
|
||||
"slug": "bolliger-mabillard",
|
||||
},
|
||||
"typical_height_range_min_ft": 200.0,
|
||||
"typical_height_range_max_ft": 325.0,
|
||||
"typical_speed_range_min_mph": 70.0,
|
||||
"typical_speed_range_max_mph": 95.0,
|
||||
"typical_capacity_range_min": 1200,
|
||||
"typical_capacity_range_max": 1800,
|
||||
"track_type": "Tubular Steel",
|
||||
"support_structure": "Steel",
|
||||
"train_configuration": "2-3 trains, 7-9 cars per train, 4 seats per car",
|
||||
"restraint_system": "Clamshell lap bar",
|
||||
"target_market": "THRILL",
|
||||
"is_discontinued": False,
|
||||
"total_installations": 15,
|
||||
"first_installation_year": 1999,
|
||||
"notable_features": "Airtime hills, smooth ride, high capacity",
|
||||
"photos": [
|
||||
{
|
||||
"id": 123,
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"caption": "B&M Hyper Coaster",
|
||||
"photo_type": "PROMOTIONAL",
|
||||
"is_primary": True,
|
||||
}
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Mega Coaster",
|
||||
"description": "200-299 ft height variant",
|
||||
"min_height_ft": 200.0,
|
||||
"max_height_ft": 299.0,
|
||||
}
|
||||
],
|
||||
"technical_specs": [
|
||||
{
|
||||
"id": 1,
|
||||
"spec_category": "DIMENSIONS",
|
||||
"spec_name": "Track Width",
|
||||
"spec_value": "1435",
|
||||
"spec_unit": "mm",
|
||||
}
|
||||
],
|
||||
"installations": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Nitro",
|
||||
"park_name": "Six Flags Great Adventure",
|
||||
"opening_date": "2001-04-07",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideModelDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model detail view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
|
||||
|
||||
# Technical specifications
|
||||
typical_height_range_min_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
typical_height_range_max_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
typical_speed_range_min_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
typical_speed_range_max_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
typical_capacity_range_min = serializers.IntegerField(allow_null=True)
|
||||
typical_capacity_range_max = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField()
|
||||
support_structure = serializers.CharField()
|
||||
train_configuration = serializers.CharField()
|
||||
restraint_system = serializers.CharField()
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(allow_null=True)
|
||||
last_installation_year = serializers.IntegerField(allow_null=True)
|
||||
is_discontinued = serializers.BooleanField()
|
||||
total_installations = serializers.IntegerField()
|
||||
|
||||
# Design features
|
||||
notable_features = serializers.CharField()
|
||||
target_market = serializers.CharField()
|
||||
|
||||
# Display properties
|
||||
height_range_display = serializers.CharField()
|
||||
speed_range_display = serializers.CharField()
|
||||
installation_years_range = serializers.CharField()
|
||||
|
||||
# SEO metadata
|
||||
meta_title = serializers.CharField()
|
||||
meta_description = serializers.CharField()
|
||||
|
||||
# Related data
|
||||
photos = RideModelPhotoOutputSerializer(many=True)
|
||||
variants = RideModelVariantOutputSerializer(many=True)
|
||||
technical_specs = RideModelTechnicalSpecOutputSerializer(many=True)
|
||||
installations = serializers.SerializerMethodField()
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this ride model."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/rides/manufacturers/{obj.manufacturer.slug}/{obj.slug}/"
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_installations(self, obj):
|
||||
"""Get ride installations using this model."""
|
||||
from django.apps import apps
|
||||
|
||||
Ride = apps.get_model("rides", "Ride")
|
||||
|
||||
installations = Ride.objects.filter(ride_model=obj).select_related("park")[:10]
|
||||
return [
|
||||
{
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"park_name": ride.park.name,
|
||||
"park_slug": ride.park.slug,
|
||||
"opening_date": ride.opening_date,
|
||||
"status": ride.status,
|
||||
}
|
||||
for ride in installations
|
||||
]
|
||||
|
||||
|
||||
class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride models."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(), allow_blank=True, default=""
|
||||
)
|
||||
|
||||
# Required manufacturer
|
||||
manufacturer_id = serializers.IntegerField()
|
||||
|
||||
# Technical specifications
|
||||
typical_height_range_min_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_height_range_max_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_speed_range_min_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_speed_range_max_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_capacity_range_min = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
typical_capacity_range_max = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField(max_length=100, allow_blank=True, default="")
|
||||
support_structure = serializers.CharField(
|
||||
max_length=100, allow_blank=True, default=""
|
||||
)
|
||||
train_configuration = serializers.CharField(
|
||||
max_length=200, allow_blank=True, default=""
|
||||
)
|
||||
restraint_system = serializers.CharField(
|
||||
max_length=100, allow_blank=True, default=""
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1800, max_value=2100
|
||||
)
|
||||
last_installation_year = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1800, max_value=2100
|
||||
)
|
||||
is_discontinued = serializers.BooleanField(default=False)
|
||||
|
||||
# Design features
|
||||
notable_features = serializers.CharField(allow_blank=True, default="")
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_target_market_choices(),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
# Height range validation
|
||||
min_height = attrs.get("typical_height_range_min_ft")
|
||||
max_height = attrs.get("typical_height_range_max_ft")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
# Speed range validation
|
||||
min_speed = attrs.get("typical_speed_range_min_mph")
|
||||
max_speed = attrs.get("typical_speed_range_max_mph")
|
||||
|
||||
if min_speed and max_speed and min_speed > max_speed:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum speed cannot be greater than maximum speed"
|
||||
)
|
||||
|
||||
# Capacity range validation
|
||||
min_capacity = attrs.get("typical_capacity_range_min")
|
||||
max_capacity = attrs.get("typical_capacity_range_max")
|
||||
|
||||
if min_capacity and max_capacity and min_capacity > max_capacity:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum capacity cannot be greater than maximum capacity"
|
||||
)
|
||||
|
||||
# Installation years validation
|
||||
first_year = attrs.get("first_installation_year")
|
||||
last_year = attrs.get("last_installation_year")
|
||||
|
||||
if first_year and last_year and first_year > last_year:
|
||||
raise serializers.ValidationError(
|
||||
"First installation year cannot be after last installation year"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride models."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(),
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Manufacturer
|
||||
manufacturer_id = serializers.IntegerField(required=False)
|
||||
|
||||
# Technical specifications
|
||||
typical_height_range_min_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_height_range_max_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_speed_range_min_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_speed_range_max_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
typical_capacity_range_min = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
typical_capacity_range_max = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
|
||||
# Design characteristics
|
||||
track_type = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||
support_structure = serializers.CharField(
|
||||
max_length=100, allow_blank=True, required=False
|
||||
)
|
||||
train_configuration = serializers.CharField(
|
||||
max_length=200, allow_blank=True, required=False
|
||||
)
|
||||
restraint_system = serializers.CharField(
|
||||
max_length=100, allow_blank=True, required=False
|
||||
)
|
||||
|
||||
# Market information
|
||||
first_installation_year = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1800, max_value=2100
|
||||
)
|
||||
last_installation_year = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1800, max_value=2100
|
||||
)
|
||||
is_discontinued = serializers.BooleanField(required=False)
|
||||
|
||||
# Design features
|
||||
notable_features = serializers.CharField(allow_blank=True, required=False)
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_target_market_choices(),
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
# Height range validation
|
||||
min_height = attrs.get("typical_height_range_min_ft")
|
||||
max_height = attrs.get("typical_height_range_max_ft")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
# Speed range validation
|
||||
min_speed = attrs.get("typical_speed_range_min_mph")
|
||||
max_speed = attrs.get("typical_speed_range_max_mph")
|
||||
|
||||
if min_speed and max_speed and min_speed > max_speed:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum speed cannot be greater than maximum speed"
|
||||
)
|
||||
|
||||
# Capacity range validation
|
||||
min_capacity = attrs.get("typical_capacity_range_min")
|
||||
max_capacity = attrs.get("typical_capacity_range_max")
|
||||
|
||||
if min_capacity and max_capacity and min_capacity > max_capacity:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum capacity cannot be greater than maximum capacity"
|
||||
)
|
||||
|
||||
# Installation years validation
|
||||
first_year = attrs.get("first_installation_year")
|
||||
last_year = attrs.get("last_installation_year")
|
||||
|
||||
if first_year and last_year and first_year > last_year:
|
||||
raise serializers.ValidationError(
|
||||
"First installation year cannot be after last installation year"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class RideModelFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for ride model filtering and search."""
|
||||
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
|
||||
# Manufacturer filter
|
||||
manufacturer_id = serializers.IntegerField(required=False)
|
||||
manufacturer_slug = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Market filter
|
||||
target_market = serializers.MultipleChoiceField(
|
||||
choices=ModelChoices.get_target_market_choices(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Status filter
|
||||
is_discontinued = serializers.BooleanField(required=False)
|
||||
|
||||
# Year filters
|
||||
first_installation_year_min = serializers.IntegerField(required=False)
|
||||
first_installation_year_max = serializers.IntegerField(required=False)
|
||||
|
||||
# Installation count filter
|
||||
min_installations = serializers.IntegerField(required=False, min_value=0)
|
||||
|
||||
# Height filters
|
||||
min_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False
|
||||
)
|
||||
max_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False
|
||||
)
|
||||
|
||||
# Speed filters
|
||||
min_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False
|
||||
)
|
||||
max_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False
|
||||
)
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
"name",
|
||||
"-name",
|
||||
"manufacturer__name",
|
||||
"-manufacturer__name",
|
||||
"first_installation_year",
|
||||
"-first_installation_year",
|
||||
"total_installations",
|
||||
"-total_installations",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
],
|
||||
required=False,
|
||||
default="manufacturer__name,name",
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL VARIANT SERIALIZERS ===
|
||||
|
||||
|
||||
class RideModelVariantCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride model variants."""
|
||||
|
||||
ride_model_id = serializers.IntegerField()
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
# Variant-specific specifications
|
||||
min_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
max_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
min_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
max_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
|
||||
# Distinguishing features
|
||||
distinguishing_features = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
# Height range validation
|
||||
min_height = attrs.get("min_height_ft")
|
||||
max_height = attrs.get("max_height_ft")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
# Speed range validation
|
||||
min_speed = attrs.get("min_speed_mph")
|
||||
max_speed = attrs.get("max_speed_mph")
|
||||
|
||||
if min_speed and max_speed and min_speed > max_speed:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum speed cannot be greater than maximum speed"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class RideModelVariantUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride model variants."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
|
||||
# Variant-specific specifications
|
||||
min_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
max_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
min_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
max_speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
|
||||
# Distinguishing features
|
||||
distinguishing_features = serializers.CharField(allow_blank=True, required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
# Height range validation
|
||||
min_height = attrs.get("min_height_ft")
|
||||
max_height = attrs.get("max_height_ft")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
# Speed range validation
|
||||
min_speed = attrs.get("min_speed_mph")
|
||||
max_speed = attrs.get("max_speed_mph")
|
||||
|
||||
if min_speed and max_speed and min_speed > max_speed:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum speed cannot be greater than maximum speed"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
# === RIDE MODEL TECHNICAL SPEC SERIALIZERS ===
|
||||
|
||||
|
||||
class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride model technical specifications."""
|
||||
|
||||
ride_model_id = serializers.IntegerField()
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_technical_spec_category_choices()
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100)
|
||||
spec_value = serializers.CharField(max_length=255)
|
||||
spec_unit = serializers.CharField(max_length=20, allow_blank=True, default="")
|
||||
notes = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
|
||||
class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride model technical specifications."""
|
||||
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_technical_spec_category_choices(),
|
||||
required=False,
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100, required=False)
|
||||
spec_value = serializers.CharField(max_length=255, required=False)
|
||||
spec_unit = serializers.CharField(max_length=20, allow_blank=True, required=False)
|
||||
notes = serializers.CharField(allow_blank=True, required=False)
|
||||
|
||||
|
||||
# === RIDE MODEL PHOTO SERIALIZERS ===
|
||||
|
||||
|
||||
class RideModelPhotoCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride model photos."""
|
||||
|
||||
ride_model_id = serializers.IntegerField()
|
||||
image = serializers.ImageField()
|
||||
caption = serializers.CharField(max_length=500, allow_blank=True, default="")
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_photo_type_choices(),
|
||||
default="PROMOTIONAL",
|
||||
)
|
||||
is_primary = serializers.BooleanField(default=False)
|
||||
photographer = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
source = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
copyright_info = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
|
||||
|
||||
class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride model photos."""
|
||||
|
||||
caption = serializers.CharField(max_length=500, allow_blank=True, required=False)
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_photo_type_choices(),
|
||||
required=False,
|
||||
)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
photographer = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
source = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
copyright_info = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
|
||||
|
||||
# === RIDE MODEL STATS SERIALIZERS ===
|
||||
|
||||
|
||||
class RideModelStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model statistics."""
|
||||
|
||||
total_models = serializers.IntegerField()
|
||||
total_installations = serializers.IntegerField()
|
||||
active_manufacturers = serializers.IntegerField()
|
||||
discontinued_models = serializers.IntegerField()
|
||||
by_category = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Model counts by category"
|
||||
)
|
||||
by_target_market = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Model counts by target market"
|
||||
)
|
||||
by_manufacturer = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Model counts by manufacturer"
|
||||
)
|
||||
recent_models = serializers.IntegerField(
|
||||
help_text="Models created in the last 30 days"
|
||||
)
|
||||
@@ -1,219 +0,0 @@
|
||||
"""
|
||||
Serializers for ride review API endpoints.
|
||||
|
||||
This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.accounts.models import User
|
||||
from apps.core.choices.serializers import RichChoiceSerializer
|
||||
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in ride reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "display_name", "avatar_url"]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar_url()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Complete Ride Review",
|
||||
summary="Full ride review response",
|
||||
description="Example response showing all fields for a ride review",
|
||||
value={
|
||||
"id": 123,
|
||||
"title": "Amazing roller coaster experience!",
|
||||
"content": "This ride was absolutely incredible. The inversions were smooth and the theming was top-notch.",
|
||||
"rating": 9,
|
||||
"visit_date": "2023-06-15",
|
||||
"created_at": "2023-06-16T10:30:00Z",
|
||||
"updated_at": "2023-06-16T10:30:00Z",
|
||||
"is_published": True,
|
||||
"user": {
|
||||
"id": 456,
|
||||
"username": "coaster_fan",
|
||||
"display_name": "Coaster Fan",
|
||||
"avatar_url": "https://example.com/avatar.jpg"
|
||||
},
|
||||
"ride": {
|
||||
"id": 789,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance"
|
||||
},
|
||||
"park": {
|
||||
"id": 101,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point"
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideReviewOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride reviews."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
|
||||
# Ride information
|
||||
ride = serializers.SerializerMethodField()
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"ride",
|
||||
"park",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"ride",
|
||||
"park",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj):
|
||||
"""Get ride information."""
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj):
|
||||
"""Get park information."""
|
||||
return {
|
||||
"id": obj.ride.park.id,
|
||||
"name": obj.ride.park.name,
|
||||
"slug": obj.ride.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class RideReviewCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride reviews."""
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride reviews."""
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride review lists."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"ride_name",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RideReviewStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride review statistics."""
|
||||
|
||||
total_reviews = serializers.IntegerField()
|
||||
published_reviews = serializers.IntegerField()
|
||||
pending_reviews = serializers.IntegerField()
|
||||
average_rating = serializers.FloatField(allow_null=True)
|
||||
rating_distribution = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Count of reviews by rating (1-10)"
|
||||
)
|
||||
recent_reviews = serializers.IntegerField()
|
||||
|
||||
|
||||
class RideReviewModerationInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for review moderation operations."""
|
||||
|
||||
review_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of review IDs to moderate"
|
||||
)
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
("publish", "Publish"),
|
||||
("unpublish", "Unpublish"),
|
||||
("delete", "Delete"),
|
||||
],
|
||||
help_text="Moderation action to perform"
|
||||
)
|
||||
moderation_notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional notes about the moderation action"
|
||||
)
|
||||
@@ -1,948 +0,0 @@
|
||||
"""
|
||||
Rides domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to rides, roller coaster statistics,
|
||||
ride locations, and ride reviews.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from config.django import base as settings
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === RIDE SERIALIZERS ===
|
||||
|
||||
|
||||
class RideParkOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride's park data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this park."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
|
||||
|
||||
|
||||
class RideModelOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride model data."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_manufacturer(self, obj) -> dict | None:
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
"id": obj.manufacturer.id,
|
||||
"name": obj.manufacturer.name,
|
||||
"slug": obj.manufacturer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride List Example",
|
||||
summary="Example ride list response",
|
||||
description="A typical ride in the list view",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"category": "ROLLER_COASTER",
|
||||
"status": "OPERATING",
|
||||
"description": "Hybrid roller coaster",
|
||||
"park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"},
|
||||
"average_rating": 4.8,
|
||||
"capacity_per_hour": 1200,
|
||||
"opening_date": "2018-05-05",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="rides"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
park = RideParkOutputSerializer()
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this ride."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Detail Example",
|
||||
summary="Example ride detail response",
|
||||
description="A complete ride detail response",
|
||||
value={
|
||||
"id": 1,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance",
|
||||
"category": "ROLLER_COASTER",
|
||||
"status": "OPERATING",
|
||||
"description": "Hybrid roller coaster featuring RMC I-Box track",
|
||||
"park": {"id": 1, "name": "Cedar Point", "slug": "cedar-point"},
|
||||
"opening_date": "2018-05-05",
|
||||
"min_height_in": 48,
|
||||
"capacity_per_hour": 1200,
|
||||
"ride_duration_seconds": 150,
|
||||
"average_rating": 4.8,
|
||||
"manufacturer": {
|
||||
"id": 1,
|
||||
"name": "Rocky Mountain Construction",
|
||||
"slug": "rocky-mountain-construction",
|
||||
},
|
||||
"photos": [
|
||||
{
|
||||
"id": 123,
|
||||
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"is_primary": True,
|
||||
"photo_type": "exterior",
|
||||
}
|
||||
],
|
||||
"primary_photo": {
|
||||
"id": 123,
|
||||
"image_url": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
"image_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/abc123def456/thumbnail",
|
||||
"medium": "https://imagedelivery.net/account-hash/abc123def456/medium",
|
||||
"large": "https://imagedelivery.net/account-hash/abc123def456/large",
|
||||
"public": "https://imagedelivery.net/account-hash/abc123def456/public",
|
||||
},
|
||||
"caption": "Amazing roller coaster photo",
|
||||
"photo_type": "exterior",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride detail view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="rides"
|
||||
)
|
||||
post_closing_status = RichChoiceFieldSerializer(
|
||||
choice_group="post_closing_statuses",
|
||||
domain="rides",
|
||||
allow_null=True
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
park = RideParkOutputSerializer()
|
||||
park_area = serializers.SerializerMethodField()
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(allow_null=True)
|
||||
closing_date = serializers.DateField(allow_null=True)
|
||||
status_since = serializers.DateField(allow_null=True)
|
||||
|
||||
# Physical specs
|
||||
min_height_in = serializers.IntegerField(allow_null=True)
|
||||
max_height_in = serializers.IntegerField(allow_null=True)
|
||||
capacity_per_hour = serializers.IntegerField(allow_null=True)
|
||||
ride_duration_seconds = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Statistics
|
||||
average_rating = serializers.DecimalField(
|
||||
max_digits=3, decimal_places=2, allow_null=True
|
||||
)
|
||||
|
||||
# Companies
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
designer = serializers.SerializerMethodField()
|
||||
|
||||
# Model
|
||||
ride_model = RideModelOutputSerializer(allow_null=True)
|
||||
|
||||
# Photos
|
||||
photos = serializers.SerializerMethodField()
|
||||
primary_photo = serializers.SerializerMethodField()
|
||||
banner_image = serializers.SerializerMethodField()
|
||||
card_image = serializers.SerializerMethodField()
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this ride."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.park.slug}/rides/{obj.slug}/"
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_park_area(self, obj) -> dict | None:
|
||||
if obj.park_area:
|
||||
return {
|
||||
"id": obj.park_area.id,
|
||||
"name": obj.park_area.name,
|
||||
"slug": obj.park_area.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_manufacturer(self, obj) -> dict | None:
|
||||
if obj.manufacturer:
|
||||
return {
|
||||
"id": obj.manufacturer.id,
|
||||
"name": obj.manufacturer.name,
|
||||
"slug": obj.manufacturer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_designer(self, obj) -> dict | None:
|
||||
if obj.designer:
|
||||
return {
|
||||
"id": obj.designer.id,
|
||||
"name": obj.designer.name,
|
||||
"slug": obj.designer.slug,
|
||||
}
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_photos(self, obj):
|
||||
"""Get all approved photos for this ride."""
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
photos = RidePhoto.objects.filter(ride=obj, is_approved=True).order_by(
|
||||
"-is_primary", "-created_at"
|
||||
)[
|
||||
:10
|
||||
] # Limit to 10 photos
|
||||
|
||||
return [
|
||||
{
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url if photo.image else None,
|
||||
"image_variants": (
|
||||
{
|
||||
"thumbnail": (
|
||||
f"{photo.image.url}/thumbnail" if photo.image else None
|
||||
),
|
||||
"medium": f"{photo.image.url}/medium" if photo.image else None,
|
||||
"large": f"{photo.image.url}/large" if photo.image else None,
|
||||
"public": f"{photo.image.url}/public" if photo.image else None,
|
||||
}
|
||||
if photo.image
|
||||
else {}
|
||||
),
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
"photo_type": photo.photo_type,
|
||||
}
|
||||
for photo in photos
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_primary_photo(self, obj):
|
||||
"""Get the primary photo for this ride."""
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
photo = RidePhoto.objects.filter(
|
||||
ride=obj, is_primary=True, is_approved=True
|
||||
).first()
|
||||
|
||||
if photo and photo.image:
|
||||
return {
|
||||
"id": photo.id,
|
||||
"image_url": photo.image.url,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{photo.image.url}/thumbnail",
|
||||
"medium": f"{photo.image.url}/medium",
|
||||
"large": f"{photo.image.url}/large",
|
||||
"public": f"{photo.image.url}/public",
|
||||
},
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"photo_type": photo.photo_type,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_banner_image(self, obj):
|
||||
"""Get the banner image for this ride with fallback to latest photo."""
|
||||
# First try the explicitly set banner image
|
||||
if obj.banner_image and obj.banner_image.image:
|
||||
return {
|
||||
"id": obj.banner_image.id,
|
||||
"image_url": obj.banner_image.image.url,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{obj.banner_image.image.url}/thumbnail",
|
||||
"medium": f"{obj.banner_image.image.url}/medium",
|
||||
"large": f"{obj.banner_image.image.url}/large",
|
||||
"public": f"{obj.banner_image.image.url}/public",
|
||||
},
|
||||
"caption": obj.banner_image.caption,
|
||||
"alt_text": obj.banner_image.alt_text,
|
||||
"photo_type": obj.banner_image.photo_type,
|
||||
}
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
latest_photo = (
|
||||
RidePhoto.objects.filter(
|
||||
ride=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.id,
|
||||
"image_url": latest_photo.image.url,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{latest_photo.image.url}/thumbnail",
|
||||
"medium": f"{latest_photo.image.url}/medium",
|
||||
"large": f"{latest_photo.image.url}/large",
|
||||
"public": f"{latest_photo.image.url}/public",
|
||||
},
|
||||
"caption": latest_photo.caption,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
"photo_type": latest_photo.photo_type,
|
||||
"is_fallback": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_card_image(self, obj):
|
||||
"""Get the card image for this ride with fallback to latest photo."""
|
||||
# First try the explicitly set card image
|
||||
if obj.card_image and obj.card_image.image:
|
||||
return {
|
||||
"id": obj.card_image.id,
|
||||
"image_url": obj.card_image.image.url,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{obj.card_image.image.url}/thumbnail",
|
||||
"medium": f"{obj.card_image.image.url}/medium",
|
||||
"large": f"{obj.card_image.image.url}/large",
|
||||
"public": f"{obj.card_image.image.url}/public",
|
||||
},
|
||||
"caption": obj.card_image.caption,
|
||||
"alt_text": obj.card_image.alt_text,
|
||||
"photo_type": obj.card_image.photo_type,
|
||||
}
|
||||
|
||||
# Fallback to latest approved photo
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
latest_photo = (
|
||||
RidePhoto.objects.filter(
|
||||
ride=obj, is_approved=True, image__isnull=False
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_photo and latest_photo.image:
|
||||
return {
|
||||
"id": latest_photo.id,
|
||||
"image_url": latest_photo.image.url,
|
||||
"image_variants": {
|
||||
"thumbnail": f"{latest_photo.image.url}/thumbnail",
|
||||
"medium": f"{latest_photo.image.url}/medium",
|
||||
"large": f"{latest_photo.image.url}/large",
|
||||
"public": f"{latest_photo.image.url}/public",
|
||||
},
|
||||
"caption": latest_photo.caption,
|
||||
"alt_text": latest_photo.alt_text,
|
||||
"photo_type": latest_photo.photo_type,
|
||||
"is_fallback": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for setting ride banner and card images."""
|
||||
|
||||
banner_image_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
card_image_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate_banner_image_id(self, value):
|
||||
"""Validate that the banner image belongs to the same ride."""
|
||||
if value is not None:
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
RidePhoto.objects.get(id=value)
|
||||
# The ride will be validated in the view
|
||||
return value
|
||||
except RidePhoto.DoesNotExist:
|
||||
raise serializers.ValidationError("Photo not found")
|
||||
return value
|
||||
|
||||
def validate_card_image_id(self, value):
|
||||
"""Validate that the card image belongs to the same ride."""
|
||||
if value is not None:
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
try:
|
||||
RidePhoto.objects.get(id=value)
|
||||
# The ride will be validated in the view
|
||||
return value
|
||||
except RidePhoto.DoesNotExist:
|
||||
raise serializers.ValidationError("Photo not found")
|
||||
return value
|
||||
|
||||
|
||||
class RideCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating rides."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices())
|
||||
status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_status_choices(), default="OPERATING"
|
||||
)
|
||||
|
||||
# Required park
|
||||
park_id = serializers.IntegerField()
|
||||
|
||||
# Optional area
|
||||
park_area_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Optional dates
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
status_since = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
# Optional specs
|
||||
min_height_in = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
max_height_in = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
ride_duration_seconds = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
|
||||
# Optional companies
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
designer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Optional model
|
||||
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
# Date validation
|
||||
opening_date = attrs.get("opening_date")
|
||||
closing_date = attrs.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
# Height validation
|
||||
min_height = attrs.get("min_height_in")
|
||||
max_height = attrs.get("max_height_in")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
# Park area validation when park changes
|
||||
park_id = attrs.get("park_id")
|
||||
park_area_id = attrs.get("park_area_id")
|
||||
|
||||
if park_id and park_area_id:
|
||||
try:
|
||||
from apps.parks.models import ParkArea
|
||||
park_area = ParkArea.objects.get(id=park_area_id)
|
||||
if park_area.park_id != park_id:
|
||||
raise serializers.ValidationError(
|
||||
f"Park area '{park_area.name}' does not belong to the selected park"
|
||||
)
|
||||
except Exception:
|
||||
# If models aren't available or area doesn't exist, let the view handle it
|
||||
pass
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class RideUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating rides."""
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_status_choices(), required=False
|
||||
)
|
||||
post_closing_status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_post_closing_choices(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
# Park and area
|
||||
park_id = serializers.IntegerField(required=False)
|
||||
park_area_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Dates
|
||||
opening_date = serializers.DateField(required=False, allow_null=True)
|
||||
closing_date = serializers.DateField(required=False, allow_null=True)
|
||||
status_since = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
# Specs
|
||||
min_height_in = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
max_height_in = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=30, max_value=90
|
||||
)
|
||||
capacity_per_hour = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
ride_duration_seconds = serializers.IntegerField(
|
||||
required=False, allow_null=True, min_value=1
|
||||
)
|
||||
|
||||
# Companies
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
designer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
# Model
|
||||
ride_model_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
# Date validation
|
||||
opening_date = attrs.get("opening_date")
|
||||
closing_date = attrs.get("closing_date")
|
||||
|
||||
if opening_date and closing_date and closing_date < opening_date:
|
||||
raise serializers.ValidationError(
|
||||
"Closing date cannot be before opening date"
|
||||
)
|
||||
|
||||
# Height validation
|
||||
min_height = attrs.get("min_height_in")
|
||||
max_height = attrs.get("max_height_in")
|
||||
|
||||
if min_height and max_height and min_height > max_height:
|
||||
raise serializers.ValidationError(
|
||||
"Minimum height cannot be greater than maximum height"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class RideFilterInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for ride filtering and search."""
|
||||
|
||||
# Search
|
||||
search = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=ModelChoices.get_ride_status_choices(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Park filter
|
||||
park_id = serializers.IntegerField(required=False)
|
||||
park_slug = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
# Company filters
|
||||
manufacturer_id = serializers.IntegerField(required=False)
|
||||
designer_id = serializers.IntegerField(required=False)
|
||||
|
||||
# Rating filter
|
||||
min_rating = serializers.DecimalField(
|
||||
max_digits=3,
|
||||
decimal_places=2,
|
||||
required=False,
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
)
|
||||
|
||||
# Height filters
|
||||
min_height_requirement = serializers.IntegerField(required=False)
|
||||
max_height_requirement = serializers.IntegerField(required=False)
|
||||
|
||||
# Capacity filter
|
||||
min_capacity = serializers.IntegerField(required=False)
|
||||
|
||||
# Ordering
|
||||
ordering = serializers.ChoiceField(
|
||||
choices=[
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"average_rating",
|
||||
"-average_rating",
|
||||
"capacity_per_hour",
|
||||
"-capacity_per_hour",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
],
|
||||
required=False,
|
||||
default="name",
|
||||
)
|
||||
|
||||
|
||||
# === ROLLER COASTER STATS SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Roller Coaster Stats Example",
|
||||
summary="Example roller coaster statistics",
|
||||
description="Detailed statistics for a roller coaster",
|
||||
value={
|
||||
"id": 1,
|
||||
"ride": {"id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance"},
|
||||
"height_ft": 205.0,
|
||||
"length_ft": 5740.0,
|
||||
"speed_mph": 74.0,
|
||||
"inversions": 4,
|
||||
"ride_time_seconds": 150,
|
||||
"track_material": "HYBRID",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"propulsion_system": "CHAIN",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RollerCoasterStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for roller coaster statistics."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
length_ft = serializers.DecimalField(
|
||||
max_digits=7, decimal_places=2, allow_null=True
|
||||
)
|
||||
speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, allow_null=True
|
||||
)
|
||||
inversions = serializers.IntegerField()
|
||||
ride_time_seconds = serializers.IntegerField(allow_null=True)
|
||||
track_type = serializers.CharField()
|
||||
track_material = RichChoiceFieldSerializer(
|
||||
choice_group="track_materials",
|
||||
domain="rides"
|
||||
)
|
||||
roller_coaster_type = RichChoiceFieldSerializer(
|
||||
choice_group="coaster_types",
|
||||
domain="rides"
|
||||
)
|
||||
max_drop_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
propulsion_system = RichChoiceFieldSerializer(
|
||||
choice_group="propulsion_systems",
|
||||
domain="rides"
|
||||
)
|
||||
train_style = serializers.CharField()
|
||||
trains_count = serializers.IntegerField(allow_null=True)
|
||||
cars_per_train = serializers.IntegerField(allow_null=True)
|
||||
seats_per_car = serializers.IntegerField(allow_null=True)
|
||||
|
||||
# Ride info
|
||||
ride = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
}
|
||||
|
||||
|
||||
class RollerCoasterStatsCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating roller coaster statistics."""
|
||||
|
||||
ride_id = serializers.IntegerField()
|
||||
height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
length_ft = serializers.DecimalField(
|
||||
max_digits=7, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
inversions = serializers.IntegerField(default=0)
|
||||
ride_time_seconds = serializers.IntegerField(required=False, allow_null=True)
|
||||
track_type = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
track_material = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_coaster_track_choices(), default="STEEL"
|
||||
)
|
||||
roller_coaster_type = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_coaster_type_choices(), default="SITDOWN"
|
||||
)
|
||||
max_drop_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
propulsion_system = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_propulsion_system_choices(), default="CHAIN"
|
||||
)
|
||||
train_style = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
trains_count = serializers.IntegerField(required=False, allow_null=True)
|
||||
cars_per_train = serializers.IntegerField(required=False, allow_null=True)
|
||||
seats_per_car = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class RollerCoasterStatsUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating roller coaster statistics."""
|
||||
|
||||
height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
length_ft = serializers.DecimalField(
|
||||
max_digits=7, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
speed_mph = serializers.DecimalField(
|
||||
max_digits=5, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
inversions = serializers.IntegerField(required=False)
|
||||
ride_time_seconds = serializers.IntegerField(required=False, allow_null=True)
|
||||
track_type = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
track_material = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_coaster_track_choices(), required=False
|
||||
)
|
||||
roller_coaster_type = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_coaster_type_choices(), required=False
|
||||
)
|
||||
max_drop_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
propulsion_system = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_propulsion_system_choices(), required=False
|
||||
)
|
||||
train_style = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False
|
||||
)
|
||||
trains_count = serializers.IntegerField(required=False, allow_null=True)
|
||||
cars_per_train = serializers.IntegerField(required=False, allow_null=True)
|
||||
seats_per_car = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# === RIDE LOCATION SERIALIZERS ===
|
||||
|
||||
|
||||
class RideLocationOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride locations."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
latitude = serializers.FloatField(allow_null=True)
|
||||
longitude = serializers.FloatField(allow_null=True)
|
||||
coordinates = serializers.CharField()
|
||||
|
||||
# Ride info
|
||||
ride = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
}
|
||||
|
||||
|
||||
class RideLocationCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride locations."""
|
||||
|
||||
ride_id = serializers.IntegerField()
|
||||
latitude = serializers.FloatField(required=False, allow_null=True)
|
||||
longitude = serializers.FloatField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class RideLocationUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride locations."""
|
||||
|
||||
latitude = serializers.FloatField(required=False, allow_null=True)
|
||||
longitude = serializers.FloatField(required=False, allow_null=True)
|
||||
|
||||
|
||||
# === RIDE REVIEW SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Review Example",
|
||||
summary="Example ride review response",
|
||||
description="A user review of a ride",
|
||||
value={
|
||||
"id": 1,
|
||||
"rating": 9,
|
||||
"title": "Amazing coaster!",
|
||||
"content": "This ride was incredible, the airtime was fantastic.",
|
||||
"visit_date": "2024-08-15",
|
||||
"ride": {"id": 1, "name": "Steel Vengeance", "slug": "steel-vengeance"},
|
||||
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
|
||||
"created_at": "2024-08-16T10:30:00Z",
|
||||
"is_published": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideReviewOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride reviews."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
rating = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
content = serializers.CharField()
|
||||
visit_date = serializers.DateField()
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
is_published = serializers.BooleanField()
|
||||
|
||||
# Ride info
|
||||
ride = serializers.SerializerMethodField()
|
||||
# User info (limited for privacy)
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> dict:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"display_name": obj.user.get_display_name(),
|
||||
}
|
||||
|
||||
|
||||
class RideReviewCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating ride reviews."""
|
||||
|
||||
ride_id = serializers.IntegerField()
|
||||
rating = serializers.IntegerField(min_value=1, max_value=10)
|
||||
title = serializers.CharField(max_length=200)
|
||||
content = serializers.CharField()
|
||||
visit_date = serializers.DateField()
|
||||
|
||||
def validate_visit_date(self, value):
|
||||
"""Validate visit date is not in the future."""
|
||||
from django.utils import timezone
|
||||
|
||||
if value > timezone.now().date():
|
||||
raise serializers.ValidationError("Visit date cannot be in the future")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride reviews."""
|
||||
|
||||
rating = serializers.IntegerField(min_value=1, max_value=10, required=False)
|
||||
title = serializers.CharField(max_length=200, required=False)
|
||||
content = serializers.CharField(required=False)
|
||||
visit_date = serializers.DateField(required=False)
|
||||
|
||||
def validate_visit_date(self, value):
|
||||
"""Validate visit date is not in the future."""
|
||||
from django.utils import timezone
|
||||
|
||||
if value and value > timezone.now().date():
|
||||
raise serializers.ValidationError("Visit date cannot be in the future")
|
||||
return value
|
||||
@@ -1,146 +0,0 @@
|
||||
"""
|
||||
Ride media serializers for ThrillWiki API.
|
||||
|
||||
This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
|
||||
class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride photos."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
file_size = serializers.ReadOnlyField()
|
||||
dimensions = serializers.ReadOnlyField()
|
||||
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"photo_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RidePhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
by_type = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Photo counts by type"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoTypeFilterSerializer(serializers.Serializer):
|
||||
"""Serializer for filtering photos by type."""
|
||||
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
required=False,
|
||||
help_text="Filter photos by type",
|
||||
)
|
||||
@@ -1,92 +0,0 @@
|
||||
"""
|
||||
Search domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for entity search, location search,
|
||||
and other search functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from ..shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === CORE ENTITY SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
class EntitySearchInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for entity search requests."""
|
||||
|
||||
query = serializers.CharField(max_length=255, help_text="Search query string")
|
||||
entity_types = serializers.ListField(
|
||||
child=serializers.ChoiceField(
|
||||
choices=ModelChoices.get_entity_type_choices()
|
||||
),
|
||||
required=False,
|
||||
help_text="Types of entities to search for",
|
||||
)
|
||||
limit = serializers.IntegerField(
|
||||
default=10,
|
||||
min_value=1,
|
||||
max_value=50,
|
||||
help_text="Maximum number of results to return",
|
||||
)
|
||||
|
||||
|
||||
class EntitySearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for individual entity search results."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
type = RichChoiceFieldSerializer(
|
||||
choice_group="entity_types",
|
||||
domain="core"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
relevance_score = serializers.FloatField()
|
||||
|
||||
# Context-specific info — renamed to avoid overriding Serializer.context
|
||||
context_info = serializers.JSONField(
|
||||
help_text="Additional context based on entity type"
|
||||
)
|
||||
|
||||
|
||||
class EntitySearchOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for entity search results."""
|
||||
|
||||
query = serializers.CharField()
|
||||
total_results = serializers.IntegerField()
|
||||
results = EntitySearchResultSerializer(many=True)
|
||||
search_time_ms = serializers.FloatField()
|
||||
|
||||
|
||||
# === LOCATION SEARCH SERIALIZERS ===
|
||||
|
||||
|
||||
class LocationSearchResultSerializer(serializers.Serializer):
|
||||
"""Serializer for location search results."""
|
||||
|
||||
display_name = serializers.CharField()
|
||||
lat = serializers.FloatField()
|
||||
lon = serializers.FloatField()
|
||||
type = serializers.CharField()
|
||||
importance = serializers.FloatField()
|
||||
address = serializers.JSONField()
|
||||
|
||||
|
||||
class LocationSearchOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for location search."""
|
||||
|
||||
results = LocationSearchResultSerializer(many=True)
|
||||
query = serializers.CharField()
|
||||
count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ReverseGeocodeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for reverse geocoding."""
|
||||
|
||||
display_name = serializers.CharField()
|
||||
lat = serializers.FloatField()
|
||||
lon = serializers.FloatField()
|
||||
address = serializers.JSONField()
|
||||
type = serializers.CharField()
|
||||
@@ -1,266 +0,0 @@
|
||||
"""
|
||||
Services domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for various services like email, maps,
|
||||
history tracking, moderation, and roadtrip planning.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
|
||||
|
||||
# === HEALTH CHECK SERIALIZERS ===
|
||||
|
||||
|
||||
class HealthCheckOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for comprehensive health check responses."""
|
||||
|
||||
status = serializers.CharField(help_text="Overall health status")
|
||||
timestamp = serializers.DateTimeField(help_text="Timestamp of health check")
|
||||
version = serializers.CharField(help_text="Application version")
|
||||
environment = serializers.CharField(help_text="Environment name")
|
||||
response_time_ms = serializers.FloatField(help_text="Response time in milliseconds")
|
||||
checks = serializers.DictField(help_text="Individual health check results")
|
||||
metrics = serializers.DictField(help_text="System metrics")
|
||||
|
||||
|
||||
class PerformanceMetricsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for performance metrics responses."""
|
||||
|
||||
timestamp = serializers.DateTimeField(help_text="Timestamp of metrics collection")
|
||||
database_analysis = serializers.DictField(help_text="Database performance analysis")
|
||||
cache_performance = serializers.DictField(help_text="Cache performance metrics")
|
||||
recent_slow_queries = serializers.DictField(help_text="Recent slow query analysis")
|
||||
|
||||
|
||||
class SimpleHealthOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for simple health check responses."""
|
||||
|
||||
status = serializers.CharField(help_text="Simple health status")
|
||||
timestamp = serializers.DateTimeField(help_text="Timestamp of health check")
|
||||
error = serializers.CharField(
|
||||
required=False, help_text="Error message if unhealthy"
|
||||
)
|
||||
|
||||
|
||||
# === EMAIL SERVICE SERIALIZERS ===
|
||||
|
||||
|
||||
class EmailSendInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for sending emails."""
|
||||
|
||||
to = serializers.EmailField()
|
||||
subject = serializers.CharField(max_length=255)
|
||||
text = serializers.CharField()
|
||||
html = serializers.CharField(required=False)
|
||||
template = serializers.CharField(required=False)
|
||||
template_context = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
class EmailTemplateOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for email templates."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
subject = serializers.CharField()
|
||||
text_template = serializers.CharField()
|
||||
html_template = serializers.CharField(required=False)
|
||||
|
||||
|
||||
# === MAP SERVICE SERIALIZERS ===
|
||||
|
||||
|
||||
class MapDataOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for map data."""
|
||||
|
||||
parks = serializers.ListField(child=serializers.DictField())
|
||||
rides = serializers.ListField(child=serializers.DictField())
|
||||
bounds = serializers.DictField()
|
||||
zoom_level = serializers.IntegerField()
|
||||
|
||||
|
||||
class CoordinateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for coordinate-based requests."""
|
||||
|
||||
latitude = serializers.FloatField(min_value=-90, max_value=90)
|
||||
longitude = serializers.FloatField(min_value=-180, max_value=180)
|
||||
radius_km = serializers.FloatField(min_value=0, max_value=1000, default=10)
|
||||
|
||||
|
||||
# === HISTORY SERIALIZERS ===
|
||||
|
||||
|
||||
class HistoryEventSerializer(serializers.Serializer):
|
||||
"""Base serializer for history events from pghistory."""
|
||||
|
||||
pgh_id = serializers.IntegerField(read_only=True)
|
||||
pgh_created_at = serializers.DateTimeField(read_only=True)
|
||||
pgh_label = serializers.CharField(read_only=True)
|
||||
pgh_obj_id = serializers.IntegerField(read_only=True)
|
||||
pgh_context = serializers.JSONField(read_only=True, allow_null=True)
|
||||
pgh_diff = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_pgh_diff(self, obj) -> dict:
|
||||
"""Get diff from previous version if available."""
|
||||
if hasattr(obj, "diff_against_previous"):
|
||||
return obj.diff_against_previous()
|
||||
return {}
|
||||
|
||||
|
||||
class HistoryEntryOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for history entries."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
model_type = serializers.CharField()
|
||||
object_id = serializers.IntegerField()
|
||||
object_name = serializers.CharField()
|
||||
action = serializers.CharField()
|
||||
changes = serializers.JSONField()
|
||||
timestamp = serializers.DateTimeField()
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_user(self, obj) -> dict | None:
|
||||
if hasattr(obj, "user") and obj.user:
|
||||
return {
|
||||
"id": obj.user.id,
|
||||
"username": obj.user.username,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
class HistoryCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating history entries."""
|
||||
|
||||
action = serializers.CharField(max_length=50)
|
||||
description = serializers.CharField(max_length=500)
|
||||
metadata = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
# === MODERATION SERIALIZERS ===
|
||||
|
||||
|
||||
class ModerationSubmissionSerializer(serializers.Serializer):
|
||||
"""Serializer for moderation submissions."""
|
||||
|
||||
submission_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("EDIT", "Edit Submission"),
|
||||
("PHOTO", "Photo Submission"),
|
||||
("REVIEW", "Review Submission"),
|
||||
],
|
||||
help_text="Type of submission"
|
||||
)
|
||||
content_type = serializers.CharField(help_text="Content type being modified")
|
||||
object_id = serializers.IntegerField(help_text="ID of object being modified")
|
||||
changes = serializers.JSONField(help_text="Changes being submitted")
|
||||
reason = serializers.CharField(
|
||||
max_length=500,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Reason for the changes",
|
||||
)
|
||||
|
||||
|
||||
class ModerationSubmissionOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for moderation submission responses."""
|
||||
|
||||
status = serializers.CharField()
|
||||
message = serializers.CharField()
|
||||
submission_id = serializers.IntegerField(required=False)
|
||||
auto_approved = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
# === ROADTRIP SERIALIZERS ===
|
||||
|
||||
|
||||
class RoadtripParkSerializer(serializers.Serializer):
|
||||
"""Serializer for parks in roadtrip planning."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
latitude = serializers.FloatField()
|
||||
longitude = serializers.FloatField()
|
||||
coaster_count = serializers.IntegerField()
|
||||
status = serializers.CharField()
|
||||
|
||||
|
||||
class RoadtripCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating roadtrips."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
park_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
help_text="List of park IDs (2-10 parks)",
|
||||
)
|
||||
start_date = serializers.DateField(required=False)
|
||||
end_date = serializers.DateField(required=False)
|
||||
notes = serializers.CharField(max_length=1000, required=False, allow_blank=True)
|
||||
|
||||
def validate_park_ids(self, value):
|
||||
"""Validate park IDs."""
|
||||
if len(value) < 2:
|
||||
raise serializers.ValidationError("At least 2 parks are required")
|
||||
if len(value) > 10:
|
||||
raise serializers.ValidationError("Maximum 10 parks allowed")
|
||||
if len(set(value)) != len(value):
|
||||
raise serializers.ValidationError("Duplicate park IDs not allowed")
|
||||
return value
|
||||
|
||||
|
||||
class RoadtripOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for roadtrip responses."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
parks = RoadtripParkSerializer(many=True)
|
||||
total_distance_miles = serializers.FloatField()
|
||||
estimated_drive_time_hours = serializers.FloatField()
|
||||
route_coordinates = serializers.ListField(
|
||||
child=serializers.ListField(child=serializers.FloatField())
|
||||
)
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class GeocodeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for geocoding requests."""
|
||||
|
||||
address = serializers.CharField(max_length=500, help_text="Address to geocode")
|
||||
|
||||
|
||||
class GeocodeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for geocoding responses."""
|
||||
|
||||
status = serializers.CharField()
|
||||
coordinates = serializers.JSONField(required=False)
|
||||
formatted_address = serializers.CharField(required=False)
|
||||
|
||||
|
||||
# === DISTANCE CALCULATION SERIALIZERS ===
|
||||
class DistanceCalculationInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for distance calculation requests."""
|
||||
|
||||
park1_id = serializers.IntegerField(help_text="ID of first park")
|
||||
park2_id = serializers.IntegerField(help_text="ID of second park")
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that park IDs are different."""
|
||||
if attrs["park1_id"] == attrs["park2_id"]:
|
||||
raise serializers.ValidationError("Park IDs must be different")
|
||||
return attrs
|
||||
|
||||
|
||||
class DistanceCalculationOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for distance calculation responses."""
|
||||
|
||||
status = serializers.CharField()
|
||||
distance_miles = serializers.FloatField(required=False)
|
||||
distance_km = serializers.FloatField(required=False)
|
||||
drive_time_hours = serializers.FloatField(required=False)
|
||||
message = serializers.CharField(required=False)
|
||||
@@ -1,657 +0,0 @@
|
||||
"""
|
||||
Shared Contract Serializers for ThrillWiki API
|
||||
|
||||
This module contains standardized serializers that enforce consistent formats
|
||||
across all API responses, ensuring they match frontend TypeScript interfaces exactly.
|
||||
|
||||
These serializers prevent contract violations by providing a single source of truth
|
||||
for common data structures used throughout the API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
class FilterOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard filter option format - matches frontend TypeScript exactly.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
selected?: boolean;
|
||||
}
|
||||
"""
|
||||
value = serializers.CharField(
|
||||
help_text="The actual value used for filtering"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable display label"
|
||||
)
|
||||
count = serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Number of items matching this filter option"
|
||||
)
|
||||
selected = serializers.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this option is currently selected"
|
||||
)
|
||||
|
||||
|
||||
class FilterRangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard range filter format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface FilterRange {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
}
|
||||
"""
|
||||
min = serializers.FloatField(
|
||||
allow_null=True,
|
||||
help_text="Minimum value for the range"
|
||||
)
|
||||
max = serializers.FloatField(
|
||||
allow_null=True,
|
||||
help_text="Maximum value for the range"
|
||||
)
|
||||
step = serializers.FloatField(
|
||||
default=1.0,
|
||||
help_text="Step size for range inputs"
|
||||
)
|
||||
unit = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Unit of measurement (e.g., 'feet', 'mph', 'stars')"
|
||||
)
|
||||
|
||||
|
||||
class BooleanFilterSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard boolean filter format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface BooleanFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
"""
|
||||
key = serializers.CharField(
|
||||
help_text="The filter parameter key"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable label for the filter"
|
||||
)
|
||||
description = serializers.CharField(
|
||||
help_text="Description of what this filter does"
|
||||
)
|
||||
|
||||
|
||||
class OrderingOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard ordering option format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface OrderingOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
"""
|
||||
value = serializers.CharField(
|
||||
help_text="The ordering parameter value"
|
||||
)
|
||||
label = serializers.CharField(
|
||||
help_text="Human-readable label for the ordering option"
|
||||
)
|
||||
|
||||
|
||||
class StandardizedFilterMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Matches frontend TypeScript interface exactly.
|
||||
|
||||
This serializer ensures all filter metadata responses follow the same structure
|
||||
that the frontend expects, preventing runtime type errors.
|
||||
"""
|
||||
categorical = serializers.DictField(
|
||||
child=FilterOptionSerializer(many=True),
|
||||
help_text="Categorical filter options with value/label/count structure"
|
||||
)
|
||||
ranges = serializers.DictField(
|
||||
child=FilterRangeSerializer(),
|
||||
help_text="Range filter metadata with min/max/step/unit"
|
||||
)
|
||||
total_count = serializers.IntegerField(
|
||||
help_text="Total number of items in the filtered dataset"
|
||||
)
|
||||
ordering_options = FilterOptionSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
help_text="Available ordering options"
|
||||
)
|
||||
boolean_filters = BooleanFilterSerializer(
|
||||
many=True,
|
||||
required=False,
|
||||
help_text="Available boolean filter options"
|
||||
)
|
||||
|
||||
|
||||
class PaginationMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard pagination metadata format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface PaginationMetadata {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
"""
|
||||
count = serializers.IntegerField(
|
||||
help_text="Total number of items across all pages"
|
||||
)
|
||||
next = serializers.URLField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="URL for the next page of results"
|
||||
)
|
||||
previous = serializers.URLField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
help_text="URL for the previous page of results"
|
||||
)
|
||||
page_size = serializers.IntegerField(
|
||||
help_text="Number of items per page"
|
||||
)
|
||||
current_page = serializers.IntegerField(
|
||||
help_text="Current page number (1-indexed)"
|
||||
)
|
||||
total_pages = serializers.IntegerField(
|
||||
help_text="Total number of pages"
|
||||
)
|
||||
|
||||
|
||||
class ApiResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard API response wrapper.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
errors?: ValidationError;
|
||||
}
|
||||
"""
|
||||
success = serializers.BooleanField(
|
||||
help_text="Whether the request was successful"
|
||||
)
|
||||
response_data = serializers.JSONField(
|
||||
required=False,
|
||||
help_text="Response data (structure varies by endpoint)",
|
||||
source='data'
|
||||
)
|
||||
message = serializers.CharField(
|
||||
required=False,
|
||||
help_text="Human-readable message about the operation"
|
||||
)
|
||||
response_errors = serializers.DictField(
|
||||
required=False,
|
||||
help_text="Validation errors (field -> error messages)",
|
||||
source='errors'
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponseSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard error response format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ApiError {
|
||||
status: "error";
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
request_user?: string;
|
||||
};
|
||||
data: null;
|
||||
}
|
||||
"""
|
||||
status = serializers.CharField(
|
||||
default="error",
|
||||
help_text="Response status indicator"
|
||||
)
|
||||
error = serializers.DictField(
|
||||
help_text="Error details"
|
||||
)
|
||||
response_data = serializers.JSONField(
|
||||
default=None,
|
||||
allow_null=True,
|
||||
help_text="Always null for error responses",
|
||||
source='data'
|
||||
)
|
||||
|
||||
|
||||
class LocationSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard location format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Location {
|
||||
city: string;
|
||||
state?: string;
|
||||
country: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
"""
|
||||
city = serializers.CharField(
|
||||
help_text="City name"
|
||||
)
|
||||
state = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="State/province name"
|
||||
)
|
||||
country = serializers.CharField(
|
||||
help_text="Country name"
|
||||
)
|
||||
address = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Street address"
|
||||
)
|
||||
latitude = serializers.FloatField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Latitude coordinate"
|
||||
)
|
||||
longitude = serializers.FloatField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Longitude coordinate"
|
||||
)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
LocationOutputSerializer = LocationSerializer
|
||||
|
||||
|
||||
class CompanyOutputSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard company output format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
roles?: string[];
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Company ID"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Company name"
|
||||
)
|
||||
slug = serializers.SlugField(
|
||||
help_text="URL-friendly identifier"
|
||||
)
|
||||
roles = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
help_text="Company roles (manufacturer, operator, etc.)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class ModelChoices:
|
||||
"""
|
||||
Utility class to provide model choices for serializers using Rich Choice Objects.
|
||||
This prevents circular imports while providing access to model choices from the registry.
|
||||
|
||||
NO FALLBACKS - All choices must be properly defined in Rich Choice Objects.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_park_status_choices():
|
||||
"""Get park status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("statuses", "parks")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_status_choices():
|
||||
"""Get ride status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("statuses", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_company_role_choices():
|
||||
"""Get company role choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
# Get rides domain company roles (MANUFACTURER, DESIGNER)
|
||||
rides_choices = get_choices("company_roles", "rides")
|
||||
# Get parks domain company roles (OPERATOR, PROPERTY_OWNER)
|
||||
parks_choices = get_choices("company_roles", "parks")
|
||||
all_choices = list(rides_choices) + list(parks_choices)
|
||||
return [(choice.value, choice.label) for choice in all_choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_category_choices():
|
||||
"""Get ride category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_post_closing_choices():
|
||||
"""Get ride post-closing status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("post_closing_statuses", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_track_choices():
|
||||
"""Get coaster track material choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("track_materials", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_type_choices():
|
||||
"""Get coaster type choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("coaster_types", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_launch_choices():
|
||||
"""Get launch system choices from Rich Choice registry (legacy method)."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("propulsion_systems", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_propulsion_system_choices():
|
||||
"""Get propulsion system choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("propulsion_systems", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_photo_type_choices():
|
||||
"""Get photo type choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("photo_types", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_spec_category_choices():
|
||||
"""Get technical specification category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("spec_categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_technical_spec_category_choices():
|
||||
"""Get technical specification category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("spec_categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_target_market_choices():
|
||||
"""Get target market choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("target_markets", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_entity_type_choices():
|
||||
"""Get entity type choices for search functionality."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("entity_types", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_health_status_choices():
|
||||
"""Get health check status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("health_statuses", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_simple_health_status_choices():
|
||||
"""Get simple health check status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("simple_health_statuses", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
|
||||
class EntityReferenceSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard entity reference format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Entity {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Unique identifier"
|
||||
)
|
||||
name = serializers.CharField(
|
||||
help_text="Display name"
|
||||
)
|
||||
slug = serializers.SlugField(
|
||||
help_text="URL-friendly identifier"
|
||||
)
|
||||
|
||||
|
||||
class ImageVariantsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard image variants format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface ImageVariants {
|
||||
thumbnail: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
avatar?: string;
|
||||
}
|
||||
"""
|
||||
thumbnail = serializers.URLField(
|
||||
help_text="Thumbnail size image URL"
|
||||
)
|
||||
medium = serializers.URLField(
|
||||
help_text="Medium size image URL"
|
||||
)
|
||||
large = serializers.URLField(
|
||||
help_text="Large size image URL"
|
||||
)
|
||||
avatar = serializers.URLField(
|
||||
required=False,
|
||||
help_text="Avatar size image URL (for user avatars)"
|
||||
)
|
||||
|
||||
|
||||
class PhotoSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard photo format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface Photo {
|
||||
id: number;
|
||||
image_variants: ImageVariants;
|
||||
alt_text?: string;
|
||||
image_url?: string;
|
||||
caption?: string;
|
||||
photo_type?: string;
|
||||
uploaded_by?: UserInfo;
|
||||
uploaded_at?: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="Photo ID"
|
||||
)
|
||||
image_variants = ImageVariantsSerializer(
|
||||
help_text="Available image size variants"
|
||||
)
|
||||
alt_text = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Alternative text for accessibility"
|
||||
)
|
||||
image_url = serializers.URLField(
|
||||
required=False,
|
||||
help_text="Primary image URL (for compatibility)"
|
||||
)
|
||||
caption = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Photo caption"
|
||||
)
|
||||
photo_type = serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Type/category of photo"
|
||||
)
|
||||
uploaded_by = EntityReferenceSerializer(
|
||||
required=False,
|
||||
help_text="User who uploaded the photo"
|
||||
)
|
||||
uploaded_at = serializers.DateTimeField(
|
||||
required=False,
|
||||
help_text="When the photo was uploaded"
|
||||
)
|
||||
|
||||
|
||||
class UserInfoSerializer(serializers.Serializer):
|
||||
"""
|
||||
Standard user info format.
|
||||
|
||||
Frontend TypeScript interface:
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
"""
|
||||
id = serializers.IntegerField(
|
||||
help_text="User ID"
|
||||
)
|
||||
username = serializers.CharField(
|
||||
help_text="Username"
|
||||
)
|
||||
display_name = serializers.CharField(
|
||||
help_text="Display name"
|
||||
)
|
||||
avatar_url = serializers.URLField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="User avatar URL"
|
||||
)
|
||||
|
||||
|
||||
def validate_filter_metadata_contract(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that filter metadata follows the expected contract.
|
||||
|
||||
This function can be used in views to ensure filter metadata
|
||||
matches the frontend TypeScript interface before returning it.
|
||||
|
||||
Args:
|
||||
data: Filter metadata dictionary
|
||||
|
||||
Returns:
|
||||
Validated and potentially transformed data
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: If data doesn't match contract
|
||||
"""
|
||||
serializer = StandardizedFilterMetadataSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# Return validated_data directly - it's already a dict
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Ensure a list of filter options follows the expected format.
|
||||
|
||||
This utility function converts various input formats to the standard
|
||||
FilterOption format expected by the frontend.
|
||||
|
||||
Args:
|
||||
options: List of options in various formats
|
||||
|
||||
Returns:
|
||||
List of options in standard format
|
||||
"""
|
||||
standardized = []
|
||||
|
||||
for option in options:
|
||||
if isinstance(option, dict):
|
||||
# Already in correct format or close to it
|
||||
standardized_option = {
|
||||
'value': str(option.get('value', option.get('id', ''))),
|
||||
'label': option.get('label', option.get('name', str(option.get('value', '')))),
|
||||
'count': option.get('count'),
|
||||
'selected': option.get('selected', False)
|
||||
}
|
||||
elif hasattr(option, 'value') and hasattr(option, 'label'):
|
||||
# RichChoice object format
|
||||
standardized_option = {
|
||||
'value': str(option.value),
|
||||
'label': str(option.label),
|
||||
'count': None,
|
||||
'selected': False
|
||||
}
|
||||
else:
|
||||
# Simple value - use as both value and label
|
||||
standardized_option = {
|
||||
'value': str(option),
|
||||
'label': str(option),
|
||||
'count': None,
|
||||
'selected': False
|
||||
}
|
||||
|
||||
standardized.append(standardized_option)
|
||||
|
||||
return standardized
|
||||
|
||||
|
||||
def ensure_range_format(range_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure range data follows the expected format.
|
||||
|
||||
Args:
|
||||
range_data: Range data dictionary
|
||||
|
||||
Returns:
|
||||
Range data in standard format
|
||||
"""
|
||||
return {
|
||||
'min': range_data.get('min'),
|
||||
'max': range_data.get('max'),
|
||||
'step': range_data.get('step', 1.0),
|
||||
'unit': range_data.get('unit')
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
Statistics serializers for ThrillWiki API.
|
||||
|
||||
Provides serialization for platform statistics data.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class StatsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for platform statistics response.
|
||||
|
||||
This serializer defines the structure of the statistics API response,
|
||||
including all the various counts and breakdowns available.
|
||||
"""
|
||||
|
||||
# Core entity counts
|
||||
total_parks = serializers.IntegerField(
|
||||
help_text="Total number of parks in the database"
|
||||
)
|
||||
total_rides = serializers.IntegerField(
|
||||
help_text="Total number of rides in the database"
|
||||
)
|
||||
total_manufacturers = serializers.IntegerField(
|
||||
help_text="Total number of ride manufacturers"
|
||||
)
|
||||
total_operators = serializers.IntegerField(
|
||||
help_text="Total number of park operators"
|
||||
)
|
||||
total_designers = serializers.IntegerField(
|
||||
help_text="Total number of ride designers"
|
||||
)
|
||||
total_property_owners = serializers.IntegerField(
|
||||
help_text="Total number of property owners"
|
||||
)
|
||||
total_roller_coasters = serializers.IntegerField(
|
||||
help_text="Total number of roller coasters with detailed stats"
|
||||
)
|
||||
|
||||
# Photo counts
|
||||
total_photos = serializers.IntegerField(
|
||||
help_text="Total number of photos (parks + rides combined)"
|
||||
)
|
||||
total_park_photos = serializers.IntegerField(
|
||||
help_text="Total number of park photos"
|
||||
)
|
||||
total_ride_photos = serializers.IntegerField(
|
||||
help_text="Total number of ride photos"
|
||||
)
|
||||
|
||||
# Review counts
|
||||
total_reviews = serializers.IntegerField(
|
||||
help_text="Total number of reviews (parks + rides)"
|
||||
)
|
||||
total_park_reviews = serializers.IntegerField(
|
||||
help_text="Total number of park reviews"
|
||||
)
|
||||
total_ride_reviews = serializers.IntegerField(
|
||||
help_text="Total number of ride reviews"
|
||||
)
|
||||
|
||||
# Ride category counts (optional fields since they depend on data)
|
||||
roller_coasters = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as roller coasters"
|
||||
)
|
||||
dark_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as dark rides"
|
||||
)
|
||||
flat_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as flat rides"
|
||||
)
|
||||
water_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as water rides"
|
||||
)
|
||||
transport_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as transport rides"
|
||||
)
|
||||
other_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides categorized as other"
|
||||
)
|
||||
|
||||
# Park status counts (optional fields since they depend on data)
|
||||
operating_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of currently operating parks"
|
||||
)
|
||||
temporarily_closed_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of temporarily closed parks"
|
||||
)
|
||||
permanently_closed_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of permanently closed parks"
|
||||
)
|
||||
under_construction_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of parks under construction"
|
||||
)
|
||||
demolished_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of demolished parks"
|
||||
)
|
||||
relocated_parks = serializers.IntegerField(
|
||||
required=False, help_text="Number of relocated parks"
|
||||
)
|
||||
|
||||
# Ride status counts (optional fields since they depend on data)
|
||||
operating_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of currently operating rides"
|
||||
)
|
||||
temporarily_closed_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of temporarily closed rides"
|
||||
)
|
||||
sbno_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides standing but not operating"
|
||||
)
|
||||
closing_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides in the process of closing"
|
||||
)
|
||||
permanently_closed_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of permanently closed rides"
|
||||
)
|
||||
under_construction_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of rides under construction"
|
||||
)
|
||||
demolished_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of demolished rides"
|
||||
)
|
||||
relocated_rides = serializers.IntegerField(
|
||||
required=False, help_text="Number of relocated rides"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
last_updated = serializers.CharField(
|
||||
help_text="ISO timestamp when these statistics were last calculated"
|
||||
)
|
||||
relative_last_updated = serializers.CharField(
|
||||
help_text="Human-readable relative time since last update (e.g., '2 minutes ago')"
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
# flake8: noqa
|
||||
"""
|
||||
Backup file intentionally cleared to avoid duplicate serializer exports.
|
||||
Original contents were merged into backend/apps/api/v1/auth/serializers.py.
|
||||
This placeholder prevents lint errors while preserving file path for history.
|
||||
"""
|
||||
@@ -1,268 +0,0 @@
|
||||
"""
|
||||
API serializers for the ride ranking system.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from apps.rides.models import RideRanking, RankingSnapshot
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Ride Ranking Example",
|
||||
summary="Example ranking response",
|
||||
description="A ride ranking with all metrics",
|
||||
value={
|
||||
"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": 523,
|
||||
"losses": 87,
|
||||
"ties": 45,
|
||||
"winning_percentage": 0.8234,
|
||||
"mutual_riders_count": 1250,
|
||||
"comparison_count": 655,
|
||||
"average_rating": 9.2,
|
||||
"last_calculated": "2024-01-15T02:00:00Z",
|
||||
"rank_change": 2,
|
||||
"previous_rank": 3,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideRankingSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ride rankings."""
|
||||
|
||||
ride = serializers.SerializerMethodField()
|
||||
rank_change = serializers.SerializerMethodField()
|
||||
previous_rank = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideRanking
|
||||
fields = [
|
||||
"id",
|
||||
"rank",
|
||||
"ride",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"winning_percentage",
|
||||
"mutual_riders_count",
|
||||
"comparison_count",
|
||||
"average_rating",
|
||||
"last_calculated",
|
||||
"rank_change",
|
||||
"previous_rank",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj):
|
||||
"""Get ride details."""
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
"park": {
|
||||
"id": obj.ride.park.id,
|
||||
"name": obj.ride.park.name,
|
||||
"slug": obj.ride.park.slug,
|
||||
},
|
||||
"category": obj.ride.category,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_rank_change(self, obj):
|
||||
"""Calculate rank change from previous snapshot."""
|
||||
from apps.rides.models import RankingSnapshot
|
||||
|
||||
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:2]
|
||||
|
||||
if len(latest_snapshots) >= 2:
|
||||
return latest_snapshots[0].rank - latest_snapshots[1].rank
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_previous_rank(self, obj):
|
||||
"""Get previous rank."""
|
||||
from apps.rides.models import RankingSnapshot
|
||||
|
||||
latest_snapshots = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:2]
|
||||
|
||||
if len(latest_snapshots) >= 2:
|
||||
return latest_snapshots[1].rank
|
||||
return None
|
||||
|
||||
|
||||
class RideRankingDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed serializer for a specific ride's ranking."""
|
||||
|
||||
ride = serializers.SerializerMethodField()
|
||||
head_to_head_comparisons = serializers.SerializerMethodField()
|
||||
ranking_history = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideRanking
|
||||
fields = [
|
||||
"id",
|
||||
"rank",
|
||||
"ride",
|
||||
"wins",
|
||||
"losses",
|
||||
"ties",
|
||||
"winning_percentage",
|
||||
"mutual_riders_count",
|
||||
"comparison_count",
|
||||
"average_rating",
|
||||
"last_calculated",
|
||||
"calculation_version",
|
||||
"head_to_head_comparisons",
|
||||
"ranking_history",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj):
|
||||
"""Get detailed ride information."""
|
||||
ride = obj.ride
|
||||
return {
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"description": ride.description,
|
||||
"park": {
|
||||
"id": ride.park.id,
|
||||
"name": ride.park.name,
|
||||
"slug": ride.park.slug,
|
||||
"location": {
|
||||
"city": (
|
||||
ride.park.location.city
|
||||
if hasattr(ride.park, "location")
|
||||
else None
|
||||
),
|
||||
"state": (
|
||||
ride.park.location.state
|
||||
if hasattr(ride.park, "location")
|
||||
else None
|
||||
),
|
||||
"country": (
|
||||
ride.park.location.country
|
||||
if hasattr(ride.park, "location")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
"category": ride.category,
|
||||
"manufacturer": (
|
||||
{"id": ride.manufacturer.id, "name": ride.manufacturer.name}
|
||||
if ride.manufacturer
|
||||
else None
|
||||
),
|
||||
"opening_date": ride.opening_date,
|
||||
"status": ride.status,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_head_to_head_comparisons(self, obj):
|
||||
"""Get top head-to-head comparisons."""
|
||||
from django.db.models import Q
|
||||
from apps.rides.models import RidePairComparison
|
||||
|
||||
comparisons = (
|
||||
RidePairComparison.objects.filter(Q(ride_a=obj.ride) | Q(ride_b=obj.ride))
|
||||
.select_related("ride_a", "ride_b")
|
||||
.order_by("-mutual_riders_count")[:10]
|
||||
)
|
||||
|
||||
results = []
|
||||
for comp in comparisons:
|
||||
if comp.ride_a == obj.ride:
|
||||
opponent = comp.ride_b
|
||||
wins = comp.ride_a_wins
|
||||
losses = comp.ride_b_wins
|
||||
else:
|
||||
opponent = comp.ride_a
|
||||
wins = comp.ride_b_wins
|
||||
losses = comp.ride_a_wins
|
||||
|
||||
result = "win" if wins > losses else "loss" if losses > wins else "tie"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"opponent": {
|
||||
"id": opponent.id,
|
||||
"name": opponent.name,
|
||||
"slug": opponent.slug,
|
||||
"park": opponent.park.name,
|
||||
},
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"ties": comp.ties,
|
||||
"result": result,
|
||||
"mutual_riders": comp.mutual_riders_count,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_ranking_history(self, obj):
|
||||
"""Get recent ranking history."""
|
||||
from apps.rides.models import RankingSnapshot
|
||||
|
||||
history = RankingSnapshot.objects.filter(ride=obj.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[:30]
|
||||
|
||||
return [
|
||||
{
|
||||
"date": snapshot.snapshot_date,
|
||||
"rank": snapshot.rank,
|
||||
"winning_percentage": float(snapshot.winning_percentage),
|
||||
}
|
||||
for snapshot in history
|
||||
]
|
||||
|
||||
|
||||
class RankingSnapshotSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ranking history snapshots."""
|
||||
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RankingSnapshot
|
||||
fields = [
|
||||
"id",
|
||||
"ride",
|
||||
"ride_name",
|
||||
"park_name",
|
||||
"rank",
|
||||
"winning_percentage",
|
||||
"snapshot_date",
|
||||
]
|
||||
|
||||
|
||||
class RankingStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for ranking system statistics."""
|
||||
|
||||
total_ranked_rides = serializers.IntegerField()
|
||||
total_comparisons = serializers.IntegerField()
|
||||
last_calculation_time = serializers.DateTimeField()
|
||||
calculation_duration = serializers.FloatField()
|
||||
top_rated_ride = serializers.DictField()
|
||||
most_compared_ride = serializers.DictField()
|
||||
biggest_rank_change = serializers.DictField()
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Django signals for automatically updating statistics cache.
|
||||
|
||||
This module contains signal handlers that invalidate the stats cache
|
||||
whenever relevant entities are created, updated, or deleted.
|
||||
"""
|
||||
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.core.cache import cache
|
||||
|
||||
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RollerCoasterStats,
|
||||
RideReview,
|
||||
RidePhoto,
|
||||
Company as RideCompany,
|
||||
)
|
||||
|
||||
|
||||
def invalidate_stats_cache():
|
||||
"""
|
||||
Invalidate the platform stats cache.
|
||||
|
||||
This function is called whenever any entity that affects statistics
|
||||
is created, updated, or deleted.
|
||||
"""
|
||||
cache.delete("platform_stats")
|
||||
# Also update the timestamp for when stats were last invalidated
|
||||
from datetime import datetime
|
||||
|
||||
cache.set("platform_stats_timestamp", datetime.now().isoformat(), 300)
|
||||
|
||||
|
||||
# Park signals
|
||||
@receiver(post_save, sender=Park)
|
||||
@receiver(post_delete, sender=Park)
|
||||
def park_changed(sender, **kwargs):
|
||||
"""Handle Park creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
# Ride signals
|
||||
@receiver(post_save, sender=Ride)
|
||||
@receiver(post_delete, sender=Ride)
|
||||
def ride_changed(sender, **kwargs):
|
||||
"""Handle Ride creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
# Roller coaster stats signals
|
||||
@receiver(post_save, sender=RollerCoasterStats)
|
||||
@receiver(post_delete, sender=RollerCoasterStats)
|
||||
def roller_coaster_stats_changed(sender, **kwargs):
|
||||
"""Handle RollerCoasterStats creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
# Company signals (both park and ride companies)
|
||||
@receiver(post_save, sender=ParkCompany)
|
||||
@receiver(post_delete, sender=ParkCompany)
|
||||
def park_company_changed(sender, **kwargs):
|
||||
"""Handle ParkCompany creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
@receiver(post_save, sender=RideCompany)
|
||||
@receiver(post_delete, sender=RideCompany)
|
||||
def ride_company_changed(sender, **kwargs):
|
||||
"""Handle RideCompany creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
# Photo signals
|
||||
@receiver(post_save, sender=ParkPhoto)
|
||||
@receiver(post_delete, sender=ParkPhoto)
|
||||
def park_photo_changed(sender, **kwargs):
|
||||
"""Handle ParkPhoto creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
@receiver(post_save, sender=RidePhoto)
|
||||
@receiver(post_delete, sender=RidePhoto)
|
||||
def ride_photo_changed(sender, **kwargs):
|
||||
"""Handle RidePhoto creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
# Review signals
|
||||
@receiver(post_save, sender=ParkReview)
|
||||
@receiver(post_delete, sender=ParkReview)
|
||||
def park_review_changed(sender, **kwargs):
|
||||
"""Handle ParkReview creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
|
||||
|
||||
@receiver(post_save, sender=RideReview)
|
||||
@receiver(post_delete, sender=RideReview)
|
||||
def ride_review_changed(sender, **kwargs):
|
||||
"""Handle RideReview creation/deletion."""
|
||||
invalidate_stats_cache()
|
||||
@@ -1,418 +0,0 @@
|
||||
"""
|
||||
Contract Compliance Tests for ThrillWiki API
|
||||
|
||||
These tests verify that API responses match frontend TypeScript interfaces exactly,
|
||||
preventing runtime errors and ensuring type safety.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, Client
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
from apps.rides.services.hybrid_loader import SmartRideLoader
|
||||
from apps.api.v1.serializers.shared import (
|
||||
validate_filter_metadata_contract,
|
||||
ensure_filter_option_format,
|
||||
ensure_range_format
|
||||
)
|
||||
|
||||
|
||||
class FilterMetadataContractTests(TestCase):
|
||||
"""Test that filter metadata follows the expected contract."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
def test_parks_filter_metadata_structure(self):
|
||||
"""Test that parks filter metadata has correct structure."""
|
||||
# Get filter metadata from the service
|
||||
metadata = smart_park_loader.get_filter_metadata()
|
||||
|
||||
# Should have required top-level keys
|
||||
self.assertIn('categorical', metadata)
|
||||
self.assertIn('ranges', metadata)
|
||||
self.assertIn('total_count', metadata)
|
||||
|
||||
# Categorical filters should be objects with value/label/count
|
||||
categorical = metadata['categorical']
|
||||
self.assertIsInstance(categorical, dict)
|
||||
|
||||
for filter_name, filter_options in categorical.items():
|
||||
with self.subTest(filter_name=filter_name):
|
||||
self.assertIsInstance(filter_options, list,
|
||||
f"Filter '{filter_name}' should be a list")
|
||||
|
||||
for i, option in enumerate(filter_options):
|
||||
with self.subTest(filter_name=filter_name, option_index=i):
|
||||
self.assertIsInstance(option, dict,
|
||||
f"Filter '{filter_name}' option {i} should be an object, not {type(option).__name__}")
|
||||
|
||||
# Check required properties
|
||||
self.assertIn('value', option,
|
||||
f"Filter '{filter_name}' option {i} missing 'value' property")
|
||||
self.assertIn('label', option,
|
||||
f"Filter '{filter_name}' option {i} missing 'label' property")
|
||||
|
||||
# Check types
|
||||
self.assertIsInstance(option['value'], str,
|
||||
f"Filter '{filter_name}' option {i} 'value' should be string")
|
||||
self.assertIsInstance(option['label'], str,
|
||||
f"Filter '{filter_name}' option {i} 'label' should be string")
|
||||
|
||||
# Count is optional but should be int if present
|
||||
if 'count' in option and option['count'] is not None:
|
||||
self.assertIsInstance(option['count'], int,
|
||||
f"Filter '{filter_name}' option {i} 'count' should be int")
|
||||
|
||||
def test_rides_filter_metadata_structure(self):
|
||||
"""Test that rides filter metadata has correct structure."""
|
||||
loader = SmartRideLoader()
|
||||
metadata = loader.get_filter_metadata()
|
||||
|
||||
# Should have required top-level keys
|
||||
self.assertIn('categorical', metadata)
|
||||
self.assertIn('ranges', metadata)
|
||||
self.assertIn('total_count', metadata)
|
||||
|
||||
# Categorical filters should be objects with value/label/count
|
||||
categorical = metadata['categorical']
|
||||
self.assertIsInstance(categorical, dict)
|
||||
|
||||
# Test specific categorical filters that were problematic
|
||||
critical_filters = ['categories', 'statuses', 'roller_coaster_types', 'track_materials']
|
||||
|
||||
for filter_name in critical_filters:
|
||||
if filter_name in categorical:
|
||||
with self.subTest(filter_name=filter_name):
|
||||
filter_options = categorical[filter_name]
|
||||
self.assertIsInstance(filter_options, list)
|
||||
|
||||
for i, option in enumerate(filter_options):
|
||||
with self.subTest(filter_name=filter_name, option_index=i):
|
||||
self.assertIsInstance(option, dict,
|
||||
f"CRITICAL: Filter '{filter_name}' option {i} is {type(option).__name__} but should be dict")
|
||||
|
||||
self.assertIn('value', option)
|
||||
self.assertIn('label', option)
|
||||
self.assertIn('count', option)
|
||||
|
||||
def test_range_metadata_structure(self):
|
||||
"""Test that range metadata has correct structure."""
|
||||
# Test parks ranges
|
||||
parks_metadata = smart_park_loader.get_filter_metadata()
|
||||
ranges = parks_metadata['ranges']
|
||||
|
||||
for range_name, range_data in ranges.items():
|
||||
with self.subTest(range_name=range_name):
|
||||
self.assertIsInstance(range_data, dict,
|
||||
f"Range '{range_name}' should be an object")
|
||||
|
||||
# Check required properties
|
||||
self.assertIn('min', range_data)
|
||||
self.assertIn('max', range_data)
|
||||
self.assertIn('step', range_data)
|
||||
self.assertIn('unit', range_data)
|
||||
|
||||
# Check types (min/max can be None)
|
||||
if range_data['min'] is not None:
|
||||
self.assertIsInstance(range_data['min'], (int, float))
|
||||
if range_data['max'] is not None:
|
||||
self.assertIsInstance(range_data['max'], (int, float))
|
||||
|
||||
self.assertIsInstance(range_data['step'], (int, float))
|
||||
# Unit can be None or string
|
||||
if range_data['unit'] is not None:
|
||||
self.assertIsInstance(range_data['unit'], str)
|
||||
|
||||
|
||||
class ContractValidationUtilityTests(TestCase):
|
||||
"""Test contract validation utility functions."""
|
||||
|
||||
def test_validate_filter_metadata_contract_valid(self):
|
||||
"""Test validation passes for valid filter metadata."""
|
||||
valid_metadata = {
|
||||
'categorical': {
|
||||
'statuses': [
|
||||
{'value': 'OPERATING', 'label': 'Operating', 'count': 5},
|
||||
{'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2}
|
||||
]
|
||||
},
|
||||
'ranges': {
|
||||
'rating': {
|
||||
'min': 1.0,
|
||||
'max': 10.0,
|
||||
'step': 0.1,
|
||||
'unit': 'stars'
|
||||
}
|
||||
},
|
||||
'total_count': 100
|
||||
}
|
||||
|
||||
# Should not raise an exception
|
||||
validated = validate_filter_metadata_contract(valid_metadata)
|
||||
self.assertIsInstance(validated, dict)
|
||||
self.assertEqual(validated['total_count'], 100)
|
||||
|
||||
def test_validate_filter_metadata_contract_invalid(self):
|
||||
"""Test validation fails for invalid filter metadata."""
|
||||
from rest_framework import serializers
|
||||
|
||||
invalid_metadata = {
|
||||
'categorical': {
|
||||
'statuses': ['OPERATING', 'CLOSED_TEMP'] # Should be objects, not strings
|
||||
},
|
||||
'ranges': {},
|
||||
'total_count': 100
|
||||
}
|
||||
|
||||
# Should raise ValidationError
|
||||
with self.assertRaises(serializers.ValidationError):
|
||||
validate_filter_metadata_contract(invalid_metadata)
|
||||
|
||||
def test_ensure_filter_option_format_strings(self):
|
||||
"""Test converting string arrays to proper format."""
|
||||
string_options = ['OPERATING', 'CLOSED_TEMP', 'UNDER_CONSTRUCTION']
|
||||
|
||||
formatted = ensure_filter_option_format(string_options)
|
||||
|
||||
self.assertEqual(len(formatted), 3)
|
||||
for i, option in enumerate(formatted):
|
||||
self.assertIsInstance(option, dict)
|
||||
self.assertIn('value', option)
|
||||
self.assertIn('label', option)
|
||||
self.assertIn('count', option)
|
||||
self.assertIn('selected', option)
|
||||
|
||||
self.assertEqual(option['value'], string_options[i])
|
||||
self.assertEqual(option['label'], string_options[i])
|
||||
self.assertIsNone(option['count'])
|
||||
self.assertFalse(option['selected'])
|
||||
|
||||
def test_ensure_filter_option_format_tuples(self):
|
||||
"""Test converting tuple arrays to proper format."""
|
||||
tuple_options = [
|
||||
('OPERATING', 'Operating', 5),
|
||||
('CLOSED_TEMP', 'Temporarily Closed', 2)
|
||||
]
|
||||
|
||||
formatted = ensure_filter_option_format(tuple_options)
|
||||
|
||||
self.assertEqual(len(formatted), 2)
|
||||
self.assertEqual(formatted[0]['value'], 'OPERATING')
|
||||
self.assertEqual(formatted[0]['label'], 'Operating')
|
||||
self.assertEqual(formatted[0]['count'], 5)
|
||||
|
||||
self.assertEqual(formatted[1]['value'], 'CLOSED_TEMP')
|
||||
self.assertEqual(formatted[1]['label'], 'Temporarily Closed')
|
||||
self.assertEqual(formatted[1]['count'], 2)
|
||||
|
||||
def test_ensure_filter_option_format_dicts(self):
|
||||
"""Test that properly formatted dicts pass through correctly."""
|
||||
dict_options = [
|
||||
{'value': 'OPERATING', 'label': 'Operating', 'count': 5},
|
||||
{'value': 'CLOSED_TEMP', 'label': 'Temporarily Closed', 'count': 2}
|
||||
]
|
||||
|
||||
formatted = ensure_filter_option_format(dict_options)
|
||||
|
||||
self.assertEqual(len(formatted), 2)
|
||||
self.assertEqual(formatted[0]['value'], 'OPERATING')
|
||||
self.assertEqual(formatted[0]['label'], 'Operating')
|
||||
self.assertEqual(formatted[0]['count'], 5)
|
||||
|
||||
def test_ensure_range_format(self):
|
||||
"""Test range format utility."""
|
||||
range_data = {
|
||||
'min': 1.0,
|
||||
'max': 10.0,
|
||||
'step': 0.5,
|
||||
'unit': 'stars'
|
||||
}
|
||||
|
||||
formatted = ensure_range_format(range_data)
|
||||
|
||||
self.assertEqual(formatted['min'], 1.0)
|
||||
self.assertEqual(formatted['max'], 10.0)
|
||||
self.assertEqual(formatted['step'], 0.5)
|
||||
self.assertEqual(formatted['unit'], 'stars')
|
||||
|
||||
def test_ensure_range_format_missing_step(self):
|
||||
"""Test range format with missing step defaults to 1.0."""
|
||||
range_data = {
|
||||
'min': 1,
|
||||
'max': 10
|
||||
}
|
||||
|
||||
formatted = ensure_range_format(range_data)
|
||||
|
||||
self.assertEqual(formatted['step'], 1.0)
|
||||
self.assertIsNone(formatted['unit'])
|
||||
|
||||
|
||||
class APIEndpointContractTests(APITestCase):
|
||||
"""Test actual API endpoints for contract compliance."""
|
||||
|
||||
def test_parks_hybrid_endpoint_contract(self):
|
||||
"""Test parks hybrid endpoint returns proper contract."""
|
||||
# This would require actual data in the database
|
||||
# For now, we'll test the structure
|
||||
pass
|
||||
|
||||
def test_rides_hybrid_endpoint_contract(self):
|
||||
"""Test rides hybrid endpoint returns proper contract."""
|
||||
# This would require actual data in the database
|
||||
# For now, we'll test the structure
|
||||
pass
|
||||
|
||||
|
||||
class TypeScriptInterfaceComplianceTests(TestCase):
|
||||
"""Test that responses match TypeScript interfaces exactly."""
|
||||
|
||||
def test_filter_option_interface_compliance(self):
|
||||
"""Test FilterOption interface compliance."""
|
||||
# TypeScript interface:
|
||||
# interface FilterOption {
|
||||
# value: string;
|
||||
# label: string;
|
||||
# count?: number;
|
||||
# selected?: boolean;
|
||||
# }
|
||||
|
||||
option = {
|
||||
'value': 'OPERATING',
|
||||
'label': 'Operating',
|
||||
'count': 5,
|
||||
'selected': False
|
||||
}
|
||||
|
||||
# All required fields present
|
||||
self.assertIn('value', option)
|
||||
self.assertIn('label', option)
|
||||
|
||||
# Correct types
|
||||
self.assertIsInstance(option['value'], str)
|
||||
self.assertIsInstance(option['label'], str)
|
||||
|
||||
# Optional fields have correct types if present
|
||||
if 'count' in option and option['count'] is not None:
|
||||
self.assertIsInstance(option['count'], int)
|
||||
if 'selected' in option:
|
||||
self.assertIsInstance(option['selected'], bool)
|
||||
|
||||
def test_filter_range_interface_compliance(self):
|
||||
"""Test FilterRange interface compliance."""
|
||||
# TypeScript interface:
|
||||
# interface FilterRange {
|
||||
# min: number;
|
||||
# max: number;
|
||||
# step: number;
|
||||
# unit?: string;
|
||||
# }
|
||||
|
||||
range_data = {
|
||||
'min': 1.0,
|
||||
'max': 10.0,
|
||||
'step': 0.1,
|
||||
'unit': 'stars'
|
||||
}
|
||||
|
||||
# All required fields present
|
||||
self.assertIn('min', range_data)
|
||||
self.assertIn('max', range_data)
|
||||
self.assertIn('step', range_data)
|
||||
|
||||
# Correct types (min/max can be null)
|
||||
if range_data['min'] is not None:
|
||||
self.assertIsInstance(range_data['min'], (int, float))
|
||||
if range_data['max'] is not None:
|
||||
self.assertIsInstance(range_data['max'], (int, float))
|
||||
|
||||
self.assertIsInstance(range_data['step'], (int, float))
|
||||
|
||||
# Optional unit field
|
||||
if 'unit' in range_data and range_data['unit'] is not None:
|
||||
self.assertIsInstance(range_data['unit'], str)
|
||||
|
||||
|
||||
class RegressionTests(TestCase):
|
||||
"""Regression tests for specific contract violations that were fixed."""
|
||||
|
||||
def test_categorical_filters_not_strings(self):
|
||||
"""Regression test: Ensure categorical filters are never returned as strings."""
|
||||
# This was the main issue - categorical filters were returned as:
|
||||
# ['OPERATING', 'CLOSED_TEMP'] instead of
|
||||
# [{'value': 'OPERATING', 'label': 'Operating', 'count': 5}, ...]
|
||||
|
||||
# Test parks
|
||||
parks_metadata = smart_park_loader.get_filter_metadata()
|
||||
categorical = parks_metadata.get('categorical', {})
|
||||
|
||||
for filter_name, filter_options in categorical.items():
|
||||
with self.subTest(filter_name=filter_name):
|
||||
self.assertIsInstance(filter_options, list)
|
||||
|
||||
for i, option in enumerate(filter_options):
|
||||
with self.subTest(filter_name=filter_name, option_index=i):
|
||||
self.assertIsInstance(option, dict,
|
||||
f"REGRESSION: Filter '{filter_name}' option {i} is a {type(option).__name__} "
|
||||
f"but should be a dict. This causes frontend crashes!")
|
||||
|
||||
# Must not be a string
|
||||
self.assertNotIsInstance(option, str,
|
||||
f"CRITICAL REGRESSION: Filter '{filter_name}' option {i} is a string '{option}' "
|
||||
f"but frontend expects object with value/label/count properties!")
|
||||
|
||||
# Test rides
|
||||
rides_loader = SmartRideLoader()
|
||||
rides_metadata = rides_loader.get_filter_metadata()
|
||||
categorical = rides_metadata.get('categorical', {})
|
||||
|
||||
for filter_name, filter_options in categorical.items():
|
||||
with self.subTest(filter_name=f"rides_{filter_name}"):
|
||||
self.assertIsInstance(filter_options, list)
|
||||
|
||||
for i, option in enumerate(filter_options):
|
||||
with self.subTest(filter_name=f"rides_{filter_name}", option_index=i):
|
||||
self.assertIsInstance(option, dict,
|
||||
f"REGRESSION: Rides filter '{filter_name}' option {i} is a {type(option).__name__} "
|
||||
f"but should be a dict. This causes frontend crashes!")
|
||||
|
||||
def test_ranges_have_step_and_unit(self):
|
||||
"""Regression test: Ensure ranges have step and unit properties."""
|
||||
# Frontend expects: { min: number, max: number, step: number, unit?: string }
|
||||
# Backend was sometimes missing step and unit
|
||||
|
||||
parks_metadata = smart_park_loader.get_filter_metadata()
|
||||
ranges = parks_metadata.get('ranges', {})
|
||||
|
||||
for range_name, range_data in ranges.items():
|
||||
with self.subTest(range_name=range_name):
|
||||
self.assertIn('step', range_data,
|
||||
f"Range '{range_name}' missing 'step' property required by frontend")
|
||||
self.assertIn('unit', range_data,
|
||||
f"Range '{range_name}' missing 'unit' property required by frontend")
|
||||
|
||||
# Step should be a number
|
||||
self.assertIsInstance(range_data['step'], (int, float),
|
||||
f"Range '{range_name}' step should be a number")
|
||||
|
||||
def test_no_undefined_values(self):
|
||||
"""Regression test: Ensure no undefined values (should be null)."""
|
||||
# JavaScript undefined !== null, and TypeScript interfaces expect null
|
||||
|
||||
parks_metadata = smart_park_loader.get_filter_metadata()
|
||||
|
||||
def check_no_undefined(obj, path=""):
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
# Python None is fine (becomes null in JSON)
|
||||
# But we shouldn't have any undefined-like values
|
||||
check_no_undefined(value, current_path)
|
||||
elif isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
current_path = f"{path}[{i}]"
|
||||
check_no_undefined(item, current_path)
|
||||
|
||||
# This will recursively check the entire metadata structure
|
||||
check_no_undefined(parks_metadata)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""
|
||||
URL configuration for ThrillWiki API v1.
|
||||
|
||||
This module provides unified API routing following RESTful conventions
|
||||
and DRF Router patterns for automatic URL generation.
|
||||
"""
|
||||
|
||||
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
|
||||
# Import other views from the views directory
|
||||
from .views import (
|
||||
HealthCheckAPIView,
|
||||
PerformanceMetricsAPIView,
|
||||
SimpleHealthAPIView,
|
||||
# Trending system views
|
||||
TrendingAPIView,
|
||||
NewContentAPIView,
|
||||
TriggerTrendingCalculationAPIView,
|
||||
)
|
||||
from .views.stats import StatsAPIView, StatsRecalculateAPIView
|
||||
from .views.reviews import LatestReviewsAPIView
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
# Create the main API router
|
||||
router = DefaultRouter()
|
||||
|
||||
# Register ranking endpoints
|
||||
router.register(r"rankings", RideRankingViewSet, basename="ranking")
|
||||
|
||||
app_name = "api_v1"
|
||||
|
||||
urlpatterns = [
|
||||
# API Documentation endpoints are handled by main Django URLs
|
||||
# See backend/thrillwiki/urls.py for documentation endpoints
|
||||
# Authentication endpoints
|
||||
path("auth/", include("apps.api.v1.auth.urls")),
|
||||
# Health check endpoints
|
||||
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
|
||||
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
|
||||
path(
|
||||
"health/performance/",
|
||||
PerformanceMetricsAPIView.as_view(),
|
||||
name="performance-metrics",
|
||||
),
|
||||
# Trending system endpoints
|
||||
path("trending/", TrendingAPIView.as_view(), name="trending"),
|
||||
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
|
||||
path(
|
||||
"trending/calculate/",
|
||||
TriggerTrendingCalculationAPIView.as_view(),
|
||||
name="trigger-trending-calculation",
|
||||
),
|
||||
# Statistics endpoints
|
||||
path("stats/", StatsAPIView.as_view(), name="stats"),
|
||||
path(
|
||||
"stats/recalculate/",
|
||||
StatsRecalculateAPIView.as_view(),
|
||||
name="stats-recalculate",
|
||||
),
|
||||
# Reviews endpoints
|
||||
path("reviews/latest/", LatestReviewsAPIView.as_view(), name="latest-reviews"),
|
||||
# Ranking system endpoints
|
||||
path(
|
||||
"rankings/calculate/",
|
||||
TriggerRankingCalculationView.as_view(),
|
||||
name="trigger-ranking-calculation",
|
||||
),
|
||||
# Domain-specific API endpoints
|
||||
path("parks/", include("apps.api.v1.parks.urls")),
|
||||
path("rides/", include("apps.api.v1.rides.urls")),
|
||||
path("accounts/", include("apps.api.v1.accounts.urls")),
|
||||
path("history/", include("apps.api.v1.history.urls")),
|
||||
path("email/", include("apps.api.v1.email.urls")),
|
||||
path("core/", include("apps.api.v1.core.urls")),
|
||||
path("maps/", include("apps.api.v1.maps.urls")),
|
||||
path("moderation/", include("apps.moderation.urls")),
|
||||
# Cloudflare Images Toolkit API endpoints
|
||||
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
|
||||
# Include router URLs (for rankings and any other router-registered endpoints)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
API v1 Views Package
|
||||
|
||||
This package contains all API view classes organized by functionality:
|
||||
- auth.py: Authentication and user management views
|
||||
- health.py: Health check and monitoring views
|
||||
- trending.py: Trending and new content discovery views
|
||||
"""
|
||||
|
||||
# Import all view classes for easy access
|
||||
from .auth import (
|
||||
LoginAPIView,
|
||||
SignupAPIView,
|
||||
LogoutAPIView,
|
||||
CurrentUserAPIView,
|
||||
PasswordResetAPIView,
|
||||
PasswordChangeAPIView,
|
||||
SocialProvidersAPIView,
|
||||
AuthStatusAPIView,
|
||||
)
|
||||
|
||||
from .health import (
|
||||
HealthCheckAPIView,
|
||||
PerformanceMetricsAPIView,
|
||||
SimpleHealthAPIView,
|
||||
)
|
||||
|
||||
from .trending import (
|
||||
TrendingAPIView,
|
||||
NewContentAPIView,
|
||||
TriggerTrendingCalculationAPIView,
|
||||
)
|
||||
|
||||
# Export all views for import convenience
|
||||
__all__ = [
|
||||
# Authentication views
|
||||
"LoginAPIView",
|
||||
"SignupAPIView",
|
||||
"LogoutAPIView",
|
||||
"CurrentUserAPIView",
|
||||
"PasswordResetAPIView",
|
||||
"PasswordChangeAPIView",
|
||||
"SocialProvidersAPIView",
|
||||
"AuthStatusAPIView",
|
||||
# Health check views
|
||||
"HealthCheckAPIView",
|
||||
"PerformanceMetricsAPIView",
|
||||
"SimpleHealthAPIView",
|
||||
# Trending views
|
||||
"TrendingAPIView",
|
||||
"NewContentAPIView",
|
||||
"TriggerTrendingCalculationAPIView",
|
||||
]
|
||||
@@ -1,414 +0,0 @@
|
||||
"""
|
||||
Authentication API views for ThrillWiki API v1.
|
||||
|
||||
This module contains all authentication-related API endpoints including
|
||||
login, signup, logout, password management, and social authentication.
|
||||
"""
|
||||
|
||||
# type: ignore[misc,attr-defined,arg-type,call-arg,index,assignment]
|
||||
|
||||
from typing import TYPE_CHECKING, Type, Any
|
||||
from django.contrib.auth import login, logout, get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
# Import serializers from the auth serializers module
|
||||
from ..serializers.auth import (
|
||||
LoginInputSerializer,
|
||||
LoginOutputSerializer,
|
||||
SignupInputSerializer,
|
||||
SignupOutputSerializer,
|
||||
LogoutOutputSerializer,
|
||||
UserOutputSerializer,
|
||||
PasswordResetInputSerializer,
|
||||
PasswordResetOutputSerializer,
|
||||
PasswordChangeInputSerializer,
|
||||
PasswordChangeOutputSerializer,
|
||||
SocialProviderOutputSerializer,
|
||||
AuthStatusOutputSerializer,
|
||||
)
|
||||
|
||||
|
||||
# Handle optional dependencies with fallback classes
|
||||
|
||||
|
||||
class FallbackTurnstileMixin:
|
||||
"""Fallback mixin if TurnstileMixin is not available."""
|
||||
|
||||
def validate_turnstile(self, request: Any) -> None:
|
||||
"""Fallback validation method that does nothing."""
|
||||
pass
|
||||
|
||||
|
||||
# Try to import the real class, use fallback if not available
|
||||
try:
|
||||
from apps.accounts.mixins import TurnstileMixin
|
||||
except ImportError:
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
|
||||
# Type hint for the mixin
|
||||
if TYPE_CHECKING:
|
||||
from typing import Union
|
||||
|
||||
TurnstileMixinType = Union[Type[FallbackTurnstileMixin], Any]
|
||||
else:
|
||||
TurnstileMixinType = TurnstileMixin
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User login",
|
||||
description="Authenticate user with username/email and password.",
|
||||
request=LoginInputSerializer,
|
||||
responses={
|
||||
200: LoginOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class LoginAPIView(TurnstileMixin, APIView): # type: ignore[misc]
|
||||
"""API endpoint for user login."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = LoginInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Validate Turnstile if configured
|
||||
self.validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = LoginInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
# The serializer handles authentication validation
|
||||
user = serializer.validated_data["user"] # type: ignore[index]
|
||||
|
||||
login(request._request, user) # type: ignore[attr-defined]
|
||||
# Optimized token creation - get_or_create is atomic
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"token": token.key,
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User registration",
|
||||
description="Register a new user account.",
|
||||
request=SignupInputSerializer,
|
||||
responses={
|
||||
201: SignupOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SignupAPIView(TurnstileMixin, APIView): # type: ignore[misc]
|
||||
"""API endpoint for user registration."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = SignupInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Validate Turnstile if configured
|
||||
self.validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = SignupInputSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
login(request._request, user) # type: ignore[attr-defined]
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
response_serializer = SignupOutputSerializer(
|
||||
{
|
||||
"token": token.key,
|
||||
"user": user,
|
||||
"message": "Registration successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User logout",
|
||||
description="Logout the current user and invalidate their token.",
|
||||
responses={
|
||||
200: LogoutOutputSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class LogoutAPIView(APIView):
|
||||
"""API endpoint for user logout."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = LogoutOutputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Delete the token for token-based auth
|
||||
if hasattr(request.user, "auth_token"):
|
||||
request.user.auth_token.delete()
|
||||
|
||||
# Logout from session
|
||||
logout(request._request) # type: ignore[attr-defined]
|
||||
|
||||
response_serializer = LogoutOutputSerializer(
|
||||
{"message": "Logout successful"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get current user",
|
||||
description="Retrieve information about the currently authenticated user.",
|
||||
responses={
|
||||
200: UserOutputSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class CurrentUserAPIView(APIView):
|
||||
"""API endpoint to get current user information."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = UserOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
serializer = UserOutputSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Request password reset",
|
||||
description="Send a password reset email to the user.",
|
||||
request=PasswordResetInputSerializer,
|
||||
responses={
|
||||
200: PasswordResetOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class PasswordResetAPIView(APIView):
|
||||
"""API endpoint to request password reset."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = PasswordResetInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordResetInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordResetOutputSerializer(
|
||||
{"detail": "Password reset email sent"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Change password",
|
||||
description="Change the current user's password.",
|
||||
request=PasswordChangeInputSerializer,
|
||||
responses={
|
||||
200: PasswordChangeOutputSerializer,
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class PasswordChangeAPIView(APIView):
|
||||
"""API endpoint to change password."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = PasswordChangeInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordChangeInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordChangeOutputSerializer(
|
||||
{"detail": "Password changed successfully"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get social providers",
|
||||
description="Retrieve available social authentication providers.",
|
||||
responses={200: "List of social providers"},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SocialProvidersAPIView(APIView):
|
||||
"""API endpoint to get available social authentication providers."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = SocialProviderOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from django.core.cache import cache
|
||||
|
||||
try:
|
||||
# Check if django-allauth is available
|
||||
try:
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
except ImportError:
|
||||
# django-allauth is not installed, return empty list
|
||||
serializer = SocialProviderOutputSerializer([], many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
site = get_current_site(request._request) # type: ignore[attr-defined]
|
||||
|
||||
# Cache key based on site and request host
|
||||
# Use pk for Site objects, domain for RequestSite objects
|
||||
site_identifier = getattr(site, "pk", site.domain)
|
||||
cache_key = f"social_providers:{site_identifier}:{request.get_host()}"
|
||||
|
||||
# Try to get from cache first (cache for 15 minutes)
|
||||
cached_providers = cache.get(cache_key)
|
||||
if cached_providers is not None:
|
||||
return Response(cached_providers)
|
||||
|
||||
providers_list = []
|
||||
|
||||
# Optimized query: filter by site and order by provider name
|
||||
try:
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
|
||||
except Exception:
|
||||
# If query fails (table doesn't exist, etc.), return empty list
|
||||
social_apps = []
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
# Simplified provider name resolution - avoid expensive provider class loading
|
||||
provider_name = social_app.name or social_app.provider.title()
|
||||
|
||||
# Build auth URL efficiently
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
|
||||
providers_list.append(
|
||||
{
|
||||
"id": social_app.provider,
|
||||
"name": provider_name,
|
||||
"authUrl": auth_url,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Skip if provider can't be loaded
|
||||
continue
|
||||
|
||||
# Serialize and cache the result
|
||||
serializer = SocialProviderOutputSerializer(providers_list, many=True)
|
||||
response_data = serializer.data
|
||||
|
||||
# Cache for 15 minutes (900 seconds)
|
||||
cache.set(cache_key, response_data, 900)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
# Return a proper JSON error response instead of letting it bubble up
|
||||
return Response(
|
||||
{
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "SOCIAL_PROVIDERS_ERROR",
|
||||
"message": "Unable to retrieve social providers",
|
||||
"details": str(e) if str(e) else None,
|
||||
"request_user": (
|
||||
str(request.user)
|
||||
if hasattr(request, "user")
|
||||
else "AnonymousUser"
|
||||
),
|
||||
},
|
||||
"data": None,
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Check authentication status",
|
||||
description="Check if user is authenticated and return user data.",
|
||||
responses={200: AuthStatusOutputSerializer},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class AuthStatusAPIView(APIView):
|
||||
"""API endpoint to check authentication status."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = AuthStatusOutputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
if request.user.is_authenticated:
|
||||
response_data = {
|
||||
"authenticated": True,
|
||||
"user": request.user,
|
||||
}
|
||||
else:
|
||||
response_data = {
|
||||
"authenticated": False,
|
||||
"user": None,
|
||||
}
|
||||
|
||||
serializer = AuthStatusOutputSerializer(response_data)
|
||||
return Response(serializer.data)
|
||||
@@ -1,460 +0,0 @@
|
||||
"""
|
||||
Base Views for Contract-Compliant API Responses
|
||||
|
||||
This module provides base view classes that ensure all API responses follow
|
||||
consistent formats that match frontend TypeScript interfaces exactly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, Type
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.serializers import Serializer
|
||||
from django.conf import settings
|
||||
|
||||
from apps.api.v1.serializers.shared import (
|
||||
validate_filter_metadata_contract
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContractCompliantAPIView(APIView):
|
||||
"""
|
||||
Base API view that ensures all responses are contract-compliant.
|
||||
|
||||
This view provides:
|
||||
- Standardized success response format
|
||||
- Consistent error response format
|
||||
- Automatic contract validation in DEBUG mode
|
||||
- Proper error logging with context
|
||||
"""
|
||||
|
||||
# Override in subclasses to specify response serializer
|
||||
response_serializer_class: Optional[Type[Serializer]] = None
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Override dispatch to add contract validation."""
|
||||
try:
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
|
||||
# Validate contract in DEBUG mode
|
||||
if settings.DEBUG and hasattr(response, 'data'):
|
||||
self._validate_response_contract(response.data)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Log the error with context
|
||||
logger.error(
|
||||
f"API error in {self.__class__.__name__}: {str(e)}",
|
||||
extra={
|
||||
'view_class': self.__class__.__name__,
|
||||
'request_path': request.path,
|
||||
'request_method': request.method,
|
||||
'user': getattr(request, 'user', None),
|
||||
'error': str(e)
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Return standardized error response
|
||||
return self.error_response(
|
||||
message="An internal error occurred",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def success_response(
|
||||
self,
|
||||
data: Any = None,
|
||||
message: str = None,
|
||||
status_code: int = status.HTTP_200_OK,
|
||||
headers: Dict[str, str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized success response.
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
status_code: HTTP status code
|
||||
headers: Optional response headers
|
||||
|
||||
Returns:
|
||||
Response with standardized format
|
||||
"""
|
||||
response_data = {
|
||||
'success': True
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response_data['data'] = data
|
||||
|
||||
if message:
|
||||
response_data['message'] = message
|
||||
|
||||
return Response(
|
||||
response_data,
|
||||
status=status_code,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
def error_response(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
error_code: str = None,
|
||||
details: Any = None,
|
||||
headers: Dict[str, str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized error response.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
status_code: HTTP status code
|
||||
error_code: Optional error code
|
||||
details: Optional error details
|
||||
headers: Optional response headers
|
||||
|
||||
Returns:
|
||||
Response with standardized error format
|
||||
"""
|
||||
error_data = {
|
||||
'code': error_code or 'API_ERROR',
|
||||
'message': message
|
||||
}
|
||||
|
||||
if details:
|
||||
error_data['details'] = details
|
||||
|
||||
# Add user context if available
|
||||
if hasattr(self, 'request') and hasattr(self.request, 'user'):
|
||||
user = self.request.user
|
||||
if user and user.is_authenticated:
|
||||
error_data['request_user'] = user.username
|
||||
|
||||
response_data = {
|
||||
'status': 'error',
|
||||
'error': error_data,
|
||||
'data': None
|
||||
}
|
||||
|
||||
return Response(
|
||||
response_data,
|
||||
status=status_code,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
def validation_error_response(
|
||||
self,
|
||||
errors: Dict[str, Any],
|
||||
message: str = "Validation failed"
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized validation error response.
|
||||
|
||||
Args:
|
||||
errors: Validation errors dictionary
|
||||
message: Error message
|
||||
|
||||
Returns:
|
||||
Response with validation errors
|
||||
"""
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': message,
|
||||
'errors': errors
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def _validate_response_contract(self, data: Any) -> None:
|
||||
"""
|
||||
Validate response data against expected contracts.
|
||||
|
||||
This method is called automatically in DEBUG mode to catch
|
||||
contract violations during development.
|
||||
"""
|
||||
try:
|
||||
# Check if this looks like filter metadata
|
||||
if isinstance(data, dict) and 'categorical' in data and 'ranges' in data:
|
||||
validate_filter_metadata_contract(data)
|
||||
|
||||
# Add more contract validations as needed
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Contract validation failed in {self.__class__.__name__}: {str(e)}",
|
||||
extra={
|
||||
'view_class': self.__class__.__name__,
|
||||
'validation_error': str(e),
|
||||
'response_data_type': type(data).__name__
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FilterMetadataAPIView(ContractCompliantAPIView):
|
||||
"""
|
||||
Base view for filter metadata endpoints.
|
||||
|
||||
This view ensures filter metadata responses always follow the correct
|
||||
contract that matches frontend TypeScript interfaces.
|
||||
"""
|
||||
|
||||
def get_filter_metadata(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Override this method in subclasses to provide filter metadata.
|
||||
|
||||
Returns:
|
||||
Filter metadata dictionary
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_filter_metadata()")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Handle GET requests for filter metadata."""
|
||||
try:
|
||||
metadata = self.get_filter_metadata()
|
||||
|
||||
# Validate the metadata contract
|
||||
validated_metadata = validate_filter_metadata_contract(metadata)
|
||||
|
||||
return self.success_response(validated_metadata)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting filter metadata in {self.__class__.__name__}: {str(e)}",
|
||||
extra={
|
||||
'view_class': self.__class__.__name__,
|
||||
'error': str(e)
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
return self.error_response(
|
||||
message="Failed to retrieve filter metadata",
|
||||
error_code="FILTER_METADATA_ERROR"
|
||||
)
|
||||
|
||||
|
||||
class HybridFilteringAPIView(ContractCompliantAPIView):
|
||||
"""
|
||||
Base view for hybrid filtering endpoints.
|
||||
|
||||
This view provides common functionality for hybrid filtering responses
|
||||
and ensures they follow the correct contract.
|
||||
"""
|
||||
|
||||
def get_hybrid_data(self, filters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Override this method in subclasses to provide hybrid data.
|
||||
|
||||
Args:
|
||||
filters: Filter parameters
|
||||
|
||||
Returns:
|
||||
Hybrid response dictionary
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_hybrid_data()")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Handle GET requests for hybrid filtering."""
|
||||
try:
|
||||
# Extract filters from request parameters
|
||||
filters = self.extract_filters(request)
|
||||
|
||||
# Get hybrid data
|
||||
hybrid_data = self.get_hybrid_data(filters)
|
||||
|
||||
# Validate hybrid response structure
|
||||
self._validate_hybrid_response(hybrid_data)
|
||||
|
||||
return self.success_response(hybrid_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in hybrid filtering for {self.__class__.__name__}: {str(e)}",
|
||||
extra={
|
||||
'view_class': self.__class__.__name__,
|
||||
'filters': getattr(self, '_extracted_filters', {}),
|
||||
'error': str(e)
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
return self.error_response(
|
||||
message="Failed to retrieve filtered data",
|
||||
error_code="HYBRID_FILTERING_ERROR"
|
||||
)
|
||||
|
||||
def extract_filters(self, request) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract filter parameters from request.
|
||||
|
||||
Override this method in subclasses to customize filter extraction.
|
||||
|
||||
Args:
|
||||
request: HTTP request object
|
||||
|
||||
Returns:
|
||||
Dictionary of filter parameters
|
||||
"""
|
||||
# Basic implementation - extract all query parameters
|
||||
filters = {}
|
||||
for key, value in request.query_params.items():
|
||||
if value: # Only include non-empty values
|
||||
filters[key] = value
|
||||
|
||||
# Store for error logging
|
||||
self._extracted_filters = filters
|
||||
|
||||
return filters
|
||||
|
||||
def _validate_hybrid_response(self, data: Dict[str, Any]) -> None:
|
||||
"""Validate hybrid response structure."""
|
||||
required_fields = ['strategy', 'total_count']
|
||||
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
raise ValueError(f"Hybrid response missing required field: {field}")
|
||||
|
||||
# Validate strategy value
|
||||
if data['strategy'] not in ['client_side', 'server_side']:
|
||||
raise ValueError(f"Invalid strategy value: {data['strategy']}")
|
||||
|
||||
# Validate filter metadata if present
|
||||
if 'filter_metadata' in data:
|
||||
validate_filter_metadata_contract(data['filter_metadata'])
|
||||
|
||||
|
||||
class PaginatedAPIView(ContractCompliantAPIView):
|
||||
"""
|
||||
Base view for paginated responses.
|
||||
|
||||
This view ensures paginated responses follow the correct contract
|
||||
with consistent pagination metadata.
|
||||
"""
|
||||
|
||||
default_page_size = 20
|
||||
max_page_size = 100
|
||||
|
||||
def get_paginated_response(
|
||||
self,
|
||||
queryset,
|
||||
serializer_class: Type[Serializer],
|
||||
request,
|
||||
page_size: int = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a paginated response.
|
||||
|
||||
Args:
|
||||
queryset: Django queryset to paginate
|
||||
serializer_class: Serializer class for items
|
||||
request: HTTP request object
|
||||
page_size: Optional page size override
|
||||
|
||||
Returns:
|
||||
Paginated response
|
||||
"""
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
|
||||
# Determine page size
|
||||
if page_size is None:
|
||||
page_size = min(
|
||||
int(request.query_params.get('page_size', self.default_page_size)),
|
||||
self.max_page_size
|
||||
)
|
||||
|
||||
# Get page number
|
||||
page_number = request.query_params.get('page', 1)
|
||||
|
||||
try:
|
||||
page_number = int(page_number)
|
||||
except (ValueError, TypeError):
|
||||
page_number = 1
|
||||
|
||||
# Create paginator
|
||||
paginator = Paginator(queryset, page_size)
|
||||
|
||||
try:
|
||||
page = paginator.page(page_number)
|
||||
except PageNotAnInteger:
|
||||
page = paginator.page(1)
|
||||
except EmptyPage:
|
||||
page = paginator.page(paginator.num_pages)
|
||||
|
||||
# Serialize data
|
||||
serializer = serializer_class(page.object_list, many=True)
|
||||
|
||||
# Build pagination URLs
|
||||
request_url = request.build_absolute_uri().split('?')[0]
|
||||
query_params = request.query_params.copy()
|
||||
|
||||
next_url = None
|
||||
if page.has_next():
|
||||
query_params['page'] = page.next_page_number()
|
||||
next_url = f"{request_url}?{query_params.urlencode()}"
|
||||
|
||||
previous_url = None
|
||||
if page.has_previous():
|
||||
query_params['page'] = page.previous_page_number()
|
||||
previous_url = f"{request_url}?{query_params.urlencode()}"
|
||||
|
||||
# Create response data
|
||||
response_data = {
|
||||
'count': paginator.count,
|
||||
'next': next_url,
|
||||
'previous': previous_url,
|
||||
'results': serializer.data,
|
||||
'page_size': page_size,
|
||||
'current_page': page.number,
|
||||
'total_pages': paginator.num_pages
|
||||
}
|
||||
|
||||
return self.success_response(response_data)
|
||||
|
||||
|
||||
def contract_compliant_view(view_class):
|
||||
"""
|
||||
Decorator to make any view contract-compliant.
|
||||
|
||||
This decorator can be applied to existing views to add contract
|
||||
validation without changing the base class.
|
||||
"""
|
||||
original_dispatch = view_class.dispatch
|
||||
|
||||
def new_dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
response = original_dispatch(self, request, *args, **kwargs)
|
||||
|
||||
# Add contract validation in DEBUG mode
|
||||
if settings.DEBUG and hasattr(response, 'data'):
|
||||
# Basic validation - can be extended
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in decorated view {view_class.__name__}: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Return basic error response
|
||||
return Response(
|
||||
{
|
||||
'status': 'error',
|
||||
'error': {
|
||||
'code': 'API_ERROR',
|
||||
'message': 'An internal error occurred'
|
||||
},
|
||||
'data': None
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
view_class.dispatch = new_dispatch
|
||||
return view_class
|
||||
@@ -1,384 +0,0 @@
|
||||
"""
|
||||
Health check API views for ThrillWiki API v1.
|
||||
|
||||
This module contains health check and monitoring endpoints for system status,
|
||||
performance metrics, and database analysis.
|
||||
"""
|
||||
|
||||
import time
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from health_check.views import MainView
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
# Import serializers
|
||||
from ..serializers import (
|
||||
HealthCheckOutputSerializer,
|
||||
PerformanceMetricsOutputSerializer,
|
||||
SimpleHealthOutputSerializer,
|
||||
)
|
||||
|
||||
# Handle optional dependencies with fallback classes
|
||||
|
||||
|
||||
class FallbackCacheMonitor:
|
||||
"""Fallback class if CacheMonitor is not available."""
|
||||
|
||||
def get_cache_stats(self):
|
||||
return {"error": "Cache monitoring not available"}
|
||||
|
||||
|
||||
class FallbackIndexAnalyzer:
|
||||
"""Fallback class if IndexAnalyzer is not available."""
|
||||
|
||||
@staticmethod
|
||||
def analyze_slow_queries(threshold):
|
||||
return {"error": "Query analysis not available"}
|
||||
|
||||
|
||||
# Try to import the real classes, use fallbacks if not available
|
||||
try:
|
||||
from apps.core.services.enhanced_cache_service import CacheMonitor
|
||||
except ImportError:
|
||||
CacheMonitor = FallbackCacheMonitor
|
||||
|
||||
try:
|
||||
from apps.core.utils.query_optimization import IndexAnalyzer
|
||||
except ImportError:
|
||||
IndexAnalyzer = FallbackIndexAnalyzer
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Health check",
|
||||
description=(
|
||||
"Get comprehensive health check information including system metrics."
|
||||
),
|
||||
responses={
|
||||
200: HealthCheckOutputSerializer,
|
||||
503: HealthCheckOutputSerializer,
|
||||
},
|
||||
tags=["Health"],
|
||||
),
|
||||
)
|
||||
class HealthCheckAPIView(APIView):
|
||||
"""Enhanced API endpoint for health checks with detailed JSON response."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = HealthCheckOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return comprehensive health check information."""
|
||||
start_time = time.time()
|
||||
|
||||
# Get basic health check results
|
||||
main_view = MainView()
|
||||
main_view.request = request._request # type: ignore[attr-defined]
|
||||
|
||||
plugins = main_view.plugins
|
||||
errors = main_view.errors
|
||||
|
||||
# Collect additional performance metrics
|
||||
try:
|
||||
cache_monitor = CacheMonitor()
|
||||
cache_stats = cache_monitor.get_cache_stats()
|
||||
except Exception:
|
||||
cache_stats = {"error": "Cache monitoring unavailable"}
|
||||
|
||||
# Build comprehensive health data
|
||||
health_data = {
|
||||
"status": "healthy" if not errors else "unhealthy",
|
||||
"timestamp": timezone.now(),
|
||||
"version": getattr(settings, "VERSION", "1.0.0"),
|
||||
"environment": getattr(settings, "ENVIRONMENT", "development"),
|
||||
"response_time_ms": 0, # Will be calculated at the end
|
||||
"checks": {},
|
||||
"metrics": {
|
||||
"cache": cache_stats,
|
||||
"database": self._get_database_metrics(),
|
||||
"system": self._get_system_metrics(),
|
||||
},
|
||||
}
|
||||
|
||||
# Process individual health checks
|
||||
for plugin in plugins:
|
||||
# Handle both plugin objects and strings
|
||||
if hasattr(plugin, "identifier"):
|
||||
plugin_name = plugin.identifier()
|
||||
plugin_class_name = plugin.__class__.__name__
|
||||
critical_service = getattr(plugin, "critical_service", False)
|
||||
response_time = getattr(plugin, "_response_time", None)
|
||||
else:
|
||||
# If plugin is a string, use it directly
|
||||
plugin_name = str(plugin)
|
||||
plugin_class_name = plugin_name
|
||||
critical_service = False
|
||||
response_time = None
|
||||
|
||||
plugin_errors = (
|
||||
errors.get(plugin_class_name, []) if isinstance(errors, dict) else []
|
||||
)
|
||||
|
||||
health_data["checks"][plugin_name] = {
|
||||
"status": "healthy" if not plugin_errors else "unhealthy",
|
||||
"critical": critical_service,
|
||||
"errors": [str(error) for error in plugin_errors],
|
||||
"response_time_ms": response_time,
|
||||
}
|
||||
|
||||
# Calculate total response time
|
||||
health_data["response_time_ms"] = round((time.time() - start_time) * 1000, 2)
|
||||
|
||||
# Determine HTTP status code
|
||||
status_code = 200
|
||||
if errors:
|
||||
# Check if any critical services are failing
|
||||
critical_errors = any(
|
||||
getattr(plugin, "critical_service", False)
|
||||
for plugin in plugins
|
||||
if isinstance(errors, dict) and errors.get(plugin.__class__.__name__)
|
||||
)
|
||||
status_code = 503 if critical_errors else 200
|
||||
|
||||
serializer = HealthCheckOutputSerializer(health_data)
|
||||
return Response(serializer.data, status=status_code)
|
||||
|
||||
def _get_database_metrics(self) -> dict:
|
||||
"""Get database performance metrics."""
|
||||
try:
|
||||
from django.db import connection
|
||||
from typing import Any
|
||||
|
||||
# Get basic connection info
|
||||
metrics: dict[str, Any] = {
|
||||
"vendor": connection.vendor,
|
||||
"connection_status": "connected",
|
||||
}
|
||||
|
||||
# Test query performance
|
||||
start_time = time.time()
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
query_time = (time.time() - start_time) * 1000
|
||||
|
||||
metrics["test_query_time_ms"] = round(query_time, 2)
|
||||
|
||||
# PostgreSQL specific metrics
|
||||
if connection.vendor == "postgresql":
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
numbackends as active_connections,
|
||||
xact_commit as transactions_committed,
|
||||
xact_rollback as transactions_rolled_back,
|
||||
blks_read as blocks_read,
|
||||
blks_hit as blocks_hit
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
"""
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
metrics.update(
|
||||
{ # type: ignore[arg-type]
|
||||
"active_connections": row[0],
|
||||
"transactions_committed": row[1],
|
||||
"transactions_rolled_back": row[2],
|
||||
"cache_hit_ratio": (
|
||||
round((row[4] / (row[3] + row[4])) * 100, 2)
|
||||
if (row[3] + row[4]) > 0
|
||||
else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # Skip advanced metrics if not available
|
||||
|
||||
return metrics
|
||||
|
||||
except Exception as e:
|
||||
return {"connection_status": "error", "error": str(e)}
|
||||
|
||||
def _get_system_metrics(self) -> dict:
|
||||
"""Get system performance metrics."""
|
||||
from typing import Any
|
||||
|
||||
metrics: dict[str, Any] = {
|
||||
"debug_mode": settings.DEBUG,
|
||||
"allowed_hosts": (settings.ALLOWED_HOSTS if settings.DEBUG else ["hidden"]),
|
||||
}
|
||||
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# Memory metrics
|
||||
memory = psutil.virtual_memory()
|
||||
metrics["memory"] = {
|
||||
"total_mb": round(memory.total / 1024 / 1024, 2),
|
||||
"available_mb": round(memory.available / 1024 / 1024, 2),
|
||||
"percent_used": memory.percent,
|
||||
}
|
||||
|
||||
# CPU metrics
|
||||
metrics["cpu"] = {
|
||||
"percent_used": psutil.cpu_percent(interval=0.1),
|
||||
"core_count": psutil.cpu_count(),
|
||||
}
|
||||
|
||||
# Disk metrics
|
||||
disk = psutil.disk_usage("/")
|
||||
metrics["disk"] = {
|
||||
"total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
|
||||
"free_gb": round(disk.free / 1024 / 1024 / 1024, 2),
|
||||
"percent_used": round((disk.used / disk.total) * 100, 2),
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
metrics["system_monitoring"] = "psutil not available"
|
||||
except Exception as e:
|
||||
metrics["system_error"] = str(e)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Performance metrics",
|
||||
description="Get performance metrics and database analysis (debug mode only).",
|
||||
responses={
|
||||
200: PerformanceMetricsOutputSerializer,
|
||||
403: "Forbidden",
|
||||
},
|
||||
tags=["Health"],
|
||||
),
|
||||
)
|
||||
class PerformanceMetricsAPIView(APIView):
|
||||
"""API view for performance metrics and database analysis."""
|
||||
|
||||
permission_classes = [AllowAny] if settings.DEBUG else []
|
||||
serializer_class = PerformanceMetricsOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return performance metrics and analysis."""
|
||||
if not settings.DEBUG:
|
||||
return Response({"error": "Only available in debug mode"}, status=403)
|
||||
|
||||
metrics = {
|
||||
"timestamp": timezone.now(),
|
||||
"database_analysis": self._get_database_analysis(),
|
||||
"cache_performance": self._get_cache_performance(),
|
||||
"recent_slow_queries": self._get_slow_queries(),
|
||||
}
|
||||
|
||||
serializer = PerformanceMetricsOutputSerializer(metrics)
|
||||
return Response(serializer.data)
|
||||
|
||||
def _get_database_analysis(self):
|
||||
"""Analyze database performance."""
|
||||
try:
|
||||
from django.db import connection
|
||||
|
||||
analysis = {
|
||||
"total_queries": len(connection.queries),
|
||||
"query_analysis": IndexAnalyzer.analyze_slow_queries(0.05),
|
||||
}
|
||||
|
||||
if connection.queries:
|
||||
query_times = [float(q.get("time", 0)) for q in connection.queries]
|
||||
analysis.update(
|
||||
{
|
||||
"total_query_time": sum(query_times),
|
||||
"average_query_time": sum(query_times) / len(query_times),
|
||||
"slowest_query_time": max(query_times),
|
||||
"fastest_query_time": min(query_times),
|
||||
}
|
||||
)
|
||||
|
||||
return analysis
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def _get_cache_performance(self):
|
||||
"""Get cache performance metrics."""
|
||||
try:
|
||||
cache_monitor = CacheMonitor()
|
||||
return cache_monitor.get_cache_stats()
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def _get_slow_queries(self):
|
||||
"""Get recent slow queries."""
|
||||
try:
|
||||
return IndexAnalyzer.analyze_slow_queries(0.1) # 100ms threshold
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Simple health check",
|
||||
description="Simple health check endpoint for load balancers.",
|
||||
responses={
|
||||
200: SimpleHealthOutputSerializer,
|
||||
503: SimpleHealthOutputSerializer,
|
||||
},
|
||||
tags=["Health"],
|
||||
),
|
||||
options=extend_schema(
|
||||
summary="CORS preflight for simple health check",
|
||||
description=(
|
||||
"Handle CORS preflight requests for the simple health check endpoint."
|
||||
),
|
||||
responses={
|
||||
200: SimpleHealthOutputSerializer,
|
||||
},
|
||||
tags=["Health"],
|
||||
),
|
||||
)
|
||||
class SimpleHealthAPIView(APIView):
|
||||
"""Simple health check endpoint for load balancers."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = SimpleHealthOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return simple OK status."""
|
||||
try:
|
||||
# Basic database connectivity test
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
|
||||
response_data = {
|
||||
"status": "ok",
|
||||
"timestamp": timezone.now(),
|
||||
}
|
||||
serializer = SimpleHealthOutputSerializer(response_data)
|
||||
return Response(serializer.data, status=200)
|
||||
except Exception as e:
|
||||
response_data = {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": timezone.now(),
|
||||
}
|
||||
serializer = SimpleHealthOutputSerializer(response_data)
|
||||
return Response(serializer.data, status=503)
|
||||
|
||||
def options(self, request: Request) -> Response:
|
||||
"""Handle OPTIONS requests for CORS preflight."""
|
||||
response_data = {
|
||||
"status": "ok",
|
||||
"timestamp": timezone.now(),
|
||||
}
|
||||
serializer = SimpleHealthOutputSerializer(response_data)
|
||||
return Response(serializer.data)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Views for review-related API endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from itertools import chain
|
||||
from operator import attrgetter
|
||||
|
||||
from apps.parks.models.reviews import ParkReview
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from ..serializers.reviews import LatestReviewSerializer
|
||||
|
||||
|
||||
class LatestReviewsAPIView(APIView):
|
||||
"""
|
||||
API endpoint to get the latest reviews from both parks and rides.
|
||||
|
||||
Returns a combined list of the most recent reviews across the platform,
|
||||
including username, user avatar, date, score, and review snippet.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get Latest Reviews",
|
||||
description=(
|
||||
"Retrieve the latest reviews from both parks and rides. "
|
||||
"Returns a combined list sorted by creation date, including "
|
||||
"user information, ratings, and content snippets."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of reviews to return (default: 20, max: 100)",
|
||||
default=20,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: LatestReviewSerializer(many=True),
|
||||
},
|
||||
tags=["Reviews"],
|
||||
)
|
||||
def get(self, request):
|
||||
"""Get the latest reviews from both parks and rides."""
|
||||
# Get limit parameter with validation
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", 20))
|
||||
limit = min(max(limit, 1), 100) # Clamp between 1 and 100
|
||||
except (ValueError, TypeError):
|
||||
limit = 20
|
||||
|
||||
# Get published reviews from both models
|
||||
park_reviews = (
|
||||
ParkReview.objects.filter(is_published=True)
|
||||
.select_related("user", "user__profile", "park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
ride_reviews = (
|
||||
RideReview.objects.filter(is_published=True)
|
||||
.select_related("user", "user__profile", "ride", "ride__park")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
# Combine and sort by created_at
|
||||
all_reviews = sorted(
|
||||
chain(park_reviews, ride_reviews),
|
||||
key=attrgetter("created_at"),
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
# Serialize the combined results
|
||||
serializer = LatestReviewSerializer(all_reviews, many=True)
|
||||
|
||||
return Response(
|
||||
{"count": len(all_reviews), "results": serializer.data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -1,368 +0,0 @@
|
||||
"""
|
||||
Statistics API views for ThrillWiki.
|
||||
|
||||
Provides aggregate statistics about the platform's content including
|
||||
counts of parks, rides, manufacturers, and other entities.
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from django.db.models import Count
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema, OpenApiExample
|
||||
from datetime import datetime
|
||||
|
||||
from apps.parks.models import Park, ParkReview, ParkPhoto, Company as ParkCompany
|
||||
from apps.rides.models import (
|
||||
Ride,
|
||||
RollerCoasterStats,
|
||||
RideReview,
|
||||
RidePhoto,
|
||||
Company as RideCompany,
|
||||
)
|
||||
from ..serializers.stats import StatsSerializer
|
||||
|
||||
|
||||
class StatsAPIView(APIView):
|
||||
"""
|
||||
API endpoint that returns aggregate statistics about the platform.
|
||||
|
||||
Returns counts of various entities like parks, rides, manufacturers, etc.
|
||||
Results are cached for performance.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def _get_relative_time(self, timestamp_str):
|
||||
"""
|
||||
Convert an ISO timestamp to a human-readable relative time.
|
||||
|
||||
Args:
|
||||
timestamp_str: ISO format timestamp string
|
||||
|
||||
Returns:
|
||||
str: Human-readable relative time (e.g., "2 days, 3 hours, 15 minutes ago", "just now")
|
||||
"""
|
||||
if not timestamp_str or timestamp_str == "just_now":
|
||||
return "just now"
|
||||
|
||||
try:
|
||||
# Parse the ISO timestamp
|
||||
if isinstance(timestamp_str, str):
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
timestamp = timestamp_str
|
||||
|
||||
# Make timezone-aware if needed
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timezone.make_aware(timestamp)
|
||||
|
||||
now = timezone.now()
|
||||
diff = now - timestamp
|
||||
total_seconds = int(diff.total_seconds())
|
||||
|
||||
# If less than a minute, return "just now"
|
||||
if total_seconds < 60:
|
||||
return "just now"
|
||||
|
||||
# Calculate time components
|
||||
days = diff.days
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
# Build the relative time string
|
||||
parts = []
|
||||
|
||||
if days > 0:
|
||||
parts.append(f'{days} day{"s" if days != 1 else ""}')
|
||||
|
||||
if hours > 0:
|
||||
parts.append(f'{hours} hour{"s" if hours != 1 else ""}')
|
||||
|
||||
if minutes > 0:
|
||||
parts.append(f'{minutes} minute{"s" if minutes != 1 else ""}')
|
||||
|
||||
# Join parts with commas and add "ago"
|
||||
if len(parts) == 0:
|
||||
return "just now"
|
||||
elif len(parts) == 1:
|
||||
return f"{parts[0]} ago"
|
||||
elif len(parts) == 2:
|
||||
return f"{parts[0]} and {parts[1]} ago"
|
||||
else:
|
||||
return f'{", ".join(parts[:-1])}, and {parts[-1]} ago'
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return "unknown"
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_platform_stats",
|
||||
summary="Get platform statistics",
|
||||
description="""
|
||||
Returns comprehensive aggregate statistics about the ThrillWiki platform.
|
||||
|
||||
This endpoint provides detailed counts and breakdowns of all major entities including:
|
||||
- Parks, rides, and roller coasters
|
||||
- Companies (manufacturers, operators, designers, property owners)
|
||||
- Photos and reviews
|
||||
- Ride categories (roller coasters, dark rides, flat rides, etc.)
|
||||
- Status breakdowns (operating, closed, under construction, etc.)
|
||||
|
||||
Results are cached for 5 minutes for optimal performance and automatically
|
||||
invalidated when relevant data changes.
|
||||
|
||||
**No authentication required** - this is a public endpoint.
|
||||
""".strip(),
|
||||
responses={
|
||||
200: StatsSerializer,
|
||||
500: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"description": "Error message if statistics calculation fails",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Statistics"],
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Sample Response",
|
||||
description="Example of platform statistics response",
|
||||
value={
|
||||
"total_parks": 7,
|
||||
"total_rides": 10,
|
||||
"total_manufacturers": 6,
|
||||
"total_operators": 7,
|
||||
"total_designers": 4,
|
||||
"total_property_owners": 0,
|
||||
"total_roller_coasters": 8,
|
||||
"total_photos": 0,
|
||||
"total_park_photos": 0,
|
||||
"total_ride_photos": 0,
|
||||
"total_reviews": 8,
|
||||
"total_park_reviews": 4,
|
||||
"total_ride_reviews": 4,
|
||||
"roller_coasters": 10,
|
||||
"operating_parks": 7,
|
||||
"operating_rides": 10,
|
||||
"last_updated": "2025-08-28T17:34:59.677143+00:00",
|
||||
"relative_last_updated": "just now",
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
def get(self, request):
|
||||
"""Get platform statistics."""
|
||||
# Try to get cached stats first
|
||||
cache_key = "platform_stats"
|
||||
cached_stats = cache.get(cache_key)
|
||||
|
||||
if cached_stats:
|
||||
return Response(cached_stats, status=status.HTTP_200_OK)
|
||||
|
||||
# Calculate fresh stats
|
||||
stats = self._calculate_stats()
|
||||
|
||||
# Cache for 5 minutes
|
||||
cache.set(cache_key, stats, 300)
|
||||
|
||||
return Response(stats, status=status.HTTP_200_OK)
|
||||
|
||||
def _calculate_stats(self):
|
||||
"""Calculate all platform statistics."""
|
||||
|
||||
# Basic entity counts
|
||||
total_parks = Park.objects.count()
|
||||
total_rides = Ride.objects.count()
|
||||
|
||||
# Company counts by role
|
||||
total_manufacturers = RideCompany.objects.filter(
|
||||
roles__contains=["MANUFACTURER"]
|
||||
).count()
|
||||
|
||||
total_operators = ParkCompany.objects.filter(
|
||||
roles__contains=["OPERATOR"]
|
||||
).count()
|
||||
|
||||
total_designers = RideCompany.objects.filter(
|
||||
roles__contains=["DESIGNER"]
|
||||
).count()
|
||||
|
||||
total_property_owners = ParkCompany.objects.filter(
|
||||
roles__contains=["PROPERTY_OWNER"]
|
||||
).count()
|
||||
|
||||
# Photo counts (combined)
|
||||
total_park_photos = ParkPhoto.objects.count()
|
||||
total_ride_photos = RidePhoto.objects.count()
|
||||
total_photos = total_park_photos + total_ride_photos
|
||||
|
||||
# Ride type counts
|
||||
total_roller_coasters = RollerCoasterStats.objects.count()
|
||||
|
||||
# Ride category counts
|
||||
ride_categories = (
|
||||
Ride.objects.values("category")
|
||||
.annotate(count=Count("id"))
|
||||
.exclude(category="")
|
||||
)
|
||||
|
||||
category_stats = {}
|
||||
for category in ride_categories:
|
||||
category_code = category["category"]
|
||||
category_count = category["count"]
|
||||
|
||||
# Convert category codes to readable names
|
||||
category_names = {
|
||||
"RC": "roller_coasters",
|
||||
"DR": "dark_rides",
|
||||
"FR": "flat_rides",
|
||||
"WR": "water_rides",
|
||||
"TR": "transport_rides",
|
||||
"OT": "other_rides",
|
||||
}
|
||||
|
||||
category_name = category_names.get(
|
||||
category_code, f"category_{category_code.lower()}"
|
||||
)
|
||||
category_stats[category_name] = category_count
|
||||
|
||||
# Park status counts
|
||||
park_statuses = Park.objects.values("status").annotate(count=Count("id"))
|
||||
|
||||
park_status_stats = {}
|
||||
for status_item in park_statuses:
|
||||
status_code = status_item["status"]
|
||||
status_count = status_item["count"]
|
||||
|
||||
# Convert status codes to readable names
|
||||
status_names = {
|
||||
"OPERATING": "operating_parks",
|
||||
"CLOSED_TEMP": "temporarily_closed_parks",
|
||||
"CLOSED_PERM": "permanently_closed_parks",
|
||||
"UNDER_CONSTRUCTION": "under_construction_parks",
|
||||
"DEMOLISHED": "demolished_parks",
|
||||
"RELOCATED": "relocated_parks",
|
||||
}
|
||||
|
||||
if status_code in status_names:
|
||||
status_name = status_names[status_code]
|
||||
else:
|
||||
raise ValueError(f"Unknown park status: {status_code}")
|
||||
park_status_stats[status_name] = status_count
|
||||
|
||||
# Ride status counts
|
||||
ride_statuses = Ride.objects.values("status").annotate(count=Count("id"))
|
||||
|
||||
ride_status_stats = {}
|
||||
for status_item in ride_statuses:
|
||||
status_code = status_item["status"]
|
||||
status_count = status_item["count"]
|
||||
|
||||
# Convert status codes to readable names
|
||||
status_names = {
|
||||
"OPERATING": "operating_rides",
|
||||
"CLOSED_TEMP": "temporarily_closed_rides",
|
||||
"SBNO": "sbno_rides",
|
||||
"CLOSING": "closing_rides",
|
||||
"CLOSED_PERM": "permanently_closed_rides",
|
||||
"UNDER_CONSTRUCTION": "under_construction_rides",
|
||||
"DEMOLISHED": "demolished_rides",
|
||||
"RELOCATED": "relocated_rides",
|
||||
}
|
||||
|
||||
status_name = status_names.get(
|
||||
status_code, f"ride_status_{status_code.lower()}"
|
||||
)
|
||||
ride_status_stats[status_name] = status_count
|
||||
|
||||
# Review counts
|
||||
total_park_reviews = ParkReview.objects.count()
|
||||
total_ride_reviews = RideReview.objects.count()
|
||||
total_reviews = total_park_reviews + total_ride_reviews
|
||||
|
||||
# Timestamp handling
|
||||
now = timezone.now()
|
||||
last_updated_iso = now.isoformat()
|
||||
|
||||
# Get cached timestamp or use current time
|
||||
cached_timestamp = cache.get("platform_stats_timestamp")
|
||||
if cached_timestamp and cached_timestamp != "just_now":
|
||||
# Use cached timestamp for consistency
|
||||
last_updated_iso = cached_timestamp
|
||||
else:
|
||||
# Set new timestamp in cache
|
||||
cache.set("platform_stats_timestamp", last_updated_iso, 300)
|
||||
|
||||
# Calculate relative time
|
||||
relative_last_updated = self._get_relative_time(last_updated_iso)
|
||||
|
||||
# Combine all stats
|
||||
stats = {
|
||||
# Core entity counts
|
||||
"total_parks": total_parks,
|
||||
"total_rides": total_rides,
|
||||
"total_manufacturers": total_manufacturers,
|
||||
"total_operators": total_operators,
|
||||
"total_designers": total_designers,
|
||||
"total_property_owners": total_property_owners,
|
||||
"total_roller_coasters": total_roller_coasters,
|
||||
# Photo counts
|
||||
"total_photos": total_photos,
|
||||
"total_park_photos": total_park_photos,
|
||||
"total_ride_photos": total_ride_photos,
|
||||
# Review counts
|
||||
"total_reviews": total_reviews,
|
||||
"total_park_reviews": total_park_reviews,
|
||||
"total_ride_reviews": total_ride_reviews,
|
||||
# Category breakdowns
|
||||
**category_stats,
|
||||
# Status breakdowns
|
||||
**park_status_stats,
|
||||
**ride_status_stats,
|
||||
# Metadata
|
||||
"last_updated": last_updated_iso,
|
||||
"relative_last_updated": relative_last_updated,
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
class StatsRecalculateAPIView(APIView):
|
||||
"""
|
||||
Admin-only API endpoint to force recalculation of platform statistics.
|
||||
|
||||
This endpoint clears the cache and forces a fresh calculation of all statistics.
|
||||
Only accessible to admin users.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def post(self, request):
|
||||
"""Force recalculation of platform statistics."""
|
||||
# Clear the cache
|
||||
cache.delete("platform_stats")
|
||||
cache.delete("platform_stats_timestamp")
|
||||
|
||||
# Create a new StatsAPIView instance to reuse the calculation logic
|
||||
stats_view = StatsAPIView()
|
||||
fresh_stats = stats_view._calculate_stats()
|
||||
|
||||
# Cache the fresh stats
|
||||
cache.set("platform_stats", fresh_stats, 300)
|
||||
|
||||
# Return success response with the fresh stats
|
||||
return Response(
|
||||
{
|
||||
"message": "Platform statistics have been successfully recalculated",
|
||||
"stats": fresh_stats,
|
||||
"recalculated_at": timezone.now().isoformat(),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -1,265 +0,0 @@
|
||||
"""
|
||||
Trending content API views for ThrillWiki API v1.
|
||||
|
||||
This module contains endpoints for trending and new content discovery
|
||||
including trending parks, rides, and recently added content.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get trending content",
|
||||
description="Retrieve trending parks and rides based on view counts, ratings, and recency.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of trending items to return (default: 20, max: 100)",
|
||||
required=False,
|
||||
type=OpenApiTypes.INT,
|
||||
default=20,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="timeframe",
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Timeframe for trending calculation (day, week, month) - default: week",
|
||||
required=False,
|
||||
type=OpenApiTypes.STR,
|
||||
enum=["day", "week", "month"],
|
||||
default="week",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Trending"],
|
||||
),
|
||||
)
|
||||
class TrendingAPIView(APIView):
|
||||
"""API endpoint for trending content."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get trending parks and rides."""
|
||||
from apps.core.services.trending_service import trending_service
|
||||
|
||||
# Parse parameters
|
||||
limit = min(int(request.query_params.get("limit", 20)), 100)
|
||||
|
||||
# Get trending content using direct calculation service
|
||||
all_trending = trending_service.get_trending_content(limit=limit * 2)
|
||||
|
||||
# Separate by content type
|
||||
trending_rides = []
|
||||
trending_parks = []
|
||||
|
||||
for item in all_trending:
|
||||
if item.get("category") == "ride":
|
||||
trending_rides.append(item)
|
||||
elif item.get("category") == "park":
|
||||
trending_parks.append(item)
|
||||
|
||||
# Limit each category
|
||||
trending_rides = trending_rides[: limit // 3] if trending_rides else []
|
||||
trending_parks = trending_parks[: limit // 3] if trending_parks else []
|
||||
|
||||
# Latest reviews will be empty until review system is implemented
|
||||
latest_reviews = []
|
||||
|
||||
# Return in expected frontend format
|
||||
response_data = {
|
||||
"trending_rides": trending_rides,
|
||||
"trending_parks": trending_parks,
|
||||
"latest_reviews": latest_reviews,
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Trigger trending content calculation",
|
||||
description="Manually trigger the calculation of trending content using Django management commands. Admin access required.",
|
||||
responses={
|
||||
202: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"trending_completed": {"type": "boolean"},
|
||||
"new_content_completed": {"type": "boolean"},
|
||||
"completion_time": {"type": "string"},
|
||||
},
|
||||
},
|
||||
403: {"description": "Admin access required"},
|
||||
},
|
||||
tags=["Trending"],
|
||||
),
|
||||
)
|
||||
class TriggerTrendingCalculationAPIView(APIView):
|
||||
"""API endpoint to manually trigger trending content calculation."""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Trigger trending content calculation using management commands."""
|
||||
try:
|
||||
from django.core.management import call_command
|
||||
import io
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
# Capture command output
|
||||
trending_output = io.StringIO()
|
||||
new_content_output = io.StringIO()
|
||||
|
||||
trending_completed = False
|
||||
new_content_completed = False
|
||||
|
||||
try:
|
||||
# Run trending calculation command
|
||||
with redirect_stdout(trending_output), redirect_stderr(trending_output):
|
||||
call_command(
|
||||
"calculate_trending", "--content-type=all", "--limit=50"
|
||||
)
|
||||
trending_completed = True
|
||||
except Exception as e:
|
||||
trending_output.write(f"Error: {str(e)}")
|
||||
|
||||
try:
|
||||
# Run new content calculation command
|
||||
with redirect_stdout(new_content_output), redirect_stderr(
|
||||
new_content_output
|
||||
):
|
||||
call_command(
|
||||
"calculate_new_content",
|
||||
"--content-type=all",
|
||||
"--days-back=30",
|
||||
"--limit=50",
|
||||
)
|
||||
new_content_completed = True
|
||||
except Exception as e:
|
||||
new_content_output.write(f"Error: {str(e)}")
|
||||
|
||||
completion_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Trending content calculation completed",
|
||||
"trending_completed": trending_completed,
|
||||
"new_content_completed": new_content_completed,
|
||||
"completion_time": completion_time,
|
||||
"trending_output": trending_output.getvalue(),
|
||||
"new_content_output": new_content_output.getvalue(),
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"error": "Failed to trigger trending content calculation",
|
||||
"details": str(e),
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get new content",
|
||||
description="Retrieve recently added parks and rides.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of new items to return (default: 20, max: 100)",
|
||||
required=False,
|
||||
type=OpenApiTypes.INT,
|
||||
default=20,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="days",
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of days to look back for new content (default: 30, max: 365)",
|
||||
required=False,
|
||||
type=OpenApiTypes.INT,
|
||||
default=30,
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Trending"],
|
||||
),
|
||||
)
|
||||
class NewContentAPIView(APIView):
|
||||
"""API endpoint for new content."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get new parks and rides."""
|
||||
from apps.core.services.trending_service import trending_service
|
||||
|
||||
# Parse parameters
|
||||
limit = min(int(request.query_params.get("limit", 20)), 100)
|
||||
days_back = min(int(request.query_params.get("days", 30)), 365)
|
||||
|
||||
# Get new content using direct calculation service
|
||||
all_new_content = trending_service.get_new_content(
|
||||
limit=limit * 2, days_back=days_back
|
||||
)
|
||||
|
||||
recently_added = []
|
||||
newly_opened = []
|
||||
upcoming = []
|
||||
|
||||
# Categorize items based on date
|
||||
today = date.today()
|
||||
|
||||
for item in all_new_content:
|
||||
date_added = item.get("date_added", "")
|
||||
if date_added:
|
||||
try:
|
||||
# Parse the date string
|
||||
if isinstance(date_added, str):
|
||||
item_date = datetime.fromisoformat(date_added).date()
|
||||
else:
|
||||
item_date = date_added
|
||||
|
||||
# Calculate days difference
|
||||
days_diff = (today - item_date).days
|
||||
|
||||
if days_diff <= 30: # Recently added (last 30 days)
|
||||
recently_added.append(item)
|
||||
elif days_diff <= 365: # Newly opened (last year)
|
||||
newly_opened.append(item)
|
||||
else: # Older items
|
||||
newly_opened.append(item)
|
||||
|
||||
except (ValueError, TypeError):
|
||||
# If date parsing fails, add to recently added
|
||||
recently_added.append(item)
|
||||
else:
|
||||
recently_added.append(item)
|
||||
|
||||
# Upcoming items will be empty until future content system is implemented
|
||||
upcoming = []
|
||||
|
||||
# Limit each category
|
||||
recently_added = recently_added[: limit // 3] if recently_added else []
|
||||
newly_opened = newly_opened[: limit // 3] if newly_opened else []
|
||||
|
||||
# Return in expected frontend format
|
||||
response_data = {
|
||||
"recently_added": recently_added,
|
||||
"newly_opened": newly_opened,
|
||||
"upcoming": upcoming,
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
Consolidated ViewSets for ThrillWiki API v1.
|
||||
|
||||
This module contains ViewSets that are shared across domains or don't fit
|
||||
into specific domain modules. Domain-specific ViewSets have been moved to:
|
||||
- Parks: api/v1/parks/views.py
|
||||
- Rides: api/v1/rides/views.py
|
||||
- Accounts: api/v1/accounts/views.py
|
||||
- History: api/v1/history/views.py
|
||||
- Auth/Health/Trending: api/v1/views/
|
||||
"""
|
||||
|
||||
# This file is intentionally minimal now that ViewSets have been distributed
|
||||
# to domain-specific modules. Only shared utilities and fallback classes remain.
|
||||
|
||||
|
||||
# Handle optional dependencies with fallback classes
|
||||
class FallbackTurnstileMixin:
|
||||
"""Fallback mixin if TurnstileMixin is not available."""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
pass
|
||||
|
||||
|
||||
class FallbackCacheMonitor:
|
||||
"""Fallback class if CacheMonitor is not available."""
|
||||
|
||||
def get_cache_stats(self):
|
||||
return {"error": "Cache monitoring not available"}
|
||||
|
||||
|
||||
class FallbackIndexAnalyzer:
|
||||
"""Fallback class if IndexAnalyzer is not available."""
|
||||
|
||||
@staticmethod
|
||||
def analyze_slow_queries(threshold):
|
||||
return {"error": "Query analysis not available"}
|
||||
|
||||
|
||||
# Try to import the real classes, use fallbacks if not available
|
||||
try:
|
||||
from apps.accounts.mixins import TurnstileMixin
|
||||
except ImportError:
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
|
||||
try:
|
||||
from apps.core.services.enhanced_cache_service import CacheMonitor
|
||||
except ImportError:
|
||||
CacheMonitor = FallbackCacheMonitor
|
||||
|
||||
try:
|
||||
from apps.core.utils.query_optimization import IndexAnalyzer
|
||||
except ImportError:
|
||||
IndexAnalyzer = FallbackIndexAnalyzer
|
||||
|
||||
# Export fallback classes for use in domain-specific modules
|
||||
__all__ = [
|
||||
"TurnstileMixin",
|
||||
"CacheMonitor",
|
||||
"IndexAnalyzer",
|
||||
"FallbackTurnstileMixin",
|
||||
"FallbackCacheMonitor",
|
||||
"FallbackIndexAnalyzer",
|
||||
]
|
||||
@@ -1,379 +0,0 @@
|
||||
"""
|
||||
API viewsets for the ride ranking system.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Type, cast
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rest_framework.views import APIView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
# Import models inside methods to avoid Django initialization issues
|
||||
from .serializers_rankings import (
|
||||
RideRankingSerializer,
|
||||
RideRankingDetailSerializer,
|
||||
RankingSnapshotSerializer,
|
||||
RankingStatsSerializer,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride rankings",
|
||||
description="Get the current ride rankings calculated using the Internet Roller Coaster Poll algorithm.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="category",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by ride category (RC, DR, FR, WR, TR, OT)",
|
||||
enum=["RC", "DR", "FR", "WR", "TR", "OT"],
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_riders",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Minimum number of mutual riders required",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="park",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by park slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Order results (rank, -rank, winning_percentage, -winning_percentage)",
|
||||
),
|
||||
],
|
||||
responses={200: RideRankingSerializer(many=True)},
|
||||
tags=["Rankings"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ranking details",
|
||||
description="Get detailed ranking information for a specific ride.",
|
||||
responses={
|
||||
200: RideRankingDetailSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Rankings"],
|
||||
),
|
||||
history=extend_schema(
|
||||
summary="Get ranking history",
|
||||
description="Get historical ranking data for a specific ride.",
|
||||
responses={200: RankingSnapshotSerializer(many=True)},
|
||||
tags=["Rankings"],
|
||||
),
|
||||
statistics=extend_schema(
|
||||
summary="Get ranking statistics",
|
||||
description="Get overall statistics about the ranking system.",
|
||||
responses={200: RankingStatsSerializer},
|
||||
tags=["Rankings", "Statistics"],
|
||||
),
|
||||
)
|
||||
class RideRankingViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for ride rankings.
|
||||
|
||||
Provides access to ride rankings calculated using the Internet Roller Coaster Poll algorithm.
|
||||
Rankings are updated daily and based on pairwise comparisons of user ratings.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "ride__slug"
|
||||
lookup_url_kwarg = "ride_slug"
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
filterset_fields = ["ride__category"]
|
||||
ordering_fields = [
|
||||
"rank",
|
||||
"winning_percentage",
|
||||
"mutual_riders_count",
|
||||
"average_rating",
|
||||
]
|
||||
ordering = ["rank"]
|
||||
|
||||
def get_queryset(self) -> QuerySet[Any]: # type: ignore
|
||||
"""Get rankings with optimized queries."""
|
||||
from apps.rides.models import RideRanking
|
||||
|
||||
queryset = RideRanking.objects.select_related(
|
||||
"ride", "ride__park", "ride__park__location", "ride__manufacturer"
|
||||
)
|
||||
|
||||
# Cast self.request to DRF Request so type checker recognizes query_params
|
||||
request = cast(Request, self.request)
|
||||
|
||||
# Filter by category
|
||||
category = request.query_params.get("category")
|
||||
if category:
|
||||
queryset = queryset.filter(ride__category=category)
|
||||
|
||||
# Filter by minimum mutual riders
|
||||
min_riders = request.query_params.get("min_riders")
|
||||
if min_riders:
|
||||
try:
|
||||
queryset = queryset.filter(mutual_riders_count__gte=int(min_riders))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Filter by park
|
||||
park_slug = request.query_params.get("park")
|
||||
if park_slug:
|
||||
queryset = queryset.filter(ride__park__slug=park_slug)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self) -> Any: # type: ignore[override]
|
||||
"""Use different serializers for list vs detail."""
|
||||
if self.action == "retrieve":
|
||||
return cast(Type[BaseSerializer], RideRankingDetailSerializer)
|
||||
elif self.action == "history":
|
||||
return cast(Type[BaseSerializer], RankingSnapshotSerializer)
|
||||
elif self.action == "statistics":
|
||||
return cast(Type[BaseSerializer], RankingStatsSerializer)
|
||||
return cast(Type[BaseSerializer], RideRankingSerializer)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def history(self, request, ride_slug=None):
|
||||
"""Get ranking history for a specific ride."""
|
||||
from apps.rides.models import RankingSnapshot
|
||||
|
||||
ranking = self.get_object()
|
||||
history = RankingSnapshot.objects.filter(ride=ranking.ride).order_by(
|
||||
"-snapshot_date"
|
||||
)[
|
||||
:90
|
||||
] # Last 3 months
|
||||
|
||||
serializer = self.get_serializer(history, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def statistics(self, request):
|
||||
"""Get overall ranking system statistics."""
|
||||
from apps.rides.models import RideRanking, RidePairComparison, RankingSnapshot
|
||||
|
||||
total_rankings = RideRanking.objects.count()
|
||||
total_comparisons = RidePairComparison.objects.count()
|
||||
|
||||
# Get last calculation time
|
||||
latest_ranking = RideRanking.objects.order_by("-last_calculated").first()
|
||||
last_calc_time = latest_ranking.last_calculated if latest_ranking else None
|
||||
|
||||
# Get top rated ride
|
||||
top_rated = RideRanking.objects.select_related("ride", "ride__park").first()
|
||||
|
||||
# Get most compared ride
|
||||
most_compared = (
|
||||
RideRanking.objects.select_related("ride", "ride__park")
|
||||
.order_by("-comparison_count")
|
||||
.first()
|
||||
)
|
||||
|
||||
# Get biggest rank change (last 7 days)
|
||||
from datetime import timedelta
|
||||
|
||||
week_ago = timezone.now().date() - timedelta(days=7)
|
||||
|
||||
biggest_change = None
|
||||
max_change = 0
|
||||
|
||||
current_rankings = RideRanking.objects.select_related("ride")
|
||||
for ranking in current_rankings[:100]: # Check top 100 for performance
|
||||
old_snapshot = (
|
||||
RankingSnapshot.objects.filter(
|
||||
ride=ranking.ride, snapshot_date__lte=week_ago
|
||||
)
|
||||
.order_by("-snapshot_date")
|
||||
.first()
|
||||
)
|
||||
|
||||
if old_snapshot:
|
||||
change = abs(old_snapshot.rank - ranking.rank)
|
||||
if change > max_change:
|
||||
max_change = change
|
||||
biggest_change = {
|
||||
"ride": {
|
||||
"id": ranking.ride.id,
|
||||
"name": ranking.ride.name,
|
||||
"slug": ranking.ride.slug,
|
||||
},
|
||||
"current_rank": ranking.rank,
|
||||
"previous_rank": old_snapshot.rank,
|
||||
"change": old_snapshot.rank - ranking.rank,
|
||||
}
|
||||
|
||||
stats = {
|
||||
"total_ranked_rides": total_rankings,
|
||||
"total_comparisons": total_comparisons,
|
||||
"last_calculation_time": last_calc_time,
|
||||
"calculation_duration": None, # Would need to track this separately
|
||||
"top_rated_ride": (
|
||||
{
|
||||
"id": top_rated.ride.id,
|
||||
"name": top_rated.ride.name,
|
||||
"slug": top_rated.ride.slug,
|
||||
"park": top_rated.ride.park.name,
|
||||
"rank": top_rated.rank,
|
||||
"winning_percentage": float(top_rated.winning_percentage),
|
||||
"average_rating": (
|
||||
float(top_rated.average_rating)
|
||||
if top_rated.average_rating
|
||||
else None
|
||||
),
|
||||
}
|
||||
if top_rated
|
||||
else None
|
||||
),
|
||||
"most_compared_ride": (
|
||||
{
|
||||
"id": most_compared.ride.id,
|
||||
"name": most_compared.ride.name,
|
||||
"slug": most_compared.ride.slug,
|
||||
"park": most_compared.ride.park.name,
|
||||
"comparison_count": most_compared.comparison_count,
|
||||
}
|
||||
if most_compared
|
||||
else None
|
||||
),
|
||||
"biggest_rank_change": biggest_change,
|
||||
}
|
||||
|
||||
serializer = RankingStatsSerializer(stats)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride comparisons",
|
||||
description="Get head-to-head comparisons for a specific ride",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Rankings"],
|
||||
)
|
||||
@action(detail=True, methods=["get"])
|
||||
def comparisons(self, request, ride_slug=None):
|
||||
"""Get head-to-head comparisons for a specific ride."""
|
||||
from apps.rides.models import RidePairComparison
|
||||
|
||||
ranking = self.get_object()
|
||||
|
||||
comparisons = (
|
||||
RidePairComparison.objects.filter(
|
||||
Q(ride_a=ranking.ride) | Q(ride_b=ranking.ride)
|
||||
)
|
||||
.select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park")
|
||||
.order_by("-mutual_riders_count")[:50]
|
||||
)
|
||||
|
||||
results = []
|
||||
for comp in comparisons:
|
||||
if comp.ride_a == ranking.ride:
|
||||
opponent = comp.ride_b
|
||||
wins = comp.ride_a_wins
|
||||
losses = comp.ride_b_wins
|
||||
else:
|
||||
opponent = comp.ride_a
|
||||
wins = comp.ride_b_wins
|
||||
losses = comp.ride_a_wins
|
||||
|
||||
result = "win" if wins > losses else "loss" if losses > wins else "tie"
|
||||
|
||||
results.append(
|
||||
{
|
||||
"opponent": {
|
||||
"id": opponent.id,
|
||||
"name": opponent.name,
|
||||
"slug": opponent.slug,
|
||||
"park": {
|
||||
"id": opponent.park.id,
|
||||
"name": opponent.park.name,
|
||||
"slug": opponent.park.slug,
|
||||
},
|
||||
},
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"ties": comp.ties,
|
||||
"result": result,
|
||||
"mutual_riders": comp.mutual_riders_count,
|
||||
"ride_a_avg_rating": (
|
||||
float(comp.ride_a_avg_rating)
|
||||
if comp.ride_a_avg_rating
|
||||
else None
|
||||
),
|
||||
"ride_b_avg_rating": (
|
||||
float(comp.ride_b_avg_rating)
|
||||
if comp.ride_b_avg_rating
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="Trigger ranking calculation",
|
||||
description="Manually trigger a ranking calculation (admin only).",
|
||||
request=None,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Rankings", "Admin"],
|
||||
)
|
||||
class TriggerRankingCalculationView(APIView):
|
||||
"""
|
||||
Admin endpoint to manually trigger ranking calculation.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
|
||||
def post(self, request):
|
||||
"""Trigger ranking calculation."""
|
||||
if not request.user.is_staff:
|
||||
return Response(
|
||||
{"error": "Admin access required"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Replace direct import with a guarded runtime import to avoid static-analysis/initialization errors
|
||||
try:
|
||||
from apps.rides.services import RideRankingService # type: ignore
|
||||
except Exception:
|
||||
RideRankingService = None # type: ignore
|
||||
|
||||
# Attempt a dynamic import as a fallback if the direct import failed
|
||||
if RideRankingService is None:
|
||||
try:
|
||||
import importlib
|
||||
|
||||
_services_mod = importlib.import_module("apps.rides.services")
|
||||
RideRankingService = getattr(_services_mod, "RideRankingService", None)
|
||||
except Exception:
|
||||
RideRankingService = None
|
||||
|
||||
if not RideRankingService:
|
||||
return Response(
|
||||
{"error": "Ranking service unavailable"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
category = request.data.get("category")
|
||||
|
||||
service = RideRankingService()
|
||||
result = service.update_all_rankings(category=category)
|
||||
|
||||
return Response(result)
|
||||
Reference in New Issue
Block a user