photos fix

This commit is contained in:
pacnpal
2024-11-01 01:27:11 +00:00
parent 80a9d61ca2
commit f51c8370ce
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.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):
default_auto_field = 'django.db.models.BigAutoField'
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.models import ContentType
from django.utils.text import slugify
from django.conf import settings
import os
from .storage import MediaStorage
from rides.models import Ride
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
content_type = instance.content_type.model
obj = instance.content_object
@@ -13,19 +16,45 @@ def photo_upload_path(instance, filename):
# Get object identifier (slug or id)
identifier = getattr(obj, 'slug', obj.id)
# Create path: content_type/identifier/filename
base, ext = os.path.splitext(filename)
new_filename = f"{slugify(base)}{ext}"
return f"{content_type}/{identifier}/{new_filename}"
# Get the next available number for this object
existing_photos = Photo.objects.filter(
content_type=instance.content_type,
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):
"""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)
alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=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
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'}"
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 self.is_primary:
Photo.objects.filter(
@@ -49,4 +82,5 @@ class Photo(models.Model):
object_id=self.object_id,
is_primary=True
).exclude(id=self.id).update(is_primary=False)
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.",
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
"owner": "The Walt Disney Company",
"size_acres": 142,
"size_acres": "142.00",
"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://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"
"https://images.unsplash.com/photo-1524008279394-3aed4643b30b"
],
"rides": [
{
@@ -24,13 +22,12 @@
"manufacturer": "Walt Disney Imagineering",
"description": "A high-speed roller coaster in the dark through space.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Magic_Kingdom_Space_Mountain.jpg/1280px-Magic_Kingdom_Space_Mountain.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Space_Mountain_%28Magic_Kingdom%29_entrance.jpg/1280px-Space_Mountain_%28Magic_Kingdom%29_entrance.jpg"
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
],
"stats": {
"height_ft": 183,
"length_ft": 3196,
"speed_mph": 27,
"height_ft": "183.00",
"length_ft": "3196.00",
"speed_mph": "27.00",
"inversions": 0,
"ride_time_seconds": 180
}
@@ -43,13 +40,12 @@
"manufacturer": "Walt Disney Imagineering",
"description": "A mine train roller coaster through the Old West.",
"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://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"
"https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
],
"stats": {
"height_ft": 104,
"length_ft": 2671,
"speed_mph": 30,
"height_ft": "104.00",
"length_ft": "2671.00",
"speed_mph": "30.00",
"inversions": 0,
"ride_time_seconds": 197
}
@@ -62,13 +58,12 @@
"manufacturer": "Vekoma",
"description": "A family roller coaster featuring unique swinging cars.",
"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://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Seven_Dwarfs_Mine_Train_drop.jpg/1280px-Seven_Dwarfs_Mine_Train_drop.jpg"
"https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f"
],
"stats": {
"height_ft": 112,
"length_ft": 2000,
"speed_mph": 34,
"height_ft": "112.00",
"length_ft": "2000.00",
"speed_mph": "34.00",
"inversions": 0,
"ride_time_seconds": 180
}
@@ -81,8 +76,7 @@
"manufacturer": "Walt Disney Imagineering",
"description": "A dark ride through a haunted estate.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Haunted_Mansion_at_Magic_Kingdom.jpg/1280px-Haunted_Mansion_at_Magic_Kingdom.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Haunted_Mansion_entrance.jpg/1280px-Haunted_Mansion_entrance.jpg"
"https://images.unsplash.com/photo-1597466599360-3b9775841aec"
]
},
{
@@ -93,8 +87,7 @@
"manufacturer": "Walt Disney Imagineering",
"description": "A boat ride through pirate-filled Caribbean waters.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg/1280px-Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Pirates_of_the_Caribbean_entrance.jpg/1280px-Pirates_of_the_Caribbean_entrance.jpg"
"https://images.unsplash.com/photo-1506126799754-92bc47fc5d78"
]
}
]
@@ -108,11 +101,9 @@
"description": "Known as the Roller Coaster Capital of the World.",
"website": "https://www.cedarpoint.com",
"owner": "Cedar Fair",
"size_acres": 364,
"size_acres": "364.00",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Cedar_Point_aerial_view.jpg/1280px-Cedar_Point_aerial_view.jpg",
"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"
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
],
"rides": [
{
@@ -123,13 +114,12 @@
"manufacturer": "Rocky Mountain Construction",
"description": "A hybrid roller coaster featuring multiple inversions.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Steel_Vengeance_at_Cedar_Point.jpg/1280px-Steel_Vengeance_at_Cedar_Point.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Steel_Vengeance_first_drop.jpg/1280px-Steel_Vengeance_first_drop.jpg"
"https://images.unsplash.com/photo-1543674892-7d64d45df18b"
],
"stats": {
"height_ft": 205,
"length_ft": 5740,
"speed_mph": 74,
"height_ft": "205.00",
"length_ft": "5740.00",
"speed_mph": "74.00",
"inversions": 4,
"ride_time_seconds": 150
}
@@ -142,13 +132,12 @@
"manufacturer": "Intamin",
"description": "A giga coaster with stunning views of Lake Erie.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Millennium_Force_at_Cedar_Point.jpg/1280px-Millennium_Force_at_Cedar_Point.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Millennium_Force_lift_hill.jpg/1280px-Millennium_Force_lift_hill.jpg"
"https://images.unsplash.com/photo-1605559911160-a3d95d213904"
],
"stats": {
"height_ft": 310,
"length_ft": 6595,
"speed_mph": 93,
"height_ft": "310.00",
"length_ft": "6595.00",
"speed_mph": "93.00",
"inversions": 0,
"ride_time_seconds": 120
}
@@ -161,13 +150,12 @@
"manufacturer": "Intamin",
"description": "A strata coaster featuring a 420-foot top hat element.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Top_Thrill_Dragster.jpg/1280px-Top_Thrill_Dragster.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Top_Thrill_Dragster_launch.jpg/1280px-Top_Thrill_Dragster_launch.jpg"
"https://images.unsplash.com/photo-1578912996078-305d92249aa6"
],
"stats": {
"height_ft": 420,
"length_ft": 2800,
"speed_mph": 120,
"height_ft": "420.00",
"length_ft": "2800.00",
"speed_mph": "120.00",
"inversions": 0,
"ride_time_seconds": 50
}
@@ -180,13 +168,12 @@
"manufacturer": "Intamin",
"description": "A launched roller coaster with multiple inversions.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/39/Maverick_at_Cedar_Point.jpg/1280px-Maverick_at_Cedar_Point.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Maverick_first_drop.jpg/1280px-Maverick_first_drop.jpg"
"https://images.unsplash.com/photo-1581309638082-877cb8132535"
],
"stats": {
"height_ft": 105,
"length_ft": 4450,
"speed_mph": 70,
"height_ft": "105.00",
"length_ft": "4450.00",
"speed_mph": "70.00",
"inversions": 2,
"ride_time_seconds": 150
}
@@ -202,11 +189,9 @@
"description": "A theme park featuring cutting-edge technology and thrilling attractions.",
"website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure",
"owner": "NBCUniversal",
"size_acres": 110,
"size_acres": "110.00",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Islands_of_Adventure_entrance.jpg/1280px-Islands_of_Adventure_entrance.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/Hogwarts_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"
"https://images.unsplash.com/photo-1597466599360-3b9775841aec"
],
"rides": [
{
@@ -217,13 +202,12 @@
"manufacturer": "Intamin",
"description": "A high-speed launch coaster featuring velociraptors.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Jurassic_World_VelociCoaster.jpg/1280px-Jurassic_World_VelociCoaster.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/VelociCoaster_top_hat.jpg/1280px-VelociCoaster_top_hat.jpg"
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
],
"stats": {
"height_ft": 155,
"length_ft": 4700,
"speed_mph": 70,
"height_ft": "155.00",
"length_ft": "4700.00",
"speed_mph": "70.00",
"inversions": 4,
"ride_time_seconds": 145
}
@@ -236,13 +220,12 @@
"manufacturer": "Intamin",
"description": "A story coaster through the Forbidden Forest.",
"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://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Hagrid%27s_entrance.jpg/1280px-Hagrid%27s_entrance.jpg"
"https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
],
"stats": {
"height_ft": 65,
"length_ft": 5053,
"speed_mph": 50,
"height_ft": "65.00",
"length_ft": "5053.00",
"speed_mph": "50.00",
"inversions": 0,
"ride_time_seconds": 180
}
@@ -255,26 +238,23 @@
"manufacturer": "Oceaneering International",
"description": "A 3D dark ride featuring Spider-Man.",
"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://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Spider-Man_ride_entrance.jpg/1280px-Spider-Man_ride_entrance.jpg"
"https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f"
]
}
]
},
{
"name": "Alton Towers",
"location": "Staffordshire, England",
"location": "Alton, England",
"country": "GB",
"opening_date": "1980-04-04",
"status": "OPERATING",
"description": "The UK's largest theme park, built around a historic stately home.",
"website": "https://www.altontowers.com",
"owner": "Merlin Entertainments",
"size_acres": 910,
"size_acres": "910.00",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Alton_Towers_aerial_view.jpg/1280px-Alton_Towers_aerial_view.jpg",
"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"
"https://images.unsplash.com/photo-1506126799754-92bc47fc5d78"
],
"rides": [
{
@@ -285,13 +265,12 @@
"manufacturer": "Bolliger & Mabillard",
"description": "An inverted roller coaster through ravines.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/98/Nemesis_at_Alton_Towers.jpg/1280px-Nemesis_at_Alton_Towers.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/29/Nemesis_loop.jpg/1280px-Nemesis_loop.jpg"
"https://images.unsplash.com/photo-1543674892-7d64d45df18b"
],
"stats": {
"height_ft": 43,
"length_ft": 2349,
"speed_mph": 50,
"height_ft": "43.00",
"length_ft": "2349.00",
"speed_mph": "50.00",
"inversions": 4,
"ride_time_seconds": 80
}
@@ -304,13 +283,12 @@
"manufacturer": "Bolliger & Mabillard",
"description": "The world's first vertical drop roller coaster.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Oblivion_at_Alton_Towers.jpg/1280px-Oblivion_at_Alton_Towers.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/Oblivion_vertical_drop.jpg/1280px-Oblivion_vertical_drop.jpg"
"https://images.unsplash.com/photo-1605559911160-a3d95d213904"
],
"stats": {
"height_ft": 65,
"length_ft": 1804,
"speed_mph": 68,
"height_ft": "65.00",
"length_ft": "1804.00",
"speed_mph": "68.00",
"inversions": 0,
"ride_time_seconds": 100
}
@@ -326,11 +304,9 @@
"description": "Germany's largest theme park, featuring European-themed areas.",
"website": "https://www.europapark.de",
"owner": "Mack Rides",
"size_acres": 235,
"size_acres": "235.00",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Europa-Park_entrance.jpg/1280px-Europa-Park_entrance.jpg",
"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"
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
],
"rides": [
{
@@ -341,13 +317,12 @@
"manufacturer": "Bolliger & Mabillard",
"description": "A hypercoaster with stunning views.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Silver_Star_at_Europa-Park.jpg/1280px-Silver_Star_at_Europa-Park.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Silver_Star_first_drop.jpg/1280px-Silver_Star_first_drop.jpg"
"https://images.unsplash.com/photo-1536768139911-e290a59011e4"
],
"stats": {
"height_ft": 239,
"length_ft": 4003,
"speed_mph": 79,
"height_ft": "239.00",
"length_ft": "4003.00",
"speed_mph": "79.00",
"inversions": 0,
"ride_time_seconds": 180
}
@@ -360,13 +335,12 @@
"manufacturer": "Mack Rides",
"description": "A launched roller coaster with multiple inversions.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Blue_Fire_at_Europa-Park.jpg/1280px-Blue_Fire_at_Europa-Park.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Blue_Fire_launch.jpg/1280px-Blue_Fire_launch.jpg"
"https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9"
],
"stats": {
"height_ft": 125,
"length_ft": 3465,
"speed_mph": 62,
"height_ft": "125.00",
"length_ft": "3465.00",
"speed_mph": "62.00",
"inversions": 4,
"ride_time_seconds": 150
}

View File

@@ -1,297 +1,216 @@
import os
import json
import random
import uuid
from datetime import datetime
import os
from django.core.management.base import BaseCommand
from django.contrib.auth.hashers import make_password
from django.core.files import File
from django.utils.text import slugify
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from faker import Faker
from django.core.files.temp import NamedTemporaryFile
from django.core.files import File
import requests
from io import BytesIO
from PIL import Image
from cities_light.models import City, Country
from parks.models import Park
from rides.models import Ride, RollerCoasterStats
from companies.models import Company, Manufacturer
from reviews.models import Review
from media.models import Photo
from accounts.models import User, UserProfile, TopList, TopListItem
from companies.models import Company, Manufacturer
from cities_light.models import Country, Region, City
from django.contrib.auth.models import Permission
import random
from datetime import datetime, timedelta
fake = Faker()
User = get_user_model()
class Command(BaseCommand):
help = 'Seeds the database with sample data'
help = 'Seeds the database with initial data'
def add_arguments(self, parser):
parser.add_argument('--users', type=int, default=50)
parser.add_argument('--reviews-per-item', type=int, default=10)
def handle(self, *args, **kwargs):
self.stdout.write('Starting database seed...')
# 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):
try:
response = requests.get(url)
img = Image.open(BytesIO(response.content))
img_io = BytesIO()
img.save(img_io, format='JPEG')
img_io.seek(0)
filename = url.split('/')[-1]
return filename, File(img_io)
except Exception as e:
self.stdout.write(self.style.WARNING(f'Failed to download image {url}: {str(e)}'))
return None, None
def setup_permissions(self):
"""Set up photo permissions for all users"""
self.stdout.write('Setting up photo permissions...')
# Get photo permissions
photo_content_type = ContentType.objects.get_for_model(Photo)
photo_permissions = Permission.objects.filter(content_type=photo_content_type)
# Update all users
users = User.objects.all()
for user in users:
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...')
users = []
# Try to get admin user
try:
# Get existing admin user
admin_user = User.objects.get(username='admin')
users.append(admin_user)
self.stdout.write('Added existing admin user')
admin = User.objects.get(username='admin')
self.stdout.write('Admin user exists, updating permissions...')
except User.DoesNotExist:
self.stdout.write(self.style.WARNING('Admin user not found, skipping...'))
# Create regular users using raw SQL
roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2
with connection.cursor() as cursor:
for _ in range(count):
# Create user
username = fake.user_name()
while User.objects.filter(username=username).exists():
username = fake.user_name()
user_id = str(uuid.uuid4())[:10]
cursor.execute("""
INSERT INTO accounts_user (
username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name,
last_name, role, is_banned, ban_reason,
theme_preference
) VALUES (
%s, %s, %s, false, false,
true, NOW(), %s, '', '',
%s, false, '', 'light'
) RETURNING id;
""", [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))
admin = User.objects.create_superuser('admin', 'admin@example.com', 'admin')
self.stdout.write('Created admin user')
# Create regular users
usernames = [
'destiny89', 'destiny97', 'thompsonchris', 'chriscohen', 'littlesharon',
'wrichardson', 'christophermiles', 'jacksonangela', 'jennifer71', 'smithemily',
'brandylong', 'milleranna', 'tlopez', 'fgriffith', 'mariah80',
'kendradavis', 'rosarioashley', 'camposkaitlyn', 'lisaherrera', 'riveratiffany',
'codytucker', 'cheyenne78', 'christinagreen', 'eric57', 'steinsuzanne',
'david95', 'rstewart', 'josephhaynes', 'umedina', 'tylerbryant',
'lcampos', 'shellyford', 'ksmith', 'qeverett', 'waguilar',
'zbrowning', 'yalexander', 'wallacewilliam', 'bsuarez', 'ismith',
'joyceosborne', 'garythomas', 'tlewis', 'robertgonzales', 'medinashannon',
'yhanson', 'howellmorgan', 'taylorsusan', 'barnold', 'bryan20'
]
for username in usernames:
if not User.objects.filter(username=username).exists():
User.objects.create_user(
username=username,
email=f'{username}@example.com',
password='password123'
)
self.stdout.write(f'Created user: {username}')
return users
def create_companies(self):
self.stdout.write('Creating companies...')
# Delete existing companies
Company.objects.all().delete()
self.stdout.write('Deleted existing companies')
companies = {
'The Walt Disney Company': {
'headquarters': 'Burbank, California',
'founded_date': '1923-10-16',
'website': 'https://www.disney.com',
},
'Cedar Fair': {
'headquarters': 'Sandusky, Ohio',
'founded_date': '1983-05-01',
'website': 'https://www.cedarfair.com',
},
'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
companies = [
'The Walt Disney Company',
'Cedar Fair',
'NBCUniversal',
'Merlin Entertainments',
'Mack Rides'
]
for name in companies:
Company.objects.create(name=name)
self.stdout.write(f'Created company: {name}')
return company_instances
def create_manufacturers(self):
self.stdout.write('Creating manufacturers...')
# Delete existing manufacturers
Manufacturer.objects.all().delete()
self.stdout.write('Deleted existing manufacturers')
manufacturers = {
'Walt Disney Imagineering': {
'headquarters': 'Glendale, California',
'founded_date': '1952-12-16',
'website': 'https://sites.disney.com/waltdisneyimagineering/',
},
'Bolliger & Mabillard': {
'headquarters': 'Monthey, Switzerland',
'founded_date': '1988-01-01',
'website': 'https://www.bolliger-mabillard.com',
},
'Intamin': {
'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
manufacturers = [
'Walt Disney Imagineering',
'Bolliger & Mabillard',
'Intamin',
'Rocky Mountain Construction',
'Vekoma',
'Mack Rides',
'Oceaneering International'
]
for name in manufacturers:
Manufacturer.objects.create(name=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):
self.stdout.write('Creating parks and rides from seed data...')
# 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)
def create_parks_and_rides(self):
# Delete existing parks and rides
Park.objects.all().delete()
self.stdout.write('Deleted existing parks and rides')
parks = []
for park_data in seed_data['parks']:
# Load seed data
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:
# Get country from cities_light
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
park = Park.objects.create(
name=park_data['name'],
slug=slugify(park_data['name']),
location=park_data['location'],
country=country,
city=city,
opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(),
opening_date=park_data['opening_date'],
status=park_data['status'],
description=park_data['description'],
website=park_data['website'],
owner=companies[park_data['owner']],
owner=Company.objects.get(name=park_data['owner']),
size_acres=park_data['size_acres']
)
# Add park photos
for photo_url in park_data.get('photos', []):
filename, file = self.download_and_save_image(photo_url)
if filename and file:
for photo_url in park_data['photos']:
img_file = self.download_image(photo_url)
if img_file:
Photo.objects.create(
content_object=park,
image=file,
uploaded_by=random.choice(users),
caption=f"Photo of {park.name}",
is_approved=True
image=img_file,
content_type=ContentType.objects.get_for_model(park),
object_id=park.id,
is_primary=True # First photo is primary
)
# Create rides for this park
# Create rides
for ride_data in park_data['rides']:
ride = Ride.objects.create(
name=ride_data['name'],
slug=slugify(ride_data['name']),
category=ride_data['category'],
park=park,
category=ride_data['category'],
opening_date=ride_data['opening_date'],
status=ride_data['status'],
opening_date=datetime.strptime(ride_data['opening_date'], '%Y-%m-%d').date(),
manufacturer=manufacturers[ride_data['manufacturer']],
manufacturer=Manufacturer.objects.get(name=ride_data['manufacturer']),
description=ride_data['description']
)
# Add roller coaster stats if applicable
if ride_data['category'] == 'RC' and 'stats' in ride_data:
# Add ride photos
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(
ride=ride,
height_ft=ride_data['stats']['height_ft'],
@@ -300,118 +219,67 @@ class Command(BaseCommand):
inversions=ride_data['stats']['inversions'],
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}')
except Exception as e:
self.stdout.write(self.style.ERROR(f'Failed to create park {park_data["name"]}: {str(e)}'))
except Country.DoesNotExist:
self.stdout.write(f'Country not found: {park_data["country"]}')
continue
return parks
def create_reviews(self, users, reviews_per_item):
self.stdout.write('Creating reviews...')
def create_reviews(self):
# Delete existing reviews
Review.objects.all().delete()
self.stdout.write('Deleted existing reviews')
# Park reviews
total_parks = Park.objects.count()
for i, park in enumerate(Park.objects.all(), 1):
for _ in range(random.randint(reviews_per_item - 5, reviews_per_item + 5)):
users = list(User.objects.exclude(username='admin'))
parks = list(Park.objects.all())
# 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(
user=random.choice(users),
content_object=park,
title=fake.sentence(),
content=fake.text(max_nb_chars=500),
rating=random.randint(1, 10),
visit_date=fake.date_between(start_date=park.opening_date, end_date='today'),
is_published=True
content_type=ContentType.objects.get_for_model(park),
object_id=park.id,
title=f'Great experience at {park.name}',
content='Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
rating=random.randint(7, 10),
visit_date=visit_date
)
if i % 5 == 0:
self.stdout.write(f'Created reviews for {i}/{total_parks} parks')
self.stdout.write(f'Created reviews for {park.name}')
# Ride reviews
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...')
def create_top_lists(self):
# Delete existing top lists
TopList.objects.all().delete()
# TopList.objects.all().delete()
self.stdout.write('Deleted existing top lists')
categories = ['RC', 'DR', 'FR', 'WR', 'PK']
total_users = len(users)
# Get content types
park_ct = ContentType.objects.get_for_model(Park)
ride_ct = ContentType.objects.get_for_model(Ride)
users = list(User.objects.exclude(username='admin'))
parks = list(Park.objects.all())
for i, user in enumerate(users, 1):
for category in categories:
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 ''
)
# Create top list for every 10th user
if i % 10 == 0:
self.stdout.write(f'Created top lists for {i}/{total_users} users')
def handle(self, *args, **options):
self.stdout.write('Starting database seed...')
users = self.create_users(options['users'])
parks = self.create_parks_and_rides(users)
self.create_reviews(users, options['reviews_per_item'])
self.create_top_lists(users)
self.stdout.write(self.style.SUCCESS('Successfully seeded database'))
# top_list = TopList.objects.create(
# user=user,
# name=f"{user.username}'s Top Parks",
# description='My favorite theme parks'
# )
# Add 3-5 random parks
# selected_parks = random.sample(parks, random.randint(3, 5))
# for j, park in enumerate(selected_parks, 1):
# TopListItem.objects.create(
# 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)
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)
region = models.ForeignKey(Region, 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 cities_light.models import Country, Region, City
def get_countries(request):
query = request.GET.get('q', '')
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
query = request.GET.get("q", "")
filter_parks = request.GET.get("filter_parks", "false") == "true"
# Base query
countries = Country.objects.filter(name__icontains=query)
# Only filter by parks if explicitly requested
if filter_parks:
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)
def get_regions(request):
query = request.GET.get('q', '')
country = request.GET.get('country', '')
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
query = request.GET.get("q", "")
country = request.GET.get("country", "")
filter_parks = request.GET.get("filter_parks", "false") == "true"
if not country:
return JsonResponse([], safe=False)
# Base query
regions = Region.objects.filter(
Q(name__icontains=query) | Q(alternate_names__icontains=query),
country__name__iexact=country
country__name__iexact=country,
)
# Only filter by parks if explicitly requested
if filter_parks:
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)
def get_cities(request):
query = request.GET.get('q', '')
region = request.GET.get('region', '')
country = request.GET.get('country', '')
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
query = request.GET.get("q", "")
region = request.GET.get("region", "")
country = request.GET.get("country", "")
filter_parks = request.GET.get("filter_parks", "false") == "true"
if not region or not country:
return JsonResponse([], safe=False)
# Base query
cities = City.objects.filter(
Q(name__icontains=query) | Q(alternate_names__icontains=query),
region__name__iexact=region,
region__country__name__iexact=country
region__country__name__iexact=country,
)
# Only filter by parks if explicitly requested
if filter_parks:
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)
class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park
form_class = ParkForm
template_name = 'parks/park_form.html'
template_name = "parks/park_form.html"
def prepare_changes_data(self, cleaned_data):
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get('owner'):
data['owner'] = data['owner'].id
if data.get('country'):
data['country'] = data['country'].id
if data.get('region'):
data['region'] = data['region'].id
if data.get('city'):
data['city'] = data['city'].id
if data.get("owner"):
data["owner"] = data["owner"].id
if data.get("country"):
data["country"] = data["country"].id
if data.get("region"):
data["region"] = data["region"].id
if data.get("city"):
data["city"] = data["city"].id
# Convert dates to ISO format strings
if data.get('opening_date'):
data['opening_date'] = data['opening_date'].isoformat()
if data.get('closing_date'):
data['closing_date'] = data['closing_date'].isoformat()
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
return data
def form_valid(self, form):
@@ -103,54 +107,55 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
submission_type='CREATE',
submission_type="CREATE",
changes=changes,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
reason=self.request.POST.get("reason", ""),
source=self.request.POST.get("source", ""),
)
# 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()
submission.object_id = self.object.id
submission.status = 'APPROVED'
submission.status = "APPROVED"
submission.handled_by = self.request.user
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())
messages.success(self.request, 'Your park submission has been sent for review')
return HttpResponseRedirect(reverse('parks:park_list'))
messages.success(self.request, "Your park submission has been sent for review")
return HttpResponseRedirect(reverse("parks:park_list"))
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):
model = Park
form_class = ParkForm
template_name = 'parks/park_form.html'
template_name = "parks/park_form.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_edit'] = True
context["is_edit"] = True
return context
def prepare_changes_data(self, cleaned_data):
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get('owner'):
data['owner'] = data['owner'].id
if data.get('country'):
data['country'] = data['country'].id
if data.get('region'):
data['region'] = data['region'].id
if data.get('city'):
data['city'] = data['city'].id
if data.get("owner"):
data["owner"] = data["owner"].id
if data.get("country"):
data["country"] = data["country"].id
if data.get("region"):
data["region"] = data["region"].id
if data.get("city"):
data["city"] = data["city"].id
# Convert dates to ISO format strings
if data.get('opening_date'):
data['opening_date'] = data['opening_date'].isoformat()
if data.get('closing_date'):
data['closing_date'] = data['closing_date'].isoformat()
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
return data
def form_valid(self, form):
@@ -161,31 +166,43 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
submission_type='EDIT',
submission_type="EDIT",
changes=changes,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
reason=self.request.POST.get("reason", ""),
source=self.request.POST.get("source", ""),
)
# 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()
submission.status = 'APPROVED'
submission.status = "APPROVED"
submission.handled_by = self.request.user
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())
messages.success(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}))
messages.success(
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):
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
template_name = 'parks/park_detail.html'
context_object_name = 'park'
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset=None):
if queryset is None:
@@ -196,26 +213,33 @@ class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixi
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['rides'] = Ride.objects.filter(
park=self.object
).select_related('coaster_stats')
context['areas'] = ParkArea.objects.filter(park=self.object)
context["rides"] = Ride.objects.filter(park=self.object).select_related(
"coaster_stats"
)
context["areas"] = ParkArea.objects.filter(park=self.object)
return context
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
template_name = 'parks/area_detail.html'
context_object_name = 'area'
slug_url_kwarg = 'area_slug'
template_name = "parks/area_detail.html"
context_object_name = "area"
slug_url_kwarg = "area_slug"
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
park_slug = self.kwargs.get('park_slug')
area_slug = self.kwargs.get('area_slug')
park_slug = self.kwargs.get("park_slug")
area_slug = self.kwargs.get("area_slug")
# Try to get by current or historical slug
obj, is_old_slug = self.model.get_by_slug(area_slug)
if obj.park.slug != park_slug:
@@ -224,62 +248,61 @@ class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmission
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['rides'] = Ride.objects.filter(
area=self.object
).select_related('coaster_stats')
context["rides"] = Ride.objects.filter(area=self.object).select_related(
"coaster_stats"
)
return context
def get_redirect_url_pattern(self):
return 'parks:park_detail'
return "parks:park_detail"
def get_redirect_url_kwargs(self):
return {
'park_slug': self.object.park.slug,
'area_slug': self.object.slug
}
return {"park_slug": self.object.park.slug, "area_slug": self.object.slug}
class ParkListView(ListView):
model = Park
template_name = 'parks/park_list.html'
context_object_name = 'parks'
template_name = "parks/park_list.html"
context_object_name = "parks"
def get_queryset(self):
queryset = Park.objects.select_related('owner', 'country', 'region', 'city').prefetch_related('photos', 'rides')
search = self.request.GET.get('search', '').strip()
country = self.request.GET.get('country', '').strip()
region = self.request.GET.get('region', '').strip()
city = self.request.GET.get('city', '').strip()
statuses = self.request.GET.getlist('status')
queryset = Park.objects.select_related(
"owner", "country", "region", "city"
).prefetch_related("photos", "rides")
search = self.request.GET.get("search", "").strip()
country = self.request.GET.get("country", "").strip()
region = self.request.GET.get("region", "").strip()
city = self.request.GET.get("city", "").strip()
statuses = self.request.GET.getlist("status")
if search:
queryset = queryset.filter(
Q(name__icontains=search) |
Q(location__icontains=search)
Q(name__icontains=search) | Q(location__icontains=search)
)
if country:
queryset = queryset.filter(country__name__icontains=country)
if region:
queryset = queryset.filter(region__name__icontains=region)
if city:
queryset = queryset.filter(city__name__icontains=city)
if statuses:
queryset = queryset.filter(status__in=statuses)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['current_filters'] = {
'search': self.request.GET.get('search', ''),
'country': self.request.GET.get('country', ''),
'region': self.request.GET.get('region', ''),
'city': self.request.GET.get('city', ''),
'statuses': self.request.GET.getlist('status')
context["current_filters"] = {
"search": self.request.GET.get("search", ""),
"country": self.request.GET.get("country", ""),
"region": self.request.GET.get("region", ""),
"city": self.request.GET.get("city", ""),
"statuses": self.request.GET.getlist("status"),
}
return context
@@ -287,5 +310,5 @@ class ParkListView(ListView):
# Check if this is an HTMX request
if request.htmx:
# 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)

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);
}
#mobileMenu.show {
max-height: 300px;
opacity: 1;
}
#mobileMenu .space-y-4 {
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-btn i::before {
@@ -2149,18 +2177,42 @@ select {
position: sticky;
}
.inset-0 {
inset: 0px;
}
.bottom-4 {
bottom: 1rem;
}
.left-0 {
left: 0px;
}
.right-0 {
right: 0px;
}
.right-4 {
right: 1rem;
}
.top-0 {
top: 0px;
}
.top-4 {
top: 1rem;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.z-40 {
z-index: 40;
}
@@ -2181,6 +2233,16 @@ select {
grid-column: 1 / -1;
}
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.mx-8 {
margin-left: 2rem;
margin-right: 2rem;
@@ -2307,6 +2369,14 @@ select {
height: 4rem;
}
.h-2 {
height: 0.5rem;
}
.h-2\.5 {
height: 0.625rem;
}
.h-24 {
height: 6rem;
}
@@ -2331,6 +2401,10 @@ select {
max-height: 15rem;
}
.max-h-\[90vh\] {
max-height: 90vh;
}
.min-h-\[calc\(100vh-16rem\)\] {
min-height: calc(100vh - 16rem);
}
@@ -2355,18 +2429,39 @@ select {
width: 1.25rem;
}
.w-64 {
width: 16rem;
}
.w-8 {
width: 2rem;
}
.w-auto {
width: auto;
}
.w-fit {
width: -moz-fit-content;
width: fit-content;
}
.w-full {
width: 100%;
}
.max-w-2xl {
max-width: 42rem;
}
.max-w-3xl {
max-width: 48rem;
}
.max-w-7xl {
max-width: 80rem;
}
.max-w-lg {
max-width: 32rem;
}
@@ -2387,6 +2482,21 @@ select {
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 {
--tw-scale-x: 1;
--tw-scale-y: 1;
@@ -2419,6 +2529,10 @@ select {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
@@ -2603,6 +2717,15 @@ select {
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 {
--tw-bg-opacity: 1;
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));
}
.bg-white\/10 {
background-color: rgb(255 255 255 / 0.1);
}
.bg-white\/90 {
background-color: rgb(255 255 255 / 0.9);
}
@@ -2682,6 +2809,19 @@ select {
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 {
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));
}
.text-yellow-600 {
--tw-text-opacity: 1;
color: rgb(202 138 4 / var(--tw-text-opacity));
}
.text-yellow-800 {
--tw-text-opacity: 1;
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);
}
.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 {
--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);
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 {
--tw-ring-opacity: 1;
--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: 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-opacity {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-transform {
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -3051,6 +3208,14 @@ select {
transition-duration: 100ms;
}
.duration-200 {
transition-duration: 200ms;
}
.duration-300 {
transition-duration: 300ms;
}
.duration-75 {
transition-duration: 75ms;
}
@@ -3134,11 +3299,25 @@ select {
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 {
--tw-bg-opacity: 1;
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 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
@@ -3154,11 +3333,21 @@ select {
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 {
--tw-text-opacity: 1;
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 {
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
@@ -3205,6 +3394,16 @@ select {
--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 *) {
--tw-border-opacity: 1;
border-color: rgb(29 78 216 / var(--tw-border-opacity));
@@ -3275,6 +3474,11 @@ select {
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 *) {
--tw-bg-opacity: 1;
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));
}
.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 *) {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
@@ -3364,11 +3573,26 @@ select {
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 *) {
--tw-text-opacity: 1;
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 *) {
--tw-text-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity));
@@ -3384,6 +3608,16 @@ select {
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 *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -3399,6 +3633,11 @@ select {
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 *) {
--tw-text-opacity: 1;
color: rgb(254 252 232 / var(--tw-text-opacity));
@@ -3448,6 +3687,10 @@ select {
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 *) {
--tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity));
@@ -3458,6 +3701,11 @@ select {
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 *) {
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
@@ -3546,10 +3794,6 @@ select {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.lg\:grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.lg\:text-6xl {
font-size: 3.75rem;
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">
<i class="mr-2 fas fa-edit"></i>Edit
</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 %}
</div>
</div>
@@ -47,6 +52,14 @@
</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 -->
<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">
@@ -258,23 +271,26 @@
{% endfor %}
</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>
<!-- 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 %}

View File

@@ -6,7 +6,8 @@
{% block content %}
<div class="container px-4 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>
{% if form.errors %}
@@ -195,6 +196,13 @@
</div>
</form>
</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>
{% endblock %}

View File

@@ -40,27 +40,23 @@
{% if user.is_authenticated %}
<div class="flex gap-2">
<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>
Edit
<i class="mr-2 fas fa-edit"></i>Edit
</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>
{% endif %}
</div>
</div>
<!-- Photos Grid -->
<!-- Photos -->
{% 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>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{% 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>
{% include "media/partials/photo_display.html" with photos=ride.photos.all content_type="rides.ride" object_id=ride.id %}
</div>
{% endif %}
@@ -68,12 +64,14 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column - Description and Details -->
<div class="lg:col-span-2">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
<div class="prose dark:prose-invert max-w-none">
{{ ride.description|linebreaks }}
{% if ride.description %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
<div class="prose dark:prose-invert max-w-none">
{{ ride.description|linebreaks }}
</div>
</div>
</div>
{% endif %}
{% if ride.previous_names %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
@@ -220,22 +218,6 @@
{% endfor %}
</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>
@@ -276,4 +258,23 @@
{% endif %}
</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 %}

View File

@@ -6,7 +6,8 @@
{% block content %}
<div class="container px-4 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">
<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">
@@ -95,6 +96,13 @@
</div>
</form>
</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>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends 'base/base.html' %}
{% load static %}
{% load ride_tags %}
{% block title %}
{% 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">
{% 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">
{% 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 }}"
alt="{{ ride.name }}"
class="object-cover w-full">
</div>
{% endif %}
{% else %}
<img src="{% get_ride_placeholder_image ride.category %}"
alt="{{ ride.name }}"
class="object-cover w-full">
{% endif %}
</div>
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<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.staticfiles",
"django.contrib.sites",
# Third-party apps
"allauth",
"allauth.account",
"allauth.socialaccount",
@@ -38,7 +37,6 @@ INSTALLED_APPS = [
"whitenoise",
"django_tailwind_cli",
"cities_light",
# Local apps
"core",
"accounts",
"companies",
@@ -46,7 +44,7 @@ INSTALLED_APPS = [
"rides",
"reviews",
"email_service",
"media",
"media.apps.MediaConfig", # Add media app
"moderation",
]

View File

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