Refactor API structure and add comprehensive user management features

- Restructure API v1 with improved serializers organization
- Add user deletion requests and moderation queue system
- Implement bulk moderation operations and permissions
- Add user profile enhancements with display names and avatars
- Expand ride and park API endpoints with better filtering
- Add manufacturer API with detailed ride relationships
- Improve authentication flows and error handling
- Update frontend documentation and API specifications
This commit is contained in:
pacnpal
2025-08-29 16:03:51 -04:00
parent 7b9f64be72
commit bb7da85516
92 changed files with 19690 additions and 9076 deletions

View File

@@ -49,12 +49,13 @@ class Company(TrackedModel):
# OPERATOR and PROPERTY_OWNER are for parks domain and handled separately
if self.roles:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
primary_role = self.roles[0] # Use first role as primary
if primary_role == 'MANUFACTURER':
if primary_role == "MANUFACTURER":
self.url = f"{frontend_domain}/rides/manufacturers/{self.slug}/"
elif primary_role == 'DESIGNER':
elif primary_role == "DESIGNER":
self.url = f"{frontend_domain}/rides/designers/{self.slug}/"
# OPERATOR and PROPERTY_OWNER URLs are handled by parks domain, not here

View File

@@ -38,8 +38,7 @@ class RidePhoto(TrackedModel):
)
image = CloudflareImagesField(
variant="public",
help_text="Ride photo stored on Cloudflare Images"
variant="public", help_text="Ride photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
@@ -111,7 +110,9 @@ class RidePhoto(TrackedModel):
RidePhoto.objects.filter(
ride=self.ride,
is_primary=True,
).exclude(pk=self.pk).update(is_primary=False)
).exclude(
pk=self.pk
).update(is_primary=False)
super().save(*args, **kwargs)

View File

@@ -31,8 +31,9 @@ class RideModel(TrackedModel):
"""
name = models.CharField(max_length=255, help_text="Name of the ride model")
slug = models.SlugField(max_length=255,
help_text="URL-friendly identifier (unique within manufacturer)")
slug = models.SlugField(
max_length=255, help_text="URL-friendly identifier (unique within manufacturer)"
)
manufacturer = models.ForeignKey(
Company,
on_delete=models.SET_NULL,
@@ -40,115 +41,133 @@ class RideModel(TrackedModel):
null=True,
blank=True,
limit_choices_to={"roles__contains": ["MANUFACTURER"]},
help_text="Primary manufacturer of this ride model"
help_text="Primary manufacturer of this ride model",
)
description = models.TextField(
blank=True, help_text="Detailed description of the ride model")
blank=True, help_text="Detailed description of the ride model"
)
category = models.CharField(
max_length=2,
choices=CATEGORY_CHOICES,
default="",
blank=True,
help_text="Primary category classification"
help_text="Primary category classification",
)
# Technical specifications
typical_height_range_min_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
help_text="Minimum typical height in feet for this model"
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Minimum typical height in feet for this model",
)
typical_height_range_max_ft = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
help_text="Maximum typical height in feet for this model"
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum typical height in feet for this model",
)
typical_speed_range_min_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Minimum typical speed in mph for this model"
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Minimum typical speed in mph for this model",
)
typical_speed_range_max_mph = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Maximum typical speed in mph for this model"
max_digits=5,
decimal_places=2,
null=True,
blank=True,
help_text="Maximum typical speed in mph for this model",
)
typical_capacity_range_min = models.PositiveIntegerField(
null=True, blank=True,
help_text="Minimum typical hourly capacity for this model"
null=True,
blank=True,
help_text="Minimum typical hourly capacity for this model",
)
typical_capacity_range_max = models.PositiveIntegerField(
null=True, blank=True,
help_text="Maximum typical hourly capacity for this model"
null=True,
blank=True,
help_text="Maximum typical hourly capacity for this model",
)
# Design characteristics
track_type = models.CharField(
max_length=100, blank=True,
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)"
max_length=100,
blank=True,
help_text="Type of track system (e.g., tubular steel, I-Box, wooden)",
)
support_structure = models.CharField(
max_length=100, blank=True,
help_text="Type of support structure (e.g., steel, wooden, hybrid)"
max_length=100,
blank=True,
help_text="Type of support structure (e.g., steel, wooden, hybrid)",
)
train_configuration = models.CharField(
max_length=200, blank=True,
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)"
max_length=200,
blank=True,
help_text="Typical train configuration (e.g., 2 trains, 7 cars per train, 4 seats per car)",
)
restraint_system = models.CharField(
max_length=100, blank=True,
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)"
max_length=100,
blank=True,
help_text="Type of restraint system (e.g., over-shoulder, lap bar, vest)",
)
# Market information
first_installation_year = models.PositiveIntegerField(
null=True, blank=True,
help_text="Year of first installation of this model"
null=True, blank=True, help_text="Year of first installation of this model"
)
last_installation_year = models.PositiveIntegerField(
null=True, blank=True,
help_text="Year of last installation of this model (if discontinued)"
null=True,
blank=True,
help_text="Year of last installation of this model (if discontinued)",
)
is_discontinued = models.BooleanField(
default=False,
help_text="Whether this model is no longer being manufactured"
default=False, help_text="Whether this model is no longer being manufactured"
)
total_installations = models.PositiveIntegerField(
default=0,
help_text="Total number of installations worldwide (auto-calculated)"
default=0, help_text="Total number of installations worldwide (auto-calculated)"
)
# Design features
notable_features = models.TextField(
blank=True,
help_text="Notable design features or innovations (JSON or comma-separated)"
help_text="Notable design features or innovations (JSON or comma-separated)",
)
target_market = models.CharField(
max_length=50, blank=True,
max_length=50,
blank=True,
choices=[
('FAMILY', 'Family'),
('THRILL', 'Thrill'),
('EXTREME', 'Extreme'),
('KIDDIE', 'Kiddie'),
('ALL_AGES', 'All Ages'),
("FAMILY", "Family"),
("THRILL", "Thrill"),
("EXTREME", "Extreme"),
("KIDDIE", "Kiddie"),
("ALL_AGES", "All Ages"),
],
help_text="Primary target market for this ride model"
help_text="Primary target market for this ride model",
)
# Media
primary_image = models.ForeignKey(
'RideModelPhoto',
"RideModelPhoto",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='ride_models_as_primary',
help_text="Primary promotional image for this ride model"
related_name="ride_models_as_primary",
help_text="Primary promotional image for this ride model",
)
# SEO and metadata
meta_title = models.CharField(
max_length=60, blank=True,
help_text="SEO meta title (auto-generated if blank)"
max_length=60, blank=True, help_text="SEO meta title (auto-generated if blank)"
)
meta_description = models.CharField(
max_length=160, blank=True,
help_text="SEO meta description (auto-generated if blank)"
max_length=160,
blank=True,
help_text="SEO meta description (auto-generated if blank)",
)
# Frontend URL
@@ -156,17 +175,18 @@ class RideModel(TrackedModel):
class Meta(TrackedModel.Meta):
ordering = ["manufacturer__name", "name"]
unique_together = [
["manufacturer", "name"],
["manufacturer", "slug"]
]
unique_together = [["manufacturer", "name"], ["manufacturer", "slug"]]
constraints = [
# Height range validation
models.CheckConstraint(
name="ride_model_height_range_logical",
condition=models.Q(typical_height_range_min_ft__isnull=True)
| models.Q(typical_height_range_max_ft__isnull=True)
| models.Q(typical_height_range_min_ft__lte=models.F("typical_height_range_max_ft")),
| models.Q(
typical_height_range_min_ft__lte=models.F(
"typical_height_range_max_ft"
)
),
violation_error_message="Minimum height cannot exceed maximum height",
),
# Speed range validation
@@ -174,7 +194,11 @@ class RideModel(TrackedModel):
name="ride_model_speed_range_logical",
condition=models.Q(typical_speed_range_min_mph__isnull=True)
| models.Q(typical_speed_range_max_mph__isnull=True)
| models.Q(typical_speed_range_min_mph__lte=models.F("typical_speed_range_max_mph")),
| models.Q(
typical_speed_range_min_mph__lte=models.F(
"typical_speed_range_max_mph"
)
),
violation_error_message="Minimum speed cannot exceed maximum speed",
),
# Capacity range validation
@@ -182,7 +206,11 @@ class RideModel(TrackedModel):
name="ride_model_capacity_range_logical",
condition=models.Q(typical_capacity_range_min__isnull=True)
| models.Q(typical_capacity_range_max__isnull=True)
| models.Q(typical_capacity_range_min__lte=models.F("typical_capacity_range_max")),
| models.Q(
typical_capacity_range_min__lte=models.F(
"typical_capacity_range_max"
)
),
violation_error_message="Minimum capacity cannot exceed maximum capacity",
),
# Installation years validation
@@ -190,7 +218,9 @@ class RideModel(TrackedModel):
name="ride_model_installation_years_logical",
condition=models.Q(first_installation_year__isnull=True)
| models.Q(last_installation_year__isnull=True)
| models.Q(first_installation_year__lte=models.F("last_installation_year")),
| models.Q(
first_installation_year__lte=models.F("last_installation_year")
),
violation_error_message="First installation year cannot be after last installation year",
),
]
@@ -205,16 +235,18 @@ class RideModel(TrackedModel):
def save(self, *args, **kwargs) -> None:
if not self.slug:
from django.utils.text import slugify
# Only use the ride model name for the slug, not manufacturer
base_slug = slugify(self.name)
self.slug = base_slug
# Ensure uniqueness within the same manufacturer
counter = 1
while RideModel.objects.filter(
manufacturer=self.manufacturer,
slug=self.slug
).exclude(pk=self.pk).exists():
while (
RideModel.objects.filter(manufacturer=self.manufacturer, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
self.slug = f"{base_slug}-{counter}"
counter += 1
@@ -222,14 +254,16 @@ class RideModel(TrackedModel):
if not self.meta_title:
self.meta_title = str(self)[:60]
if not self.meta_description:
desc = f"{self} - {self.description[:100]}" if self.description else str(
self)
desc = (
f"{self} - {self.description[:100]}" if self.description else str(self)
)
self.meta_description = desc[:160]
# Generate frontend URL
if self.manufacturer:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
self.url = f"{frontend_domain}/rides/manufacturers/{self.manufacturer.slug}/{self.slug}/"
super().save(*args, **kwargs)
@@ -238,9 +272,10 @@ class RideModel(TrackedModel):
"""Update the total installations count based on actual ride instances."""
# Import here to avoid circular import
from django.apps import apps
Ride = apps.get_model('rides', 'Ride')
Ride = apps.get_model("rides", "Ride")
self.total_installations = Ride.objects.filter(ride_model=self).count()
self.save(update_fields=['total_installations'])
self.save(update_fields=["total_installations"])
@property
def installation_years_range(self) -> str:
@@ -248,7 +283,11 @@ class RideModel(TrackedModel):
if self.first_installation_year and self.last_installation_year:
return f"{self.first_installation_year}-{self.last_installation_year}"
elif self.first_installation_year:
return f"{self.first_installation_year}-present" if not self.is_discontinued else f"{self.first_installation_year}+"
return (
f"{self.first_installation_year}-present"
if not self.is_discontinued
else f"{self.first_installation_year}+"
)
return "Unknown"
@property
@@ -282,13 +321,12 @@ class RideModelVariant(TrackedModel):
"""
ride_model = models.ForeignKey(
RideModel,
on_delete=models.CASCADE,
related_name="variants"
RideModel, on_delete=models.CASCADE, related_name="variants"
)
name = models.CharField(max_length=255, help_text="Name of this variant")
description = models.TextField(
blank=True, help_text="Description of variant differences")
blank=True, help_text="Description of variant differences"
)
# Variant-specific specifications
min_height_ft = models.DecimalField(
@@ -306,8 +344,7 @@ class RideModelVariant(TrackedModel):
# Distinguishing features
distinguishing_features = models.TextField(
blank=True,
help_text="What makes this variant unique from the base model"
blank=True, help_text="What makes this variant unique from the base model"
)
class Meta(TrackedModel.Meta):
@@ -323,13 +360,10 @@ class RideModelPhoto(TrackedModel):
"""Photos associated with ride models for catalog/promotional purposes."""
ride_model = models.ForeignKey(
RideModel,
on_delete=models.CASCADE,
related_name="photos"
RideModel, on_delete=models.CASCADE, related_name="photos"
)
image = models.ImageField(
upload_to="ride_models/photos/",
help_text="Photo of the ride model"
upload_to="ride_models/photos/", help_text="Photo of the ride model"
)
caption = models.CharField(max_length=500, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
@@ -338,18 +372,17 @@ class RideModelPhoto(TrackedModel):
photo_type = models.CharField(
max_length=20,
choices=[
('PROMOTIONAL', 'Promotional'),
('TECHNICAL', 'Technical Drawing'),
('INSTALLATION', 'Installation Example'),
('RENDERING', '3D Rendering'),
('CATALOG', 'Catalog Image'),
("PROMOTIONAL", "Promotional"),
("TECHNICAL", "Technical Drawing"),
("INSTALLATION", "Installation Example"),
("RENDERING", "3D Rendering"),
("CATALOG", "Catalog Image"),
],
default='PROMOTIONAL'
default="PROMOTIONAL",
)
is_primary = models.BooleanField(
default=False,
help_text="Whether this is the primary photo for the ride model"
default=False, help_text="Whether this is the primary photo for the ride model"
)
# Attribution
@@ -367,8 +400,7 @@ class RideModelPhoto(TrackedModel):
# Ensure only one primary photo per ride model
if self.is_primary:
RideModelPhoto.objects.filter(
ride_model=self.ride_model,
is_primary=True
ride_model=self.ride_model, is_primary=True
).exclude(pk=self.pk).update(is_primary=False)
super().save(*args, **kwargs)
@@ -381,32 +413,33 @@ class RideModelTechnicalSpec(TrackedModel):
"""
ride_model = models.ForeignKey(
RideModel,
on_delete=models.CASCADE,
related_name="technical_specs"
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
)
spec_category = models.CharField(
max_length=50,
choices=[
('DIMENSIONS', 'Dimensions'),
('PERFORMANCE', 'Performance'),
('CAPACITY', 'Capacity'),
('SAFETY', 'Safety Features'),
('ELECTRICAL', 'Electrical Requirements'),
('FOUNDATION', 'Foundation Requirements'),
('MAINTENANCE', 'Maintenance'),
('OTHER', 'Other'),
]
("DIMENSIONS", "Dimensions"),
("PERFORMANCE", "Performance"),
("CAPACITY", "Capacity"),
("SAFETY", "Safety Features"),
("ELECTRICAL", "Electrical Requirements"),
("FOUNDATION", "Foundation Requirements"),
("MAINTENANCE", "Maintenance"),
("OTHER", "Other"),
],
)
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
spec_value = models.CharField(
max_length=255, help_text="Value of the specification")
spec_unit = models.CharField(max_length=20, blank=True,
help_text="Unit of measurement")
max_length=255, help_text="Value of the specification"
)
spec_unit = models.CharField(
max_length=20, blank=True, help_text="Unit of measurement"
)
notes = models.TextField(
blank=True, help_text="Additional notes about this specification")
blank=True, help_text="Additional notes about this specification"
)
class Meta(TrackedModel.Meta):
ordering = ["spec_category", "spec_name"]
@@ -510,7 +543,7 @@ class Ride(TrackedModel):
null=True,
blank=True,
related_name="rides_using_as_banner",
help_text="Photo to use as banner image for this ride"
help_text="Photo to use as banner image for this ride",
)
card_image = models.ForeignKey(
"RidePhoto",
@@ -518,13 +551,14 @@ class Ride(TrackedModel):
null=True,
blank=True,
related_name="rides_using_as_card",
help_text="Photo to use as card image for this ride"
help_text="Photo to use as card image for this ride",
)
# Frontend URL
url = models.URLField(blank=True, help_text="Frontend URL for this ride")
park_url = models.URLField(
blank=True, help_text="Frontend URL for this ride's park")
blank=True, help_text="Frontend URL for this ride's park"
)
class Meta(TrackedModel.Meta):
ordering = ["name"]
@@ -596,7 +630,8 @@ class Ride(TrackedModel):
# Generate frontend URLs
if self.park:
frontend_domain = getattr(
settings, 'FRONTEND_DOMAIN', 'https://thrillwiki.com')
settings, "FRONTEND_DOMAIN", "https://thrillwiki.com"
)
self.url = f"{frontend_domain}/parks/{self.park.slug}/rides/{self.slug}/"
self.park_url = f"{frontend_domain}/parks/{self.park.slug}/"