From 9c46ef8b03a5e18a9149bc5194c3c330618a866a Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sat, 8 Nov 2025 11:43:27 -0500 Subject: [PATCH] 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 --- .../__pycache__/admin.cpython-313.pyc | Bin 0 -> 4756 bytes .../__pycache__/models.cpython-313.pyc | Bin 175 -> 19686 bytes django/apps/entities/admin.py | 168 ++++ .../apps/entities/migrations/0001_initial.py | 846 ++++++++++++++++++ django/apps/entities/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-313.pyc | Bin 0 -> 16395 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 188 bytes django/apps/entities/models.py | 701 +++++++++++++++ .../media/__pycache__/admin.cpython-313.pyc | Bin 0 -> 3370 bytes .../media/__pycache__/models.cpython-313.pyc | Bin 172 -> 8194 bytes django/apps/media/admin.py | 92 ++ django/apps/media/migrations/0001_initial.py | 253 ++++++ django/apps/media/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-313.pyc | Bin 0 -> 6124 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 185 bytes django/apps/media/models.py | 266 ++++++ django/db.sqlite3 | Bin 507904 -> 811008 bytes 17 files changed, 2326 insertions(+) create mode 100644 django/apps/entities/__pycache__/admin.cpython-313.pyc create mode 100644 django/apps/entities/admin.py create mode 100644 django/apps/entities/migrations/0001_initial.py create mode 100644 django/apps/entities/migrations/__init__.py create mode 100644 django/apps/entities/migrations/__pycache__/0001_initial.cpython-313.pyc create mode 100644 django/apps/entities/migrations/__pycache__/__init__.cpython-313.pyc create mode 100644 django/apps/media/__pycache__/admin.cpython-313.pyc create mode 100644 django/apps/media/admin.py create mode 100644 django/apps/media/migrations/0001_initial.py create mode 100644 django/apps/media/migrations/__init__.py create mode 100644 django/apps/media/migrations/__pycache__/0001_initial.cpython-313.pyc create mode 100644 django/apps/media/migrations/__pycache__/__init__.cpython-313.pyc 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 0000000000000000000000000000000000000000..257f1a06d3761818211796f77969ead1a843f5dc GIT binary patch literal 4756 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..22a02b67190bb81a3aab175f5672e7c30f2b79f5 GIT binary patch literal 188 zcmey&%ge<81bd43GePuY5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa|L!erR!OQL%nW zVorXMetKp}Mro3Ma!!6;Do`w=C^ILgq$n{tTQ{|$0H`3fNIxYjF)uw|Ke3>oSU)wd zB(o$Fs4_P*y(qCHGe56bKR!M)FS8^*Uaz3?7Kcr4eoARhs$CH)&@PZ$ib0Hz%#4hT IMa)1J063yD^8f$< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b021fdb5a1994ca21f8562ad52f27ab752004599 GIT binary patch literal 3370 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 ZlWJGQ3Y298;$jfvBQql-V-Yiu1pwwN5KRC8 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 0000000000000000000000000000000000000000..fe5e135caa707bdd22436629879f4ca10f524298 GIT binary patch literal 6124 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 U!{+|Izt$hA_D6o;=!{AHA6KtBb^rhX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d91d417ba548946a77ac30b1c48d0c5b79d36a6c GIT binary patch literal 185 zcmey&%ge<81bd43GePuY5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa|LEerR!OQL%nW zVorXMetKp}Mro3Ma!!6;Do`w=C^ILgq$n{tTQ{|$0H`3fNIxYjF)uw|Ke3>oSU)#4 zB{NY!H#5B`u_QA;uUJ1mJ~J<~BtBlRpz;=nO>TZlX-=wL5i8IlkVA?=jE~HWjEqIh GKo$UfI5CR= literal 0 HcmV?d00001 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 577fd2a09d2455887fb79ae2e9b9926ae4a6a448..99adc1dc501c7d201d5783f7c22c6bcefa2f50df 100644 GIT binary patch delta 13228 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 delta 357 zcmZoTVA#+gKS5eBmw|zy28f}6QDCBuv1~4bUhfZ9pb#7X1CT^9|8M?p{2%$>@IT{! zuvzdxI{)U!{K^6x2~7OE`JeJ7Z01u4L2FvoCn#^vX&ca{DAF|oTK#yl~ftIF*6XeZ1c*DBA;SF1hu?i1M4(+!r`DvwvpT1PTpCCZMBI oShn}5u-mdTa&14Q&2H7gtit(iI{OB8PoUf~&h6iJvU9Nk0M+4WtN;K2