diff --git a/django/apps/entities/__pycache__/admin.cpython-313.pyc b/django/apps/entities/__pycache__/admin.cpython-313.pyc new file mode 100644 index 00000000..257f1a06 Binary files /dev/null and b/django/apps/entities/__pycache__/admin.cpython-313.pyc differ diff --git a/django/apps/entities/__pycache__/models.cpython-313.pyc b/django/apps/entities/__pycache__/models.cpython-313.pyc index 62112874..97611bb1 100644 Binary files a/django/apps/entities/__pycache__/models.cpython-313.pyc and b/django/apps/entities/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/entities/admin.py b/django/apps/entities/admin.py new file mode 100644 index 00000000..7f163ae1 --- /dev/null +++ b/django/apps/entities/admin.py @@ -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',) + }), + ) diff --git a/django/apps/entities/migrations/0001_initial.py b/django/apps/entities/migrations/0001_initial.py new file mode 100644 index 00000000..1ad70e03 --- /dev/null +++ b/django/apps/entities/migrations/0001_initial.py @@ -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"), + ), + ] diff --git a/django/apps/entities/migrations/__init__.py b/django/apps/entities/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 00000000..01eb3292 Binary files /dev/null and b/django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..22a02b67 Binary files /dev/null and b/django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/entities/models.py b/django/apps/entities/models.py index e69de29b..85392340 100644 --- a/django/apps/entities/models.py +++ b/django/apps/entities/models.py @@ -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() diff --git a/django/apps/media/__pycache__/admin.cpython-313.pyc b/django/apps/media/__pycache__/admin.cpython-313.pyc new file mode 100644 index 00000000..b021fdb5 Binary files /dev/null and b/django/apps/media/__pycache__/admin.cpython-313.pyc differ diff --git a/django/apps/media/__pycache__/models.cpython-313.pyc b/django/apps/media/__pycache__/models.cpython-313.pyc index 10abc1d6..142d2967 100644 Binary files a/django/apps/media/__pycache__/models.cpython-313.pyc and b/django/apps/media/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/media/admin.py b/django/apps/media/admin.py new file mode 100644 index 00000000..7d8e1d33 --- /dev/null +++ b/django/apps/media/admin.py @@ -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" diff --git a/django/apps/media/migrations/0001_initial.py b/django/apps/media/migrations/0001_initial.py new file mode 100644 index 00000000..8296f42b --- /dev/null +++ b/django/apps/media/migrations/0001_initial.py @@ -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), + ), + ] diff --git a/django/apps/media/migrations/__init__.py b/django/apps/media/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 00000000..fe5e135c Binary files /dev/null and b/django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..d91d417b Binary files /dev/null and b/django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/media/models.py b/django/apps/media/models.py index e69de29b..76e13f7d 100644 --- a/django/apps/media/models.py +++ b/django/apps/media/models.py @@ -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()) diff --git a/django/db.sqlite3 b/django/db.sqlite3 index 577fd2a0..99adc1dc 100644 Binary files a/django/db.sqlite3 and b/django/db.sqlite3 differ