photos fix
@@ -0,0 +1 @@
|
||||
default_app_config = 'media.apps.MediaConfig'
|
||||
|
||||
@@ -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)
|
||||
|
||||
114
media/management/commands/download_photos.py
Normal 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')
|
||||
58
media/management/commands/fix_photo_paths.py
Normal 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')
|
||||
116
media/management/commands/move_photos.py
Normal 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")
|
||||
26
media/migrations/0002_photo_uploaded_by.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
69
media/migrations/0003_update_photo_field_and_normalize.py
Normal 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),
|
||||
]
|
||||
10
media/migrations/0004_update_photo_paths.py
Normal 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
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
BIN
media/park/alton-towers/alton-towers_1.jpg
Normal file
|
After Width: | Height: | Size: 12 MiB |
BIN
media/park/alton-towers/nemesis/nemesis_1.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
media/park/alton-towers/oblivion/oblivion_1.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
media/park/cedar-point/cedar-point_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
media/park/cedar-point/maverick/maverick_1.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
media/park/cedar-point/millennium-force/millennium-force_1.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
media/park/cedar-point/steel-vengeance/steel-vengeance_1.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
BIN
media/park/europa-park/blue-fire/blue-fire_1.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
media/park/europa-park/europa-park_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
media/park/europa-park/silver-star/silver-star_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 12 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 770 KiB |
38
media/storage.py
Normal 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
|
||||
18
media/templatetags/json_filters.py
Normal 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
11
media/urls.py
Normal 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
@@ -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)
|
||||
@@ -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.[AWS-SECRET-REMOVED]_Mansion_at_Magic_Kingdom.jpg/1280px-Haunted_Mansion_at_Magic_Kingdom.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_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.[AWS-SECRET-REMOVED]_of_the_Caribbean_%28Magic_Kingdom%29.jpg/1280px-Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_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.[AWS-SECRET-REMOVED]ium_Force_at_Cedar_Point.jpg/1280px-Millennium_Force_at_Cedar_Point.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_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.[AWS-SECRET-REMOVED]k_at_Cedar_Point.jpg/1280px-Maverick_at_Cedar_Point.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]k_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.[AWS-SECRET-REMOVED]_of_Adventure_entrance.jpg/1280px-Islands_of_Adventure_entrance.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]s_Castle_at_Universal%27s_Islands_of_Adventure.jpg/1280px-Hogwarts_Castle_at_Universal%27s_Islands_of_Adventure.jpg",
|
||||
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Port_of_Entry_at_Islands_of_Adventure.jpg/1280px-Port_of_Entry_at_Islands_of_Adventure.jpg"
|
||||
"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.[AWS-SECRET-REMOVED]c_World_VelociCoaster.jpg/1280px-Jurassic_World_VelociCoaster.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]oaster_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.[AWS-SECRET-REMOVED]_at_Alton_Towers.jpg/1280px-Nemesis_at_Alton_Towers.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_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.[AWS-SECRET-REMOVED]n_at_Alton_Towers.jpg/1280px-Oblivion_at_Alton_Towers.jpg",
|
||||
"https://upload.wikimedia.[AWS-SECRET-REMOVED]n_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
|
||||
}
|
||||
|
||||
@@ -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...')
|
||||
|
||||
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
|
||||
# Create users and set permissions
|
||||
self.create_users()
|
||||
self.setup_permissions()
|
||||
|
||||
def create_users(self, count):
|
||||
# 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 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):
|
||||
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...'))
|
||||
admin = User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
||||
self.stdout.write('Created admin user')
|
||||
|
||||
# Create regular users using raw SQL
|
||||
roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2
|
||||
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()
|
||||
# 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'
|
||||
]
|
||||
|
||||
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))
|
||||
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',
|
||||
},
|
||||
}
|
||||
companies = [
|
||||
'The Walt Disney Company',
|
||||
'Cedar Fair',
|
||||
'NBCUniversal',
|
||||
'Merlin Entertainments',
|
||||
'Mack Rides'
|
||||
]
|
||||
|
||||
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
|
||||
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',
|
||||
},
|
||||
}
|
||||
manufacturers = [
|
||||
'Walt Disney Imagineering',
|
||||
'Bolliger & Mabillard',
|
||||
'Intamin',
|
||||
'Rocky Mountain Construction',
|
||||
'Vekoma',
|
||||
'Mack Rides',
|
||||
'Oceaneering International'
|
||||
]
|
||||
|
||||
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
|
||||
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']:
|
||||
try:
|
||||
# Get country from cities_light
|
||||
country = Country.objects.get(code2=park_data['country'])
|
||||
# Load seed data
|
||||
with open(os.path.join(os.path.dirname(__file__), 'seed_data.json')) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 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'))
|
||||
country_map = {
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'DE': 'Germany'
|
||||
}
|
||||
|
||||
for park_data in data['parks']:
|
||||
try:
|
||||
country = Country.objects.get(code2=park_data['country'])
|
||||
|
||||
# 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'],
|
||||
@@ -301,117 +220,66 @@ class Command(BaseCommand):
|
||||
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')
|
||||
# top_list = TopList.objects.create(
|
||||
# user=user,
|
||||
# name=f"{user.username}'s Top Parks",
|
||||
# description='My favorite theme parks'
|
||||
# )
|
||||
|
||||
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'))
|
||||
# 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')
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
219
parks/views.py
@@ -15,9 +15,10 @@ 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)
|
||||
@@ -26,13 +27,14 @@ def get_countries(request):
|
||||
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)
|
||||
@@ -40,21 +42,22 @@ def get_regions(request):
|
||||
# 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)
|
||||
@@ -63,37 +66,38 @@ def get_cities(request):
|
||||
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,38 +248,37 @@ 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')
|
||||
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')
|
||||
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:
|
||||
@@ -274,12 +297,12 @@ class ParkListView(ListView):
|
||||
|
||||
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)
|
||||
|
||||
0
rides/templatetags/__init__.py
Normal file
24
rides/templatetags/ride_tags.py
Normal 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")
|
||||
@@ -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;
|
||||
|
||||
0
static/images/placeholders/dark-ride.jpg
Normal file
0
static/images/placeholders/default-park.jpg
Normal file
0
static/images/placeholders/default-ride.jpg
Normal file
0
static/images/placeholders/flat-ride.jpg
Normal file
0
static/images/placeholders/other-ride.jpg
Normal file
0
static/images/placeholders/roller-coaster.jpg
Normal file
0
static/images/placeholders/transport.jpg
Normal file
0
static/images/placeholders/water-ride.jpg
Normal file
213
templates/media/partials/photo_display.html
Normal 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>
|
||||
257
templates/media/partials/photo_manager.html
Normal 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>
|
||||
270
templates/media/partials/photo_upload.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
BIN
uploads/park/alton-towers/alton-towers_1.jpg
Normal file
|
After Width: | Height: | Size: 12 MiB |
BIN
uploads/park/cedar-point/cedar-point_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
uploads/park/europa-park/europa-park_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 770 KiB |
|
After Width: | Height: | Size: 3.2 MiB |
BIN
uploads/ride/blue-fire/blue-fire_1.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
BIN
uploads/ride/haunted-mansion/haunted-mansion_1.jpg
Normal file
|
After Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 3.9 MiB |
BIN
uploads/ride/maverick/maverick_1.jpg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
uploads/ride/millennium-force/millennium-force_1.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
uploads/ride/nemesis/nemesis_1.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
uploads/ride/oblivion/oblivion_1.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 12 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
BIN
uploads/ride/silver-star/silver-star_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
uploads/ride/space-mountain/space-mountain_1.jpg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
uploads/ride/steel-vengeance/steel-vengeance_1.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
BIN
uploads/ride/top-thrill-dragster/top-thrill-dragster_1.jpg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |