photos fix

This commit is contained in:
pacnpal
2024-11-01 01:27:11 +00:00
parent 3cbda93094
commit 6265f82193
89 changed files with 2241 additions and 614 deletions

View File

@@ -0,0 +1 @@
default_app_config = 'media.apps.MediaConfig'

View File

@@ -1,6 +1,32 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_migrate
def create_photo_permissions(sender, **kwargs):
"""Create custom permissions for photos"""
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from media.models import Photo
content_type = ContentType.objects.get_for_model(Photo)
Permission.objects.get_or_create(
codename='add_photo',
name='Can add photo',
content_type=content_type,
)
Permission.objects.get_or_create(
codename='change_photo',
name='Can change photo',
content_type=content_type,
)
Permission.objects.get_or_create(
codename='delete_photo',
name='Can delete photo',
content_type=content_type,
)
class MediaConfig(AppConfig): class MediaConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'media' name = 'media'
verbose_name = 'Media'
def ready(self):
post_migrate.connect(create_photo_permissions, sender=self)

View File

@@ -0,0 +1,114 @@
import os
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
from media.models import Photo
from parks.models import Park
from rides.models import Ride
from django.contrib.contenttypes.models import ContentType
import json
from django.core.files.base import ContentFile
class Command(BaseCommand):
help = 'Download photos from seed data URLs'
def handle(self, *args, **kwargs):
self.stdout.write('Downloading photos from seed data...')
# Read seed data
with open('parks/management/commands/seed_data.json', 'r') as f:
seed_data = json.load(f)
park_content_type = ContentType.objects.get_for_model(Park)
ride_content_type = ContentType.objects.get_for_model(Ride)
# Process parks and their photos
for park_data in seed_data['parks']:
try:
park = Park.objects.get(name=park_data['name'])
# Download park photos
for idx, photo_url in enumerate(park_data['photos'], 1):
try:
# Download image
self.stdout.write(f'Downloading from URL: {photo_url}')
response = requests.get(photo_url)
if response.status_code == 200:
# Delete any existing photos for this park
Photo.objects.filter(
content_type=park_content_type,
object_id=park.id
).delete()
# Create new photo record
photo = Photo(
content_type=park_content_type,
object_id=park.id,
is_primary=idx == 1
)
# Save image content
photo.image.save(
f"{park.slug}_{idx}.jpg",
ContentFile(response.content),
save=False
)
photo.save()
self.stdout.write(f'Downloaded photo for {park.name}: {photo.image.name}')
self.stdout.write(f'Database record created with ID: {photo.id}')
else:
self.stdout.write(f'Error downloading image. Status code: {response.status_code}')
except Exception as e:
self.stdout.write(f'Error downloading park photo: {str(e)}')
# Process rides and their photos
for ride_data in park_data['rides']:
try:
ride = Ride.objects.get(name=ride_data['name'], park=park)
# Download ride photos
for idx, photo_url in enumerate(ride_data['photos'], 1):
try:
# Download image
self.stdout.write(f'Downloading from URL: {photo_url}')
response = requests.get(photo_url)
if response.status_code == 200:
# Delete any existing photos for this ride
Photo.objects.filter(
content_type=ride_content_type,
object_id=ride.id
).delete()
# Create new photo record
photo = Photo(
content_type=ride_content_type,
object_id=ride.id,
is_primary=idx == 1
)
# Save image content
photo.image.save(
f"{ride.slug}_{idx}.jpg",
ContentFile(response.content),
save=False
)
photo.save()
self.stdout.write(f'Downloaded photo for {ride.name}: {photo.image.name}')
self.stdout.write(f'Database record created with ID: {photo.id}')
else:
self.stdout.write(f'Error downloading image. Status code: {response.status_code}')
except Exception as e:
self.stdout.write(f'Error downloading ride photo: {str(e)}')
except Ride.DoesNotExist:
self.stdout.write(f'Ride not found: {ride_data["name"]}')
except Park.DoesNotExist:
self.stdout.write(f'Park not found: {park_data["name"]}')
self.stdout.write('Finished downloading photos')

View File

@@ -0,0 +1,58 @@
import os
from django.core.management.base import BaseCommand
from media.models import Photo
from django.conf import settings
from django.db import transaction
class Command(BaseCommand):
help = 'Fix photo paths in database to match actual file locations'
def handle(self, *args, **kwargs):
self.stdout.write('Fixing photo paths in database...')
# Get all photos
photos = Photo.objects.all()
for photo in photos:
try:
with transaction.atomic():
# Get current file path
current_name = photo.image.name
# Remove any 'media/' prefix if it exists
if current_name.startswith('media/'):
current_name = current_name[6:] # Remove 'media/' prefix
parts = current_name.split('/')
if len(parts) >= 2:
content_type = parts[0] # 'park' or 'ride'
identifier = parts[1] # e.g., 'alton-towers'
# Look for files in the media directory
media_dir = os.path.join('media', content_type, identifier)
if os.path.exists(media_dir):
files = [f for f in os.listdir(media_dir)
if not f.startswith('.') and # Skip hidden files
not f.startswith('tmp') and # Skip temp files
os.path.isfile(os.path.join(media_dir, f))]
if files:
# Get the first file and update the database record
file_path = os.path.join(content_type, identifier, files[0])
if os.path.exists(os.path.join('media', file_path)):
photo.image.name = file_path
photo.save()
self.stdout.write(f'Updated path for photo {photo.id} to {file_path}')
else:
self.stdout.write(f'File not found for photo {photo.id}: {file_path}')
else:
self.stdout.write(f'No files found in directory for photo {photo.id}: {media_dir}')
else:
self.stdout.write(f'Directory not found for photo {photo.id}: {media_dir}')
except Exception as e:
self.stdout.write(f'Error updating photo {photo.id}: {str(e)}')
continue
self.stdout.write('Finished fixing photo paths')

View File

@@ -0,0 +1,116 @@
import os
from django.core.management.base import BaseCommand
from media.models import Photo
from django.conf import settings
import shutil
class Command(BaseCommand):
help = "Move photo files to their normalized locations"
def handle(self, *args, **kwargs):
self.stdout.write("Moving photo files to normalized locations...")
# Get all photos
photos = Photo.objects.all()
# Track processed files to clean up later
processed_files = set()
for photo in photos:
try:
# Get current file path
current_name = photo.image.name
current_path = os.path.join(settings.MEDIA_ROOT, current_name)
# Try to find the actual file
if not os.path.exists(current_path):
# Check if file exists in the old location structure
parts = current_name.split("/")
if len(parts) >= 2:
content_type = parts[0] # 'park' or 'ride'
identifier = parts[1] # e.g., 'alton-towers'
# Look for any files in that directory
old_dir = os.path.join(
settings.MEDIA_ROOT, content_type, identifier
)
if os.path.exists(old_dir):
files = [
f
for f in os.listdir(old_dir)
if not f.startswith(".") # Skip hidden files
and not f.startswith("tmp") # Skip temp files
and os.path.isfile(os.path.join(old_dir, f))
]
if files:
current_path = os.path.join(old_dir, files[0])
# Skip if file still not found
if not os.path.exists(current_path):
self.stdout.write(f"Skipping {current_name} - file not found")
continue
# Get content type and object
content_type_model = photo.content_type.model
obj = photo.content_object
identifier = getattr(obj, "slug", obj.id)
# Get photo number
photo_number = Photo.objects.filter(
content_type=photo.content_type,
object_id=photo.object_id,
created_at__lte=photo.created_at,
).count()
# Create new filename
_, ext = os.path.splitext(current_path)
if not ext:
ext = ".jpg"
ext = ext.lower()
new_filename = f"{identifier}_{photo_number}{ext}"
# Create new path
new_relative_path = f"{content_type_model}/{identifier}/{new_filename}"
new_full_path = os.path.join(settings.MEDIA_ROOT, new_relative_path)
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(new_full_path), exist_ok=True)
# Move the file
if current_path != new_full_path:
shutil.copy2(
current_path, new_full_path
) # Use copy2 to preserve metadata
processed_files.add(current_path)
else:
processed_files.add(current_path)
# Update database
photo.image.name = new_relative_path
photo.save()
self.stdout.write(f"Moved {current_name} to {new_relative_path}")
except Exception as e:
self.stdout.write(f"Error moving photo {photo.id}: {str(e)}")
continue
# Clean up old files
self.stdout.write("Cleaning up old files...")
for content_type in ["park", "ride"]:
base_dir = os.path.join(settings.MEDIA_ROOT, content_type)
if os.path.exists(base_dir):
for root, dirs, files in os.walk(base_dir):
for file in files:
file_path = os.path.join(root, file)
if file_path not in processed_files:
try:
os.remove(file_path)
self.stdout.write(f"Removed old file: {file_path}")
except Exception as e:
self.stdout.write(
f"Error removing {file_path}: {str(e)}"
)
self.stdout.write("Finished moving photo files and cleaning up")

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.1.2 on 2024-11-01 00:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="photo",
name="uploaded_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_photos",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -0,0 +1,69 @@
from django.db import migrations, models
import os
from django.db import transaction
def normalize_filenames(apps, schema_editor):
Photo = apps.get_model('media', 'Photo')
db_alias = schema_editor.connection.alias
# Get all photos
photos = Photo.objects.using(db_alias).all()
for photo in photos:
try:
with transaction.atomic():
# Get content type model name
content_type_model = photo.content_type.model
# Get current filename and extension
old_path = photo.image.name
_, ext = os.path.splitext(old_path)
if not ext:
ext = '.jpg' # Default to .jpg if no extension
ext = ext.lower()
# Get the photo number (based on creation order)
photo_number = Photo.objects.using(db_alias).filter(
content_type=photo.content_type,
object_id=photo.object_id,
created_at__lte=photo.created_at
).count()
# Extract identifier from current path
parts = old_path.split('/')
if len(parts) >= 2:
identifier = parts[1] # e.g., "alton-towers" from "park/alton-towers/..."
# Create new normalized filename
new_filename = f"{identifier}_{photo_number}{ext}"
new_path = f"{content_type_model}/{identifier}/{new_filename}"
# Update the image field if path would change
if old_path != new_path:
photo.image.name = new_path
photo.save(using=db_alias)
except Exception as e:
print(f"Error normalizing photo {photo.id}: {str(e)}")
# Continue with next photo even if this one fails
continue
def reverse_normalize(apps, schema_editor):
# No reverse operation needed since we're just renaming files
pass
class Migration(migrations.Migration):
dependencies = [
('media', '0002_photo_uploaded_by'),
]
operations = [
# First increase the field length
migrations.AlterField(
model_name='photo',
name='image',
field=models.ImageField(max_length=255, upload_to='photos'),
),
# Then normalize the filenames
migrations.RunPython(normalize_filenames, reverse_normalize),
]

View File

@@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('media', '0003_update_photo_field_and_normalize'),
]
operations = [
# No schema changes needed, just need to trigger the new upload_to path
]

View File

@@ -2,10 +2,13 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify from django.utils.text import slugify
from django.conf import settings
import os import os
from .storage import MediaStorage
from rides.models import Ride
def photo_upload_path(instance, filename): def photo_upload_path(instance, filename):
"""Generate upload path for photos""" """Generate upload path for photos using normalized filenames"""
# Get the content type and object # Get the content type and object
content_type = instance.content_type.model content_type = instance.content_type.model
obj = instance.content_object obj = instance.content_object
@@ -13,19 +16,45 @@ def photo_upload_path(instance, filename):
# Get object identifier (slug or id) # Get object identifier (slug or id)
identifier = getattr(obj, 'slug', obj.id) identifier = getattr(obj, 'slug', obj.id)
# Create path: content_type/identifier/filename # Get the next available number for this object
base, ext = os.path.splitext(filename) existing_photos = Photo.objects.filter(
new_filename = f"{slugify(base)}{ext}" content_type=instance.content_type,
return f"{content_type}/{identifier}/{new_filename}" object_id=instance.object_id
).count()
next_number = existing_photos + 1
# Create normalized filename
ext = os.path.splitext(filename)[1].lower()
if not ext:
ext = '.jpg' # Default to .jpg if no extension
new_filename = f"{identifier}_{next_number}{ext}"
# If it's a ride photo, store it under the park's directory
if content_type == 'ride':
ride = Ride.objects.get(id=obj.id)
return f"park/{ride.park.slug}/{identifier}/{new_filename}"
# For park photos, store directly in park directory
return f"park/{identifier}/{new_filename}"
class Photo(models.Model): class Photo(models.Model):
"""Generic photo model that can be attached to any model""" """Generic photo model that can be attached to any model"""
image = models.ImageField(upload_to=photo_upload_path) image = models.ImageField(
upload_to=photo_upload_path,
max_length=255,
storage=MediaStorage()
)
caption = models.CharField(max_length=255, blank=True) caption = models.CharField(max_length=255, blank=True)
alt_text = models.CharField(max_length=255, blank=True) alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False) is_primary = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='uploaded_photos'
)
# Generic foreign key fields # Generic foreign key fields
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
@@ -42,6 +71,10 @@ class Photo(models.Model):
return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}" return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Set default caption if not provided
if not self.caption and self.uploaded_by:
self.caption = f"Uploaded by {self.uploaded_by.username} on {self.created_at.strftime('%B %d, %Y at %I:%M %p')}"
# If this is marked as primary, unmark other primary photos # If this is marked as primary, unmark other primary photos
if self.is_primary: if self.is_primary:
Photo.objects.filter( Photo.objects.filter(
@@ -49,4 +82,5 @@ class Photo(models.Model):
object_id=self.object_id, object_id=self.object_id,
is_primary=True is_primary=True
).exclude(id=self.id).update(is_primary=False) ).exclude(id=self.id).update(is_primary=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

38
media/storage.py Normal file
View File

@@ -0,0 +1,38 @@
from django.core.files.storage import FileSystemStorage
from django.conf import settings
import os
class MediaStorage(FileSystemStorage):
def __init__(self, *args, **kwargs):
kwargs['location'] = settings.MEDIA_ROOT
kwargs['base_url'] = settings.MEDIA_URL
super().__init__(*args, **kwargs)
def get_available_name(self, name, max_length=None):
"""
Returns a filename that's free on the target storage system.
"""
# Get the directory and filename
directory = os.path.dirname(name)
filename = os.path.basename(name)
# Create directory if it doesn't exist
full_dir = os.path.join(self.location, directory)
os.makedirs(full_dir, exist_ok=True)
# Return the name as is since our upload path already handles uniqueness
return name
def _save(self, name, content):
"""
Save with proper permissions
"""
# Save the file
name = super()._save(name, content)
# Set proper permissions
full_path = self.path(name)
os.chmod(full_path, 0o644)
os.chmod(os.path.dirname(full_path), 0o755)
return name

View File

@@ -0,0 +1,18 @@
from django import template
from django.core.serializers.json import DjangoJSONEncoder
import json
register = template.Library()
@register.filter
def serialize_photos(photos):
"""Serialize photos queryset to JSON for AlpineJS"""
photo_data = []
for photo in photos:
photo_data.append({
'id': photo.id,
'url': photo.image.url,
'caption': photo.caption or '',
'is_primary': photo.is_primary
})
return json.dumps(photo_data, cls=DjangoJSONEncoder)

0
media/test.txt Normal file
View File

11
media/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from . import views
app_name = 'photos'
urlpatterns = [
path('upload/', views.upload_photo, name='upload'),
path('upload/<int:photo_id>/', views.delete_photo, name='delete'), # Updated to match frontend
path('upload/<int:photo_id>/primary/', views.set_primary_photo, name='set_primary'),
path('upload/<int:photo_id>/caption/', views.update_caption, name='update_caption'),
]

164
media/views.py Normal file
View File

@@ -0,0 +1,164 @@
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
import json
import logging
from .models import Photo
logger = logging.getLogger(__name__)
@login_required
@require_http_methods(["POST"])
def upload_photo(request):
"""Handle photo upload for any model"""
try:
# Get app label, model, and object ID
app_label = request.POST.get('app_label')
model = request.POST.get('model')
object_id = request.POST.get('object_id')
# Log received data
logger.debug(f"Received upload request - app_label: {app_label}, model: {model}, object_id: {object_id}")
logger.debug(f"Files in request: {request.FILES}")
# Validate required fields
missing_fields = []
if not app_label:
missing_fields.append('app_label')
if not model:
missing_fields.append('model')
if not object_id:
missing_fields.append('object_id')
if 'image' not in request.FILES:
missing_fields.append('image')
if missing_fields:
return JsonResponse({
'error': f'Missing required fields: {", ".join(missing_fields)}'
}, status=400)
# Get content type
try:
content_type = ContentType.objects.get(
app_label=app_label.lower(),
model=model.lower()
)
except ContentType.DoesNotExist:
return JsonResponse({
'error': f'Invalid content type: {app_label}.{model}'
}, status=400)
# Get the object instance
try:
obj = content_type.get_object_for_this_type(id=object_id)
except Exception as e:
return JsonResponse({
'error': f'Object not found: {app_label}.{model} with id {object_id}. Error: {str(e)}'
}, status=404)
# Check if user has permission to add photos
if not request.user.has_perm('media.add_photo'):
logger.warning(f"User {request.user} attempted to upload photo without permission")
return JsonResponse({
'error': 'You do not have permission to upload photos'
}, status=403)
# Create the photo
photo = Photo.objects.create(
image=request.FILES['image'],
content_type=content_type,
object_id=obj.id,
uploaded_by=request.user, # Add the user who uploaded the photo
# Set as primary if it's the first photo
is_primary=not Photo.objects.filter(
content_type=content_type,
object_id=obj.id
).exists()
)
return JsonResponse({
'id': photo.id,
'url': photo.image.url,
'caption': photo.caption,
'is_primary': photo.is_primary
})
except Exception as e:
logger.error(f"Error in upload_photo: {str(e)}", exc_info=True)
return JsonResponse({
'error': f'An error occurred while uploading the photo: {str(e)}'
}, status=400)
@login_required
@require_http_methods(["POST"])
def set_primary_photo(request, photo_id):
"""Set a photo as primary"""
try:
photo = get_object_or_404(Photo, id=photo_id)
# Check if user has permission to edit photos
if not request.user.has_perm('media.change_photo'):
return JsonResponse({
'error': 'You do not have permission to edit photos'
}, status=403)
# Set this photo as primary
photo.is_primary = True
photo.save() # This will automatically unset other primary photos
return JsonResponse({'status': 'success'})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return JsonResponse({'error': str(e)}, status=400)
@login_required
@require_http_methods(["POST"])
def update_caption(request, photo_id):
"""Update a photo's caption"""
try:
photo = get_object_or_404(Photo, id=photo_id)
# Check if user has permission to edit photos
if not request.user.has_perm('media.change_photo'):
return JsonResponse({
'error': 'You do not have permission to edit photos'
}, status=403)
# Update caption
data = json.loads(request.body)
photo.caption = data.get('caption', '')
photo.save()
return JsonResponse({
'id': photo.id,
'caption': photo.caption
})
except Exception as e:
logger.error(f"Error in update_caption: {str(e)}", exc_info=True)
return JsonResponse({'error': str(e)}, status=400)
@login_required
@require_http_methods(["DELETE"])
def delete_photo(request, photo_id):
"""Delete a photo"""
try:
photo = get_object_or_404(Photo, id=photo_id)
# Check if user has permission to delete photos
if not request.user.has_perm('media.delete_photo'):
return JsonResponse({
'error': 'You do not have permission to delete photos'
}, status=403)
photo.delete()
return JsonResponse({'status': 'success'})
except Exception as e:
logger.error(f"Error in delete_photo: {str(e)}", exc_info=True)
return JsonResponse({'error': str(e)}, status=400)

View File

@@ -9,11 +9,9 @@
"description": "The most visited theme park in the world, Magic Kingdom is Walt Disney World's first theme park.", "description": "The most visited theme park in the world, Magic Kingdom is Walt Disney World's first theme park.",
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/", "website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
"owner": "The Walt Disney Company", "owner": "The Walt Disney Company",
"size_acres": 142, "size_acres": "142.00",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg/1280px-Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg", "https://images.unsplash.com/photo-1524008279394-3aed4643b30b"
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Magic_Kingdom_Main_Street_USA_Panorama.jpg/1280px-Magic_Kingdom_Main_Street_USA_Panorama.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg/1280px-Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg"
], ],
"rides": [ "rides": [
{ {
@@ -24,13 +22,12 @@
"manufacturer": "Walt Disney Imagineering", "manufacturer": "Walt Disney Imagineering",
"description": "A high-speed roller coaster in the dark through space.", "description": "A high-speed roller coaster in the dark through space.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Magic_Kingdom_Space_Mountain.jpg/1280px-Magic_Kingdom_Space_Mountain.jpg", "https://images.unsplash.com/photo-1536768139911-e290a59011e4"
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Space_Mountain_%28Magic_Kingdom%29_entrance.jpg/1280px-Space_Mountain_%28Magic_Kingdom%29_entrance.jpg"
], ],
"stats": { "stats": {
"height_ft": 183, "height_ft": "183.00",
"length_ft": 3196, "length_ft": "3196.00",
"speed_mph": 27, "speed_mph": "27.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 180 "ride_time_seconds": 180
} }
@@ -43,13 +40,12 @@
"manufacturer": "Walt Disney Imagineering", "manufacturer": "Walt Disney Imagineering",
"description": "A mine train roller coaster through the Old West.", "description": "A mine train roller coaster through the Old West.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg/1280px-Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg", "https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg/1280px-Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg"
], ],
"stats": { "stats": {
"height_ft": 104, "height_ft": "104.00",
"length_ft": 2671, "length_ft": "2671.00",
"speed_mph": 30, "speed_mph": "30.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 197 "ride_time_seconds": 197
} }
@@ -62,13 +58,12 @@
"manufacturer": "Vekoma", "manufacturer": "Vekoma",
"description": "A family roller coaster featuring unique swinging cars.", "description": "A family roller coaster featuring unique swinging cars.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg/1280px-Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg", "https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f"
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Seven_Dwarfs_Mine_Train_drop.jpg/1280px-Seven_Dwarfs_Mine_Train_drop.jpg"
], ],
"stats": { "stats": {
"height_ft": 112, "height_ft": "112.00",
"length_ft": 2000, "length_ft": "2000.00",
"speed_mph": 34, "speed_mph": "34.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 180 "ride_time_seconds": 180
} }
@@ -81,8 +76,7 @@
"manufacturer": "Walt Disney Imagineering", "manufacturer": "Walt Disney Imagineering",
"description": "A dark ride through a haunted estate.", "description": "A dark ride through a haunted estate.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_Mansion_at_Magic_Kingdom.jpg/1280px-Haunted_Mansion_at_Magic_Kingdom.jpg", "https://images.unsplash.com/photo-1597466599360-3b9775841aec"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_Mansion_entrance.jpg/1280px-Haunted_Mansion_entrance.jpg"
] ]
}, },
{ {
@@ -93,8 +87,7 @@
"manufacturer": "Walt Disney Imagineering", "manufacturer": "Walt Disney Imagineering",
"description": "A boat ride through pirate-filled Caribbean waters.", "description": "A boat ride through pirate-filled Caribbean waters.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_the_Caribbean_%28Magic_Kingdom%29.jpg/1280px-Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg", "https://images.unsplash.com/photo-1506126799754-92bc47fc5d78"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_the_Caribbean_entrance.jpg/1280px-Pirates_of_the_Caribbean_entrance.jpg"
] ]
} }
] ]
@@ -108,11 +101,9 @@
"description": "Known as the Roller Coaster Capital of the World.", "description": "Known as the Roller Coaster Capital of the World.",
"website": "https://www.cedarpoint.com", "website": "https://www.cedarpoint.com",
"owner": "Cedar Fair", "owner": "Cedar Fair",
"size_acres": 364, "size_acres": "364.00",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Cedar_Point_aerial_view.jpg/1280px-Cedar_Point_aerial_view.jpg", "https://images.unsplash.com/photo-1536768139911-e290a59011e4"
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Cedar_Point_Beach.jpg/1280px-Cedar_Point_Beach.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cedar_Point_at_dusk.jpg/1280px-Cedar_Point_at_dusk.jpg"
], ],
"rides": [ "rides": [
{ {
@@ -123,13 +114,12 @@
"manufacturer": "Rocky Mountain Construction", "manufacturer": "Rocky Mountain Construction",
"description": "A hybrid roller coaster featuring multiple inversions.", "description": "A hybrid roller coaster featuring multiple inversions.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Steel_Vengeance_at_Cedar_Point.jpg/1280px-Steel_Vengeance_at_Cedar_Point.jpg", "https://images.unsplash.com/photo-1543674892-7d64d45df18b"
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Steel_Vengeance_first_drop.jpg/1280px-Steel_Vengeance_first_drop.jpg"
], ],
"stats": { "stats": {
"height_ft": 205, "height_ft": "205.00",
"length_ft": 5740, "length_ft": "5740.00",
"speed_mph": 74, "speed_mph": "74.00",
"inversions": 4, "inversions": 4,
"ride_time_seconds": 150 "ride_time_seconds": 150
} }
@@ -142,13 +132,12 @@
"manufacturer": "Intamin", "manufacturer": "Intamin",
"description": "A giga coaster with stunning views of Lake Erie.", "description": "A giga coaster with stunning views of Lake Erie.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_Force_at_Cedar_Point.jpg/1280px-Millennium_Force_at_Cedar_Point.jpg", "https://images.unsplash.com/photo-1605559911160-a3d95d213904"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_Force_lift_hill.jpg/1280px-Millennium_Force_lift_hill.jpg"
], ],
"stats": { "stats": {
"height_ft": 310, "height_ft": "310.00",
"length_ft": 6595, "length_ft": "6595.00",
"speed_mph": 93, "speed_mph": "93.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 120 "ride_time_seconds": 120
} }
@@ -161,13 +150,12 @@
"manufacturer": "Intamin", "manufacturer": "Intamin",
"description": "A strata coaster featuring a 420-foot top hat element.", "description": "A strata coaster featuring a 420-foot top hat element.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Top_Thrill_Dragster.jpg/1280px-Top_Thrill_Dragster.jpg", "https://images.unsplash.com/photo-1578912996078-305d92249aa6"
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Top_Thrill_Dragster_launch.jpg/1280px-Top_Thrill_Dragster_launch.jpg"
], ],
"stats": { "stats": {
"height_ft": 420, "height_ft": "420.00",
"length_ft": 2800, "length_ft": "2800.00",
"speed_mph": 120, "speed_mph": "120.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 50 "ride_time_seconds": 50
} }
@@ -180,13 +168,12 @@
"manufacturer": "Intamin", "manufacturer": "Intamin",
"description": "A launched roller coaster with multiple inversions.", "description": "A launched roller coaster with multiple inversions.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]k_at_Cedar_Point.jpg/1280px-Maverick_at_Cedar_Point.jpg", "https://images.unsplash.com/photo-1581309638082-877cb8132535"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]k_first_drop.jpg/1280px-Maverick_first_drop.jpg"
], ],
"stats": { "stats": {
"height_ft": 105, "height_ft": "105.00",
"length_ft": 4450, "length_ft": "4450.00",
"speed_mph": 70, "speed_mph": "70.00",
"inversions": 2, "inversions": 2,
"ride_time_seconds": 150 "ride_time_seconds": 150
} }
@@ -202,11 +189,9 @@
"description": "A theme park featuring cutting-edge technology and thrilling attractions.", "description": "A theme park featuring cutting-edge technology and thrilling attractions.",
"website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure", "website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure",
"owner": "NBCUniversal", "owner": "NBCUniversal",
"size_acres": 110, "size_acres": "110.00",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_Adventure_entrance.jpg/1280px-Islands_of_Adventure_entrance.jpg", "https://images.unsplash.com/photo-1597466599360-3b9775841aec"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]s_Castle_at_Universal%27s_Islands_of_Adventure.jpg/1280px-Hogwarts_Castle_at_Universal%27s_Islands_of_Adventure.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Port_of_Entry_at_Islands_of_Adventure.jpg/1280px-Port_of_Entry_at_Islands_of_Adventure.jpg"
], ],
"rides": [ "rides": [
{ {
@@ -217,13 +202,12 @@
"manufacturer": "Intamin", "manufacturer": "Intamin",
"description": "A high-speed launch coaster featuring velociraptors.", "description": "A high-speed launch coaster featuring velociraptors.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]c_World_VelociCoaster.jpg/1280px-Jurassic_World_VelociCoaster.jpg", "https://images.unsplash.com/photo-1536768139911-e290a59011e4"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]oaster_top_hat.jpg/1280px-VelociCoaster_top_hat.jpg"
], ],
"stats": { "stats": {
"height_ft": 155, "height_ft": "155.00",
"length_ft": 4700, "length_ft": "4700.00",
"speed_mph": 70, "speed_mph": "70.00",
"inversions": 4, "inversions": 4,
"ride_time_seconds": 145 "ride_time_seconds": 145
} }
@@ -236,13 +220,12 @@
"manufacturer": "Intamin", "manufacturer": "Intamin",
"description": "A story coaster through the Forbidden Forest.", "description": "A story coaster through the Forbidden Forest.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg/1280px-Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg", "https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
"https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Hagrid%27s_entrance.jpg/1280px-Hagrid%27s_entrance.jpg"
], ],
"stats": { "stats": {
"height_ft": 65, "height_ft": "65.00",
"length_ft": 5053, "length_ft": "5053.00",
"speed_mph": 50, "speed_mph": "50.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 180 "ride_time_seconds": 180
} }
@@ -255,26 +238,23 @@
"manufacturer": "Oceaneering International", "manufacturer": "Oceaneering International",
"description": "A 3D dark ride featuring Spider-Man.", "description": "A 3D dark ride featuring Spider-Man.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/The_Amazing_Adventures_of_Spider-Man.jpg/1280px-The_Amazing_Adventures_of_Spider-Man.jpg", "https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f"
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Spider-Man_ride_entrance.jpg/1280px-Spider-Man_ride_entrance.jpg"
] ]
} }
] ]
}, },
{ {
"name": "Alton Towers", "name": "Alton Towers",
"location": "Staffordshire, England", "location": "Alton, England",
"country": "GB", "country": "GB",
"opening_date": "1980-04-04", "opening_date": "1980-04-04",
"status": "OPERATING", "status": "OPERATING",
"description": "The UK's largest theme park, built around a historic stately home.", "description": "The UK's largest theme park, built around a historic stately home.",
"website": "https://www.altontowers.com", "website": "https://www.altontowers.com",
"owner": "Merlin Entertainments", "owner": "Merlin Entertainments",
"size_acres": 910, "size_acres": "910.00",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Alton_Towers_aerial_view.jpg/1280px-Alton_Towers_aerial_view.jpg", "https://images.unsplash.com/photo-1506126799754-92bc47fc5d78"
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Alton_Towers_mansion.jpg/1280px-Alton_Towers_mansion.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Alton_Towers_gardens.jpg/1280px-Alton_Towers_gardens.jpg"
], ],
"rides": [ "rides": [
{ {
@@ -285,13 +265,12 @@
"manufacturer": "Bolliger & Mabillard", "manufacturer": "Bolliger & Mabillard",
"description": "An inverted roller coaster through ravines.", "description": "An inverted roller coaster through ravines.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_at_Alton_Towers.jpg/1280px-Nemesis_at_Alton_Towers.jpg", "https://images.unsplash.com/photo-1543674892-7d64d45df18b"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_loop.jpg/1280px-Nemesis_loop.jpg"
], ],
"stats": { "stats": {
"height_ft": 43, "height_ft": "43.00",
"length_ft": 2349, "length_ft": "2349.00",
"speed_mph": 50, "speed_mph": "50.00",
"inversions": 4, "inversions": 4,
"ride_time_seconds": 80 "ride_time_seconds": 80
} }
@@ -304,13 +283,12 @@
"manufacturer": "Bolliger & Mabillard", "manufacturer": "Bolliger & Mabillard",
"description": "The world's first vertical drop roller coaster.", "description": "The world's first vertical drop roller coaster.",
"photos": [ "photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]n_at_Alton_Towers.jpg/1280px-Oblivion_at_Alton_Towers.jpg", "https://images.unsplash.com/photo-1605559911160-a3d95d213904"
"https://upload.wikimedia.[AWS-SECRET-REMOVED]n_vertical_drop.jpg/1280px-Oblivion_vertical_drop.jpg"
], ],
"stats": { "stats": {
"height_ft": 65, "height_ft": "65.00",
"length_ft": 1804, "length_ft": "1804.00",
"speed_mph": 68, "speed_mph": "68.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 100 "ride_time_seconds": 100
} }
@@ -326,11 +304,9 @@
"description": "Germany's largest theme park, featuring European-themed areas.", "description": "Germany's largest theme park, featuring European-themed areas.",
"website": "https://www.europapark.de", "website": "https://www.europapark.de",
"owner": "Mack Rides", "owner": "Mack Rides",
"size_acres": 235, "size_acres": "235.00",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Europa-Park_entrance.jpg/1280px-Europa-Park_entrance.jpg", "https://images.unsplash.com/photo-1536768139911-e290a59011e4"
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Europa-Park_aerial_view.jpg/1280px-Europa-Park_aerial_view.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Europa-Park_at_night.jpg/1280px-Europa-Park_at_night.jpg"
], ],
"rides": [ "rides": [
{ {
@@ -341,13 +317,12 @@
"manufacturer": "Bolliger & Mabillard", "manufacturer": "Bolliger & Mabillard",
"description": "A hypercoaster with stunning views.", "description": "A hypercoaster with stunning views.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Silver_Star_at_Europa-Park.jpg/1280px-Silver_Star_at_Europa-Park.jpg", "https://images.unsplash.com/photo-1536768139911-e290a59011e4"
"https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Silver_Star_first_drop.jpg/1280px-Silver_Star_first_drop.jpg"
], ],
"stats": { "stats": {
"height_ft": 239, "height_ft": "239.00",
"length_ft": 4003, "length_ft": "4003.00",
"speed_mph": 79, "speed_mph": "79.00",
"inversions": 0, "inversions": 0,
"ride_time_seconds": 180 "ride_time_seconds": 180
} }
@@ -360,13 +335,12 @@
"manufacturer": "Mack Rides", "manufacturer": "Mack Rides",
"description": "A launched roller coaster with multiple inversions.", "description": "A launched roller coaster with multiple inversions.",
"photos": [ "photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Blue_Fire_at_Europa-Park.jpg/1280px-Blue_Fire_at_Europa-Park.jpg", "https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Blue_Fire_launch.jpg/1280px-Blue_Fire_launch.jpg"
], ],
"stats": { "stats": {
"height_ft": 125, "height_ft": "125.00",
"length_ft": 3465, "length_ft": "3465.00",
"speed_mph": 62, "speed_mph": "62.00",
"inversions": 4, "inversions": 4,
"ride_time_seconds": 150 "ride_time_seconds": 150
} }

View File

@@ -1,297 +1,216 @@
import os
import json import json
import random import os
import uuid
from datetime import datetime
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.auth.hashers import make_password from django.contrib.auth import get_user_model
from django.core.files import File
from django.utils.text import slugify
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import connection from django.core.files.temp import NamedTemporaryFile
from faker import Faker from django.core.files import File
import requests import requests
from io import BytesIO
from PIL import Image
from cities_light.models import City, Country
from parks.models import Park from parks.models import Park
from rides.models import Ride, RollerCoasterStats from rides.models import Ride, RollerCoasterStats
from companies.models import Company, Manufacturer
from reviews.models import Review from reviews.models import Review
from media.models import Photo from media.models import Photo
from accounts.models import User, UserProfile, TopList, TopListItem from cities_light.models import Country, Region, City
from companies.models import Company, Manufacturer from django.contrib.auth.models import Permission
import random
from datetime import datetime, timedelta
fake = Faker() User = get_user_model()
class Command(BaseCommand): class Command(BaseCommand):
help = 'Seeds the database with sample data' help = 'Seeds the database with initial data'
def add_arguments(self, parser): def handle(self, *args, **kwargs):
parser.add_argument('--users', type=int, default=50) self.stdout.write('Starting database seed...')
parser.add_argument('--reviews-per-item', type=int, default=10)
# Create users and set permissions
self.create_users()
self.setup_permissions()
# Create parks and rides
self.stdout.write('Creating parks and rides from seed data...')
self.create_companies()
self.create_manufacturers()
self.create_parks_and_rides()
# Create reviews
self.stdout.write('Creating reviews...')
self.create_reviews()
# Create top lists
self.stdout.write('Creating top lists...')
self.create_top_lists()
self.stdout.write('Successfully seeded database')
def download_and_save_image(self, url): def setup_permissions(self):
try: """Set up photo permissions for all users"""
response = requests.get(url) self.stdout.write('Setting up photo permissions...')
img = Image.open(BytesIO(response.content))
img_io = BytesIO() # Get photo permissions
img.save(img_io, format='JPEG') photo_content_type = ContentType.objects.get_for_model(Photo)
img_io.seek(0) photo_permissions = Permission.objects.filter(content_type=photo_content_type)
filename = url.split('/')[-1]
return filename, File(img_io) # Update all users
except Exception as e: users = User.objects.all()
self.stdout.write(self.style.WARNING(f'Failed to download image {url}: {str(e)}')) for user in users:
return None, None for perm in photo_permissions:
user.user_permissions.add(perm)
user.save()
self.stdout.write(f'Updated permissions for user: {user.username}')
def create_users(self, count): def create_users(self):
self.stdout.write('Creating users...') self.stdout.write('Creating users...')
users = []
# Try to get admin user
try: try:
# Get existing admin user admin = User.objects.get(username='admin')
admin_user = User.objects.get(username='admin') self.stdout.write('Admin user exists, updating permissions...')
users.append(admin_user)
self.stdout.write('Added existing admin user')
except User.DoesNotExist: except User.DoesNotExist:
self.stdout.write(self.style.WARNING('Admin user not found, skipping...')) admin = User.objects.create_superuser('admin', 'admin@example.com', 'admin')
self.stdout.write('Created admin user')
# Create regular users using raw SQL
roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2 # Create regular users
with connection.cursor() as cursor: usernames = [
for _ in range(count): 'destiny89', 'destiny97', 'thompsonchris', 'chriscohen', 'littlesharon',
# Create user 'wrichardson', 'christophermiles', 'jacksonangela', 'jennifer71', 'smithemily',
username = fake.user_name() 'brandylong', 'milleranna', 'tlopez', 'fgriffith', 'mariah80',
while User.objects.filter(username=username).exists(): 'kendradavis', 'rosarioashley', 'camposkaitlyn', 'lisaherrera', 'riveratiffany',
username = fake.user_name() 'codytucker', 'cheyenne78', 'christinagreen', 'eric57', 'steinsuzanne',
'david95', 'rstewart', 'josephhaynes', 'umedina', 'tylerbryant',
user_id = str(uuid.uuid4())[:10] 'lcampos', 'shellyford', 'ksmith', 'qeverett', 'waguilar',
cursor.execute(""" 'zbrowning', 'yalexander', 'wallacewilliam', 'bsuarez', 'ismith',
INSERT INTO accounts_user ( 'joyceosborne', 'garythomas', 'tlewis', 'robertgonzales', 'medinashannon',
username, password, email, is_superuser, is_staff, 'yhanson', 'howellmorgan', 'taylorsusan', 'barnold', 'bryan20'
is_active, date_joined, user_id, first_name, ]
last_name, role, is_banned, ban_reason,
theme_preference for username in usernames:
) VALUES ( if not User.objects.filter(username=username).exists():
%s, %s, %s, false, false, User.objects.create_user(
true, NOW(), %s, '', '', username=username,
%s, false, '', 'light' email=f'{username}@example.com',
) RETURNING id; password='password123'
""", [username, make_password('password123'), fake.email(), user_id, random.choice(roles)]) )
user_db_id = cursor.fetchone()[0]
# Create profile
profile_id = str(uuid.uuid4())[:10]
display_name = f"{fake.first_name()}_{fake.last_name()}_{fake.random_number(digits=4)}"
cursor.execute("""
INSERT INTO accounts_userprofile (
profile_id, display_name, pronouns, bio,
twitter, instagram, youtube, discord,
coaster_credits, dark_ride_credits,
flat_ride_credits, water_ride_credits,
user_id, avatar
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, ''
);
""", [
profile_id, display_name, random.choice(['he/him', 'she/her', 'they/them', '']),
fake.text(max_nb_chars=200),
fake.url() if random.choice([True, False]) else '',
fake.url() if random.choice([True, False]) else '',
fake.url() if random.choice([True, False]) else '',
fake.user_name() if random.choice([True, False]) else '',
random.randint(0, 500), random.randint(0, 200),
random.randint(0, 300), random.randint(0, 100),
user_db_id
])
users.append(User.objects.get(id=user_db_id))
self.stdout.write(f'Created user: {username}') self.stdout.write(f'Created user: {username}')
return users
def create_companies(self): def create_companies(self):
self.stdout.write('Creating companies...') self.stdout.write('Creating companies...')
# Delete existing companies # Delete existing companies
Company.objects.all().delete() Company.objects.all().delete()
self.stdout.write('Deleted existing companies') self.stdout.write('Deleted existing companies')
companies = { companies = [
'The Walt Disney Company': { 'The Walt Disney Company',
'headquarters': 'Burbank, California', 'Cedar Fair',
'founded_date': '1923-10-16', 'NBCUniversal',
'website': 'https://www.disney.com', 'Merlin Entertainments',
}, 'Mack Rides'
'Cedar Fair': { ]
'headquarters': 'Sandusky, Ohio',
'founded_date': '1983-05-01', for name in companies:
'website': 'https://www.cedarfair.com', Company.objects.create(name=name)
},
'NBCUniversal': {
'headquarters': 'New York City, New York',
'founded_date': '1912-04-30',
'website': 'https://www.nbcuniversal.com',
},
'Merlin Entertainments': {
'headquarters': 'Poole, England',
'founded_date': '1999-05-19',
'website': 'https://www.merlinentertainments.biz',
},
'Mack Rides': {
'headquarters': 'Waldkirch, Germany',
'founded_date': '1780-01-01',
'website': 'https://mack-rides.com',
},
}
company_instances = {}
for name, details in companies.items():
company = Company.objects.create(
name=name,
slug=slugify(name),
headquarters=details['headquarters'],
founded_date=datetime.strptime(details['founded_date'], '%Y-%m-%d').date(),
website=details['website'],
)
company_instances[name] = company
self.stdout.write(f'Created company: {name}') self.stdout.write(f'Created company: {name}')
return company_instances
def create_manufacturers(self): def create_manufacturers(self):
self.stdout.write('Creating manufacturers...') self.stdout.write('Creating manufacturers...')
# Delete existing manufacturers # Delete existing manufacturers
Manufacturer.objects.all().delete() Manufacturer.objects.all().delete()
self.stdout.write('Deleted existing manufacturers') self.stdout.write('Deleted existing manufacturers')
manufacturers = { manufacturers = [
'Walt Disney Imagineering': { 'Walt Disney Imagineering',
'headquarters': 'Glendale, California', 'Bolliger & Mabillard',
'founded_date': '1952-12-16', 'Intamin',
'website': 'https://sites.disney.com/waltdisneyimagineering/', 'Rocky Mountain Construction',
}, 'Vekoma',
'Bolliger & Mabillard': { 'Mack Rides',
'headquarters': 'Monthey, Switzerland', 'Oceaneering International'
'founded_date': '1988-01-01', ]
'website': 'https://www.bolliger-mabillard.com',
}, for name in manufacturers:
'Intamin': { Manufacturer.objects.create(name=name)
'headquarters': 'Schaan, Liechtenstein',
'founded_date': '1967-01-01',
'website': 'https://www.intamin.com',
},
'Rocky Mountain Construction': {
'headquarters': 'Hayden, Idaho',
'founded_date': '2001-01-01',
'website': 'https://www.rockymountainconstruction.com',
},
'Vekoma': {
'headquarters': 'Vlodrop, Netherlands',
'founded_date': '1926-01-01',
'website': 'https://www.vekoma.com',
},
'Mack Rides': {
'headquarters': 'Waldkirch, Germany',
'founded_date': '1780-01-01',
'website': 'https://mack-rides.com',
},
'Oceaneering International': {
'headquarters': 'Houston, Texas',
'founded_date': '1964-01-01',
'website': 'https://www.oceaneering.com',
},
}
manufacturer_instances = {}
for name, details in manufacturers.items():
manufacturer = Manufacturer.objects.create(
name=name,
slug=slugify(name),
headquarters=details['headquarters'],
founded_date=datetime.strptime(details['founded_date'], '%Y-%m-%d').date(),
website=details['website'],
)
manufacturer_instances[name] = manufacturer
self.stdout.write(f'Created manufacturer: {name}') self.stdout.write(f'Created manufacturer: {name}')
return manufacturer_instances def download_image(self, url):
"""Download image from URL and return as Django File object"""
response = requests.get(url)
if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content)
img_temp.flush()
return File(img_temp)
return None
def create_parks_and_rides(self, users): def create_parks_and_rides(self):
self.stdout.write('Creating parks and rides from seed data...') # Delete existing parks and rides
# Create companies and manufacturers first
companies = self.create_companies()
manufacturers = self.create_manufacturers()
# Load seed data
seed_data_path = os.path.join(os.path.dirname(__file__), 'seed_data.json')
with open(seed_data_path, 'r') as f:
seed_data = json.load(f)
# Delete existing parks (this will cascade delete rides)
Park.objects.all().delete() Park.objects.all().delete()
self.stdout.write('Deleted existing parks and rides') self.stdout.write('Deleted existing parks and rides')
parks = [] # Load seed data
for park_data in seed_data['parks']: with open(os.path.join(os.path.dirname(__file__), 'seed_data.json')) as f:
data = json.load(f)
country_map = {
'US': 'United States',
'GB': 'United Kingdom',
'DE': 'Germany'
}
for park_data in data['parks']:
try: try:
# Get country from cities_light
country = Country.objects.get(code2=park_data['country']) country = Country.objects.get(code2=park_data['country'])
# Try to find city, but don't require it
city = None
try:
city_name = park_data['location'].split(',')[0].strip()
city = City.objects.filter(name__iexact=city_name, country=country).first()
except:
self.stdout.write(self.style.WARNING(f'City not found for {park_data["name"]}, using location text'))
# Create park # Create park
park = Park.objects.create( park = Park.objects.create(
name=park_data['name'], name=park_data['name'],
slug=slugify(park_data['name']),
location=park_data['location'],
country=country, country=country,
city=city, opening_date=park_data['opening_date'],
opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(),
status=park_data['status'], status=park_data['status'],
description=park_data['description'], description=park_data['description'],
website=park_data['website'], website=park_data['website'],
owner=companies[park_data['owner']], owner=Company.objects.get(name=park_data['owner']),
size_acres=park_data['size_acres'] size_acres=park_data['size_acres']
) )
# Add park photos # Add park photos
for photo_url in park_data.get('photos', []): for photo_url in park_data['photos']:
filename, file = self.download_and_save_image(photo_url) img_file = self.download_image(photo_url)
if filename and file: if img_file:
Photo.objects.create( Photo.objects.create(
content_object=park, image=img_file,
image=file, content_type=ContentType.objects.get_for_model(park),
uploaded_by=random.choice(users), object_id=park.id,
caption=f"Photo of {park.name}", is_primary=True # First photo is primary
is_approved=True
) )
# Create rides for this park # Create rides
for ride_data in park_data['rides']: for ride_data in park_data['rides']:
ride = Ride.objects.create( ride = Ride.objects.create(
name=ride_data['name'], name=ride_data['name'],
slug=slugify(ride_data['name']),
category=ride_data['category'],
park=park, park=park,
category=ride_data['category'],
opening_date=ride_data['opening_date'],
status=ride_data['status'], status=ride_data['status'],
opening_date=datetime.strptime(ride_data['opening_date'], '%Y-%m-%d').date(), manufacturer=Manufacturer.objects.get(name=ride_data['manufacturer']),
manufacturer=manufacturers[ride_data['manufacturer']],
description=ride_data['description'] description=ride_data['description']
) )
# Add roller coaster stats if applicable # Add ride photos
if ride_data['category'] == 'RC' and 'stats' in ride_data: for photo_url in ride_data['photos']:
img_file = self.download_image(photo_url)
if img_file:
Photo.objects.create(
image=img_file,
content_type=ContentType.objects.get_for_model(ride),
object_id=ride.id,
is_primary=True # First photo is primary
)
# Add coaster stats if present
if 'stats' in ride_data:
RollerCoasterStats.objects.create( RollerCoasterStats.objects.create(
ride=ride, ride=ride,
height_ft=ride_data['stats']['height_ft'], height_ft=ride_data['stats']['height_ft'],
@@ -300,118 +219,67 @@ class Command(BaseCommand):
inversions=ride_data['stats']['inversions'], inversions=ride_data['stats']['inversions'],
ride_time_seconds=ride_data['stats']['ride_time_seconds'] ride_time_seconds=ride_data['stats']['ride_time_seconds']
) )
# Add ride photos
for photo_url in ride_data.get('photos', []):
filename, file = self.download_and_save_image(photo_url)
if filename and file:
Photo.objects.create(
content_object=ride,
image=file,
uploaded_by=random.choice(users),
caption=f"Photo of {ride.name}",
is_approved=True
)
parks.append(park)
self.stdout.write(f'Created park and rides: {park.name}') self.stdout.write(f'Created park and rides: {park.name}')
except Country.DoesNotExist:
except Exception as e: self.stdout.write(f'Country not found: {park_data["country"]}')
self.stdout.write(self.style.ERROR(f'Failed to create park {park_data["name"]}: {str(e)}'))
continue continue
return parks
def create_reviews(self, users, reviews_per_item): def create_reviews(self):
self.stdout.write('Creating reviews...')
# Delete existing reviews # Delete existing reviews
Review.objects.all().delete() Review.objects.all().delete()
self.stdout.write('Deleted existing reviews') self.stdout.write('Deleted existing reviews')
# Park reviews users = list(User.objects.exclude(username='admin'))
total_parks = Park.objects.count() parks = list(Park.objects.all())
for i, park in enumerate(Park.objects.all(), 1):
for _ in range(random.randint(reviews_per_item - 5, reviews_per_item + 5)): # Generate random dates within the last year
today = datetime.now().date()
one_year_ago = today - timedelta(days=365)
for park in parks:
# Create 3-5 reviews per park
num_reviews = random.randint(3, 5)
for _ in range(num_reviews):
# Generate random visit date
days_offset = random.randint(0, 365)
visit_date = one_year_ago + timedelta(days=days_offset)
Review.objects.create( Review.objects.create(
user=random.choice(users), user=random.choice(users),
content_object=park, content_type=ContentType.objects.get_for_model(park),
title=fake.sentence(), object_id=park.id,
content=fake.text(max_nb_chars=500), title=f'Great experience at {park.name}',
rating=random.randint(1, 10), content='Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
visit_date=fake.date_between(start_date=park.opening_date, end_date='today'), rating=random.randint(7, 10),
is_published=True visit_date=visit_date
) )
if i % 5 == 0: self.stdout.write(f'Created reviews for {park.name}')
self.stdout.write(f'Created reviews for {i}/{total_parks} parks')
# Ride reviews def create_top_lists(self):
total_rides = Ride.objects.count()
for i, ride in enumerate(Ride.objects.all(), 1):
for _ in range(random.randint(reviews_per_item - 5, reviews_per_item + 5)):
Review.objects.create(
user=random.choice(users),
content_object=ride,
title=fake.sentence(),
content=fake.text(max_nb_chars=500),
rating=random.randint(1, 10),
visit_date=fake.date_between(start_date=ride.opening_date, end_date='today'),
is_published=True
)
if i % 20 == 0:
self.stdout.write(f'Created reviews for {i}/{total_rides} rides')
def create_top_lists(self, users):
self.stdout.write('Creating top lists...')
# Delete existing top lists # Delete existing top lists
TopList.objects.all().delete() # TopList.objects.all().delete()
self.stdout.write('Deleted existing top lists') self.stdout.write('Deleted existing top lists')
categories = ['RC', 'DR', 'FR', 'WR', 'PK'] users = list(User.objects.exclude(username='admin'))
total_users = len(users) parks = list(Park.objects.all())
# Get content types
park_ct = ContentType.objects.get_for_model(Park)
ride_ct = ContentType.objects.get_for_model(Ride)
for i, user in enumerate(users, 1): for i, user in enumerate(users, 1):
for category in categories: # Create top list for every 10th user
if random.choice([True, False]): # 50% chance to create a list
top_list = TopList.objects.create(
user=user,
title=f"My Top {random.randint(5, 20)} {dict(TopList.Categories.choices)[category]}s",
category=category,
description=fake.text(max_nb_chars=200)
)
# Add items to the list
items = []
if category == 'PK':
items = list(Park.objects.all())
content_type = park_ct
else:
items = list(Ride.objects.filter(category=category))
content_type = ride_ct
if items:
selected_items = random.sample(items, min(len(items), random.randint(5, 20)))
for rank, item in enumerate(selected_items, 1):
TopListItem.objects.create(
top_list=top_list,
content_type=content_type,
object_id=item.id,
rank=rank,
notes=fake.sentence() if random.choice([True, False]) else ''
)
if i % 10 == 0: if i % 10 == 0:
self.stdout.write(f'Created top lists for {i}/{total_users} users') # top_list = TopList.objects.create(
# user=user,
def handle(self, *args, **options): # name=f"{user.username}'s Top Parks",
self.stdout.write('Starting database seed...') # description='My favorite theme parks'
# )
users = self.create_users(options['users'])
parks = self.create_parks_and_rides(users) # Add 3-5 random parks
self.create_reviews(users, options['reviews_per_item']) # selected_parks = random.sample(parks, random.randint(3, 5))
self.create_top_lists(users) # for j, park in enumerate(selected_parks, 1):
# TopListItem.objects.create(
self.stdout.write(self.style.SUCCESS('Successfully seeded database')) # top_list=top_list,
# content_type=ContentType.objects.get_for_model(park),
# object_id=park.id,
# rank=j
# )
self.stdout.write(f'Created top lists for {i}/50 users')

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.1.2 on 2024-11-01 00:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cities_light", "0011_alter_city_country_alter_city_region_and_more"),
("parks", "0020_remove_historicalpark_city_text"),
]
operations = [
migrations.AlterField(
model_name="historicalpark",
name="country",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="cities_light.country",
),
),
migrations.AlterField(
model_name="park",
name="location",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@@ -16,7 +16,7 @@ class Park(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True) slug = models.SlugField(max_length=255, unique=True)
location = models.CharField(max_length=255) location = models.CharField(max_length=255, blank=True, null=True) # Made nullable
country = models.ForeignKey(Country, on_delete=models.PROTECT) country = models.ForeignKey(Country, on_delete=models.PROTECT)
region = models.ForeignKey(Region, on_delete=models.PROTECT, null=True, blank=True) region = models.ForeignKey(Region, on_delete=models.PROTECT, null=True, blank=True)
city = models.ForeignKey(City, on_delete=models.PROTECT, null=True, blank=True) city = models.ForeignKey(City, on_delete=models.PROTECT, null=True, blank=True)

View File

@@ -15,85 +15,89 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
from moderation.models import EditSubmission from moderation.models import EditSubmission
from cities_light.models import Country, Region, City from cities_light.models import Country, Region, City
def get_countries(request): def get_countries(request):
query = request.GET.get('q', '') query = request.GET.get("q", "")
filter_parks = request.GET.get('filter_parks', 'false') == 'true' filter_parks = request.GET.get("filter_parks", "false") == "true"
# Base query # Base query
countries = Country.objects.filter(name__icontains=query) countries = Country.objects.filter(name__icontains=query)
# Only filter by parks if explicitly requested # Only filter by parks if explicitly requested
if filter_parks: if filter_parks:
countries = countries.filter(park__isnull=False) countries = countries.filter(park__isnull=False)
countries = countries.distinct().values('id', 'name')[:10] countries = countries.distinct().values("id", "name")[:10]
return JsonResponse(list(countries), safe=False) return JsonResponse(list(countries), safe=False)
def get_regions(request): def get_regions(request):
query = request.GET.get('q', '') query = request.GET.get("q", "")
country = request.GET.get('country', '') country = request.GET.get("country", "")
filter_parks = request.GET.get('filter_parks', 'false') == 'true' filter_parks = request.GET.get("filter_parks", "false") == "true"
if not country: if not country:
return JsonResponse([], safe=False) return JsonResponse([], safe=False)
# Base query # Base query
regions = Region.objects.filter( regions = Region.objects.filter(
Q(name__icontains=query) | Q(alternate_names__icontains=query), Q(name__icontains=query) | Q(alternate_names__icontains=query),
country__name__iexact=country country__name__iexact=country,
) )
# Only filter by parks if explicitly requested # Only filter by parks if explicitly requested
if filter_parks: if filter_parks:
regions = regions.filter(park__isnull=False) regions = regions.filter(park__isnull=False)
regions = regions.distinct().values('id', 'name')[:10] regions = regions.distinct().values("id", "name")[:10]
return JsonResponse(list(regions), safe=False) return JsonResponse(list(regions), safe=False)
def get_cities(request): def get_cities(request):
query = request.GET.get('q', '') query = request.GET.get("q", "")
region = request.GET.get('region', '') region = request.GET.get("region", "")
country = request.GET.get('country', '') country = request.GET.get("country", "")
filter_parks = request.GET.get('filter_parks', 'false') == 'true' filter_parks = request.GET.get("filter_parks", "false") == "true"
if not region or not country: if not region or not country:
return JsonResponse([], safe=False) return JsonResponse([], safe=False)
# Base query # Base query
cities = City.objects.filter( cities = City.objects.filter(
Q(name__icontains=query) | Q(alternate_names__icontains=query), Q(name__icontains=query) | Q(alternate_names__icontains=query),
region__name__iexact=region, region__name__iexact=region,
region__country__name__iexact=country region__country__name__iexact=country,
) )
# Only filter by parks if explicitly requested # Only filter by parks if explicitly requested
if filter_parks: if filter_parks:
cities = cities.filter(park__isnull=False) cities = cities.filter(park__isnull=False)
cities = cities.distinct().values('id', 'name')[:10] cities = cities.distinct().values("id", "name")[:10]
return JsonResponse(list(cities), safe=False) return JsonResponse(list(cities), safe=False)
class ParkCreateView(LoginRequiredMixin, CreateView): class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park model = Park
form_class = ParkForm form_class = ParkForm
template_name = 'parks/park_form.html' template_name = "parks/park_form.html"
def prepare_changes_data(self, cleaned_data): def prepare_changes_data(self, cleaned_data):
data = cleaned_data.copy() data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization # Convert model instances to IDs for JSON serialization
if data.get('owner'): if data.get("owner"):
data['owner'] = data['owner'].id data["owner"] = data["owner"].id
if data.get('country'): if data.get("country"):
data['country'] = data['country'].id data["country"] = data["country"].id
if data.get('region'): if data.get("region"):
data['region'] = data['region'].id data["region"] = data["region"].id
if data.get('city'): if data.get("city"):
data['city'] = data['city'].id data["city"] = data["city"].id
# Convert dates to ISO format strings # Convert dates to ISO format strings
if data.get('opening_date'): if data.get("opening_date"):
data['opening_date'] = data['opening_date'].isoformat() data["opening_date"] = data["opening_date"].isoformat()
if data.get('closing_date'): if data.get("closing_date"):
data['closing_date'] = data['closing_date'].isoformat() data["closing_date"] = data["closing_date"].isoformat()
return data return data
def form_valid(self, form): def form_valid(self, form):
@@ -103,54 +107,55 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
submission = EditSubmission.objects.create( submission = EditSubmission.objects.create(
user=self.request.user, user=self.request.user,
content_type=ContentType.objects.get_for_model(Park), content_type=ContentType.objects.get_for_model(Park),
submission_type='CREATE', submission_type="CREATE",
changes=changes, changes=changes,
reason=self.request.POST.get('reason', ''), reason=self.request.POST.get("reason", ""),
source=self.request.POST.get('source', '') source=self.request.POST.get("source", ""),
) )
# If user is moderator or above, auto-approve # If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: if self.request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
self.object = form.save() self.object = form.save()
submission.object_id = self.object.id submission.object_id = self.object.id
submission.status = 'APPROVED' submission.status = "APPROVED"
submission.handled_by = self.request.user submission.handled_by = self.request.user
submission.save() submission.save()
messages.success(self.request, f'Successfully created {self.object.name}') messages.success(self.request, f"Successfully created {self.object.name}")
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, 'Your park submission has been sent for review') messages.success(self.request, "Your park submission has been sent for review")
return HttpResponseRedirect(reverse('parks:park_list')) return HttpResponseRedirect(reverse("parks:park_list"))
def get_success_url(self): def get_success_url(self):
return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
class ParkUpdateView(LoginRequiredMixin, UpdateView): class ParkUpdateView(LoginRequiredMixin, UpdateView):
model = Park model = Park
form_class = ParkForm form_class = ParkForm
template_name = 'parks/park_form.html' template_name = "parks/park_form.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['is_edit'] = True context["is_edit"] = True
return context return context
def prepare_changes_data(self, cleaned_data): def prepare_changes_data(self, cleaned_data):
data = cleaned_data.copy() data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization # Convert model instances to IDs for JSON serialization
if data.get('owner'): if data.get("owner"):
data['owner'] = data['owner'].id data["owner"] = data["owner"].id
if data.get('country'): if data.get("country"):
data['country'] = data['country'].id data["country"] = data["country"].id
if data.get('region'): if data.get("region"):
data['region'] = data['region'].id data["region"] = data["region"].id
if data.get('city'): if data.get("city"):
data['city'] = data['city'].id data["city"] = data["city"].id
# Convert dates to ISO format strings # Convert dates to ISO format strings
if data.get('opening_date'): if data.get("opening_date"):
data['opening_date'] = data['opening_date'].isoformat() data["opening_date"] = data["opening_date"].isoformat()
if data.get('closing_date'): if data.get("closing_date"):
data['closing_date'] = data['closing_date'].isoformat() data["closing_date"] = data["closing_date"].isoformat()
return data return data
def form_valid(self, form): def form_valid(self, form):
@@ -161,31 +166,43 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
user=self.request.user, user=self.request.user,
content_type=ContentType.objects.get_for_model(Park), content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id, object_id=self.object.id,
submission_type='EDIT', submission_type="EDIT",
changes=changes, changes=changes,
reason=self.request.POST.get('reason', ''), reason=self.request.POST.get("reason", ""),
source=self.request.POST.get('source', '') source=self.request.POST.get("source", ""),
) )
# If user is moderator or above, auto-approve # If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: if self.request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
self.object = form.save() self.object = form.save()
submission.status = 'APPROVED' submission.status = "APPROVED"
submission.handled_by = self.request.user submission.handled_by = self.request.user
submission.save() submission.save()
messages.success(self.request, f'Successfully updated {self.object.name}') messages.success(self.request, f"Successfully updated {self.object.name}")
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') messages.success(
return HttpResponseRedirect(reverse('parks:park_detail', kwargs={'slug': self.object.slug})) self.request,
f"Your changes to {self.object.name} have been sent for review",
)
return HttpResponseRedirect(
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
)
def get_success_url(self): def get_success_url(self):
return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
class ParkDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView,
):
model = Park model = Park
template_name = 'parks/park_detail.html' template_name = "parks/park_detail.html"
context_object_name = 'park' context_object_name = "park"
def get_object(self, queryset=None): def get_object(self, queryset=None):
if queryset is None: if queryset is None:
@@ -196,26 +213,33 @@ class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixi
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['rides'] = Ride.objects.filter( context["rides"] = Ride.objects.filter(park=self.object).select_related(
park=self.object "coaster_stats"
).select_related('coaster_stats') )
context['areas'] = ParkArea.objects.filter(park=self.object) context["areas"] = ParkArea.objects.filter(park=self.object)
return context return context
def get_redirect_url_pattern(self): def get_redirect_url_pattern(self):
return 'parks:park_detail' return "parks:park_detail"
class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
class ParkAreaDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView,
):
model = ParkArea model = ParkArea
template_name = 'parks/area_detail.html' template_name = "parks/area_detail.html"
context_object_name = 'area' context_object_name = "area"
slug_url_kwarg = 'area_slug' slug_url_kwarg = "area_slug"
def get_object(self, queryset=None): def get_object(self, queryset=None):
if queryset is None: if queryset is None:
queryset = self.get_queryset() queryset = self.get_queryset()
park_slug = self.kwargs.get('park_slug') park_slug = self.kwargs.get("park_slug")
area_slug = self.kwargs.get('area_slug') area_slug = self.kwargs.get("area_slug")
# Try to get by current or historical slug # Try to get by current or historical slug
obj, is_old_slug = self.model.get_by_slug(area_slug) obj, is_old_slug = self.model.get_by_slug(area_slug)
if obj.park.slug != park_slug: if obj.park.slug != park_slug:
@@ -224,62 +248,61 @@ class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmission
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['rides'] = Ride.objects.filter( context["rides"] = Ride.objects.filter(area=self.object).select_related(
area=self.object "coaster_stats"
).select_related('coaster_stats') )
return context return context
def get_redirect_url_pattern(self): def get_redirect_url_pattern(self):
return 'parks:park_detail' return "parks:park_detail"
def get_redirect_url_kwargs(self): def get_redirect_url_kwargs(self):
return { return {"park_slug": self.object.park.slug, "area_slug": self.object.slug}
'park_slug': self.object.park.slug,
'area_slug': self.object.slug
}
class ParkListView(ListView): class ParkListView(ListView):
model = Park model = Park
template_name = 'parks/park_list.html' template_name = "parks/park_list.html"
context_object_name = 'parks' context_object_name = "parks"
def get_queryset(self): def get_queryset(self):
queryset = Park.objects.select_related('owner', 'country', 'region', 'city').prefetch_related('photos', 'rides') queryset = Park.objects.select_related(
"owner", "country", "region", "city"
search = self.request.GET.get('search', '').strip() ).prefetch_related("photos", "rides")
country = self.request.GET.get('country', '').strip()
region = self.request.GET.get('region', '').strip() search = self.request.GET.get("search", "").strip()
city = self.request.GET.get('city', '').strip() country = self.request.GET.get("country", "").strip()
statuses = self.request.GET.getlist('status') region = self.request.GET.get("region", "").strip()
city = self.request.GET.get("city", "").strip()
statuses = self.request.GET.getlist("status")
if search: if search:
queryset = queryset.filter( queryset = queryset.filter(
Q(name__icontains=search) | Q(name__icontains=search) | Q(location__icontains=search)
Q(location__icontains=search)
) )
if country: if country:
queryset = queryset.filter(country__name__icontains=country) queryset = queryset.filter(country__name__icontains=country)
if region: if region:
queryset = queryset.filter(region__name__icontains=region) queryset = queryset.filter(region__name__icontains=region)
if city: if city:
queryset = queryset.filter(city__name__icontains=city) queryset = queryset.filter(city__name__icontains=city)
if statuses: if statuses:
queryset = queryset.filter(status__in=statuses) queryset = queryset.filter(status__in=statuses)
return queryset return queryset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['current_filters'] = { context["current_filters"] = {
'search': self.request.GET.get('search', ''), "search": self.request.GET.get("search", ""),
'country': self.request.GET.get('country', ''), "country": self.request.GET.get("country", ""),
'region': self.request.GET.get('region', ''), "region": self.request.GET.get("region", ""),
'city': self.request.GET.get('city', ''), "city": self.request.GET.get("city", ""),
'statuses': self.request.GET.getlist('status') "statuses": self.request.GET.getlist("status"),
} }
return context return context
@@ -287,5 +310,5 @@ class ParkListView(ListView):
# Check if this is an HTMX request # Check if this is an HTMX request
if request.htmx: if request.htmx:
# If it is, return just the parks list partial # If it is, return just the parks list partial
self.template_name = 'parks/partials/park_list.html' self.template_name = "parks/partials/park_list.html"
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)

View File

View File

@@ -0,0 +1,24 @@
from django import template
from django.templatetags.static import static
register = template.Library()
@register.simple_tag
def get_ride_placeholder_image(category):
"""Return placeholder image based on ride category"""
category_images = {
"RC": "images/placeholders/roller-coaster.jpg",
"DR": "images/placeholders/dark-ride.jpg",
"FR": "images/placeholders/flat-ride.jpg",
"WR": "images/placeholders/water-ride.jpg",
"TR": "images/placeholders/transport.jpg",
"OT": "images/placeholders/other-ride.jpg",
}
return static(category_images.get(category, "images/placeholders/default-ride.jpg"))
@register.simple_tag
def get_park_placeholder_image():
"""Return placeholder image for parks"""
return static("images/placeholders/default-park.jpg")

View File

@@ -1509,10 +1509,38 @@ select {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
} }
#mobileMenu.show {
max-height: 300px;
opacity: 1;
}
#mobileMenu .space-y-4 { #mobileMenu .space-y-4 {
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
} }
.mobile-nav-link.primary {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
--tw-gradient-from: #4f46e5 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: #e11d48 var(--tw-gradient-to-position);
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.mobile-nav-link.primary:hover {
--tw-gradient-from: rgb(79 70 229 / 0.9) var(--tw-gradient-from-position);
--tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: rgb(225 29 72 / 0.9) var(--tw-gradient-to-position);
}
.mobile-nav-link.primary i {
margin-right: 0.75rem;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
/* Theme Toggle */ /* Theme Toggle */
#theme-toggle+.theme-toggle-btn i::before { #theme-toggle+.theme-toggle-btn i::before {
@@ -2149,18 +2177,42 @@ select {
position: sticky; position: sticky;
} }
.inset-0 {
inset: 0px;
}
.bottom-4 {
bottom: 1rem;
}
.left-0 {
left: 0px;
}
.right-0 { .right-0 {
right: 0px; right: 0px;
} }
.right-4 {
right: 1rem;
}
.top-0 { .top-0 {
top: 0px; top: 0px;
} }
.top-4 {
top: 1rem;
}
.z-10 { .z-10 {
z-index: 10; z-index: 10;
} }
.z-20 {
z-index: 20;
}
.z-40 { .z-40 {
z-index: 40; z-index: 40;
} }
@@ -2181,6 +2233,16 @@ select {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.mx-8 { .mx-8 {
margin-left: 2rem; margin-left: 2rem;
margin-right: 2rem; margin-right: 2rem;
@@ -2307,6 +2369,14 @@ select {
height: 4rem; height: 4rem;
} }
.h-2 {
height: 0.5rem;
}
.h-2\.5 {
height: 0.625rem;
}
.h-24 { .h-24 {
height: 6rem; height: 6rem;
} }
@@ -2331,6 +2401,10 @@ select {
max-height: 15rem; max-height: 15rem;
} }
.max-h-\[90vh\] {
max-height: 90vh;
}
.min-h-\[calc\(100vh-16rem\)\] { .min-h-\[calc\(100vh-16rem\)\] {
min-height: calc(100vh - 16rem); min-height: calc(100vh - 16rem);
} }
@@ -2355,18 +2429,39 @@ select {
width: 1.25rem; width: 1.25rem;
} }
.w-64 {
width: 16rem;
}
.w-8 { .w-8 {
width: 2rem; width: 2rem;
} }
.w-auto {
width: auto;
}
.w-fit {
width: -moz-fit-content;
width: fit-content;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
.max-w-2xl {
max-width: 42rem;
}
.max-w-3xl { .max-w-3xl {
max-width: 48rem; max-width: 48rem;
} }
.max-w-7xl {
max-width: 80rem;
}
.max-w-lg { .max-w-lg {
max-width: 32rem; max-width: 32rem;
} }
@@ -2387,6 +2482,21 @@ select {
flex-grow: 1; flex-grow: 1;
} }
.-translate-y-2 {
--tw-translate-y: -0.5rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.-translate-y-full {
--tw-translate-y: -100%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.translate-y-0 {
--tw-translate-y: 0px;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.scale-100 { .scale-100 {
--tw-scale-x: 1; --tw-scale-x: 1;
--tw-scale-y: 1; --tw-scale-y: 1;
@@ -2419,6 +2529,10 @@ select {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -2603,6 +2717,15 @@ select {
border-color: transparent; border-color: transparent;
} }
.bg-black {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
}
.bg-black\/50 {
background-color: rgb(0 0 0 / 0.5);
}
.bg-blue-100 { .bg-blue-100 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity)); background-color: rgb(219 234 254 / var(--tw-bg-opacity));
@@ -2668,6 +2791,10 @@ select {
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
} }
.bg-white\/10 {
background-color: rgb(255 255 255 / 0.1);
}
.bg-white\/90 { .bg-white\/90 {
background-color: rgb(255 255 255 / 0.9); background-color: rgb(255 255 255 / 0.9);
} }
@@ -2682,6 +2809,19 @@ select {
background-color: rgb(234 179 8 / var(--tw-bg-opacity)); background-color: rgb(234 179 8 / var(--tw-bg-opacity));
} }
.bg-yellow-600 {
--tw-bg-opacity: 1;
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
.bg-opacity-90 {
--tw-bg-opacity: 0.9;
}
.bg-gradient-to-br { .bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
} }
@@ -2959,6 +3099,11 @@ select {
color: rgb(234 179 8 / var(--tw-text-opacity)); color: rgb(234 179 8 / var(--tw-text-opacity));
} }
.text-yellow-600 {
--tw-text-opacity: 1;
color: rgb(202 138 4 / var(--tw-text-opacity));
}
.text-yellow-800 { .text-yellow-800 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(133 77 14 / var(--tw-text-opacity)); color: rgb(133 77 14 / var(--tw-text-opacity));
@@ -2996,21 +3141,27 @@ select {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.shadow-xl {
--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.ring-2 { .ring-2 {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
} }
.ring-primary\/20 {
--tw-ring-color: rgb(79 70 229 / 0.2);
}
.ring-blue-500 { .ring-blue-500 {
--tw-ring-opacity: 1; --tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
} }
.ring-primary\/20 {
--tw-ring-color: rgb(79 70 229 / 0.2);
}
.filter { .filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
} }
@@ -3041,6 +3192,12 @@ select {
transition-duration: 150ms; transition-duration: 150ms;
} }
.transition-opacity {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-transform { .transition-transform {
transition-property: transform; transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -3051,6 +3208,14 @@ select {
transition-duration: 100ms; transition-duration: 100ms;
} }
.duration-200 {
transition-duration: 200ms;
}
.duration-300 {
transition-duration: 300ms;
}
.duration-75 { .duration-75 {
transition-duration: 75ms; transition-duration: 75ms;
} }
@@ -3134,11 +3299,25 @@ select {
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
} }
.hover\:bg-red-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 242 242 / var(--tw-bg-opacity));
}
.hover\:bg-red-700:hover { .hover\:bg-red-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity)); background-color: rgb(185 28 28 / var(--tw-bg-opacity));
} }
.hover\:bg-white\/20:hover {
background-color: rgb(255 255 255 / 0.2);
}
.hover\:bg-yellow-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
}
.hover\:text-blue-500:hover { .hover\:text-blue-500:hover {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity)); color: rgb(59 130 246 / var(--tw-text-opacity));
@@ -3154,11 +3333,21 @@ select {
color: rgb(29 78 216 / var(--tw-text-opacity)); color: rgb(29 78 216 / var(--tw-text-opacity));
} }
.hover\:text-gray-300:hover {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.hover\:text-gray-600:hover { .hover\:text-gray-600:hover {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity)); color: rgb(75 85 99 / var(--tw-text-opacity));
} }
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:text-primary:hover { .hover\:text-primary:hover {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity)); color: rgb(79 70 229 / var(--tw-text-opacity));
@@ -3205,6 +3394,16 @@ select {
--tw-ring-offset-width: 2px; --tw-ring-offset-width: 2px;
} }
.group:hover .group-hover\:scale-105 {
--tw-scale-x: 1.05;
--tw-scale-y: 1.05;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group:hover .group-hover\:opacity-100 {
opacity: 1;
}
.dark\:border-blue-700:is(.dark *) { .dark\:border-blue-700:is(.dark *) {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(29 78 216 / var(--tw-border-opacity)); border-color: rgb(29 78 216 / var(--tw-border-opacity));
@@ -3275,6 +3474,11 @@ select {
background-color: rgb(31 41 55 / 0.9); background-color: rgb(31 41 55 / 0.9);
} }
.dark\:bg-green-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(187 247 208 / var(--tw-bg-opacity));
}
.dark\:bg-green-500:is(.dark *) { .dark\:bg-green-500:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity)); background-color: rgb(34 197 94 / var(--tw-bg-opacity));
@@ -3285,6 +3489,11 @@ select {
background-color: rgb(20 83 45 / var(--tw-bg-opacity)); background-color: rgb(20 83 45 / var(--tw-bg-opacity));
} }
.dark\:bg-red-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(254 202 202 / var(--tw-bg-opacity));
}
.dark\:bg-red-500:is(.dark *) { .dark\:bg-red-500:is(.dark *) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity)); background-color: rgb(239 68 68 / var(--tw-bg-opacity));
@@ -3364,11 +3573,26 @@ select {
color: rgb(156 163 175 / var(--tw-text-opacity)); color: rgb(156 163 175 / var(--tw-text-opacity));
} }
.dark\:text-gray-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.dark\:text-gray-600:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.dark\:text-green-200:is(.dark *) { .dark\:text-green-200:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(187 247 208 / var(--tw-text-opacity)); color: rgb(187 247 208 / var(--tw-text-opacity));
} }
.dark\:text-green-900:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(20 83 45 / var(--tw-text-opacity));
}
.dark\:text-red-100:is(.dark *) { .dark\:text-red-100:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity)); color: rgb(254 226 226 / var(--tw-text-opacity));
@@ -3384,6 +3608,16 @@ select {
color: rgb(248 113 113 / var(--tw-text-opacity)); color: rgb(248 113 113 / var(--tw-text-opacity));
} }
.dark\:text-red-800:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
}
.dark\:text-red-900:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(127 29 29 / var(--tw-text-opacity));
}
.dark\:text-white:is(.dark *) { .dark\:text-white:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -3399,6 +3633,11 @@ select {
color: rgb(253 224 71 / var(--tw-text-opacity)); color: rgb(253 224 71 / var(--tw-text-opacity));
} }
.dark\:text-yellow-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(250 204 21 / var(--tw-text-opacity));
}
.dark\:text-yellow-50:is(.dark *) { .dark\:text-yellow-50:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(254 252 232 / var(--tw-text-opacity)); color: rgb(254 252 232 / var(--tw-text-opacity));
@@ -3448,6 +3687,10 @@ select {
background-color: rgb(220 38 38 / var(--tw-bg-opacity)); background-color: rgb(220 38 38 / var(--tw-bg-opacity));
} }
.dark\:hover\:bg-red-900\/20:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.2);
}
.dark\:hover\:text-blue-300:hover:is(.dark *) { .dark\:hover\:text-blue-300:hover:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity)); color: rgb(147 197 253 / var(--tw-text-opacity));
@@ -3458,6 +3701,11 @@ select {
color: rgb(96 165 250 / var(--tw-text-opacity)); color: rgb(96 165 250 / var(--tw-text-opacity));
} }
.dark\:hover\:text-gray-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.dark\:hover\:text-primary:hover:is(.dark *) { .dark\:hover\:text-primary:hover:is(.dark *) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity)); color: rgb(79 70 229 / var(--tw-text-opacity));
@@ -3546,10 +3794,6 @@ select {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.lg\:grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.lg\:text-6xl { .lg\:text-6xl {
font-size: 3.75rem; font-size: 3.75rem;
line-height: 1; line-height: 1;

View File

View File

View File

View File

@@ -0,0 +1,213 @@
{% load static %}
<div x-data="photoDisplay({
photos: [
{% for photo in photos %}
{
id: {{ photo.id }},
url: '{{ photo.image.url }}',
caption: '{{ photo.caption|default:""|escapejs }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
],
contentType: '{{ content_type }}',
objectId: {{ object_id }},
csrfToken: '{{ csrf_token }}',
uploadUrl: '{% url "photos:upload" %}'
})" class="w-full">
<!-- Photo Grid - Adaptive Layout -->
<div class="relative">
<!-- Success Message -->
<div x-show="showSuccess"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2"
class="absolute top-0 left-0 right-0 z-20 px-4 py-2 mx-auto mt-2 text-sm text-center text-green-800 transform -translate-y-full bg-green-100 rounded-lg w-fit dark:bg-green-200 dark:text-green-900">
Photo uploaded successfully!
</div>
<!-- Upload Progress -->
<template x-if="uploading">
<div class="absolute top-0 left-0 right-0 z-20 p-4 mx-auto mt-2 bg-white rounded-lg shadow-lg w-fit dark:bg-gray-800">
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
</div>
<div class="w-64 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
<div class="h-2 transition-all duration-300 bg-blue-600 rounded-full"
:style="'width: ' + uploadProgress + '%'"></div>
</div>
</div>
</template>
<!-- Error Message -->
<template x-if="error">
<div class="absolute top-0 left-0 right-0 z-20 px-4 py-2 mx-auto mt-2 text-sm text-center text-red-800 bg-red-100 rounded-lg w-fit dark:bg-red-200 dark:text-red-900"
x-text="error"></div>
</template>
<!-- Photo Grid -->
<div :class="{
'grid gap-4': true,
'grid-cols-1 max-w-2xl mx-auto': photos.length === 1,
'grid-cols-2 max-w-3xl mx-auto': photos.length === 2,
'grid-cols-2 md:grid-cols-3 lg:grid-cols-4': photos.length > 2
}">
<template x-for="photo in photos" :key="photo.id">
<div class="relative cursor-pointer group aspect-w-16 aspect-h-9" @click="showFullscreen(photo)">
<img :src="photo.url"
:alt="photo.caption || ''"
class="object-cover transition-transform duration-300 rounded-lg group-hover:scale-105">
</div>
</template>
</div>
<!-- No Photos Message -->
<template x-if="photos.length === 0">
<div class="flex flex-col items-center justify-center py-12 text-center">
<i class="mb-4 text-4xl text-gray-400 fas fa-camera dark:text-gray-600"></i>
<p class="mb-4 text-gray-500 dark:text-gray-400">No photos available yet.</p>
{% if user.is_authenticated and perms.media.add_photo %}
<p class="text-sm text-gray-400 dark:text-gray-500">Click the upload button to add the first photo!</p>
{% endif %}
</div>
</template>
</div>
<!-- Fullscreen Photo Modal -->
<div x-show="fullscreenPhoto"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90"
@click.self="fullscreenPhoto = null"
@keydown.escape.window="fullscreenPhoto = null">
<div class="relative p-4 mx-auto max-w-7xl">
<!-- Close Button -->
<button @click="fullscreenPhoto = null"
class="absolute text-white top-4 right-4 hover:text-gray-300">
<i class="text-2xl fas fa-times"></i>
</button>
<!-- Photo -->
<img :src="fullscreenPhoto?.url"
:alt="fullscreenPhoto?.caption || ''"
class="max-h-[90vh] w-auto mx-auto rounded-lg">
<!-- Caption -->
<div x-show="fullscreenPhoto?.caption"
class="mt-4 text-center text-white"
x-text="fullscreenPhoto?.caption">
</div>
<!-- Actions -->
<div class="absolute flex gap-2 bottom-4 right-4">
<a :href="fullscreenPhoto?.url"
download
class="p-2 text-white rounded-full bg-white/10 hover:bg-white/20"
title="Download">
<i class="fas fa-download"></i>
</a>
<button @click="sharePhoto(fullscreenPhoto)"
class="p-2 text-white rounded-full bg-white/10 hover:bg-white/20"
title="Share">
<i class="fas fa-share-alt"></i>
</button>
</div>
</div>
</div>
</div>
<!-- AlpineJS Component Script -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) return;
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});
</script>

View File

@@ -0,0 +1,257 @@
{% load static %}
<div x-data="photoManager({
photos: [
{% for photo in photos %}
{
id: {{ photo.id }},
url: '{{ photo.image.url }}',
caption: '{{ photo.caption|default:""|escapejs }}',
is_primary: {{ photo.is_primary|yesno:"true,false" }}
}{% if not forloop.last %},{% endif %}
{% endfor %}
],
contentType: '{{ content_type }}',
objectId: {{ object_id }},
csrfToken: '{{ csrf_token }}',
uploadUrl: '{% url "photos:upload" %}'
})" class="w-full">
<div class="relative space-y-6">
<!-- Upload Section -->
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Photos</h3>
<label class="cursor-pointer btn-secondary">
<i class="mr-2 fas fa-camera"></i>
<span>Upload Photo</span>
<input type="file"
class="hidden"
accept="image/*"
@change="handleFileSelect"
multiple>
</label>
</div>
<!-- Success Message -->
<div x-show="showSuccess"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2"
class="p-4 text-sm text-green-800 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-900">
Photo uploaded successfully!
</div>
<!-- Upload Progress -->
<template x-if="uploading">
<div class="p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
</div>
<div class="w-full h-2 bg-gray-200 rounded-full dark:bg-gray-700">
<div class="h-2 transition-all duration-300 bg-blue-600 rounded-full"
:style="'width: ' + uploadProgress + '%'"></div>
</div>
</div>
</template>
<!-- Error Message -->
<template x-if="error">
<div class="p-4 text-sm text-red-800 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-900"
x-text="error"></div>
</template>
<!-- Photo Grid -->
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<template x-for="photo in photos" :key="photo.id">
<div class="relative p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<!-- Photo -->
<div class="relative aspect-w-16 aspect-h-9 group">
<img :src="photo.url"
:alt="photo.caption || ''"
class="object-cover rounded-lg">
</div>
<!-- Caption -->
<div class="mt-4 space-y-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Caption</label>
<textarea x-model="photo.caption"
@change="updateCaption(photo)"
class="w-full text-sm border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="2"></textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-between mt-4">
<button @click="togglePrimary(photo)"
:class="{
'text-yellow-600 dark:text-yellow-400': photo.is_primary,
'text-gray-400 dark:text-gray-500': !photo.is_primary
}"
class="flex items-center gap-2 px-3 py-1 text-sm font-medium rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600">
<i class="fas" :class="photo.is_primary ? 'fa-star' : 'fa-star-o'"></i>
<span x-text="photo.is_primary ? 'Featured' : 'Set as Featured'"></span>
</button>
<button @click="deletePhoto(photo)"
class="flex items-center gap-2 px-3 py-1 text-sm font-medium text-red-600 rounded-lg dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20">
<i class="fas fa-trash"></i>
<span>Delete</span>
</button>
</div>
</div>
</template>
</div>
<!-- No Photos Message -->
<template x-if="photos.length === 0">
<div class="flex flex-col items-center justify-center py-12 text-center">
<i class="mb-4 text-4xl text-gray-400 fas fa-camera dark:text-gray-600"></i>
<p class="mb-4 text-gray-500 dark:text-gray-400">No photos available yet.</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Click the upload button to add photos!</p>
</div>
</template>
</div>
</div>
<!-- AlpineJS Component Script -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('photoManager', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) return;
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async updateCaption(photo) {
try {
const response = await fetch(`${uploadUrl}${photo.id}/caption/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
caption: photo.caption
})
});
if (!response.ok) {
throw new Error('Failed to update caption');
}
} catch (err) {
this.error = err.message || 'Failed to update caption';
console.error('Caption update error:', err);
}
},
async togglePrimary(photo) {
try {
const response = await fetch(`${uploadUrl}${photo.id}/primary/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to update primary photo');
}
// Update local state
this.photos = this.photos.map(p => ({
...p,
is_primary: p.id === photo.id
}));
} catch (err) {
this.error = err.message || 'Failed to update primary photo';
console.error('Primary photo update error:', err);
}
},
async deletePhoto(photo) {
if (!confirm('Are you sure you want to delete this photo?')) {
return;
}
try {
const response = await fetch(`${uploadUrl}${photo.id}/`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
}
});
if (!response.ok) {
throw new Error('Failed to delete photo');
}
// Update local state
this.photos = this.photos.filter(p => p.id !== photo.id);
} catch (err) {
this.error = err.message || 'Failed to delete photo';
console.error('Delete error:', err);
}
}
}));
});
</script>

View File

@@ -0,0 +1,270 @@
{% load static %}
<div x-data="photoUpload({
contentType: '{{ content_type }}',
objectId: {{ object_id }},
csrfToken: '{{ csrf_token }}',
uploadUrl: '{% url "photos:upload" %}',
maxFiles: {{ max_files|default:5 }},
initialPhotos: [
{% for photo in photos %}
{
id: {{ photo.id }},
url: '{{ photo.image.url }}',
caption: '{{ photo.caption|default:""|escapejs }}',
is_primary: {{ photo.is_primary|yesno:"true,false" }}
}{% if not forloop.last %},{% endif %}
{% endfor %}
]
})" class="w-full">
<!-- Photo Upload Button -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
<template x-if="canAddMorePhotos">
<label class="cursor-pointer btn-secondary">
<i class="mr-2 fas fa-camera"></i>
<span>Upload Photo</span>
<input type="file"
class="hidden"
accept="image/*"
@change="handleFileSelect"
multiple>
</label>
</template>
</div>
<!-- Upload Progress -->
<template x-if="uploading">
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300">Uploading...</span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="uploadProgress + '%'"></span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div class="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
:style="'width: ' + uploadProgress + '%'"></div>
</div>
</div>
</template>
<!-- Error Messages -->
<template x-if="error">
<div class="p-4 mb-4 text-sm text-red-800 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
x-text="error"></div>
</template>
<!-- Photo Grid -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4" x-show="photos.length > 0">
<template x-for="photo in photos" :key="photo.id">
<div class="relative group aspect-w-16 aspect-h-9">
<img :src="photo.url"
:alt="photo.caption || ''"
class="object-cover rounded-lg">
<!-- Overlay Controls -->
<div class="absolute inset-0 flex items-center justify-center transition-opacity rounded-lg opacity-0 bg-black/50 group-hover:opacity-100">
<!-- Primary Photo Toggle -->
<button @click="togglePrimary(photo)"
class="p-2 mx-1 text-white bg-blue-600 rounded-full hover:bg-blue-700"
:class="{ 'bg-yellow-500 hover:bg-yellow-600': photo.is_primary }">
<i class="fas" :class="photo.is_primary ? 'fa-star' : 'fa-star-o'"></i>
</button>
<!-- Edit Caption -->
<button @click="editCaption(photo)"
class="p-2 mx-1 text-white bg-blue-600 rounded-full hover:bg-blue-700">
<i class="fas fa-edit"></i>
</button>
<!-- Delete Photo -->
<button @click="deletePhoto(photo)"
class="p-2 mx-1 text-white bg-red-600 rounded-full hover:bg-red-700">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</template>
</div>
<!-- No Photos Message -->
<template x-if="photos.length === 0">
<div class="py-8 text-center text-gray-500 dark:text-gray-400">
No photos available. Click the upload button to add photos.
</div>
</template>
<!-- Caption Edit Modal -->
<div x-show="showCaptionModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showCaptionModal = false">
<div class="w-full max-w-md p-6 bg-white rounded-lg shadow-xl dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Edit Photo Caption</h3>
<input type="text"
x-model="editingPhoto.caption"
class="w-full p-2 mb-4 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Enter caption">
<div class="flex justify-end gap-2">
<button @click="showCaptionModal = false"
class="btn-secondary">Cancel</button>
<button @click="saveCaption"
class="btn-primary">Save</button>
</div>
</div>
</div>
</div>
<!-- AlpineJS Component Script -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('photoUpload', ({ contentType, objectId, csrfToken, uploadUrl, maxFiles, initialPhotos }) => ({
photos: initialPhotos || [],
uploading: false,
uploadProgress: 0,
error: null,
showCaptionModal: false,
editingPhoto: null,
get canAddMorePhotos() {
return this.photos.length < maxFiles;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) return;
if (this.photos.length + files.length > maxFiles) {
this.error = `You can only upload up to ${maxFiles} photos`;
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
},
async togglePrimary(photo) {
try {
const response = await fetch(`${uploadUrl}${photo.id}/primary/`, { // Added trailing slash
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to update primary photo');
}
// Update local state
this.photos = this.photos.map(p => ({
...p,
is_primary: p.id === photo.id
}));
} catch (err) {
this.error = err.message || 'Failed to update primary photo';
console.error('Primary photo update error:', err);
}
},
editCaption(photo) {
this.editingPhoto = { ...photo };
this.showCaptionModal = true;
},
async saveCaption() {
try {
const response = await fetch(`${uploadUrl}${this.editingPhoto.id}/caption/`, { // Added trailing slash
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
caption: this.editingPhoto.caption
})
});
if (!response.ok) {
throw new Error('Failed to update caption');
}
// Update local state
this.photos = this.photos.map(p =>
p.id === this.editingPhoto.id
? { ...p, caption: this.editingPhoto.caption }
: p
);
this.showCaptionModal = false;
this.editingPhoto = null;
} catch (err) {
this.error = err.message || 'Failed to update caption';
console.error('Caption update error:', err);
}
},
async deletePhoto(photo) {
if (!confirm('Are you sure you want to delete this photo?')) {
return;
}
try {
const response = await fetch(`${uploadUrl}${photo.id}/`, { // Added trailing slash
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
}
});
if (!response.ok) {
throw new Error('Failed to delete photo');
}
// Update local state
this.photos = this.photos.filter(p => p.id !== photo.id);
} catch (err) {
this.error = err.message || 'Failed to delete photo';
console.error('Delete error:', err);
}
}
}));
});
</script>

View File

@@ -26,6 +26,11 @@
<a href="{% url 'parks:park_edit' slug=park.slug %}" class="btn-secondary"> <a href="{% url 'parks:park_edit' slug=park.slug %}" class="btn-secondary">
<i class="mr-2 fas fa-edit"></i>Edit <i class="mr-2 fas fa-edit"></i>Edit
</a> </a>
{% if perms.media.add_photo %}
<button class="btn-secondary" @click="$dispatch('show-photo-upload')">
<i class="mr-2 fas fa-camera"></i>Upload Photo
</button>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -47,6 +52,14 @@
</div> </div>
</div> </div>
<!-- Photos -->
{% if park.photos.exists %}
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
{% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %}
</div>
{% endif %}
<!-- Park Stats --> <!-- Park Stats -->
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3"> <div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1"> <a href="{% url 'parks:rides:ride_list' park.slug %}" class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
@@ -258,23 +271,26 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<!-- Photos -->
{% if park.photos.exists %}
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
<div class="grid grid-cols-2 gap-2">
{% for photo in park.photos.all %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ photo.image.url }}"
alt="{{ photo.caption|default:park.name }}"
class="object-cover rounded-lg">
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
<div x-data="{ show: false }"
@show-photo-upload.window="show = true"
x-show="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="show = false">
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<i class="text-xl fas fa-times"></i>
</button>
</div>
{% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %}
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,8 @@
{% block content %} {% block content %}
<div class="container px-4 mx-auto"> <div class="container px-4 mx-auto">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800"> <!-- Park Form -->
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Park</h1> <h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Park</h1>
{% if form.errors %} {% if form.errors %}
@@ -195,6 +196,13 @@
</div> </div>
</form> </form>
</div> </div>
<!-- Photos Section (only shown on edit) -->
{% if is_edit %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800" id="photos">
{% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="parks.park" object_id=object.id %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -40,27 +40,23 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{% url 'parks:rides:ride_edit' park_slug=ride.park.slug ride_slug=ride.slug %}" class="btn-secondary"> <a href="{% url 'parks:rides:ride_edit' park_slug=ride.park.slug ride_slug=ride.slug %}" class="btn-secondary">
<i class="mr-2 fas fa-edit"></i> <i class="mr-2 fas fa-edit"></i>Edit
Edit
</a> </a>
{% if perms.media.add_photo %}
<button class="btn-secondary" @click="$dispatch('show-photo-upload')">
<i class="mr-2 fas fa-camera"></i>Upload Photo
</button>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Photos Grid --> <!-- Photos -->
{% if ride.photos.exists %} {% if ride.photos.exists %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2> <h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4"> {% include "media/partials/photo_display.html" with photos=ride.photos.all content_type="rides.ride" object_id=ride.id %}
{% for photo in ride.photos.all %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ photo.image.url }}"
alt="{{ photo.caption|default:ride.name }}"
class="object-cover rounded-lg">
</div>
{% endfor %}
</div>
</div> </div>
{% endif %} {% endif %}
@@ -68,12 +64,14 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column - Description and Details --> <!-- Left Column - Description and Details -->
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800"> {% if ride.description %}
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2> <div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="prose dark:prose-invert max-w-none"> <h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
{{ ride.description|linebreaks }} <div class="prose dark:prose-invert max-w-none">
{{ ride.description|linebreaks }}
</div>
</div> </div>
</div> {% endif %}
{% if ride.previous_names %} {% if ride.previous_names %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
@@ -220,22 +218,6 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<!-- Photos -->
{% if ride.photos.exists %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
<div class="grid grid-cols-2 gap-2">
{% for photo in ride.photos.all %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ photo.image.url }}"
alt="{{ photo.caption|default:ride.name }}"
class="object-cover rounded-lg">
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -276,4 +258,23 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
<div x-data="{ show: false }"
@show-photo-upload.window="show = true"
x-show="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="show = false">
<div class="w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Upload Photos</h3>
<button @click="show = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<i class="text-xl fas fa-times"></i>
</button>
</div>
{% include "media/partials/photo_upload.html" with content_type="rides.ride" object_id=ride.id %}
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,8 @@
{% block content %} {% block content %}
<div class="container px-4 mx-auto"> <div class="container px-4 mx-auto">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800"> <!-- Ride Form -->
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }}</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }}</h1>
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> <a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
@@ -95,6 +96,13 @@
</div> </div>
</form> </form>
</div> </div>
<!-- Photos Section (only shown on edit) -->
{% if is_edit %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800" id="photos">
{% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="rides.ride" object_id=object.id %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends 'base/base.html' %} {% extends 'base/base.html' %}
{% load static %} {% load static %}
{% load ride_tags %}
{% block title %} {% block title %}
{% if park %} {% if park %}
@@ -76,13 +77,17 @@
<div id="rides-grid" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div id="rides-grid" class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %} {% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1"> <div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
{% if ride.photos.exists %} <div class="aspect-w-16 aspect-h-9">
<div class="aspect-w-16 aspect-h-9"> {% if ride.photos.exists %}
<img src="{{ ride.photos.first.image.url }}" <img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}" alt="{{ ride.name }}"
class="object-cover w-full"> class="object-cover w-full">
</div> {% else %}
{% endif %} <img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</div>
<div class="p-4"> <div class="p-4">
<h2 class="mb-2 text-xl font-bold"> <h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}" <a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"

View File

@@ -25,7 +25,6 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
# Third-party apps
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount", "allauth.socialaccount",
@@ -38,7 +37,6 @@ INSTALLED_APPS = [
"whitenoise", "whitenoise",
"django_tailwind_cli", "django_tailwind_cli",
"cities_light", "cities_light",
# Local apps
"core", "core",
"accounts", "accounts",
"companies", "companies",
@@ -46,7 +44,7 @@ INSTALLED_APPS = [
"rides", "rides",
"reviews", "reviews",
"email_service", "email_service",
"media", "media.apps.MediaConfig", # Add media app
"moderation", "moderation",
] ]

View File

@@ -19,6 +19,7 @@ urlpatterns = [
# Other URLs # Other URLs
path('reviews/', include('reviews.urls')), path('reviews/', include('reviews.urls')),
path('companies/', include('companies.urls')), path('companies/', include('companies.urls')),
path('photos/', include('media.urls', namespace='photos')), # Add photos URLs
path('search/', SearchView.as_view(), name='search'), path('search/', SearchView.as_view(), name='search'),
path('terms/', TemplateView.as_view(template_name='pages/terms.html'), name='terms'), path('terms/', TemplateView.as_view(template_name='pages/terms.html'), name='terms'),
path('privacy/', TemplateView.as_view(template_name='pages/privacy.html'), name='privacy'), path('privacy/', TemplateView.as_view(template_name='pages/privacy.html'), name='privacy'),

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB