mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
feat: Implement Phase 1.5 entity models (Park, Ride, Company, RideModel, Photo)
- Created Company model with location tracking, date precision, and CloudFlare Images - Created RideModel model for manufacturer's ride models with specifications - Created Park model with location, dates, operator, and cached statistics - Created Ride model with comprehensive stats, manufacturer, and park relationship - Created Photo model with CloudFlare Images integration and generic relations - Added lifecycle hooks for auto-slug generation and count updates - Created migrations and applied to database - Registered all models in Django admin with detailed fieldsets - Fixed admin autocomplete_fields to use raw_id_fields where needed - All models inherit from VersionedModel for automatic version tracking - Models include date precision tracking for opening/closing dates - Added comprehensive indexes for query performance Phase 1.5 complete - Entity models ready for API development
This commit is contained in:
BIN
django/apps/entities/__pycache__/admin.cpython-313.pyc
Normal file
BIN
django/apps/entities/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
168
django/apps/entities/admin.py
Normal file
168
django/apps/entities/admin.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Django Admin configuration for entity models.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Company, RideModel, Park, Ride
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Company)
|
||||||
|
class CompanyAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Company model."""
|
||||||
|
|
||||||
|
list_display = ['name', 'slug', 'location', 'park_count', 'ride_count', 'created', 'modified']
|
||||||
|
list_filter = ['company_types', 'founded_date']
|
||||||
|
search_fields = ['name', 'slug', 'description']
|
||||||
|
readonly_fields = ['id', 'created', 'modified', 'park_count', 'ride_count']
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'slug', 'description', 'company_types')
|
||||||
|
}),
|
||||||
|
('Location', {
|
||||||
|
'fields': ('location',)
|
||||||
|
}),
|
||||||
|
('Dates', {
|
||||||
|
'fields': (
|
||||||
|
'founded_date', 'founded_date_precision',
|
||||||
|
'closed_date', 'closed_date_precision'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Media', {
|
||||||
|
'fields': ('logo_image_id', 'logo_image_url', 'website')
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('park_count', 'ride_count'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('System', {
|
||||||
|
'fields': ('id', 'created', 'modified'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RideModel)
|
||||||
|
class RideModelAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for RideModel model."""
|
||||||
|
|
||||||
|
list_display = ['name', 'manufacturer', 'model_type', 'installation_count', 'created', 'modified']
|
||||||
|
list_filter = ['model_type', 'manufacturer']
|
||||||
|
search_fields = ['name', 'slug', 'description', 'manufacturer__name']
|
||||||
|
readonly_fields = ['id', 'created', 'modified', 'installation_count']
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
autocomplete_fields = ['manufacturer']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'slug', 'description', 'manufacturer', 'model_type')
|
||||||
|
}),
|
||||||
|
('Typical Specifications', {
|
||||||
|
'fields': ('typical_height', 'typical_speed', 'typical_capacity')
|
||||||
|
}),
|
||||||
|
('Media', {
|
||||||
|
'fields': ('image_id', 'image_url')
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('installation_count',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('System', {
|
||||||
|
'fields': ('id', 'created', 'modified'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Park)
|
||||||
|
class ParkAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Park model."""
|
||||||
|
|
||||||
|
list_display = ['name', 'location', 'park_type', 'status', 'ride_count', 'coaster_count', 'opening_date']
|
||||||
|
list_filter = ['park_type', 'status', 'operator', 'opening_date']
|
||||||
|
search_fields = ['name', 'slug', 'description', 'location__name']
|
||||||
|
readonly_fields = ['id', 'created', 'modified', 'ride_count', 'coaster_count']
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
autocomplete_fields = ['operator']
|
||||||
|
raw_id_fields = ['location']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'slug', 'description', 'park_type', 'status')
|
||||||
|
}),
|
||||||
|
('Location', {
|
||||||
|
'fields': ('location', 'latitude', 'longitude')
|
||||||
|
}),
|
||||||
|
('Dates', {
|
||||||
|
'fields': (
|
||||||
|
'opening_date', 'opening_date_precision',
|
||||||
|
'closing_date', 'closing_date_precision'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Operator', {
|
||||||
|
'fields': ('operator',)
|
||||||
|
}),
|
||||||
|
('Media', {
|
||||||
|
'fields': (
|
||||||
|
'banner_image_id', 'banner_image_url',
|
||||||
|
'logo_image_id', 'logo_image_url',
|
||||||
|
'website'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('ride_count', 'coaster_count'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Custom Data', {
|
||||||
|
'fields': ('custom_fields',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('System', {
|
||||||
|
'fields': ('id', 'created', 'modified'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Ride)
|
||||||
|
class RideAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Ride model."""
|
||||||
|
|
||||||
|
list_display = ['name', 'park', 'ride_category', 'status', 'is_coaster', 'manufacturer', 'opening_date']
|
||||||
|
list_filter = ['ride_category', 'status', 'is_coaster', 'park', 'manufacturer', 'opening_date']
|
||||||
|
search_fields = ['name', 'slug', 'description', 'park__name', 'manufacturer__name']
|
||||||
|
readonly_fields = ['id', 'created', 'modified', 'is_coaster']
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
autocomplete_fields = ['park', 'manufacturer', 'model']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'slug', 'description', 'park')
|
||||||
|
}),
|
||||||
|
('Classification', {
|
||||||
|
'fields': ('ride_category', 'ride_type', 'is_coaster', 'status')
|
||||||
|
}),
|
||||||
|
('Dates', {
|
||||||
|
'fields': (
|
||||||
|
'opening_date', 'opening_date_precision',
|
||||||
|
'closing_date', 'closing_date_precision'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Manufacturer', {
|
||||||
|
'fields': ('manufacturer', 'model')
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('height', 'speed', 'length', 'duration', 'inversions', 'capacity')
|
||||||
|
}),
|
||||||
|
('Media', {
|
||||||
|
'fields': ('image_id', 'image_url')
|
||||||
|
}),
|
||||||
|
('Custom Data', {
|
||||||
|
'fields': ('custom_fields',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('System', {
|
||||||
|
'fields': ('id', 'created', 'modified'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
846
django/apps/entities/migrations/0001_initial.py
Normal file
846
django/apps/entities/migrations/0001_initial.py
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
# Generated by Django 4.2.8 on 2025-11-08 16:41
|
||||||
|
|
||||||
|
import dirtyfields.dirtyfields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import django_lifecycle.mixins
|
||||||
|
import model_utils.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Company",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="Official company name",
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slug",
|
||||||
|
models.SlugField(
|
||||||
|
help_text="URL-friendly identifier", max_length=255, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Company description and history"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"company_types",
|
||||||
|
models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of company types (manufacturer, operator, etc.)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"founded_date",
|
||||||
|
models.DateField(
|
||||||
|
blank=True, help_text="Company founding date", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"founded_date_precision",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||||
|
default="day",
|
||||||
|
help_text="Precision of founded date",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"closed_date",
|
||||||
|
models.DateField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Company closure date (if applicable)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"closed_date_precision",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||||
|
default="day",
|
||||||
|
help_text="Precision of closed date",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"website",
|
||||||
|
models.URLField(blank=True, help_text="Official company website"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"logo_image_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for company logo",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"logo_image_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True, help_text="CloudFlare image URL for company logo"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"park_count",
|
||||||
|
models.IntegerField(
|
||||||
|
default=0, help_text="Number of parks operated (for operators)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ride_count",
|
||||||
|
models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of rides manufactured (for manufacturers)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Company headquarters location",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="companies",
|
||||||
|
to="core.locality",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Company",
|
||||||
|
"verbose_name_plural": "Companies",
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
bases=(
|
||||||
|
dirtyfields.dirtyfields.DirtyFieldsMixin,
|
||||||
|
django_lifecycle.mixins.LifecycleModelMixin,
|
||||||
|
models.Model,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Park",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, help_text="Official park name", max_length=255
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slug",
|
||||||
|
models.SlugField(
|
||||||
|
help_text="URL-friendly identifier", max_length=255, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Park description and history"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"park_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("theme_park", "Theme Park"),
|
||||||
|
("amusement_park", "Amusement Park"),
|
||||||
|
("water_park", "Water Park"),
|
||||||
|
(
|
||||||
|
"family_entertainment_center",
|
||||||
|
"Family Entertainment Center",
|
||||||
|
),
|
||||||
|
("traveling_park", "Traveling Park"),
|
||||||
|
("zoo", "Zoo"),
|
||||||
|
("aquarium", "Aquarium"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of park",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("operating", "Operating"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("sbno", "Standing But Not Operating"),
|
||||||
|
("under_construction", "Under Construction"),
|
||||||
|
("planned", "Planned"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="operating",
|
||||||
|
help_text="Current operational status",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opening_date",
|
||||||
|
models.DateField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Park opening date",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opening_date_precision",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||||
|
default="day",
|
||||||
|
help_text="Precision of opening date",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"closing_date",
|
||||||
|
models.DateField(
|
||||||
|
blank=True, help_text="Park closing date (if closed)", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"closing_date_precision",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||||
|
default="day",
|
||||||
|
help_text="Precision of closing date",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"latitude",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=7,
|
||||||
|
help_text="Latitude coordinate",
|
||||||
|
max_digits=10,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"longitude",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=7,
|
||||||
|
help_text="Longitude coordinate",
|
||||||
|
max_digits=10,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"website",
|
||||||
|
models.URLField(blank=True, help_text="Official park website"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"banner_image_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for park banner",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"banner_image_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True, help_text="CloudFlare image URL for park banner"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"logo_image_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for park logo",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"logo_image_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True, help_text="CloudFlare image URL for park logo"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ride_count",
|
||||||
|
models.IntegerField(default=0, help_text="Total number of rides"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"coaster_count",
|
||||||
|
models.IntegerField(
|
||||||
|
default=0, help_text="Number of roller coasters"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"custom_fields",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
help_text="Additional park-specific data",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"location",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Park location",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="parks",
|
||||||
|
to="core.locality",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"operator",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Current park operator",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="operated_parks",
|
||||||
|
to="entities.company",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Park",
|
||||||
|
"verbose_name_plural": "Parks",
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
bases=(
|
||||||
|
dirtyfields.dirtyfields.DirtyFieldsMixin,
|
||||||
|
django_lifecycle.mixins.LifecycleModelMixin,
|
||||||
|
models.Model,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RideModel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slug",
|
||||||
|
models.SlugField(
|
||||||
|
help_text="URL-friendly identifier", max_length=255, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Model description and technical details"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"model_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("coaster_model", "Roller Coaster Model"),
|
||||||
|
("flat_ride_model", "Flat Ride Model"),
|
||||||
|
("water_ride_model", "Water Ride Model"),
|
||||||
|
("dark_ride_model", "Dark Ride Model"),
|
||||||
|
("transport_ride_model", "Transport Ride Model"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of ride model",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"typical_height",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=1,
|
||||||
|
help_text="Typical height in feet",
|
||||||
|
max_digits=6,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"typical_speed",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=1,
|
||||||
|
help_text="Typical speed in mph",
|
||||||
|
max_digits=6,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"typical_capacity",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True, help_text="Typical hourly capacity", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="CloudFlare image ID", max_length=255
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_url",
|
||||||
|
models.URLField(blank=True, help_text="CloudFlare image URL"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"installation_count",
|
||||||
|
models.IntegerField(
|
||||||
|
default=0, help_text="Number of installations worldwide"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"manufacturer",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="Manufacturer of this ride model",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ride_models",
|
||||||
|
to="entities.company",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ride Model",
|
||||||
|
"verbose_name_plural": "Ride Models",
|
||||||
|
"ordering": ["manufacturer__name", "name"],
|
||||||
|
},
|
||||||
|
bases=(
|
||||||
|
dirtyfields.dirtyfields.DirtyFieldsMixin,
|
||||||
|
django_lifecycle.mixins.LifecycleModelMixin,
|
||||||
|
models.Model,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Ride",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, help_text="Ride name", max_length=255
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slug",
|
||||||
|
models.SlugField(
|
||||||
|
help_text="URL-friendly identifier", max_length=255, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Ride description and history"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ride_category",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("roller_coaster", "Roller Coaster"),
|
||||||
|
("flat_ride", "Flat Ride"),
|
||||||
|
("water_ride", "Water Ride"),
|
||||||
|
("dark_ride", "Dark Ride"),
|
||||||
|
("transport_ride", "Transport Ride"),
|
||||||
|
("other", "Other"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
help_text="Broad ride category",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ride_type",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')",
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_coaster",
|
||||||
|
models.BooleanField(
|
||||||
|
db_index=True,
|
||||||
|
default=False,
|
||||||
|
help_text="Is this ride a roller coaster?",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("operating", "Operating"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("sbno", "Standing But Not Operating"),
|
||||||
|
("relocated", "Relocated"),
|
||||||
|
("under_construction", "Under Construction"),
|
||||||
|
("planned", "Planned"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="operating",
|
||||||
|
help_text="Current operational status",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opening_date",
|
||||||
|
models.DateField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Ride opening date",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"opening_date_precision",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||||
|
default="day",
|
||||||
|
help_text="Precision of opening date",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"closing_date",
|
||||||
|
models.DateField(
|
||||||
|
blank=True, help_text="Ride closing date (if closed)", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"closing_date_precision",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||||
|
default="day",
|
||||||
|
help_text="Precision of closing date",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"height",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=1,
|
||||||
|
help_text="Height in feet",
|
||||||
|
max_digits=6,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"speed",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=1,
|
||||||
|
help_text="Top speed in mph",
|
||||||
|
max_digits=6,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"length",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=1,
|
||||||
|
help_text="Track/ride length in feet",
|
||||||
|
max_digits=8,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"duration",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True, help_text="Ride duration in seconds", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"inversions",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Number of inversions (for coasters)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"capacity",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Hourly capacity (riders per hour)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for main photo",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True, help_text="CloudFlare image URL for main photo"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"custom_fields",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
help_text="Additional ride-specific data",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"manufacturer",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Ride manufacturer",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="manufactured_rides",
|
||||||
|
to="entities.company",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"model",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific ride model",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="rides",
|
||||||
|
to="entities.ridemodel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"park",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="Park where ride is located",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="rides",
|
||||||
|
to="entities.park",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Ride",
|
||||||
|
"verbose_name_plural": "Rides",
|
||||||
|
"ordering": ["park__name", "name"],
|
||||||
|
},
|
||||||
|
bases=(
|
||||||
|
dirtyfields.dirtyfields.DirtyFieldsMixin,
|
||||||
|
django_lifecycle.mixins.LifecycleModelMixin,
|
||||||
|
models.Model,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ridemodel",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["manufacturer", "name"], name="entities_ri_manufac_1fe3c1_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ridemodel",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["model_type"], name="entities_ri_model_t_610d23_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="ridemodel",
|
||||||
|
unique_together={("manufacturer", "name")},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["park", "name"], name="entities_ri_park_id_e73e3b_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(fields=["slug"], name="entities_ri_slug_d2d6bb_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(fields=["status"], name="entities_ri_status_b69114_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["is_coaster"], name="entities_ri_is_coas_912a4d_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["ride_category"], name="entities_ri_ride_ca_bc4554_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["opening_date"], name="entities_ri_opening_c4fc53_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="ride",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["manufacturer"], name="entities_ri_manufac_0d9a25_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="park",
|
||||||
|
index=models.Index(fields=["name"], name="entities_pa_name_f8a746_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="park",
|
||||||
|
index=models.Index(fields=["slug"], name="entities_pa_slug_a21c73_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="park",
|
||||||
|
index=models.Index(fields=["status"], name="entities_pa_status_805296_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="park",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["park_type"], name="entities_pa_park_ty_8eba41_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="park",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["opening_date"], name="entities_pa_opening_102a60_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="park",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["location"], name="entities_pa_locatio_20a884_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="company",
|
||||||
|
index=models.Index(fields=["name"], name="entities_co_name_d061e8_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="company",
|
||||||
|
index=models.Index(fields=["slug"], name="entities_co_slug_00ae5c_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
django/apps/entities/migrations/__init__.py
Normal file
0
django/apps/entities/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,701 @@
|
|||||||
|
"""
|
||||||
|
Entity models for ThrillWiki Django backend.
|
||||||
|
|
||||||
|
This module contains the core entity models:
|
||||||
|
- Company: Manufacturers, operators, designers
|
||||||
|
- RideModel: Specific ride models from manufacturers
|
||||||
|
- Park: Theme parks, amusement parks, water parks, FECs
|
||||||
|
- Ride: Individual rides and roller coasters
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE
|
||||||
|
|
||||||
|
from apps.core.models import VersionedModel, BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Company(VersionedModel):
|
||||||
|
"""
|
||||||
|
Represents a company in the amusement industry.
|
||||||
|
Can be a manufacturer, operator, designer, or combination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMPANY_TYPE_CHOICES = [
|
||||||
|
('manufacturer', 'Manufacturer'),
|
||||||
|
('operator', 'Operator'),
|
||||||
|
('designer', 'Designer'),
|
||||||
|
('supplier', 'Supplier'),
|
||||||
|
('contractor', 'Contractor'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Basic Info
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Official company name"
|
||||||
|
)
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="URL-friendly identifier"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Company description and history"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Company Types (can be multiple)
|
||||||
|
company_types = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of company types (manufacturer, operator, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Location
|
||||||
|
location = models.ForeignKey(
|
||||||
|
'core.Locality',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='companies',
|
||||||
|
help_text="Company headquarters location"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dates with precision tracking
|
||||||
|
founded_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Company founding date"
|
||||||
|
)
|
||||||
|
founded_date_precision = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='day',
|
||||||
|
choices=[
|
||||||
|
('year', 'Year'),
|
||||||
|
('month', 'Month'),
|
||||||
|
('day', 'Day'),
|
||||||
|
],
|
||||||
|
help_text="Precision of founded date"
|
||||||
|
)
|
||||||
|
|
||||||
|
closed_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Company closure date (if applicable)"
|
||||||
|
)
|
||||||
|
closed_date_precision = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='day',
|
||||||
|
choices=[
|
||||||
|
('year', 'Year'),
|
||||||
|
('month', 'Month'),
|
||||||
|
('day', 'Day'),
|
||||||
|
],
|
||||||
|
help_text="Precision of closed date"
|
||||||
|
)
|
||||||
|
|
||||||
|
# External Links
|
||||||
|
website = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Official company website"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CloudFlare Images
|
||||||
|
logo_image_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for company logo"
|
||||||
|
)
|
||||||
|
logo_image_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image URL for company logo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cached statistics
|
||||||
|
park_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of parks operated (for operators)"
|
||||||
|
)
|
||||||
|
ride_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of rides manufactured (for manufacturers)"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Company'
|
||||||
|
verbose_name_plural = 'Companies'
|
||||||
|
ordering = ['name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['name']),
|
||||||
|
models.Index(fields=['slug']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@hook(BEFORE_SAVE, when='slug', is_now=None)
|
||||||
|
def auto_generate_slug(self):
|
||||||
|
"""Auto-generate slug from name if not provided."""
|
||||||
|
if not self.slug and self.name:
|
||||||
|
base_slug = slugify(self.name)
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while Company.objects.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
self.slug = slug
|
||||||
|
|
||||||
|
def update_counts(self):
|
||||||
|
"""Update cached park and ride counts."""
|
||||||
|
self.park_count = self.operated_parks.count()
|
||||||
|
self.ride_count = self.manufactured_rides.count()
|
||||||
|
self.save(update_fields=['park_count', 'ride_count'])
|
||||||
|
|
||||||
|
|
||||||
|
class RideModel(VersionedModel):
|
||||||
|
"""
|
||||||
|
Represents a specific ride model from a manufacturer.
|
||||||
|
E.g., "B&M Inverted Coaster", "Vekoma Boomerang", "Zamperla Family Gravity Coaster"
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODEL_TYPE_CHOICES = [
|
||||||
|
('coaster_model', 'Roller Coaster Model'),
|
||||||
|
('flat_ride_model', 'Flat Ride Model'),
|
||||||
|
('water_ride_model', 'Water Ride Model'),
|
||||||
|
('dark_ride_model', 'Dark Ride Model'),
|
||||||
|
('transport_ride_model', 'Transport Ride Model'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Basic Info
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')"
|
||||||
|
)
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="URL-friendly identifier"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Model description and technical details"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manufacturer
|
||||||
|
manufacturer = models.ForeignKey(
|
||||||
|
'Company',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='ride_models',
|
||||||
|
help_text="Manufacturer of this ride model"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model Type
|
||||||
|
model_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=MODEL_TYPE_CHOICES,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of ride model"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Technical Specifications (common to most instances)
|
||||||
|
typical_height = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=1,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Typical height in feet"
|
||||||
|
)
|
||||||
|
typical_speed = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=1,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Typical speed in mph"
|
||||||
|
)
|
||||||
|
typical_capacity = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Typical hourly capacity"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CloudFlare Images
|
||||||
|
image_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID"
|
||||||
|
)
|
||||||
|
image_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cached statistics
|
||||||
|
installation_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of installations worldwide"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Ride Model'
|
||||||
|
verbose_name_plural = 'Ride Models'
|
||||||
|
ordering = ['manufacturer__name', 'name']
|
||||||
|
unique_together = [['manufacturer', 'name']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['manufacturer', 'name']),
|
||||||
|
models.Index(fields=['model_type']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.manufacturer.name} {self.name}"
|
||||||
|
|
||||||
|
@hook(BEFORE_SAVE, when='slug', is_now=None)
|
||||||
|
def auto_generate_slug(self):
|
||||||
|
"""Auto-generate slug from manufacturer and name if not provided."""
|
||||||
|
if not self.slug and self.manufacturer and self.name:
|
||||||
|
base_slug = slugify(f"{self.manufacturer.name} {self.name}")
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while RideModel.objects.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
self.slug = slug
|
||||||
|
|
||||||
|
def update_installation_count(self):
|
||||||
|
"""Update cached installation count."""
|
||||||
|
self.installation_count = self.rides.count()
|
||||||
|
self.save(update_fields=['installation_count'])
|
||||||
|
|
||||||
|
|
||||||
|
class Park(VersionedModel):
|
||||||
|
"""
|
||||||
|
Represents an amusement park, theme park, water park, or FEC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PARK_TYPE_CHOICES = [
|
||||||
|
('theme_park', 'Theme Park'),
|
||||||
|
('amusement_park', 'Amusement Park'),
|
||||||
|
('water_park', 'Water Park'),
|
||||||
|
('family_entertainment_center', 'Family Entertainment Center'),
|
||||||
|
('traveling_park', 'Traveling Park'),
|
||||||
|
('zoo', 'Zoo'),
|
||||||
|
('aquarium', 'Aquarium'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('operating', 'Operating'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
('sbno', 'Standing But Not Operating'),
|
||||||
|
('under_construction', 'Under Construction'),
|
||||||
|
('planned', 'Planned'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Basic Info
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Official park name"
|
||||||
|
)
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="URL-friendly identifier"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Park description and history"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type & Status
|
||||||
|
park_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=PARK_TYPE_CHOICES,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of park"
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='operating',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Current operational status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dates with precision tracking
|
||||||
|
opening_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Park opening date"
|
||||||
|
)
|
||||||
|
opening_date_precision = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='day',
|
||||||
|
choices=[
|
||||||
|
('year', 'Year'),
|
||||||
|
('month', 'Month'),
|
||||||
|
('day', 'Day'),
|
||||||
|
],
|
||||||
|
help_text="Precision of opening date"
|
||||||
|
)
|
||||||
|
|
||||||
|
closing_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Park closing date (if closed)"
|
||||||
|
)
|
||||||
|
closing_date_precision = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='day',
|
||||||
|
choices=[
|
||||||
|
('year', 'Year'),
|
||||||
|
('month', 'Month'),
|
||||||
|
('day', 'Day'),
|
||||||
|
],
|
||||||
|
help_text="Precision of closing date"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Location
|
||||||
|
location = models.ForeignKey(
|
||||||
|
'core.Locality',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='parks',
|
||||||
|
help_text="Park location"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precise coordinates for mapping
|
||||||
|
latitude = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=7,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Latitude coordinate"
|
||||||
|
)
|
||||||
|
longitude = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=7,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Longitude coordinate"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
operator = models.ForeignKey(
|
||||||
|
'Company',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='operated_parks',
|
||||||
|
help_text="Current park operator"
|
||||||
|
)
|
||||||
|
|
||||||
|
# External Links
|
||||||
|
website = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Official park website"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CloudFlare Images
|
||||||
|
banner_image_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for park banner"
|
||||||
|
)
|
||||||
|
banner_image_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image URL for park banner"
|
||||||
|
)
|
||||||
|
logo_image_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for park logo"
|
||||||
|
)
|
||||||
|
logo_image_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image URL for park logo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cached statistics (for performance)
|
||||||
|
ride_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Total number of rides"
|
||||||
|
)
|
||||||
|
coaster_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Number of roller coasters"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom fields for flexible data
|
||||||
|
custom_fields = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Additional park-specific data"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Park'
|
||||||
|
verbose_name_plural = 'Parks'
|
||||||
|
ordering = ['name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['name']),
|
||||||
|
models.Index(fields=['slug']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['park_type']),
|
||||||
|
models.Index(fields=['opening_date']),
|
||||||
|
models.Index(fields=['location']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@hook(BEFORE_SAVE, when='slug', is_now=None)
|
||||||
|
def auto_generate_slug(self):
|
||||||
|
"""Auto-generate slug from name if not provided."""
|
||||||
|
if not self.slug and self.name:
|
||||||
|
base_slug = slugify(self.name)
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while Park.objects.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
self.slug = slug
|
||||||
|
|
||||||
|
def update_counts(self):
|
||||||
|
"""Update cached ride counts."""
|
||||||
|
self.ride_count = self.rides.count()
|
||||||
|
self.coaster_count = self.rides.filter(is_coaster=True).count()
|
||||||
|
self.save(update_fields=['ride_count', 'coaster_count'])
|
||||||
|
|
||||||
|
|
||||||
|
class Ride(VersionedModel):
|
||||||
|
"""
|
||||||
|
Represents an individual ride or roller coaster.
|
||||||
|
"""
|
||||||
|
|
||||||
|
RIDE_CATEGORY_CHOICES = [
|
||||||
|
('roller_coaster', 'Roller Coaster'),
|
||||||
|
('flat_ride', 'Flat Ride'),
|
||||||
|
('water_ride', 'Water Ride'),
|
||||||
|
('dark_ride', 'Dark Ride'),
|
||||||
|
('transport_ride', 'Transport Ride'),
|
||||||
|
('other', 'Other'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('operating', 'Operating'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
('sbno', 'Standing But Not Operating'),
|
||||||
|
('relocated', 'Relocated'),
|
||||||
|
('under_construction', 'Under Construction'),
|
||||||
|
('planned', 'Planned'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Basic Info
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Ride name"
|
||||||
|
)
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="URL-friendly identifier"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Ride description and history"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Park Relationship
|
||||||
|
park = models.ForeignKey(
|
||||||
|
'Park',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='rides',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Park where ride is located"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ride Classification
|
||||||
|
ride_category = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=RIDE_CATEGORY_CHOICES,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Broad ride category"
|
||||||
|
)
|
||||||
|
ride_type = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quick coaster identification
|
||||||
|
is_coaster = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Is this ride a roller coaster?"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='operating',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Current operational status"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dates with precision tracking
|
||||||
|
opening_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Ride opening date"
|
||||||
|
)
|
||||||
|
opening_date_precision = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='day',
|
||||||
|
choices=[
|
||||||
|
('year', 'Year'),
|
||||||
|
('month', 'Month'),
|
||||||
|
('day', 'Day'),
|
||||||
|
],
|
||||||
|
help_text="Precision of opening date"
|
||||||
|
)
|
||||||
|
|
||||||
|
closing_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Ride closing date (if closed)"
|
||||||
|
)
|
||||||
|
closing_date_precision = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='day',
|
||||||
|
choices=[
|
||||||
|
('year', 'Year'),
|
||||||
|
('month', 'Month'),
|
||||||
|
('day', 'Day'),
|
||||||
|
],
|
||||||
|
help_text="Precision of closing date"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manufacturer & Model
|
||||||
|
manufacturer = models.ForeignKey(
|
||||||
|
'Company',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='manufactured_rides',
|
||||||
|
help_text="Ride manufacturer"
|
||||||
|
)
|
||||||
|
model = models.ForeignKey(
|
||||||
|
'RideModel',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='rides',
|
||||||
|
help_text="Specific ride model"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
height = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=1,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Height in feet"
|
||||||
|
)
|
||||||
|
speed = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=1,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Top speed in mph"
|
||||||
|
)
|
||||||
|
length = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=1,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Track/ride length in feet"
|
||||||
|
)
|
||||||
|
duration = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Ride duration in seconds"
|
||||||
|
)
|
||||||
|
inversions = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Number of inversions (for coasters)"
|
||||||
|
)
|
||||||
|
capacity = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Hourly capacity (riders per hour)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CloudFlare Images
|
||||||
|
image_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image ID for main photo"
|
||||||
|
)
|
||||||
|
image_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare image URL for main photo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom fields for flexible data
|
||||||
|
custom_fields = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Additional ride-specific data"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Ride'
|
||||||
|
verbose_name_plural = 'Rides'
|
||||||
|
ordering = ['park__name', 'name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['park', 'name']),
|
||||||
|
models.Index(fields=['slug']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['is_coaster']),
|
||||||
|
models.Index(fields=['ride_category']),
|
||||||
|
models.Index(fields=['opening_date']),
|
||||||
|
models.Index(fields=['manufacturer']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.park.name})"
|
||||||
|
|
||||||
|
@hook(BEFORE_SAVE, when='slug', is_now=None)
|
||||||
|
def auto_generate_slug(self):
|
||||||
|
"""Auto-generate slug from park and name if not provided."""
|
||||||
|
if not self.slug and self.park and self.name:
|
||||||
|
base_slug = slugify(f"{self.park.name} {self.name}")
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while Ride.objects.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
self.slug = slug
|
||||||
|
|
||||||
|
@hook(BEFORE_SAVE)
|
||||||
|
def set_is_coaster_flag(self):
|
||||||
|
"""Auto-set is_coaster flag based on ride_category."""
|
||||||
|
self.is_coaster = (self.ride_category == 'roller_coaster')
|
||||||
|
|
||||||
|
@hook(AFTER_CREATE)
|
||||||
|
@hook(AFTER_UPDATE, when='park', has_changed=True)
|
||||||
|
def update_park_counts(self):
|
||||||
|
"""Update parent park's ride counts when ride is created or moved."""
|
||||||
|
if self.park:
|
||||||
|
self.park.update_counts()
|
||||||
|
|||||||
BIN
django/apps/media/__pycache__/admin.cpython-313.pyc
Normal file
BIN
django/apps/media/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
92
django/apps/media/admin.py
Normal file
92
django/apps/media/admin.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Django Admin configuration for media models.
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Photo
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Photo)
|
||||||
|
class PhotoAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Photo model."""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'title', 'cloudflare_image_id', 'photo_type', 'moderation_status',
|
||||||
|
'is_approved', 'uploaded_by', 'created'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'moderation_status', 'is_approved', 'photo_type',
|
||||||
|
'is_featured', 'is_public', 'created'
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'title', 'description', 'cloudflare_image_id',
|
||||||
|
'uploaded_by__email', 'uploaded_by__username'
|
||||||
|
]
|
||||||
|
readonly_fields = [
|
||||||
|
'id', 'created', 'modified', 'content_type', 'object_id',
|
||||||
|
'moderated_at'
|
||||||
|
]
|
||||||
|
raw_id_fields = ['uploaded_by', 'moderated_by']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('CloudFlare Image', {
|
||||||
|
'fields': (
|
||||||
|
'cloudflare_image_id', 'cloudflare_url',
|
||||||
|
'cloudflare_thumbnail_url'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('title', 'description', 'credit', 'photo_type')
|
||||||
|
}),
|
||||||
|
('Associated Entity', {
|
||||||
|
'fields': ('content_type', 'object_id')
|
||||||
|
}),
|
||||||
|
('Upload Information', {
|
||||||
|
'fields': ('uploaded_by',)
|
||||||
|
}),
|
||||||
|
('Moderation', {
|
||||||
|
'fields': (
|
||||||
|
'moderation_status', 'is_approved',
|
||||||
|
'moderated_by', 'moderated_at', 'moderation_notes'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Image Details', {
|
||||||
|
'fields': ('width', 'height', 'file_size'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Display Settings', {
|
||||||
|
'fields': ('display_order', 'is_featured', 'is_public')
|
||||||
|
}),
|
||||||
|
('System', {
|
||||||
|
'fields': ('id', 'created', 'modified'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = ['approve_photos', 'reject_photos', 'flag_photos']
|
||||||
|
|
||||||
|
def approve_photos(self, request, queryset):
|
||||||
|
"""Bulk approve selected photos."""
|
||||||
|
count = 0
|
||||||
|
for photo in queryset:
|
||||||
|
photo.approve(moderator=request.user, notes='Bulk approved')
|
||||||
|
count += 1
|
||||||
|
self.message_user(request, f"{count} photo(s) approved successfully.")
|
||||||
|
approve_photos.short_description = "Approve selected photos"
|
||||||
|
|
||||||
|
def reject_photos(self, request, queryset):
|
||||||
|
"""Bulk reject selected photos."""
|
||||||
|
count = 0
|
||||||
|
for photo in queryset:
|
||||||
|
photo.reject(moderator=request.user, notes='Bulk rejected')
|
||||||
|
count += 1
|
||||||
|
self.message_user(request, f"{count} photo(s) rejected.")
|
||||||
|
reject_photos.short_description = "Reject selected photos"
|
||||||
|
|
||||||
|
def flag_photos(self, request, queryset):
|
||||||
|
"""Bulk flag selected photos for review."""
|
||||||
|
count = 0
|
||||||
|
for photo in queryset:
|
||||||
|
photo.flag(moderator=request.user, notes='Flagged for review')
|
||||||
|
count += 1
|
||||||
|
self.message_user(request, f"{count} photo(s) flagged for review.")
|
||||||
|
flag_photos.short_description = "Flag selected photos"
|
||||||
253
django/apps/media/migrations/0001_initial.py
Normal file
253
django/apps/media/migrations/0001_initial.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Generated by Django 4.2.8 on 2025-11-08 16:41
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import django_lifecycle.mixins
|
||||||
|
import model_utils.fields
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Photo",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
model_utils.fields.AutoCreatedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
model_utils.fields.AutoLastModifiedField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="modified",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cloudflare_image_id",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="Unique CloudFlare image identifier",
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cloudflare_url",
|
||||||
|
models.URLField(help_text="CloudFlare CDN URL for the image"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cloudflare_thumbnail_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare thumbnail URL (if different from main URL)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"title",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Photo title or caption", max_length=255
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
blank=True, help_text="Photo description or details"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"credit",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Photo credit/photographer name",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"photo_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("main", "Main Photo"),
|
||||||
|
("gallery", "Gallery Photo"),
|
||||||
|
("banner", "Banner Image"),
|
||||||
|
("logo", "Logo"),
|
||||||
|
("thumbnail", "Thumbnail"),
|
||||||
|
("other", "Other"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="gallery",
|
||||||
|
help_text="Type of photo",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"object_id",
|
||||||
|
models.UUIDField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="ID of the entity this photo belongs to",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"moderation_status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending Review"),
|
||||||
|
("approved", "Approved"),
|
||||||
|
("rejected", "Rejected"),
|
||||||
|
("flagged", "Flagged"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="pending",
|
||||||
|
help_text="Moderation status",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_approved",
|
||||||
|
models.BooleanField(
|
||||||
|
db_index=True,
|
||||||
|
default=False,
|
||||||
|
help_text="Quick filter for approved photos",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"moderated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, help_text="When the photo was moderated", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"moderation_notes",
|
||||||
|
models.TextField(blank=True, help_text="Notes from moderator"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"width",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True, help_text="Image width in pixels", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"height",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True, help_text="Image height in pixels", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file_size",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True, help_text="File size in bytes", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_order",
|
||||||
|
models.IntegerField(
|
||||||
|
db_index=True,
|
||||||
|
default=0,
|
||||||
|
help_text="Order for displaying in galleries (lower numbers first)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_featured",
|
||||||
|
models.BooleanField(
|
||||||
|
db_index=True,
|
||||||
|
default=False,
|
||||||
|
help_text="Is this a featured photo?",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_public",
|
||||||
|
models.BooleanField(
|
||||||
|
db_index=True,
|
||||||
|
default=True,
|
||||||
|
help_text="Is this photo publicly visible?",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="Type of entity this photo belongs to",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"moderated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Moderator who approved/rejected this photo",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="moderated_photos",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uploaded_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="User who uploaded this photo",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="uploaded_photos",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Photo",
|
||||||
|
"verbose_name_plural": "Photos",
|
||||||
|
"ordering": ["display_order", "-created"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["content_type", "object_id"],
|
||||||
|
name="media_photo_content_0187f5_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["cloudflare_image_id"],
|
||||||
|
name="media_photo_cloudfl_63ac12_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["moderation_status"],
|
||||||
|
name="media_photo_moderat_2033b1_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["is_approved"], name="media_photo_is_appr_13ab34_idx"
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["uploaded_by"], name="media_photo_uploade_220d3a_idx"
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["photo_type"], name="media_photo_photo_t_b387e7_idx"
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["display_order"], name="media_photo_display_04e358_idx"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
django/apps/media/migrations/__init__.py
Normal file
0
django/apps/media/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Media models for ThrillWiki Django backend.
|
||||||
|
|
||||||
|
This module contains models for handling media content:
|
||||||
|
- Photo: CloudFlare Images integration with generic relations
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE
|
||||||
|
|
||||||
|
from apps.core.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Photo(BaseModel):
|
||||||
|
"""
|
||||||
|
Represents a photo stored in CloudFlare Images.
|
||||||
|
Uses generic relations to attach to any entity (Park, Ride, Company, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
PHOTO_TYPE_CHOICES = [
|
||||||
|
('main', 'Main Photo'),
|
||||||
|
('gallery', 'Gallery Photo'),
|
||||||
|
('banner', 'Banner Image'),
|
||||||
|
('logo', 'Logo'),
|
||||||
|
('thumbnail', 'Thumbnail'),
|
||||||
|
('other', 'Other'),
|
||||||
|
]
|
||||||
|
|
||||||
|
MODERATION_STATUS_CHOICES = [
|
||||||
|
('pending', 'Pending Review'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('flagged', 'Flagged'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# CloudFlare Image Integration
|
||||||
|
cloudflare_image_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Unique CloudFlare image identifier"
|
||||||
|
)
|
||||||
|
cloudflare_url = models.URLField(
|
||||||
|
help_text="CloudFlare CDN URL for the image"
|
||||||
|
)
|
||||||
|
cloudflare_thumbnail_url = models.URLField(
|
||||||
|
blank=True,
|
||||||
|
help_text="CloudFlare thumbnail URL (if different from main URL)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
title = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="Photo title or caption"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Photo description or details"
|
||||||
|
)
|
||||||
|
credit = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
help_text="Photo credit/photographer name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Photo Type
|
||||||
|
photo_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=PHOTO_TYPE_CHOICES,
|
||||||
|
default='gallery',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of photo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generic relation to attach to any entity
|
||||||
|
content_type = models.ForeignKey(
|
||||||
|
ContentType,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
help_text="Type of entity this photo belongs to"
|
||||||
|
)
|
||||||
|
object_id = models.UUIDField(
|
||||||
|
db_index=True,
|
||||||
|
help_text="ID of the entity this photo belongs to"
|
||||||
|
)
|
||||||
|
content_object = GenericForeignKey('content_type', 'object_id')
|
||||||
|
|
||||||
|
# User who uploaded
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='uploaded_photos',
|
||||||
|
help_text="User who uploaded this photo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Moderation
|
||||||
|
moderation_status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=MODERATION_STATUS_CHOICES,
|
||||||
|
default='pending',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Moderation status"
|
||||||
|
)
|
||||||
|
is_approved = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Quick filter for approved photos"
|
||||||
|
)
|
||||||
|
moderated_by = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='moderated_photos',
|
||||||
|
help_text="Moderator who approved/rejected this photo"
|
||||||
|
)
|
||||||
|
moderated_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When the photo was moderated"
|
||||||
|
)
|
||||||
|
moderation_notes = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes from moderator"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image Metadata
|
||||||
|
width = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Image width in pixels"
|
||||||
|
)
|
||||||
|
height = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Image height in pixels"
|
||||||
|
)
|
||||||
|
file_size = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="File size in bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display Order
|
||||||
|
display_order = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Order for displaying in galleries (lower numbers first)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Visibility
|
||||||
|
is_featured = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Is this a featured photo?"
|
||||||
|
)
|
||||||
|
is_public = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Is this photo publicly visible?"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Photo'
|
||||||
|
verbose_name_plural = 'Photos'
|
||||||
|
ordering = ['display_order', '-created']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
|
models.Index(fields=['cloudflare_image_id']),
|
||||||
|
models.Index(fields=['moderation_status']),
|
||||||
|
models.Index(fields=['is_approved']),
|
||||||
|
models.Index(fields=['uploaded_by']),
|
||||||
|
models.Index(fields=['photo_type']),
|
||||||
|
models.Index(fields=['display_order']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.title:
|
||||||
|
return self.title
|
||||||
|
return f"Photo {self.cloudflare_image_id[:8]}..."
|
||||||
|
|
||||||
|
@hook(AFTER_UPDATE, when='moderation_status', was='pending', is_now='approved')
|
||||||
|
def set_approved_flag_on_approval(self):
|
||||||
|
"""Set is_approved flag when status changes to approved."""
|
||||||
|
self.is_approved = True
|
||||||
|
self.save(update_fields=['is_approved'])
|
||||||
|
|
||||||
|
@hook(AFTER_UPDATE, when='moderation_status', was='approved', is_not='approved')
|
||||||
|
def clear_approved_flag_on_rejection(self):
|
||||||
|
"""Clear is_approved flag when status changes from approved."""
|
||||||
|
self.is_approved = False
|
||||||
|
self.save(update_fields=['is_approved'])
|
||||||
|
|
||||||
|
def approve(self, moderator, notes=''):
|
||||||
|
"""Approve this photo."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
self.moderation_status = 'approved'
|
||||||
|
self.is_approved = True
|
||||||
|
self.moderated_by = moderator
|
||||||
|
self.moderated_at = timezone.now()
|
||||||
|
self.moderation_notes = notes
|
||||||
|
self.save(update_fields=[
|
||||||
|
'moderation_status',
|
||||||
|
'is_approved',
|
||||||
|
'moderated_by',
|
||||||
|
'moderated_at',
|
||||||
|
'moderation_notes'
|
||||||
|
])
|
||||||
|
|
||||||
|
def reject(self, moderator, notes=''):
|
||||||
|
"""Reject this photo."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
self.moderation_status = 'rejected'
|
||||||
|
self.is_approved = False
|
||||||
|
self.moderated_by = moderator
|
||||||
|
self.moderated_at = timezone.now()
|
||||||
|
self.moderation_notes = notes
|
||||||
|
self.save(update_fields=[
|
||||||
|
'moderation_status',
|
||||||
|
'is_approved',
|
||||||
|
'moderated_by',
|
||||||
|
'moderated_at',
|
||||||
|
'moderation_notes'
|
||||||
|
])
|
||||||
|
|
||||||
|
def flag(self, moderator, notes=''):
|
||||||
|
"""Flag this photo for review."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
self.moderation_status = 'flagged'
|
||||||
|
self.is_approved = False
|
||||||
|
self.moderated_by = moderator
|
||||||
|
self.moderated_at = timezone.now()
|
||||||
|
self.moderation_notes = notes
|
||||||
|
self.save(update_fields=[
|
||||||
|
'moderation_status',
|
||||||
|
'is_approved',
|
||||||
|
'moderated_by',
|
||||||
|
'moderated_at',
|
||||||
|
'moderation_notes'
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoManager(models.Manager):
|
||||||
|
"""Custom manager for Photo model."""
|
||||||
|
|
||||||
|
def approved(self):
|
||||||
|
"""Return only approved photos."""
|
||||||
|
return self.filter(is_approved=True)
|
||||||
|
|
||||||
|
def pending(self):
|
||||||
|
"""Return only pending photos."""
|
||||||
|
return self.filter(moderation_status='pending')
|
||||||
|
|
||||||
|
def public(self):
|
||||||
|
"""Return only public, approved photos."""
|
||||||
|
return self.filter(is_approved=True, is_public=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Add custom manager to Photo model
|
||||||
|
Photo.add_to_class('objects', PhotoManager())
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user