mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:51:13 -05:00
feat: Implement Phase 1.5 entity models (Park, Ride, Company, RideModel, Photo)
- Created Company model with location tracking, date precision, and CloudFlare Images - Created RideModel model for manufacturer's ride models with specifications - Created Park model with location, dates, operator, and cached statistics - Created Ride model with comprehensive stats, manufacturer, and park relationship - Created Photo model with CloudFlare Images integration and generic relations - Added lifecycle hooks for auto-slug generation and count updates - Created migrations and applied to database - Registered all models in Django admin with detailed fieldsets - Fixed admin autocomplete_fields to use raw_id_fields where needed - All models inherit from VersionedModel for automatic version tracking - Models include date precision tracking for opening/closing dates - Added comprehensive indexes for query performance Phase 1.5 complete - Entity models ready for API development
This commit is contained in:
BIN
django/apps/media/__pycache__/admin.cpython-313.pyc
Normal file
BIN
django/apps/media/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
92
django/apps/media/admin.py
Normal file
92
django/apps/media/admin.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Django Admin configuration for media models.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from .models import Photo
|
||||
|
||||
|
||||
@admin.register(Photo)
|
||||
class PhotoAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Photo model."""
|
||||
|
||||
list_display = [
|
||||
'title', 'cloudflare_image_id', 'photo_type', 'moderation_status',
|
||||
'is_approved', 'uploaded_by', 'created'
|
||||
]
|
||||
list_filter = [
|
||||
'moderation_status', 'is_approved', 'photo_type',
|
||||
'is_featured', 'is_public', 'created'
|
||||
]
|
||||
search_fields = [
|
||||
'title', 'description', 'cloudflare_image_id',
|
||||
'uploaded_by__email', 'uploaded_by__username'
|
||||
]
|
||||
readonly_fields = [
|
||||
'id', 'created', 'modified', 'content_type', 'object_id',
|
||||
'moderated_at'
|
||||
]
|
||||
raw_id_fields = ['uploaded_by', 'moderated_by']
|
||||
|
||||
fieldsets = (
|
||||
('CloudFlare Image', {
|
||||
'fields': (
|
||||
'cloudflare_image_id', 'cloudflare_url',
|
||||
'cloudflare_thumbnail_url'
|
||||
)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('title', 'description', 'credit', 'photo_type')
|
||||
}),
|
||||
('Associated Entity', {
|
||||
'fields': ('content_type', 'object_id')
|
||||
}),
|
||||
('Upload Information', {
|
||||
'fields': ('uploaded_by',)
|
||||
}),
|
||||
('Moderation', {
|
||||
'fields': (
|
||||
'moderation_status', 'is_approved',
|
||||
'moderated_by', 'moderated_at', 'moderation_notes'
|
||||
)
|
||||
}),
|
||||
('Image Details', {
|
||||
'fields': ('width', 'height', 'file_size'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Display Settings', {
|
||||
'fields': ('display_order', 'is_featured', 'is_public')
|
||||
}),
|
||||
('System', {
|
||||
'fields': ('id', 'created', 'modified'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['approve_photos', 'reject_photos', 'flag_photos']
|
||||
|
||||
def approve_photos(self, request, queryset):
|
||||
"""Bulk approve selected photos."""
|
||||
count = 0
|
||||
for photo in queryset:
|
||||
photo.approve(moderator=request.user, notes='Bulk approved')
|
||||
count += 1
|
||||
self.message_user(request, f"{count} photo(s) approved successfully.")
|
||||
approve_photos.short_description = "Approve selected photos"
|
||||
|
||||
def reject_photos(self, request, queryset):
|
||||
"""Bulk reject selected photos."""
|
||||
count = 0
|
||||
for photo in queryset:
|
||||
photo.reject(moderator=request.user, notes='Bulk rejected')
|
||||
count += 1
|
||||
self.message_user(request, f"{count} photo(s) rejected.")
|
||||
reject_photos.short_description = "Reject selected photos"
|
||||
|
||||
def flag_photos(self, request, queryset):
|
||||
"""Bulk flag selected photos for review."""
|
||||
count = 0
|
||||
for photo in queryset:
|
||||
photo.flag(moderator=request.user, notes='Flagged for review')
|
||||
count += 1
|
||||
self.message_user(request, f"{count} photo(s) flagged for review.")
|
||||
flag_photos.short_description = "Flag selected photos"
|
||||
253
django/apps/media/migrations/0001_initial.py
Normal file
253
django/apps/media/migrations/0001_initial.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 16:41
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import django_lifecycle.mixins
|
||||
import model_utils.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Photo",
|
||||
fields=[
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"cloudflare_image_id",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
help_text="Unique CloudFlare image identifier",
|
||||
max_length=255,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"cloudflare_url",
|
||||
models.URLField(help_text="CloudFlare CDN URL for the image"),
|
||||
),
|
||||
(
|
||||
"cloudflare_thumbnail_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
help_text="CloudFlare thumbnail URL (if different from main URL)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True, help_text="Photo title or caption", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Photo description or details"
|
||||
),
|
||||
),
|
||||
(
|
||||
"credit",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Photo credit/photographer name",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"photo_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("main", "Main Photo"),
|
||||
("gallery", "Gallery Photo"),
|
||||
("banner", "Banner Image"),
|
||||
("logo", "Logo"),
|
||||
("thumbnail", "Thumbnail"),
|
||||
("other", "Other"),
|
||||
],
|
||||
db_index=True,
|
||||
default="gallery",
|
||||
help_text="Type of photo",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"object_id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
help_text="ID of the entity this photo belongs to",
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderation_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending Review"),
|
||||
("approved", "Approved"),
|
||||
("rejected", "Rejected"),
|
||||
("flagged", "Flagged"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
help_text="Moderation status",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_approved",
|
||||
models.BooleanField(
|
||||
db_index=True,
|
||||
default=False,
|
||||
help_text="Quick filter for approved photos",
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderated_at",
|
||||
models.DateTimeField(
|
||||
blank=True, help_text="When the photo was moderated", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderation_notes",
|
||||
models.TextField(blank=True, help_text="Notes from moderator"),
|
||||
),
|
||||
(
|
||||
"width",
|
||||
models.IntegerField(
|
||||
blank=True, help_text="Image width in pixels", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.IntegerField(
|
||||
blank=True, help_text="Image height in pixels", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.IntegerField(
|
||||
blank=True, help_text="File size in bytes", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"display_order",
|
||||
models.IntegerField(
|
||||
db_index=True,
|
||||
default=0,
|
||||
help_text="Order for displaying in galleries (lower numbers first)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_featured",
|
||||
models.BooleanField(
|
||||
db_index=True,
|
||||
default=False,
|
||||
help_text="Is this a featured photo?",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
db_index=True,
|
||||
default=True,
|
||||
help_text="Is this photo publicly visible?",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
help_text="Type of entity this photo belongs to",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Moderator who approved/rejected this photo",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="moderated_photos",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who uploaded this photo",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="uploaded_photos",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Photo",
|
||||
"verbose_name_plural": "Photos",
|
||||
"ordering": ["display_order", "-created"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="media_photo_content_0187f5_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["cloudflare_image_id"],
|
||||
name="media_photo_cloudfl_63ac12_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["moderation_status"],
|
||||
name="media_photo_moderat_2033b1_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["is_approved"], name="media_photo_is_appr_13ab34_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["uploaded_by"], name="media_photo_uploade_220d3a_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["photo_type"], name="media_photo_photo_t_b387e7_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["display_order"], name="media_photo_display_04e358_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
|
||||
),
|
||||
]
|
||||
0
django/apps/media/migrations/__init__.py
Normal file
0
django/apps/media/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Media models for ThrillWiki Django backend.
|
||||
|
||||
This module contains models for handling media content:
|
||||
- Photo: CloudFlare Images integration with generic relations
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE
|
||||
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
|
||||
class Photo(BaseModel):
|
||||
"""
|
||||
Represents a photo stored in CloudFlare Images.
|
||||
Uses generic relations to attach to any entity (Park, Ride, Company, etc.)
|
||||
"""
|
||||
|
||||
PHOTO_TYPE_CHOICES = [
|
||||
('main', 'Main Photo'),
|
||||
('gallery', 'Gallery Photo'),
|
||||
('banner', 'Banner Image'),
|
||||
('logo', 'Logo'),
|
||||
('thumbnail', 'Thumbnail'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
MODERATION_STATUS_CHOICES = [
|
||||
('pending', 'Pending Review'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('flagged', 'Flagged'),
|
||||
]
|
||||
|
||||
# CloudFlare Image Integration
|
||||
cloudflare_image_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique CloudFlare image identifier"
|
||||
)
|
||||
cloudflare_url = models.URLField(
|
||||
help_text="CloudFlare CDN URL for the image"
|
||||
)
|
||||
cloudflare_thumbnail_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="CloudFlare thumbnail URL (if different from main URL)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
title = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Photo title or caption"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Photo description or details"
|
||||
)
|
||||
credit = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Photo credit/photographer name"
|
||||
)
|
||||
|
||||
# Photo Type
|
||||
photo_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PHOTO_TYPE_CHOICES,
|
||||
default='gallery',
|
||||
db_index=True,
|
||||
help_text="Type of photo"
|
||||
)
|
||||
|
||||
# Generic relation to attach to any entity
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Type of entity this photo belongs to"
|
||||
)
|
||||
object_id = models.UUIDField(
|
||||
db_index=True,
|
||||
help_text="ID of the entity this photo belongs to"
|
||||
)
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
# User who uploaded
|
||||
uploaded_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='uploaded_photos',
|
||||
help_text="User who uploaded this photo"
|
||||
)
|
||||
|
||||
# Moderation
|
||||
moderation_status = models.CharField(
|
||||
max_length=50,
|
||||
choices=MODERATION_STATUS_CHOICES,
|
||||
default='pending',
|
||||
db_index=True,
|
||||
help_text="Moderation status"
|
||||
)
|
||||
is_approved = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Quick filter for approved photos"
|
||||
)
|
||||
moderated_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='moderated_photos',
|
||||
help_text="Moderator who approved/rejected this photo"
|
||||
)
|
||||
moderated_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the photo was moderated"
|
||||
)
|
||||
moderation_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from moderator"
|
||||
)
|
||||
|
||||
# Image Metadata
|
||||
width = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Image width in pixels"
|
||||
)
|
||||
height = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Image height in pixels"
|
||||
)
|
||||
file_size = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="File size in bytes"
|
||||
)
|
||||
|
||||
# Display Order
|
||||
display_order = models.IntegerField(
|
||||
default=0,
|
||||
db_index=True,
|
||||
help_text="Order for displaying in galleries (lower numbers first)"
|
||||
)
|
||||
|
||||
# Visibility
|
||||
is_featured = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Is this a featured photo?"
|
||||
)
|
||||
is_public = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Is this photo publicly visible?"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Photo'
|
||||
verbose_name_plural = 'Photos'
|
||||
ordering = ['display_order', '-created']
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=['cloudflare_image_id']),
|
||||
models.Index(fields=['moderation_status']),
|
||||
models.Index(fields=['is_approved']),
|
||||
models.Index(fields=['uploaded_by']),
|
||||
models.Index(fields=['photo_type']),
|
||||
models.Index(fields=['display_order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
return f"Photo {self.cloudflare_image_id[:8]}..."
|
||||
|
||||
@hook(AFTER_UPDATE, when='moderation_status', was='pending', is_now='approved')
|
||||
def set_approved_flag_on_approval(self):
|
||||
"""Set is_approved flag when status changes to approved."""
|
||||
self.is_approved = True
|
||||
self.save(update_fields=['is_approved'])
|
||||
|
||||
@hook(AFTER_UPDATE, when='moderation_status', was='approved', is_not='approved')
|
||||
def clear_approved_flag_on_rejection(self):
|
||||
"""Clear is_approved flag when status changes from approved."""
|
||||
self.is_approved = False
|
||||
self.save(update_fields=['is_approved'])
|
||||
|
||||
def approve(self, moderator, notes=''):
|
||||
"""Approve this photo."""
|
||||
from django.utils import timezone
|
||||
|
||||
self.moderation_status = 'approved'
|
||||
self.is_approved = True
|
||||
self.moderated_by = moderator
|
||||
self.moderated_at = timezone.now()
|
||||
self.moderation_notes = notes
|
||||
self.save(update_fields=[
|
||||
'moderation_status',
|
||||
'is_approved',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
'moderation_notes'
|
||||
])
|
||||
|
||||
def reject(self, moderator, notes=''):
|
||||
"""Reject this photo."""
|
||||
from django.utils import timezone
|
||||
|
||||
self.moderation_status = 'rejected'
|
||||
self.is_approved = False
|
||||
self.moderated_by = moderator
|
||||
self.moderated_at = timezone.now()
|
||||
self.moderation_notes = notes
|
||||
self.save(update_fields=[
|
||||
'moderation_status',
|
||||
'is_approved',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
'moderation_notes'
|
||||
])
|
||||
|
||||
def flag(self, moderator, notes=''):
|
||||
"""Flag this photo for review."""
|
||||
from django.utils import timezone
|
||||
|
||||
self.moderation_status = 'flagged'
|
||||
self.is_approved = False
|
||||
self.moderated_by = moderator
|
||||
self.moderated_at = timezone.now()
|
||||
self.moderation_notes = notes
|
||||
self.save(update_fields=[
|
||||
'moderation_status',
|
||||
'is_approved',
|
||||
'moderated_by',
|
||||
'moderated_at',
|
||||
'moderation_notes'
|
||||
])
|
||||
|
||||
|
||||
class PhotoManager(models.Manager):
|
||||
"""Custom manager for Photo model."""
|
||||
|
||||
def approved(self):
|
||||
"""Return only approved photos."""
|
||||
return self.filter(is_approved=True)
|
||||
|
||||
def pending(self):
|
||||
"""Return only pending photos."""
|
||||
return self.filter(moderation_status='pending')
|
||||
|
||||
def public(self):
|
||||
"""Return only public, approved photos."""
|
||||
return self.filter(is_approved=True, is_public=True)
|
||||
|
||||
|
||||
# Add custom manager to Photo model
|
||||
Photo.add_to_class('objects', PhotoManager())
|
||||
|
||||
Reference in New Issue
Block a user