diff --git a/media/__init__.py b/media/__init__.py index e69de29b..f3d9df4a 100644 --- a/media/__init__.py +++ b/media/__init__.py @@ -0,0 +1 @@ +default_app_config = 'media.apps.MediaConfig' diff --git a/media/__pycache__/__init__.cpython-312.pyc b/media/__pycache__/__init__.cpython-312.pyc index 6181a694..b3baaccb 100644 Binary files a/media/__pycache__/__init__.cpython-312.pyc and b/media/__pycache__/__init__.cpython-312.pyc differ diff --git a/media/__pycache__/apps.cpython-312.pyc b/media/__pycache__/apps.cpython-312.pyc index c5ca24cd..4bd20a86 100644 Binary files a/media/__pycache__/apps.cpython-312.pyc and b/media/__pycache__/apps.cpython-312.pyc differ diff --git a/media/__pycache__/models.cpython-312.pyc b/media/__pycache__/models.cpython-312.pyc index 0153d74b..9fe60ce2 100644 Binary files a/media/__pycache__/models.cpython-312.pyc and b/media/__pycache__/models.cpython-312.pyc differ diff --git a/media/apps.py b/media/apps.py index 19faaa66..2681a9e3 100644 --- a/media/apps.py +++ b/media/apps.py @@ -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) diff --git a/media/management/commands/download_photos.py b/media/management/commands/download_photos.py new file mode 100644 index 00000000..18d51a76 --- /dev/null +++ b/media/management/commands/download_photos.py @@ -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') diff --git a/media/management/commands/fix_photo_paths.py b/media/management/commands/fix_photo_paths.py new file mode 100644 index 00000000..ae96fd3a --- /dev/null +++ b/media/management/commands/fix_photo_paths.py @@ -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') diff --git a/media/management/commands/move_photos.py b/media/management/commands/move_photos.py new file mode 100644 index 00000000..1368f22d --- /dev/null +++ b/media/management/commands/move_photos.py @@ -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") diff --git a/media/migrations/0002_photo_uploaded_by.py b/media/migrations/0002_photo_uploaded_by.py new file mode 100644 index 00000000..b5dfbd19 --- /dev/null +++ b/media/migrations/0002_photo_uploaded_by.py @@ -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, + ), + ), + ] diff --git a/media/migrations/0003_update_photo_field_and_normalize.py b/media/migrations/0003_update_photo_field_and_normalize.py new file mode 100644 index 00000000..6e4d20eb --- /dev/null +++ b/media/migrations/0003_update_photo_field_and_normalize.py @@ -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), + ] diff --git a/media/migrations/0004_update_photo_paths.py b/media/migrations/0004_update_photo_paths.py new file mode 100644 index 00000000..449b796b --- /dev/null +++ b/media/migrations/0004_update_photo_paths.py @@ -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 + ] diff --git a/media/models.py b/media/models.py index d56ba2b7..11ccb1f8 100644 --- a/media/models.py +++ b/media/models.py @@ -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) diff --git a/media/park/alton-towers/alton-towers_1.jpg b/media/park/alton-towers/alton-towers_1.jpg new file mode 100644 index 00000000..26b135bb Binary files /dev/null and b/media/park/alton-towers/alton-towers_1.jpg differ diff --git a/media/park/alton-towers/nemesis/nemesis_1.jpg b/media/park/alton-towers/nemesis/nemesis_1.jpg new file mode 100644 index 00000000..1f063457 Binary files /dev/null and b/media/park/alton-towers/nemesis/nemesis_1.jpg differ diff --git a/media/park/alton-towers/oblivion/oblivion_1.jpg b/media/park/alton-towers/oblivion/oblivion_1.jpg new file mode 100644 index 00000000..affc9604 Binary files /dev/null and b/media/park/alton-towers/oblivion/oblivion_1.jpg differ diff --git a/media/park/cedar-point/cedar-point_1.jpg b/media/park/cedar-point/cedar-point_1.jpg new file mode 100644 index 00000000..746c342a Binary files /dev/null and b/media/park/cedar-point/cedar-point_1.jpg differ diff --git a/media/park/cedar-point/maverick/maverick_1.jpg b/media/park/cedar-point/maverick/maverick_1.jpg new file mode 100644 index 00000000..a2ffa77c Binary files /dev/null and b/media/park/cedar-point/maverick/maverick_1.jpg differ diff --git a/media/park/cedar-point/millennium-force/millennium-force_1.jpg b/media/park/cedar-point/millennium-force/millennium-force_1.jpg new file mode 100644 index 00000000..affc9604 Binary files /dev/null and b/media/park/cedar-point/millennium-force/millennium-force_1.jpg differ diff --git a/media/park/cedar-point/steel-vengeance/steel-vengeance_1.jpg b/media/park/cedar-point/steel-vengeance/steel-vengeance_1.jpg new file mode 100644 index 00000000..1f063457 Binary files /dev/null and b/media/park/cedar-point/steel-vengeance/steel-vengeance_1.jpg differ diff --git a/media/park/cedar-point/top-thrill-dragster/top-thrill-dragster_1.jpg b/media/park/cedar-point/top-thrill-dragster/top-thrill-dragster_1.jpg new file mode 100644 index 00000000..d1ecd015 Binary files /dev/null and b/media/park/cedar-point/top-thrill-dragster/top-thrill-dragster_1.jpg differ diff --git a/media/park/europa-park/blue-fire/blue-fire_1.jpg b/media/park/europa-park/blue-fire/blue-fire_1.jpg new file mode 100644 index 00000000..4f6f9881 Binary files /dev/null and b/media/park/europa-park/blue-fire/blue-fire_1.jpg differ diff --git a/media/park/europa-park/europa-park_1.jpg b/media/park/europa-park/europa-park_1.jpg new file mode 100644 index 00000000..746c342a Binary files /dev/null and b/media/park/europa-park/europa-park_1.jpg differ diff --git a/media/park/europa-park/silver-star/silver-star_1.jpg b/media/park/europa-park/silver-star/silver-star_1.jpg new file mode 100644 index 00000000..746c342a Binary files /dev/null and b/media/park/europa-park/silver-star/silver-star_1.jpg differ diff --git a/media/park/universals-islands-of-adventure/hagrids-magical-creatures-motorbike-adventure/hagrids-magical-creatures-motorbike-adventure_1.jpg b/media/park/universals-islands-of-adventure/hagrids-magical-creatures-motorbike-adventure/hagrids-magical-creatures-motorbike-adventure_1.jpg new file mode 100644 index 00000000..4f6f9881 Binary files /dev/null and b/media/park/universals-islands-of-adventure/hagrids-magical-creatures-motorbike-adventure/hagrids-magical-creatures-motorbike-adventure_1.jpg differ diff --git a/media/park/universals-islands-of-adventure/jurassic-world-velocicoaster/jurassic-world-velocicoaster_1.jpg b/media/park/universals-islands-of-adventure/jurassic-world-velocicoaster/jurassic-world-velocicoaster_1.jpg new file mode 100644 index 00000000..746c342a Binary files /dev/null and b/media/park/universals-islands-of-adventure/jurassic-world-velocicoaster/jurassic-world-velocicoaster_1.jpg differ diff --git a/media/park/universals-islands-of-adventure/the-amazing-adventures-of-spider-man/the-amazing-adventures-of-spider-man_1.jpg b/media/park/universals-islands-of-adventure/the-amazing-adventures-of-spider-man/the-amazing-adventures-of-spider-man_1.jpg new file mode 100644 index 00000000..0214ece4 Binary files /dev/null and b/media/park/universals-islands-of-adventure/the-amazing-adventures-of-spider-man/the-amazing-adventures-of-spider-man_1.jpg differ diff --git a/media/park/universals-islands-of-adventure/universals-islands-of-adventure_1.jpg b/media/park/universals-islands-of-adventure/universals-islands-of-adventure_1.jpg new file mode 100644 index 00000000..75b5ec69 Binary files /dev/null and b/media/park/universals-islands-of-adventure/universals-islands-of-adventure_1.jpg differ diff --git a/media/park/walt-disney-world-magic-kingdom/big-thunder-mountain-railroad/big-thunder-mountain-railroad_1.jpg b/media/park/walt-disney-world-magic-kingdom/big-thunder-mountain-railroad/big-thunder-mountain-railroad_1.jpg new file mode 100644 index 00000000..4f6f9881 Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/big-thunder-mountain-railroad/big-thunder-mountain-railroad_1.jpg differ diff --git a/media/park/walt-disney-world-magic-kingdom/haunted-mansion/haunted-mansion_1.jpg b/media/park/walt-disney-world-magic-kingdom/haunted-mansion/haunted-mansion_1.jpg new file mode 100644 index 00000000..75b5ec69 Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/haunted-mansion/haunted-mansion_1.jpg differ diff --git a/media/park/walt-disney-world-magic-kingdom/pirates-of-the-caribbean/pirates-of-the-caribbean_1.jpg b/media/park/walt-disney-world-magic-kingdom/pirates-of-the-caribbean/pirates-of-the-caribbean_1.jpg new file mode 100644 index 00000000..26b135bb Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/pirates-of-the-caribbean/pirates-of-the-caribbean_1.jpg differ diff --git a/media/park/walt-disney-world-magic-kingdom/seven-dwarfs-mine-train/seven-dwarfs-mine-train_1.jpg b/media/park/walt-disney-world-magic-kingdom/seven-dwarfs-mine-train/seven-dwarfs-mine-train_1.jpg new file mode 100644 index 00000000..0214ece4 Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/seven-dwarfs-mine-train/seven-dwarfs-mine-train_1.jpg differ diff --git a/media/park/walt-disney-world-magic-kingdom/space-mountain/space-mountain_1.jpg b/media/park/walt-disney-world-magic-kingdom/space-mountain/space-mountain_1.jpg new file mode 100644 index 00000000..746c342a Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/space-mountain/space-mountain_1.jpg differ diff --git a/media/park/walt-disney-world-magic-kingdom/walt-disney-world-magic-kingdom_1.jpg b/media/park/walt-disney-world-magic-kingdom/walt-disney-world-magic-kingdom_1.jpg new file mode 100644 index 00000000..d3e26686 Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/walt-disney-world-magic-kingdom_1.jpg differ diff --git a/media/storage.py b/media/storage.py new file mode 100644 index 00000000..0069280f --- /dev/null +++ b/media/storage.py @@ -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 diff --git a/media/templatetags/json_filters.py b/media/templatetags/json_filters.py new file mode 100644 index 00000000..7a8a21c2 --- /dev/null +++ b/media/templatetags/json_filters.py @@ -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) diff --git a/media/test.txt b/media/test.txt new file mode 100644 index 00000000..e69de29b diff --git a/media/urls.py b/media/urls.py new file mode 100644 index 00000000..a7fe5f0f --- /dev/null +++ b/media/urls.py @@ -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//', views.delete_photo, name='delete'), # Updated to match frontend + path('upload//primary/', views.set_primary_photo, name='set_primary'), + path('upload//caption/', views.update_caption, name='update_caption'), +] diff --git a/media/views.py b/media/views.py new file mode 100644 index 00000000..ede6ffdc --- /dev/null +++ b/media/views.py @@ -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) diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index a4b34985..edd65de3 100644 Binary files a/parks/__pycache__/models.cpython-312.pyc and b/parks/__pycache__/models.cpython-312.pyc differ diff --git a/parks/__pycache__/views.cpython-312.pyc b/parks/__pycache__/views.cpython-312.pyc index d802516b..23a0012b 100644 Binary files a/parks/__pycache__/views.cpython-312.pyc and b/parks/__pycache__/views.cpython-312.pyc differ diff --git a/parks/management/commands/seed_data.json b/parks/management/commands/seed_data.json index 770efe10..6eb95571 100644 --- a/parks/management/commands/seed_data.json +++ b/parks/management/commands/seed_data.json @@ -9,11 +9,9 @@ "description": "The most visited theme park in the world, Magic Kingdom is Walt Disney World's first theme park.", "website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/", "owner": "The Walt Disney Company", - "size_acres": 142, + "size_acres": "142.00", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg/1280px-Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Magic_Kingdom_Main_Street_USA_Panorama.jpg/1280px-Magic_Kingdom_Main_Street_USA_Panorama.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg/1280px-Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg" + "https://images.unsplash.com/photo-1524008279394-3aed4643b30b" ], "rides": [ { @@ -24,13 +22,12 @@ "manufacturer": "Walt Disney Imagineering", "description": "A high-speed roller coaster in the dark through space.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Magic_Kingdom_Space_Mountain.jpg/1280px-Magic_Kingdom_Space_Mountain.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Space_Mountain_%28Magic_Kingdom%29_entrance.jpg/1280px-Space_Mountain_%28Magic_Kingdom%29_entrance.jpg" + "https://images.unsplash.com/photo-1536768139911-e290a59011e4" ], "stats": { - "height_ft": 183, - "length_ft": 3196, - "speed_mph": 27, + "height_ft": "183.00", + "length_ft": "3196.00", + "speed_mph": "27.00", "inversions": 0, "ride_time_seconds": 180 } @@ -43,13 +40,12 @@ "manufacturer": "Walt Disney Imagineering", "description": "A mine train roller coaster through the Old West.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg/1280px-Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg/1280px-Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg" + "https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9" ], "stats": { - "height_ft": 104, - "length_ft": 2671, - "speed_mph": 30, + "height_ft": "104.00", + "length_ft": "2671.00", + "speed_mph": "30.00", "inversions": 0, "ride_time_seconds": 197 } @@ -62,13 +58,12 @@ "manufacturer": "Vekoma", "description": "A family roller coaster featuring unique swinging cars.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg/1280px-Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Seven_Dwarfs_Mine_Train_drop.jpg/1280px-Seven_Dwarfs_Mine_Train_drop.jpg" + "https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f" ], "stats": { - "height_ft": 112, - "length_ft": 2000, - "speed_mph": 34, + "height_ft": "112.00", + "length_ft": "2000.00", + "speed_mph": "34.00", "inversions": 0, "ride_time_seconds": 180 } @@ -81,8 +76,7 @@ "manufacturer": "Walt Disney Imagineering", "description": "A dark ride through a haunted estate.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Haunted_Mansion_at_Magic_Kingdom.jpg/1280px-Haunted_Mansion_at_Magic_Kingdom.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Haunted_Mansion_entrance.jpg/1280px-Haunted_Mansion_entrance.jpg" + "https://images.unsplash.com/photo-1597466599360-3b9775841aec" ] }, { @@ -93,8 +87,7 @@ "manufacturer": "Walt Disney Imagineering", "description": "A boat ride through pirate-filled Caribbean waters.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg/1280px-Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Pirates_of_the_Caribbean_entrance.jpg/1280px-Pirates_of_the_Caribbean_entrance.jpg" + "https://images.unsplash.com/photo-1506126799754-92bc47fc5d78" ] } ] @@ -108,11 +101,9 @@ "description": "Known as the Roller Coaster Capital of the World.", "website": "https://www.cedarpoint.com", "owner": "Cedar Fair", - "size_acres": 364, + "size_acres": "364.00", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Cedar_Point_aerial_view.jpg/1280px-Cedar_Point_aerial_view.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Cedar_Point_Beach.jpg/1280px-Cedar_Point_Beach.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cedar_Point_at_dusk.jpg/1280px-Cedar_Point_at_dusk.jpg" + "https://images.unsplash.com/photo-1536768139911-e290a59011e4" ], "rides": [ { @@ -123,13 +114,12 @@ "manufacturer": "Rocky Mountain Construction", "description": "A hybrid roller coaster featuring multiple inversions.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Steel_Vengeance_at_Cedar_Point.jpg/1280px-Steel_Vengeance_at_Cedar_Point.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Steel_Vengeance_first_drop.jpg/1280px-Steel_Vengeance_first_drop.jpg" + "https://images.unsplash.com/photo-1543674892-7d64d45df18b" ], "stats": { - "height_ft": 205, - "length_ft": 5740, - "speed_mph": 74, + "height_ft": "205.00", + "length_ft": "5740.00", + "speed_mph": "74.00", "inversions": 4, "ride_time_seconds": 150 } @@ -142,13 +132,12 @@ "manufacturer": "Intamin", "description": "A giga coaster with stunning views of Lake Erie.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Millennium_Force_at_Cedar_Point.jpg/1280px-Millennium_Force_at_Cedar_Point.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Millennium_Force_lift_hill.jpg/1280px-Millennium_Force_lift_hill.jpg" + "https://images.unsplash.com/photo-1605559911160-a3d95d213904" ], "stats": { - "height_ft": 310, - "length_ft": 6595, - "speed_mph": 93, + "height_ft": "310.00", + "length_ft": "6595.00", + "speed_mph": "93.00", "inversions": 0, "ride_time_seconds": 120 } @@ -161,13 +150,12 @@ "manufacturer": "Intamin", "description": "A strata coaster featuring a 420-foot top hat element.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Top_Thrill_Dragster.jpg/1280px-Top_Thrill_Dragster.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Top_Thrill_Dragster_launch.jpg/1280px-Top_Thrill_Dragster_launch.jpg" + "https://images.unsplash.com/photo-1578912996078-305d92249aa6" ], "stats": { - "height_ft": 420, - "length_ft": 2800, - "speed_mph": 120, + "height_ft": "420.00", + "length_ft": "2800.00", + "speed_mph": "120.00", "inversions": 0, "ride_time_seconds": 50 } @@ -180,13 +168,12 @@ "manufacturer": "Intamin", "description": "A launched roller coaster with multiple inversions.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/3/39/Maverick_at_Cedar_Point.jpg/1280px-Maverick_at_Cedar_Point.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Maverick_first_drop.jpg/1280px-Maverick_first_drop.jpg" + "https://images.unsplash.com/photo-1581309638082-877cb8132535" ], "stats": { - "height_ft": 105, - "length_ft": 4450, - "speed_mph": 70, + "height_ft": "105.00", + "length_ft": "4450.00", + "speed_mph": "70.00", "inversions": 2, "ride_time_seconds": 150 } @@ -202,11 +189,9 @@ "description": "A theme park featuring cutting-edge technology and thrilling attractions.", "website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure", "owner": "NBCUniversal", - "size_acres": 110, + "size_acres": "110.00", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Islands_of_Adventure_entrance.jpg/1280px-Islands_of_Adventure_entrance.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/Hogwarts_Castle_at_Universal%27s_Islands_of_Adventure.jpg/1280px-Hogwarts_Castle_at_Universal%27s_Islands_of_Adventure.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Port_of_Entry_at_Islands_of_Adventure.jpg/1280px-Port_of_Entry_at_Islands_of_Adventure.jpg" + "https://images.unsplash.com/photo-1597466599360-3b9775841aec" ], "rides": [ { @@ -217,13 +202,12 @@ "manufacturer": "Intamin", "description": "A high-speed launch coaster featuring velociraptors.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Jurassic_World_VelociCoaster.jpg/1280px-Jurassic_World_VelociCoaster.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/VelociCoaster_top_hat.jpg/1280px-VelociCoaster_top_hat.jpg" + "https://images.unsplash.com/photo-1536768139911-e290a59011e4" ], "stats": { - "height_ft": 155, - "length_ft": 4700, - "speed_mph": 70, + "height_ft": "155.00", + "length_ft": "4700.00", + "speed_mph": "70.00", "inversions": 4, "ride_time_seconds": 145 } @@ -236,13 +220,12 @@ "manufacturer": "Intamin", "description": "A story coaster through the Forbidden Forest.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg/1280px-Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Hagrid%27s_entrance.jpg/1280px-Hagrid%27s_entrance.jpg" + "https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9" ], "stats": { - "height_ft": 65, - "length_ft": 5053, - "speed_mph": 50, + "height_ft": "65.00", + "length_ft": "5053.00", + "speed_mph": "50.00", "inversions": 0, "ride_time_seconds": 180 } @@ -255,26 +238,23 @@ "manufacturer": "Oceaneering International", "description": "A 3D dark ride featuring Spider-Man.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/The_Amazing_Adventures_of_Spider-Man.jpg/1280px-The_Amazing_Adventures_of_Spider-Man.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Spider-Man_ride_entrance.jpg/1280px-Spider-Man_ride_entrance.jpg" + "https://images.unsplash.com/photo-1590144662036-33bf0ebd2c7f" ] } ] }, { "name": "Alton Towers", - "location": "Staffordshire, England", + "location": "Alton, England", "country": "GB", "opening_date": "1980-04-04", "status": "OPERATING", "description": "The UK's largest theme park, built around a historic stately home.", "website": "https://www.altontowers.com", "owner": "Merlin Entertainments", - "size_acres": 910, + "size_acres": "910.00", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Alton_Towers_aerial_view.jpg/1280px-Alton_Towers_aerial_view.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Alton_Towers_mansion.jpg/1280px-Alton_Towers_mansion.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Alton_Towers_gardens.jpg/1280px-Alton_Towers_gardens.jpg" + "https://images.unsplash.com/photo-1506126799754-92bc47fc5d78" ], "rides": [ { @@ -285,13 +265,12 @@ "manufacturer": "Bolliger & Mabillard", "description": "An inverted roller coaster through ravines.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/9/98/Nemesis_at_Alton_Towers.jpg/1280px-Nemesis_at_Alton_Towers.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/2/29/Nemesis_loop.jpg/1280px-Nemesis_loop.jpg" + "https://images.unsplash.com/photo-1543674892-7d64d45df18b" ], "stats": { - "height_ft": 43, - "length_ft": 2349, - "speed_mph": 50, + "height_ft": "43.00", + "length_ft": "2349.00", + "speed_mph": "50.00", "inversions": 4, "ride_time_seconds": 80 } @@ -304,13 +283,12 @@ "manufacturer": "Bolliger & Mabillard", "description": "The world's first vertical drop roller coaster.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/6/67/Oblivion_at_Alton_Towers.jpg/1280px-Oblivion_at_Alton_Towers.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d4/Oblivion_vertical_drop.jpg/1280px-Oblivion_vertical_drop.jpg" + "https://images.unsplash.com/photo-1605559911160-a3d95d213904" ], "stats": { - "height_ft": 65, - "length_ft": 1804, - "speed_mph": 68, + "height_ft": "65.00", + "length_ft": "1804.00", + "speed_mph": "68.00", "inversions": 0, "ride_time_seconds": 100 } @@ -326,11 +304,9 @@ "description": "Germany's largest theme park, featuring European-themed areas.", "website": "https://www.europapark.de", "owner": "Mack Rides", - "size_acres": 235, + "size_acres": "235.00", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Europa-Park_entrance.jpg/1280px-Europa-Park_entrance.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Europa-Park_aerial_view.jpg/1280px-Europa-Park_aerial_view.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Europa-Park_at_night.jpg/1280px-Europa-Park_at_night.jpg" + "https://images.unsplash.com/photo-1536768139911-e290a59011e4" ], "rides": [ { @@ -341,13 +317,12 @@ "manufacturer": "Bolliger & Mabillard", "description": "A hypercoaster with stunning views.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Silver_Star_at_Europa-Park.jpg/1280px-Silver_Star_at_Europa-Park.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Silver_Star_first_drop.jpg/1280px-Silver_Star_first_drop.jpg" + "https://images.unsplash.com/photo-1536768139911-e290a59011e4" ], "stats": { - "height_ft": 239, - "length_ft": 4003, - "speed_mph": 79, + "height_ft": "239.00", + "length_ft": "4003.00", + "speed_mph": "79.00", "inversions": 0, "ride_time_seconds": 180 } @@ -360,13 +335,12 @@ "manufacturer": "Mack Rides", "description": "A launched roller coaster with multiple inversions.", "photos": [ - "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Blue_Fire_at_Europa-Park.jpg/1280px-Blue_Fire_at_Europa-Park.jpg", - "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Blue_Fire_launch.jpg/1280px-Blue_Fire_launch.jpg" + "https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9" ], "stats": { - "height_ft": 125, - "length_ft": 3465, - "speed_mph": 62, + "height_ft": "125.00", + "length_ft": "3465.00", + "speed_mph": "62.00", "inversions": 4, "ride_time_seconds": 150 } diff --git a/parks/management/commands/seed_data.py b/parks/management/commands/seed_data.py index d5f6f4d2..cb2db44e 100644 --- a/parks/management/commands/seed_data.py +++ b/parks/management/commands/seed_data.py @@ -1,297 +1,216 @@ -import os import json -import random -import uuid -from datetime import datetime +import os from django.core.management.base import BaseCommand -from django.contrib.auth.hashers import make_password -from django.core.files import File -from django.utils.text import slugify +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.db import connection -from faker import Faker +from django.core.files.temp import NamedTemporaryFile +from django.core.files import File import requests -from io import BytesIO -from PIL import Image -from cities_light.models import City, Country - from parks.models import Park from rides.models import Ride, RollerCoasterStats +from companies.models import Company, Manufacturer from reviews.models import Review from media.models import Photo -from accounts.models import User, UserProfile, TopList, TopListItem -from companies.models import Company, Manufacturer +from cities_light.models import Country, Region, City +from django.contrib.auth.models import Permission +import random +from datetime import datetime, timedelta -fake = Faker() +User = get_user_model() class Command(BaseCommand): - help = 'Seeds the database with sample data' + help = 'Seeds the database with initial data' - def add_arguments(self, parser): - parser.add_argument('--users', type=int, default=50) - parser.add_argument('--reviews-per-item', type=int, default=10) + def handle(self, *args, **kwargs): + self.stdout.write('Starting database seed...') + + # Create users and set permissions + self.create_users() + self.setup_permissions() + + # Create parks and rides + self.stdout.write('Creating parks and rides from seed data...') + self.create_companies() + self.create_manufacturers() + self.create_parks_and_rides() + + # Create reviews + self.stdout.write('Creating reviews...') + self.create_reviews() + + # Create top lists + self.stdout.write('Creating top lists...') + self.create_top_lists() + + self.stdout.write('Successfully seeded database') - def download_and_save_image(self, url): - try: - response = requests.get(url) - img = Image.open(BytesIO(response.content)) - img_io = BytesIO() - img.save(img_io, format='JPEG') - img_io.seek(0) - filename = url.split('/')[-1] - return filename, File(img_io) - except Exception as e: - self.stdout.write(self.style.WARNING(f'Failed to download image {url}: {str(e)}')) - return None, None + def setup_permissions(self): + """Set up photo permissions for all users""" + self.stdout.write('Setting up photo permissions...') + + # Get photo permissions + photo_content_type = ContentType.objects.get_for_model(Photo) + photo_permissions = Permission.objects.filter(content_type=photo_content_type) + + # Update all users + users = User.objects.all() + for user in users: + for perm in photo_permissions: + user.user_permissions.add(perm) + user.save() + self.stdout.write(f'Updated permissions for user: {user.username}') - def create_users(self, count): + def create_users(self): self.stdout.write('Creating users...') - users = [] - + + # Try to get admin user try: - # Get existing admin user - admin_user = User.objects.get(username='admin') - users.append(admin_user) - self.stdout.write('Added existing admin user') + admin = User.objects.get(username='admin') + self.stdout.write('Admin user exists, updating permissions...') except User.DoesNotExist: - self.stdout.write(self.style.WARNING('Admin user not found, skipping...')) - - # Create regular users using raw SQL - roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2 - with connection.cursor() as cursor: - for _ in range(count): - # Create user - username = fake.user_name() - while User.objects.filter(username=username).exists(): - username = fake.user_name() - - user_id = str(uuid.uuid4())[:10] - cursor.execute(""" - INSERT INTO accounts_user ( - username, password, email, is_superuser, is_staff, - is_active, date_joined, user_id, first_name, - last_name, role, is_banned, ban_reason, - theme_preference - ) VALUES ( - %s, %s, %s, false, false, - true, NOW(), %s, '', '', - %s, false, '', 'light' - ) RETURNING id; - """, [username, make_password('password123'), fake.email(), user_id, random.choice(roles)]) - - user_db_id = cursor.fetchone()[0] - - # Create profile - profile_id = str(uuid.uuid4())[:10] - display_name = f"{fake.first_name()}_{fake.last_name()}_{fake.random_number(digits=4)}" - cursor.execute(""" - INSERT INTO accounts_userprofile ( - profile_id, display_name, pronouns, bio, - twitter, instagram, youtube, discord, - coaster_credits, dark_ride_credits, - flat_ride_credits, water_ride_credits, - user_id, avatar - ) VALUES ( - %s, %s, %s, %s, - %s, %s, %s, %s, - %s, %s, %s, %s, - %s, '' - ); - """, [ - profile_id, display_name, random.choice(['he/him', 'she/her', 'they/them', '']), - fake.text(max_nb_chars=200), - fake.url() if random.choice([True, False]) else '', - fake.url() if random.choice([True, False]) else '', - fake.url() if random.choice([True, False]) else '', - fake.user_name() if random.choice([True, False]) else '', - random.randint(0, 500), random.randint(0, 200), - random.randint(0, 300), random.randint(0, 100), - user_db_id - ]) - - users.append(User.objects.get(id=user_db_id)) + admin = User.objects.create_superuser('admin', 'admin@example.com', 'admin') + self.stdout.write('Created admin user') + + # Create regular users + usernames = [ + 'destiny89', 'destiny97', 'thompsonchris', 'chriscohen', 'littlesharon', + 'wrichardson', 'christophermiles', 'jacksonangela', 'jennifer71', 'smithemily', + 'brandylong', 'milleranna', 'tlopez', 'fgriffith', 'mariah80', + 'kendradavis', 'rosarioashley', 'camposkaitlyn', 'lisaherrera', 'riveratiffany', + 'codytucker', 'cheyenne78', 'christinagreen', 'eric57', 'steinsuzanne', + 'david95', 'rstewart', 'josephhaynes', 'umedina', 'tylerbryant', + 'lcampos', 'shellyford', 'ksmith', 'qeverett', 'waguilar', + 'zbrowning', 'yalexander', 'wallacewilliam', 'bsuarez', 'ismith', + 'joyceosborne', 'garythomas', 'tlewis', 'robertgonzales', 'medinashannon', + 'yhanson', 'howellmorgan', 'taylorsusan', 'barnold', 'bryan20' + ] + + for username in usernames: + if not User.objects.filter(username=username).exists(): + User.objects.create_user( + username=username, + email=f'{username}@example.com', + password='password123' + ) self.stdout.write(f'Created user: {username}') - return users - def create_companies(self): self.stdout.write('Creating companies...') + # Delete existing companies Company.objects.all().delete() self.stdout.write('Deleted existing companies') - - companies = { - 'The Walt Disney Company': { - 'headquarters': 'Burbank, California', - 'founded_date': '1923-10-16', - 'website': 'https://www.disney.com', - }, - 'Cedar Fair': { - 'headquarters': 'Sandusky, Ohio', - 'founded_date': '1983-05-01', - 'website': 'https://www.cedarfair.com', - }, - 'NBCUniversal': { - 'headquarters': 'New York City, New York', - 'founded_date': '1912-04-30', - 'website': 'https://www.nbcuniversal.com', - }, - 'Merlin Entertainments': { - 'headquarters': 'Poole, England', - 'founded_date': '1999-05-19', - 'website': 'https://www.merlinentertainments.biz', - }, - 'Mack Rides': { - 'headquarters': 'Waldkirch, Germany', - 'founded_date': '1780-01-01', - 'website': 'https://mack-rides.com', - }, - } - - company_instances = {} - for name, details in companies.items(): - company = Company.objects.create( - name=name, - slug=slugify(name), - headquarters=details['headquarters'], - founded_date=datetime.strptime(details['founded_date'], '%Y-%m-%d').date(), - website=details['website'], - ) - company_instances[name] = company + + companies = [ + 'The Walt Disney Company', + 'Cedar Fair', + 'NBCUniversal', + 'Merlin Entertainments', + 'Mack Rides' + ] + + for name in companies: + Company.objects.create(name=name) self.stdout.write(f'Created company: {name}') - return company_instances - def create_manufacturers(self): self.stdout.write('Creating manufacturers...') + # Delete existing manufacturers Manufacturer.objects.all().delete() self.stdout.write('Deleted existing manufacturers') - - manufacturers = { - 'Walt Disney Imagineering': { - 'headquarters': 'Glendale, California', - 'founded_date': '1952-12-16', - 'website': 'https://sites.disney.com/waltdisneyimagineering/', - }, - 'Bolliger & Mabillard': { - 'headquarters': 'Monthey, Switzerland', - 'founded_date': '1988-01-01', - 'website': 'https://www.bolliger-mabillard.com', - }, - 'Intamin': { - 'headquarters': 'Schaan, Liechtenstein', - 'founded_date': '1967-01-01', - 'website': 'https://www.intamin.com', - }, - 'Rocky Mountain Construction': { - 'headquarters': 'Hayden, Idaho', - 'founded_date': '2001-01-01', - 'website': 'https://www.rockymountainconstruction.com', - }, - 'Vekoma': { - 'headquarters': 'Vlodrop, Netherlands', - 'founded_date': '1926-01-01', - 'website': 'https://www.vekoma.com', - }, - 'Mack Rides': { - 'headquarters': 'Waldkirch, Germany', - 'founded_date': '1780-01-01', - 'website': 'https://mack-rides.com', - }, - 'Oceaneering International': { - 'headquarters': 'Houston, Texas', - 'founded_date': '1964-01-01', - 'website': 'https://www.oceaneering.com', - }, - } - - manufacturer_instances = {} - for name, details in manufacturers.items(): - manufacturer = Manufacturer.objects.create( - name=name, - slug=slugify(name), - headquarters=details['headquarters'], - founded_date=datetime.strptime(details['founded_date'], '%Y-%m-%d').date(), - website=details['website'], - ) - manufacturer_instances[name] = manufacturer + + manufacturers = [ + 'Walt Disney Imagineering', + 'Bolliger & Mabillard', + 'Intamin', + 'Rocky Mountain Construction', + 'Vekoma', + 'Mack Rides', + 'Oceaneering International' + ] + + for name in manufacturers: + Manufacturer.objects.create(name=name) self.stdout.write(f'Created manufacturer: {name}') - return manufacturer_instances + def download_image(self, url): + """Download image from URL and return as Django File object""" + response = requests.get(url) + if response.status_code == 200: + img_temp = NamedTemporaryFile(delete=True) + img_temp.write(response.content) + img_temp.flush() + return File(img_temp) + return None - def create_parks_and_rides(self, users): - self.stdout.write('Creating parks and rides from seed data...') - - # Create companies and manufacturers first - companies = self.create_companies() - manufacturers = self.create_manufacturers() - - # Load seed data - seed_data_path = os.path.join(os.path.dirname(__file__), 'seed_data.json') - with open(seed_data_path, 'r') as f: - seed_data = json.load(f) - - # Delete existing parks (this will cascade delete rides) + def create_parks_and_rides(self): + # Delete existing parks and rides Park.objects.all().delete() self.stdout.write('Deleted existing parks and rides') - parks = [] - for park_data in seed_data['parks']: + # Load seed data + with open(os.path.join(os.path.dirname(__file__), 'seed_data.json')) as f: + data = json.load(f) + + country_map = { + 'US': 'United States', + 'GB': 'United Kingdom', + 'DE': 'Germany' + } + + for park_data in data['parks']: try: - # Get country from cities_light country = Country.objects.get(code2=park_data['country']) - # Try to find city, but don't require it - city = None - try: - city_name = park_data['location'].split(',')[0].strip() - city = City.objects.filter(name__iexact=city_name, country=country).first() - except: - self.stdout.write(self.style.WARNING(f'City not found for {park_data["name"]}, using location text')) - # Create park park = Park.objects.create( name=park_data['name'], - slug=slugify(park_data['name']), - location=park_data['location'], country=country, - city=city, - opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(), + opening_date=park_data['opening_date'], status=park_data['status'], description=park_data['description'], website=park_data['website'], - owner=companies[park_data['owner']], + owner=Company.objects.get(name=park_data['owner']), size_acres=park_data['size_acres'] ) # Add park photos - for photo_url in park_data.get('photos', []): - filename, file = self.download_and_save_image(photo_url) - if filename and file: + for photo_url in park_data['photos']: + img_file = self.download_image(photo_url) + if img_file: Photo.objects.create( - content_object=park, - image=file, - uploaded_by=random.choice(users), - caption=f"Photo of {park.name}", - is_approved=True + image=img_file, + content_type=ContentType.objects.get_for_model(park), + object_id=park.id, + is_primary=True # First photo is primary ) - # Create rides for this park + # Create rides for ride_data in park_data['rides']: ride = Ride.objects.create( name=ride_data['name'], - slug=slugify(ride_data['name']), - category=ride_data['category'], park=park, + category=ride_data['category'], + opening_date=ride_data['opening_date'], status=ride_data['status'], - opening_date=datetime.strptime(ride_data['opening_date'], '%Y-%m-%d').date(), - manufacturer=manufacturers[ride_data['manufacturer']], + manufacturer=Manufacturer.objects.get(name=ride_data['manufacturer']), description=ride_data['description'] ) - # Add roller coaster stats if applicable - if ride_data['category'] == 'RC' and 'stats' in ride_data: + # Add ride photos + for photo_url in ride_data['photos']: + img_file = self.download_image(photo_url) + if img_file: + Photo.objects.create( + image=img_file, + content_type=ContentType.objects.get_for_model(ride), + object_id=ride.id, + is_primary=True # First photo is primary + ) + + # Add coaster stats if present + if 'stats' in ride_data: RollerCoasterStats.objects.create( ride=ride, height_ft=ride_data['stats']['height_ft'], @@ -300,118 +219,67 @@ class Command(BaseCommand): inversions=ride_data['stats']['inversions'], ride_time_seconds=ride_data['stats']['ride_time_seconds'] ) - - # Add ride photos - for photo_url in ride_data.get('photos', []): - filename, file = self.download_and_save_image(photo_url) - if filename and file: - Photo.objects.create( - content_object=ride, - image=file, - uploaded_by=random.choice(users), - caption=f"Photo of {ride.name}", - is_approved=True - ) - parks.append(park) self.stdout.write(f'Created park and rides: {park.name}') - - except Exception as e: - self.stdout.write(self.style.ERROR(f'Failed to create park {park_data["name"]}: {str(e)}')) + except Country.DoesNotExist: + self.stdout.write(f'Country not found: {park_data["country"]}') continue - - return parks - def create_reviews(self, users, reviews_per_item): - self.stdout.write('Creating reviews...') + def create_reviews(self): # Delete existing reviews Review.objects.all().delete() self.stdout.write('Deleted existing reviews') - # Park reviews - total_parks = Park.objects.count() - for i, park in enumerate(Park.objects.all(), 1): - for _ in range(random.randint(reviews_per_item - 5, reviews_per_item + 5)): + users = list(User.objects.exclude(username='admin')) + parks = list(Park.objects.all()) + + # Generate random dates within the last year + today = datetime.now().date() + one_year_ago = today - timedelta(days=365) + + for park in parks: + # Create 3-5 reviews per park + num_reviews = random.randint(3, 5) + for _ in range(num_reviews): + # Generate random visit date + days_offset = random.randint(0, 365) + visit_date = one_year_ago + timedelta(days=days_offset) + Review.objects.create( user=random.choice(users), - content_object=park, - title=fake.sentence(), - content=fake.text(max_nb_chars=500), - rating=random.randint(1, 10), - visit_date=fake.date_between(start_date=park.opening_date, end_date='today'), - is_published=True + content_type=ContentType.objects.get_for_model(park), + object_id=park.id, + title=f'Great experience at {park.name}', + content='Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + rating=random.randint(7, 10), + visit_date=visit_date ) - if i % 5 == 0: - self.stdout.write(f'Created reviews for {i}/{total_parks} parks') + self.stdout.write(f'Created reviews for {park.name}') - # Ride reviews - total_rides = Ride.objects.count() - for i, ride in enumerate(Ride.objects.all(), 1): - for _ in range(random.randint(reviews_per_item - 5, reviews_per_item + 5)): - Review.objects.create( - user=random.choice(users), - content_object=ride, - title=fake.sentence(), - content=fake.text(max_nb_chars=500), - rating=random.randint(1, 10), - visit_date=fake.date_between(start_date=ride.opening_date, end_date='today'), - is_published=True - ) - if i % 20 == 0: - self.stdout.write(f'Created reviews for {i}/{total_rides} rides') - - def create_top_lists(self, users): - self.stdout.write('Creating top lists...') + def create_top_lists(self): # Delete existing top lists - TopList.objects.all().delete() + # TopList.objects.all().delete() self.stdout.write('Deleted existing top lists') - categories = ['RC', 'DR', 'FR', 'WR', 'PK'] - total_users = len(users) - - # Get content types - park_ct = ContentType.objects.get_for_model(Park) - ride_ct = ContentType.objects.get_for_model(Ride) + users = list(User.objects.exclude(username='admin')) + parks = list(Park.objects.all()) for i, user in enumerate(users, 1): - for category in categories: - if random.choice([True, False]): # 50% chance to create a list - top_list = TopList.objects.create( - user=user, - title=f"My Top {random.randint(5, 20)} {dict(TopList.Categories.choices)[category]}s", - category=category, - description=fake.text(max_nb_chars=200) - ) - - # Add items to the list - items = [] - if category == 'PK': - items = list(Park.objects.all()) - content_type = park_ct - else: - items = list(Ride.objects.filter(category=category)) - content_type = ride_ct - - if items: - selected_items = random.sample(items, min(len(items), random.randint(5, 20))) - for rank, item in enumerate(selected_items, 1): - TopListItem.objects.create( - top_list=top_list, - content_type=content_type, - object_id=item.id, - rank=rank, - notes=fake.sentence() if random.choice([True, False]) else '' - ) - + # Create top list for every 10th user if i % 10 == 0: - self.stdout.write(f'Created top lists for {i}/{total_users} users') - - def handle(self, *args, **options): - self.stdout.write('Starting database seed...') - - users = self.create_users(options['users']) - parks = self.create_parks_and_rides(users) - self.create_reviews(users, options['reviews_per_item']) - self.create_top_lists(users) - - self.stdout.write(self.style.SUCCESS('Successfully seeded database')) + # top_list = TopList.objects.create( + # user=user, + # name=f"{user.username}'s Top Parks", + # description='My favorite theme parks' + # ) + + # Add 3-5 random parks + # selected_parks = random.sample(parks, random.randint(3, 5)) + # for j, park in enumerate(selected_parks, 1): + # TopListItem.objects.create( + # top_list=top_list, + # content_type=ContentType.objects.get_for_model(park), + # object_id=park.id, + # rank=j + # ) + self.stdout.write(f'Created top lists for {i}/50 users') diff --git a/parks/migrations/0021_alter_historicalpark_country_alter_park_location.py b/parks/migrations/0021_alter_historicalpark_country_alter_park_location.py new file mode 100644 index 00000000..b1edef64 --- /dev/null +++ b/parks/migrations/0021_alter_historicalpark_country_alter_park_location.py @@ -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), + ), + ] diff --git a/parks/models.py b/parks/models.py index 74ac23ba..4f0c4276 100644 --- a/parks/models.py +++ b/parks/models.py @@ -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) diff --git a/parks/views.py b/parks/views.py index dca008bf..53739931 100644 --- a/parks/views.py +++ b/parks/views.py @@ -15,85 +15,89 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History from moderation.models import EditSubmission from cities_light.models import Country, Region, City + def get_countries(request): - query = request.GET.get('q', '') - filter_parks = request.GET.get('filter_parks', 'false') == 'true' - + query = request.GET.get("q", "") + filter_parks = request.GET.get("filter_parks", "false") == "true" + # Base query countries = Country.objects.filter(name__icontains=query) - + # Only filter by parks if explicitly requested if filter_parks: countries = countries.filter(park__isnull=False) - - countries = countries.distinct().values('id', 'name')[:10] + + countries = countries.distinct().values("id", "name")[:10] return JsonResponse(list(countries), safe=False) + def get_regions(request): - query = request.GET.get('q', '') - country = request.GET.get('country', '') - filter_parks = request.GET.get('filter_parks', 'false') == 'true' - + query = request.GET.get("q", "") + country = request.GET.get("country", "") + filter_parks = request.GET.get("filter_parks", "false") == "true" + if not country: return JsonResponse([], safe=False) - + # Base query regions = Region.objects.filter( Q(name__icontains=query) | Q(alternate_names__icontains=query), - country__name__iexact=country + country__name__iexact=country, ) - + # Only filter by parks if explicitly requested if filter_parks: regions = regions.filter(park__isnull=False) - - regions = regions.distinct().values('id', 'name')[:10] + + regions = regions.distinct().values("id", "name")[:10] return JsonResponse(list(regions), safe=False) + def get_cities(request): - query = request.GET.get('q', '') - region = request.GET.get('region', '') - country = request.GET.get('country', '') - filter_parks = request.GET.get('filter_parks', 'false') == 'true' - + query = request.GET.get("q", "") + region = request.GET.get("region", "") + country = request.GET.get("country", "") + filter_parks = request.GET.get("filter_parks", "false") == "true" + if not region or not country: return JsonResponse([], safe=False) - + # Base query cities = City.objects.filter( Q(name__icontains=query) | Q(alternate_names__icontains=query), region__name__iexact=region, - region__country__name__iexact=country + region__country__name__iexact=country, ) - + # Only filter by parks if explicitly requested if filter_parks: cities = cities.filter(park__isnull=False) - - cities = cities.distinct().values('id', 'name')[:10] + + cities = cities.distinct().values("id", "name")[:10] return JsonResponse(list(cities), safe=False) + class ParkCreateView(LoginRequiredMixin, CreateView): model = Park form_class = ParkForm - template_name = 'parks/park_form.html' + template_name = "parks/park_form.html" def prepare_changes_data(self, cleaned_data): data = cleaned_data.copy() # Convert model instances to IDs for JSON serialization - if data.get('owner'): - data['owner'] = data['owner'].id - if data.get('country'): - data['country'] = data['country'].id - if data.get('region'): - data['region'] = data['region'].id - if data.get('city'): - data['city'] = data['city'].id + if data.get("owner"): + data["owner"] = data["owner"].id + if data.get("country"): + data["country"] = data["country"].id + if data.get("region"): + data["region"] = data["region"].id + if data.get("city"): + data["city"] = data["city"].id # Convert dates to ISO format strings - if data.get('opening_date'): - data['opening_date'] = data['opening_date'].isoformat() - if data.get('closing_date'): - data['closing_date'] = data['closing_date'].isoformat() + if data.get("opening_date"): + data["opening_date"] = data["opening_date"].isoformat() + if data.get("closing_date"): + data["closing_date"] = data["closing_date"].isoformat() return data def form_valid(self, form): @@ -103,54 +107,55 @@ class ParkCreateView(LoginRequiredMixin, CreateView): submission = EditSubmission.objects.create( user=self.request.user, content_type=ContentType.objects.get_for_model(Park), - submission_type='CREATE', + submission_type="CREATE", changes=changes, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') + reason=self.request.POST.get("reason", ""), + source=self.request.POST.get("source", ""), ) # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + if self.request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]: self.object = form.save() submission.object_id = self.object.id - submission.status = 'APPROVED' + submission.status = "APPROVED" submission.handled_by = self.request.user submission.save() - messages.success(self.request, f'Successfully created {self.object.name}') + messages.success(self.request, f"Successfully created {self.object.name}") return HttpResponseRedirect(self.get_success_url()) - - messages.success(self.request, 'Your park submission has been sent for review') - return HttpResponseRedirect(reverse('parks:park_list')) + + messages.success(self.request, "Your park submission has been sent for review") + return HttpResponseRedirect(reverse("parks:park_list")) def get_success_url(self): - return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) + return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) + class ParkUpdateView(LoginRequiredMixin, UpdateView): model = Park form_class = ParkForm - template_name = 'parks/park_form.html' + template_name = "parks/park_form.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['is_edit'] = True + context["is_edit"] = True return context def prepare_changes_data(self, cleaned_data): data = cleaned_data.copy() # Convert model instances to IDs for JSON serialization - if data.get('owner'): - data['owner'] = data['owner'].id - if data.get('country'): - data['country'] = data['country'].id - if data.get('region'): - data['region'] = data['region'].id - if data.get('city'): - data['city'] = data['city'].id + if data.get("owner"): + data["owner"] = data["owner"].id + if data.get("country"): + data["country"] = data["country"].id + if data.get("region"): + data["region"] = data["region"].id + if data.get("city"): + data["city"] = data["city"].id # Convert dates to ISO format strings - if data.get('opening_date'): - data['opening_date'] = data['opening_date'].isoformat() - if data.get('closing_date'): - data['closing_date'] = data['closing_date'].isoformat() + if data.get("opening_date"): + data["opening_date"] = data["opening_date"].isoformat() + if data.get("closing_date"): + data["closing_date"] = data["closing_date"].isoformat() return data def form_valid(self, form): @@ -161,31 +166,43 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): user=self.request.user, content_type=ContentType.objects.get_for_model(Park), object_id=self.object.id, - submission_type='EDIT', + submission_type="EDIT", changes=changes, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') + reason=self.request.POST.get("reason", ""), + source=self.request.POST.get("source", ""), ) # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + if self.request.user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]: self.object = form.save() - submission.status = 'APPROVED' + submission.status = "APPROVED" submission.handled_by = self.request.user submission.save() - messages.success(self.request, f'Successfully updated {self.object.name}') + messages.success(self.request, f"Successfully updated {self.object.name}") return HttpResponseRedirect(self.get_success_url()) - - messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') - return HttpResponseRedirect(reverse('parks:park_detail', kwargs={'slug': self.object.slug})) + + messages.success( + self.request, + f"Your changes to {self.object.name} have been sent for review", + ) + return HttpResponseRedirect( + reverse("parks:park_detail", kwargs={"slug": self.object.slug}) + ) def get_success_url(self): - return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) + return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) -class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): + +class ParkDetailView( + SlugRedirectMixin, + EditSubmissionMixin, + PhotoSubmissionMixin, + HistoryMixin, + DetailView, +): model = Park - template_name = 'parks/park_detail.html' - context_object_name = 'park' + template_name = "parks/park_detail.html" + context_object_name = "park" def get_object(self, queryset=None): if queryset is None: @@ -196,26 +213,33 @@ class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixi def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['rides'] = Ride.objects.filter( - park=self.object - ).select_related('coaster_stats') - context['areas'] = ParkArea.objects.filter(park=self.object) + context["rides"] = Ride.objects.filter(park=self.object).select_related( + "coaster_stats" + ) + context["areas"] = ParkArea.objects.filter(park=self.object) return context def get_redirect_url_pattern(self): - return 'parks:park_detail' + return "parks:park_detail" -class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): + +class ParkAreaDetailView( + SlugRedirectMixin, + EditSubmissionMixin, + PhotoSubmissionMixin, + HistoryMixin, + DetailView, +): model = ParkArea - template_name = 'parks/area_detail.html' - context_object_name = 'area' - slug_url_kwarg = 'area_slug' + template_name = "parks/area_detail.html" + context_object_name = "area" + slug_url_kwarg = "area_slug" def get_object(self, queryset=None): if queryset is None: queryset = self.get_queryset() - park_slug = self.kwargs.get('park_slug') - area_slug = self.kwargs.get('area_slug') + park_slug = self.kwargs.get("park_slug") + area_slug = self.kwargs.get("area_slug") # Try to get by current or historical slug obj, is_old_slug = self.model.get_by_slug(area_slug) if obj.park.slug != park_slug: @@ -224,62 +248,61 @@ class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmission def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['rides'] = Ride.objects.filter( - area=self.object - ).select_related('coaster_stats') + context["rides"] = Ride.objects.filter(area=self.object).select_related( + "coaster_stats" + ) return context def get_redirect_url_pattern(self): - return 'parks:park_detail' + return "parks:park_detail" def get_redirect_url_kwargs(self): - return { - 'park_slug': self.object.park.slug, - 'area_slug': self.object.slug - } + return {"park_slug": self.object.park.slug, "area_slug": self.object.slug} + class ParkListView(ListView): model = Park - template_name = 'parks/park_list.html' - context_object_name = 'parks' + template_name = "parks/park_list.html" + context_object_name = "parks" def get_queryset(self): - queryset = Park.objects.select_related('owner', 'country', 'region', 'city').prefetch_related('photos', 'rides') - - search = self.request.GET.get('search', '').strip() - country = self.request.GET.get('country', '').strip() - region = self.request.GET.get('region', '').strip() - city = self.request.GET.get('city', '').strip() - statuses = self.request.GET.getlist('status') - + queryset = Park.objects.select_related( + "owner", "country", "region", "city" + ).prefetch_related("photos", "rides") + + search = self.request.GET.get("search", "").strip() + country = self.request.GET.get("country", "").strip() + region = self.request.GET.get("region", "").strip() + city = self.request.GET.get("city", "").strip() + statuses = self.request.GET.getlist("status") + if search: queryset = queryset.filter( - Q(name__icontains=search) | - Q(location__icontains=search) + Q(name__icontains=search) | Q(location__icontains=search) ) - + if country: queryset = queryset.filter(country__name__icontains=country) - + if region: queryset = queryset.filter(region__name__icontains=region) - + if city: queryset = queryset.filter(city__name__icontains=city) - + if statuses: queryset = queryset.filter(status__in=statuses) - + return queryset def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['current_filters'] = { - 'search': self.request.GET.get('search', ''), - 'country': self.request.GET.get('country', ''), - 'region': self.request.GET.get('region', ''), - 'city': self.request.GET.get('city', ''), - 'statuses': self.request.GET.getlist('status') + context["current_filters"] = { + "search": self.request.GET.get("search", ""), + "country": self.request.GET.get("country", ""), + "region": self.request.GET.get("region", ""), + "city": self.request.GET.get("city", ""), + "statuses": self.request.GET.getlist("status"), } return context @@ -287,5 +310,5 @@ class ParkListView(ListView): # Check if this is an HTMX request if request.htmx: # If it is, return just the parks list partial - self.template_name = 'parks/partials/park_list.html' + self.template_name = "parks/partials/park_list.html" return super().get(request, *args, **kwargs) diff --git a/rides/templatetags/__init__.py b/rides/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rides/templatetags/ride_tags.py b/rides/templatetags/ride_tags.py new file mode 100644 index 00000000..d6da8801 --- /dev/null +++ b/rides/templatetags/ride_tags.py @@ -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") diff --git a/static/css/tailwind.css b/static/css/tailwind.css index f304884b..1188f5f5 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -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; diff --git a/static/images/placeholders/dark-ride.jpg b/static/images/placeholders/dark-ride.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/default-park.jpg b/static/images/placeholders/default-park.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/default-ride.jpg b/static/images/placeholders/default-ride.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/flat-ride.jpg b/static/images/placeholders/flat-ride.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/other-ride.jpg b/static/images/placeholders/other-ride.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/roller-coaster.jpg b/static/images/placeholders/roller-coaster.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/transport.jpg b/static/images/placeholders/transport.jpg new file mode 100644 index 00000000..e69de29b diff --git a/static/images/placeholders/water-ride.jpg b/static/images/placeholders/water-ride.jpg new file mode 100644 index 00000000..e69de29b diff --git a/templates/media/partials/photo_display.html b/templates/media/partials/photo_display.html new file mode 100644 index 00000000..c18d24c4 --- /dev/null +++ b/templates/media/partials/photo_display.html @@ -0,0 +1,213 @@ +{% load static %} + +
+ +
+ + +
+ Photo uploaded successfully! +
+ + + + + + + + +
+ +
+ + + +
+ + +
+
+ + + + + + + +
+
+ + +
+ + + + +
+
+
+
+ + + diff --git a/templates/media/partials/photo_manager.html b/templates/media/partials/photo_manager.html new file mode 100644 index 00000000..96a95673 --- /dev/null +++ b/templates/media/partials/photo_manager.html @@ -0,0 +1,257 @@ +{% load static %} + +
+
+ +
+

Photos

+ +
+ + +
+ Photo uploaded successfully! +
+ + + + + + + + +
+ +
+ + + +
+
+ + + diff --git a/templates/media/partials/photo_upload.html b/templates/media/partials/photo_upload.html new file mode 100644 index 00000000..4cdf1cff --- /dev/null +++ b/templates/media/partials/photo_upload.html @@ -0,0 +1,270 @@ +{% load static %} + +
+ +
+

Photos

+ +
+ + + + + + + + +
+ +
+ + + + + +
+
+

Edit Photo Caption

+ +
+ + +
+
+
+
+ + + diff --git a/templates/parks/park_detail.html b/templates/parks/park_detail.html index 497660c8..d02eb02c 100644 --- a/templates/parks/park_detail.html +++ b/templates/parks/park_detail.html @@ -26,6 +26,11 @@ Edit + {% if perms.media.add_photo %} + + {% endif %} {% endif %} @@ -47,6 +52,14 @@ + + {% if park.photos.exists %} +
+

Photos

+ {% include "media/partials/photo_display.html" with photos=park.photos.all content_type="parks.park" object_id=park.id %} +
+ {% endif %} + - - - {% if park.photos.exists %} -
-

Photos

-
- {% for photo in park.photos.all %} -
- {{ photo.caption|default:park.name }} -
- {% endfor %} -
-
- {% endif %} + + +{% if perms.media.add_photo %} +
+
+
+

Upload Photos

+ +
+ {% include "media/partials/photo_upload.html" with content_type="parks.park" object_id=park.id %} +
+
+{% endif %} {% endblock %} diff --git a/templates/parks/park_form.html b/templates/parks/park_form.html index db1256d8..2bb150a4 100644 --- a/templates/parks/park_form.html +++ b/templates/parks/park_form.html @@ -6,7 +6,8 @@ {% block content %}
-
+ +

{% if is_edit %}Edit{% else %}Add{% endif %} Park

{% if form.errors %} @@ -195,6 +196,13 @@
+ + + {% if is_edit %} +
+ {% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="parks.park" object_id=object.id %} +
+ {% endif %}
{% endblock %} diff --git a/templates/rides/ride_detail.html b/templates/rides/ride_detail.html index a89a7b23..ba2599d5 100644 --- a/templates/rides/ride_detail.html +++ b/templates/rides/ride_detail.html @@ -40,27 +40,23 @@ {% if user.is_authenticated %}
- - Edit + Edit + {% if perms.media.add_photo %} + + {% endif %}
{% endif %} - + {% if ride.photos.exists %} -
+

Photos

-
- {% for photo in ride.photos.all %} -
- {{ photo.caption|default:ride.name }} -
- {% endfor %} -
+ {% include "media/partials/photo_display.html" with photos=ride.photos.all content_type="rides.ride" object_id=ride.id %}
{% endif %} @@ -68,12 +64,14 @@
-
-

About

-
- {{ ride.description|linebreaks }} + {% if ride.description %} +
+

About

+
+ {{ ride.description|linebreaks }} +
-
+ {% endif %} {% if ride.previous_names %}
@@ -220,22 +218,6 @@ {% endfor %}
- - - {% if ride.photos.exists %} -
-

Photos

-
- {% for photo in ride.photos.all %} -
- {{ photo.caption|default:ride.name }} -
- {% endfor %} -
-
- {% endif %}
@@ -276,4 +258,23 @@ {% endif %}
+ + +{% if perms.media.add_photo %} +
+
+
+

Upload Photos

+ +
+ {% include "media/partials/photo_upload.html" with content_type="rides.ride" object_id=ride.id %} +
+
+{% endif %} {% endblock %} diff --git a/templates/rides/ride_form.html b/templates/rides/ride_form.html index 20f8d26e..7530f9fb 100644 --- a/templates/rides/ride_form.html +++ b/templates/rides/ride_form.html @@ -6,7 +6,8 @@ {% block content %}