diff --git a/analytics/__init__.py b/analytics/__init__.py new file mode 100644 index 00000000..df337401 --- /dev/null +++ b/analytics/__init__.py @@ -0,0 +1 @@ +default_app_config = 'analytics.apps.AnalyticsConfig' diff --git a/analytics/admin.py b/analytics/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/analytics/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/analytics/apps.py b/analytics/apps.py new file mode 100644 index 00000000..50f32eb2 --- /dev/null +++ b/analytics/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class AnalyticsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'analytics' diff --git a/analytics/management/commands/update_trending.py b/analytics/management/commands/update_trending.py new file mode 100644 index 00000000..522bdb24 --- /dev/null +++ b/analytics/management/commands/update_trending.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand +from django.core.cache import cache +from parks.models import Park +from rides.models import Ride +from analytics.models import PageView + +class Command(BaseCommand): + help = 'Updates trending parks and rides cache based on views in the last 24 hours' + + def handle(self, *args, **kwargs): + """ + Updates the trending parks and rides in the cache. + + This command is designed to be run every hour via cron to keep the trending + items up to date. It looks at page views from the last 24 hours and caches + the top 10 most viewed parks and rides. + + The cached data is used by the home page to display trending items without + having to query the database on every request. + """ + # Get top 10 trending parks and rides from the last 24 hours + trending_parks = PageView.get_trending_items(Park, hours=24, limit=10) + trending_rides = PageView.get_trending_items(Ride, hours=24, limit=10) + + # Cache the results for 1 hour + cache.set('trending_parks', trending_parks, 3600) # 3600 seconds = 1 hour + cache.set('trending_rides', trending_rides, 3600) + + self.stdout.write( + self.style.SUCCESS( + 'Successfully updated trending parks and rides. ' + 'Cached 10 items each for parks and rides based on views in the last 24 hours.' + ) + ) diff --git a/analytics/middleware.py b/analytics/middleware.py new file mode 100644 index 00000000..bfaca3eb --- /dev/null +++ b/analytics/middleware.py @@ -0,0 +1,39 @@ +from django.utils.deprecation import MiddlewareMixin +from django.contrib.contenttypes.models import ContentType +from django.views.generic.detail import DetailView +from .models import PageView + +class PageViewMiddleware(MiddlewareMixin): + def process_view(self, request, view_func, view_args, view_kwargs): + # Only track GET requests + if request.method != 'GET': + return None + + # Get view class if it exists + view_class = getattr(view_func, 'view_class', None) + if not view_class or not issubclass(view_class, DetailView): + return None + + # Get the object if it's a detail view + try: + view_instance = view_class() + view_instance.request = request + view_instance.args = view_args + view_instance.kwargs = view_kwargs + obj = view_instance.get_object() + except (AttributeError, Exception): + return None + + # Record the page view + try: + PageView.objects.create( + content_type=ContentType.objects.get_for_model(obj.__class__), + object_id=obj.pk, + ip_address=request.META.get('REMOTE_ADDR', ''), + user_agent=request.META.get('HTTP_USER_AGENT', '')[:512] + ) + except Exception: + # Fail silently to not interrupt the request + pass + + return None diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py new file mode 100644 index 00000000..89a5f5b6 --- /dev/null +++ b/analytics/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 5.1.2 on 2024-11-04 00:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="PageView", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.PositiveIntegerField()), + ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), + ("ip_address", models.GenericIPAddressField()), + ("user_agent", models.CharField(blank=True, max_length=512)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_views", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["timestamp"], name="analytics_p_timesta_835321_idx" + ), + models.Index( + fields=["content_type", "object_id"], + name="analytics_p_content_73920a_idx", + ), + ], + }, + ), + ] diff --git a/analytics/migrations/__init__.py b/analytics/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analytics/models.py b/analytics/models.py new file mode 100644 index 00000000..2c097ef8 --- /dev/null +++ b/analytics/models.py @@ -0,0 +1,57 @@ +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +from django.db.models import Count +from django.conf import settings + +class PageView(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='page_views') + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + ip_address = models.GenericIPAddressField() + user_agent = models.CharField(max_length=512, blank=True) + + class Meta: + indexes = [ + models.Index(fields=['timestamp']), + models.Index(fields=['content_type', 'object_id']), + ] + + @classmethod + def get_trending_items(cls, model_class, hours=24, limit=10): + """Get trending items of a specific model class based on views in last X hours. + + Args: + model_class: The model class to get trending items for (e.g., Park, Ride) + hours (int): Number of hours to look back for views (default: 24) + limit (int): Maximum number of items to return (default: 10) + + Returns: + QuerySet: The trending items ordered by view count + """ + content_type = ContentType.objects.get_for_model(model_class) + cutoff = timezone.now() - timezone.timedelta(hours=hours) + + # Query through the ContentType relationship + item_ids = cls.objects.filter( + content_type=content_type, + timestamp__gte=cutoff + ).values('object_id').annotate( + view_count=Count('id') + ).filter( + view_count__gt=0 + ).order_by('-view_count').values_list('object_id', flat=True)[:limit] + + # Get the actual items in the correct order + if item_ids: + # Convert the list to a string of comma-separated values + id_list = list(item_ids) + # Use Case/When to preserve the ordering + from django.db.models import Case, When + preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(id_list)]) + return model_class.objects.filter(pk__in=id_list).order_by(preserved) + + return model_class.objects.none() diff --git a/analytics/tests.py b/analytics/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/analytics/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/analytics/views.py b/analytics/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/analytics/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 00000000..e69de29b diff --git a/media/__pycache__/models.cpython-312.pyc b/media/__pycache__/models.cpython-312.pyc index 9fe60ce2..f9ed7f9a 100644 Binary files a/media/__pycache__/models.cpython-312.pyc and b/media/__pycache__/models.cpython-312.pyc differ diff --git a/media/models.py b/media/models.py index 11ccb1f8..129e008e 100644 --- a/media/models.py +++ b/media/models.py @@ -1,3 +1,4 @@ +from typing import Any, Optional, Union, cast from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -6,32 +7,37 @@ from django.conf import settings import os from .storage import MediaStorage from rides.models import Ride +from django.utils import timezone -def photo_upload_path(instance, filename): +def photo_upload_path(instance: models.Model, filename: str) -> str: """Generate upload path for photos using normalized filenames""" # Get the content type and object - content_type = instance.content_type.model - obj = instance.content_object + photo = cast(Photo, instance) + content_type = photo.content_type.model + obj = photo.content_object + + if obj is None: + raise ValueError("Content object cannot be None") # Get object identifier (slug or id) - identifier = getattr(obj, 'slug', obj.id) + identifier = getattr(obj, 'slug', None) + if identifier is None: + identifier = obj.pk # Use pk instead of id as it's guaranteed to exist # Get the next available number for this object existing_photos = Photo.objects.filter( - content_type=instance.content_type, - object_id=instance.object_id + content_type=photo.content_type, + object_id=photo.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 + ext = os.path.splitext(filename)[1].lower() or '.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) + ride = cast(Ride, obj) return f"park/{ride.park.slug}/{identifier}/{new_filename}" # For park photos, store directly in park directory @@ -40,7 +46,7 @@ def photo_upload_path(instance, filename): class Photo(models.Model): """Generic photo model that can be attached to any model""" image = models.ImageField( - upload_to=photo_upload_path, + upload_to=photo_upload_path, # type: ignore[arg-type] max_length=255, storage=MediaStorage() ) @@ -67,13 +73,14 @@ class Photo(models.Model): models.Index(fields=['content_type', 'object_id']), ] - def __str__(self): + def __str__(self) -> str: return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}" - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: # 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')}" + current_time = timezone.now() + self.caption = f"Uploaded by {self.uploaded_by.username} on {current_time.strftime('%B %d, %Y at %I:%M %p')}" # If this is marked as primary, unmark other primary photos if self.is_primary: @@ -81,6 +88,6 @@ class Photo(models.Model): content_type=self.content_type, object_id=self.object_id, is_primary=True - ).exclude(id=self.id).update(is_primary=False) + ).exclude(pk=self.pk).update(is_primary=False) # Use pk instead of id super().save(*args, **kwargs) diff --git a/media/park/walt-disney-world-magic-kingdom/big-thunder-mountain-railroad/big-thunder-mountain-railroad_2.png b/media/park/walt-disney-world-magic-kingdom/big-thunder-mountain-railroad/big-thunder-mountain-railroad_2.png new file mode 100644 index 00000000..fbcebfae Binary files /dev/null and b/media/park/walt-disney-world-magic-kingdom/big-thunder-mountain-railroad/big-thunder-mountain-railroad_2.png differ diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index 3a561457..be7cd022 100644 Binary files a/parks/__pycache__/models.cpython-312.pyc and b/parks/__pycache__/models.cpython-312.pyc differ diff --git a/rides/__pycache__/models.cpython-312.pyc b/rides/__pycache__/models.cpython-312.pyc index f5cd228a..540a227b 100644 Binary files a/rides/__pycache__/models.cpython-312.pyc and b/rides/__pycache__/models.cpython-312.pyc differ diff --git a/rides/__pycache__/views.cpython-312.pyc b/rides/__pycache__/views.cpython-312.pyc index 73623e5c..587eff38 100644 Binary files a/rides/__pycache__/views.cpython-312.pyc and b/rides/__pycache__/views.cpython-312.pyc differ diff --git a/rides/models.py b/rides/models.py index c3577616..7925fe55 100644 --- a/rides/models.py +++ b/rides/models.py @@ -1,9 +1,11 @@ +from typing import Tuple, Optional, Any from django.db import models from django.contrib.contenttypes.fields import GenericRelation from django.utils.text import slugify from simple_history.models import HistoricalRecords +from history_tracking.models import HistoricalModel -class Ride(models.Model): +class Ride(HistoricalModel): CATEGORY_CHOICES = [ ('RC', 'Roller Coaster'), ('DR', 'Dark Ride'), @@ -80,31 +82,43 @@ class Ride(models.Model): updated_at = models.DateTimeField(auto_now=True) photos = GenericRelation('media.Photo') reviews = GenericRelation('reviews.Review') - history = HistoricalRecords() + history: HistoricalRecords = HistoricalRecords() # type: ignore class Meta: ordering = ['name'] unique_together = ['park', 'slug'] - def __str__(self): + def __str__(self) -> str: return f"{self.name} at {self.park.name}" - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) @classmethod - def get_by_slug(cls, slug): - """Get ride by current or historical slug""" + def get_by_slug(cls, slug: str) -> Tuple['Ride', bool]: + """Get ride by current or historical slug. + + Args: + slug: The slug to look up + + Returns: + A tuple of (Ride object, bool indicating if it's a historical slug) + + Raises: + cls.DoesNotExist: If no ride is found with the given slug + """ try: return cls.objects.get(slug=slug), False - except cls.DoesNotExist: + except cls.DoesNotExist as e: # Check historical slugs - history = cls.history.filter(slug=slug).order_by('-history_date').first() - if history: - return cls.objects.get(id=history.id), True - raise cls.DoesNotExist("No ride found with this slug") + if history := cls.history.filter(slug=slug).order_by('-history_date').first(): # type: ignore[attr-defined] + try: + return cls.objects.get(pk=history.instance.pk), True + except cls.DoesNotExist as inner_e: + raise cls.DoesNotExist("No ride found with this slug") from inner_e + raise cls.DoesNotExist("No ride found with this slug") from e class RollerCoasterStats(models.Model): LAUNCH_CHOICES = [ @@ -198,11 +212,11 @@ class RollerCoasterStats(models.Model): trains_count = models.PositiveIntegerField(null=True, blank=True) cars_per_train = models.PositiveIntegerField(null=True, blank=True) seats_per_car = models.PositiveIntegerField(null=True, blank=True) - history = HistoricalRecords() + history: HistoricalRecords = HistoricalRecords() # type: ignore class Meta: verbose_name = 'Roller Coaster Statistics' verbose_name_plural = 'Roller Coaster Statistics' - def __str__(self): + def __str__(self) -> str: return f"Stats for {self.ride.name}" diff --git a/rides/views.py b/rides/views.py index 1734783a..ffba53bb 100644 --- a/rides/views.py +++ b/rides/views.py @@ -1,3 +1,4 @@ +from typing import Any, Dict, Optional, Tuple, Union, cast from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.shortcuts import get_object_or_404 from django.core.serializers.json import DjangoJSONEncoder @@ -6,344 +7,464 @@ from django.db.models import Q from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.contrib import messages -from django.http import JsonResponse, HttpResponseRedirect, Http404 +from django.http import ( + JsonResponse, + HttpResponseRedirect, + Http404, + HttpRequest, + HttpResponse, +) from django.db.models import Count +from django.core.files.uploadedfile import UploadedFile +from django.forms import ModelForm from .models import Ride, RollerCoasterStats from .forms import RideForm from parks.models import Park from core.views import SlugRedirectMixin from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin +from moderation.models import EditSubmission from media.models import Photo +from accounts.models import User + + +def is_privileged_user(user: Any) -> bool: + """Check if the user has privileged access. + + Args: + user: The user to check + + Returns: + bool: True if user has privileged or higher privileges + """ + return isinstance(user, User) and user.role in ["MODERATOR", "ADMIN", "SUPERUSER"] + + +def handle_photo_uploads(request: HttpRequest, ride: Ride) -> int: + """Handle photo uploads for a ride. + + Args: + request: The HTTP request containing files + ride: The ride to attach photos to + + Returns: + int: Number of successfully uploaded photos + """ + uploaded_count = 0 + photos = request.FILES.getlist("photos") + for photo_file in photos: + try: + Photo.objects.create( + image=photo_file, + uploaded_by=request.user, + content_type=ContentType.objects.get_for_model(Ride), + object_id=ride.pk, + ) + uploaded_count += 1 + except Exception as e: + messages.error(request, f"Error uploading photo {photo_file.name}: {str(e)}") + return uploaded_count + + +def prepare_form_data(cleaned_data: Dict[str, Any], park: Park) -> Dict[str, Any]: + """Prepare form data for submission. + + Args: + cleaned_data: The form's cleaned data + park: The park instance + + Returns: + Dict[str, Any]: Processed form data ready for submission + """ + data = cleaned_data.copy() + data["park"] = park.pk + if data.get("park_area"): + data["park_area"] = data["park_area"].pk + if data.get("manufacturer"): + data["manufacturer"] = data["manufacturer"].pk + return data + + +def handle_form_errors(request: HttpRequest, form: ModelForm) -> None: + """Handle form validation errors by adding appropriate error messages. + + Args: + request: The HTTP request + form: The form containing validation errors + """ + messages.error( + request, + "Please correct the errors below. Required fields are marked with an asterisk (*).", + ) + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"{field}: {error}") + + +def create_edit_submission( + request: HttpRequest, + submission_type: str, + changes: Dict[str, Any], + object_id: Optional[int] = None, +) -> EditSubmission: + """Create an EditSubmission object for ride changes. + + Args: + request: The HTTP request + submission_type: Type of submission (CREATE or EDIT) + changes: The changes to be submitted + object_id: Optional ID of the existing object for edits + + Returns: + EditSubmission: The created submission object + """ + submission_data = { + "user": request.user, + "content_type": ContentType.objects.get_for_model(Ride), + "submission_type": submission_type, + "changes": changes, + "reason": request.POST.get("reason", ""), + "source": request.POST.get("source", ""), + } + + if object_id is not None: + submission_data["object_id"] = object_id + + return EditSubmission.objects.create(**submission_data) + + +def handle_privileged_save( + request: HttpRequest, form: RideForm, submission: EditSubmission +) -> Tuple[bool, str]: + """Handle saving form and updating submission for privileged users. + + Args: + request: The HTTP request + form: The form to save + submission: The edit submission to update + + Returns: + Tuple[bool, str]: Success status and error message (empty string if successful) + """ + try: + ride = form.save() + if submission.submission_type == "CREATE": + submission.object_id = ride.pk + submission.status = "APPROVED" + submission.handled_by = request.user + submission.save() + return True, "" + except Exception as e: + error_msg = ( + f"Error {submission.submission_type.lower()}ing ride: {str(e)}. " + "Please check your input and try again." + ) + return False, error_msg + class SingleCategoryListView(ListView): model = Ride - template_name = 'rides/ride_category_list.html' - context_object_name = 'categories' + template_name = "rides/ride_category_list.html" + context_object_name = "categories" - def get_category_code(self): - category = self.kwargs.get('category') - if not category: - raise Http404("Category not found") - return category + def get_category_code(self) -> str: + if category := self.kwargs.get("category"): + return category + raise Http404("Category not found") def get_queryset(self): category_code = self.get_category_code() category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - - rides = Ride.objects.filter(category=category_code).select_related( - 'park', 'manufacturer' - ).order_by('name') - + + rides = ( + Ride.objects.filter(category=category_code) + .select_related("park", "manufacturer") + .order_by("name") + ) + return {category_name: rides} if rides.exists() else {} - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) category_code = self.get_category_code() category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - context['title'] = f'All {category_name}s' - context['category_code'] = category_code + context["title"] = f"All {category_name}s" + context["category_code"] = category_code return context + class ParkSingleCategoryListView(ListView): model = Ride - template_name = 'rides/ride_category_list.html' - context_object_name = 'categories' + template_name = "rides/ride_category_list.html" + context_object_name = "categories" - def setup(self, request, *args, **kwargs): + def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: super().setup(request, *args, **kwargs) - self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) - def get_category_code(self): - category = self.kwargs.get('category') - if not category: - raise Http404("Category not found") - return category + def get_category_code(self) -> str: + if category := self.kwargs.get("category"): + return category + raise Http404("Category not found") def get_queryset(self): category_code = self.get_category_code() category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - - rides = Ride.objects.filter( - park=self.park, - category=category_code - ).select_related('manufacturer').order_by('name') - + + rides = ( + Ride.objects.filter(park=self.park, category=category_code) + .select_related("manufacturer") + .order_by("name") + ) + return {category_name: rides} if rides.exists() else {} - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) - context['park'] = self.park + context["park"] = self.park category_code = self.get_category_code() category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - context['title'] = f'{category_name}s at {self.park.name}' - context['category_code'] = category_code + context["title"] = f"{category_name}s at {self.park.name}" + context["category_code"] = category_code return context + class RideCreateView(LoginRequiredMixin, CreateView): model = Ride form_class = RideForm - template_name = 'rides/ride_form.html' + template_name = "rides/ride_form.html" - def setup(self, request, *args, **kwargs): + def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: super().setup(request, *args, **kwargs) - self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) - def get_form_kwargs(self): + def get_form_kwargs(self) -> Dict[str, Any]: kwargs = super().get_form_kwargs() - kwargs['park'] = self.park + kwargs["park"] = self.park return kwargs - def form_valid(self, form): - form.instance.park = self.park - - cleaned_data = form.cleaned_data.copy() - cleaned_data['park'] = self.park.id - # Convert model instances to IDs for JSON serialization - if cleaned_data.get('park_area'): - cleaned_data['park_area'] = cleaned_data['park_area'].id - if cleaned_data.get('manufacturer'): - cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id + def handle_submission( + self, form: RideForm, cleaned_data: Dict[str, Any] + ) -> HttpResponseRedirect: + """Handle the form submission. - # Create submission record - submission = EditSubmission.objects.create( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Ride), - submission_type='CREATE', - changes=cleaned_data, - 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']: - try: - self.object = form.save() - submission.object_id = self.object.id - submission.status = 'APPROVED' - submission.handled_by = self.request.user - submission.save() + Args: + form: The form to process + cleaned_data: The cleaned form data - # Handle photo uploads - photos = self.request.FILES.getlist('photos') - uploaded_count = 0 - for photo_file in photos: - try: - Photo.objects.create( - image=photo_file, - uploaded_by=self.request.user, - content_type=ContentType.objects.get_for_model(Ride), - object_id=self.object.id - ) - uploaded_count += 1 - except Exception as e: - messages.error(self.request, f"Error uploading photo {photo_file.name}: {str(e)}") + Returns: + HttpResponseRedirect to appropriate URL + """ + submission = create_edit_submission(self.request, "CREATE", cleaned_data) + if is_privileged_user(self.request.user): + success, error_msg = handle_privileged_save(self.request, form, submission) + if success: + self.object = form.instance + uploaded_count = handle_photo_uploads(self.request, self.object) messages.success( self.request, f"Successfully created {self.object.name} at {self.park.name}. " - f"Added {uploaded_count} photo(s)." + f"Added {uploaded_count} photo(s).", ) return HttpResponseRedirect(self.get_success_url()) - except Exception as e: - messages.error( - self.request, - f"Error creating ride: {str(e)}. Please check your input and try again." - ) - return self.form_invalid(form) - + else: + if error_msg: # Only add error message if there is one + messages.error(self.request, error_msg) + return cast(HttpResponseRedirect, self.form_invalid(form)) + messages.success( self.request, "Your ride submission has been sent for review. " - "You will be notified when it is approved." + "You will be notified when it is approved.", + ) + return HttpResponseRedirect( + reverse("parks:rides:ride_list", kwargs={"park_slug": self.park.slug}) ) - return HttpResponseRedirect(reverse('parks:rides:ride_list', kwargs={'park_slug': self.park.slug})) - def form_invalid(self, form): - messages.error( - self.request, - "Please correct the errors below. Required fields are marked with an asterisk (*)." - ) - for field, errors in form.errors.items(): - for error in errors: - messages.error(self.request, f"{field}: {error}") + def form_valid(self, form: RideForm) -> HttpResponseRedirect: + form.instance.park = self.park + cleaned_data = prepare_form_data(form.cleaned_data, self.park) + return self.handle_submission(form, cleaned_data) + + def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]: + """Handle invalid form submission. + + Args: + form: The invalid form + + Returns: + Response with error messages + """ + handle_form_errors(self.request, form) return super().form_invalid(form) - def get_success_url(self): - return reverse('parks:rides:ride_detail', kwargs={ - 'park_slug': self.park.slug, - 'ride_slug': self.object.slug - }) + def get_success_url(self) -> str: + return reverse( + "parks:rides:ride_detail", + kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug}, + ) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) - context['park'] = self.park + context["park"] = self.park return context + class RideUpdateView(LoginRequiredMixin, UpdateView): model = Ride form_class = RideForm - template_name = 'rides/ride_form.html' - slug_url_kwarg = 'ride_slug' + template_name = "rides/ride_form.html" + slug_url_kwarg = "ride_slug" - def setup(self, request, *args, **kwargs): + def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: super().setup(request, *args, **kwargs) - self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) - def get_form_kwargs(self): + def get_form_kwargs(self) -> Dict[str, Any]: kwargs = super().get_form_kwargs() - kwargs['park'] = self.park + kwargs["park"] = self.park return kwargs - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) - context['park'] = self.park - context['is_edit'] = True + context["park"] = self.park + context["is_edit"] = True return context - def form_valid(self, form): - cleaned_data = form.cleaned_data.copy() - cleaned_data['park'] = self.park.id - # Convert model instances to IDs for JSON serialization - if cleaned_data.get('park_area'): - cleaned_data['park_area'] = cleaned_data['park_area'].id - if cleaned_data.get('manufacturer'): - cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id + def handle_submission( + self, form: RideForm, cleaned_data: Dict[str, Any] + ) -> HttpResponseRedirect: + """Handle the form submission. - # Create submission record - submission = EditSubmission.objects.create( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Ride), - object_id=self.object.id, - submission_type='EDIT', - changes=cleaned_data, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') + Args: + form: The form to process + cleaned_data: The cleaned form data + + Returns: + HttpResponseRedirect to appropriate URL + """ + submission = create_edit_submission( + self.request, "EDIT", cleaned_data, self.object.pk ) - # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - try: - self.object = form.save() - submission.status = 'APPROVED' - submission.handled_by = self.request.user - submission.save() - - # Handle photo uploads - photos = self.request.FILES.getlist('photos') - uploaded_count = 0 - for photo_file in photos: - try: - Photo.objects.create( - image=photo_file, - uploaded_by=self.request.user, - content_type=ContentType.objects.get_for_model(Ride), - object_id=self.object.id - ) - uploaded_count += 1 - except Exception as e: - messages.error(self.request, f"Error uploading photo {photo_file.name}: {str(e)}") - + if is_privileged_user(self.request.user): + success, error_msg = handle_privileged_save(self.request, form, submission) + if success: + self.object = form.instance + uploaded_count = handle_photo_uploads(self.request, self.object) messages.success( self.request, f"Successfully updated {self.object.name}. " - f"Added {uploaded_count} new photo(s)." + f"Added {uploaded_count} new photo(s).", ) return HttpResponseRedirect(self.get_success_url()) - except Exception as e: - messages.error( - self.request, - f"Error updating ride: {str(e)}. Please check your input and try again." - ) - return self.form_invalid(form) - + else: + if error_msg: # Only add error message if there is one + messages.error(self.request, error_msg) + return cast(HttpResponseRedirect, self.form_invalid(form)) + messages.success( self.request, f"Your changes to {self.object.name} have been sent for review. " - "You will be notified when they are approved." + "You will be notified when they are approved.", + ) + return HttpResponseRedirect( + reverse( + "parks:rides:ride_detail", + kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug}, + ) ) - return HttpResponseRedirect(reverse('parks:rides:ride_detail', kwargs={ - 'park_slug': self.park.slug, - 'ride_slug': self.object.slug - })) - def form_invalid(self, form): - messages.error( - self.request, - "Please correct the errors below. Required fields are marked with an asterisk (*)." - ) - for field, errors in form.errors.items(): - for error in errors: - messages.error(self.request, f"{field}: {error}") + def form_valid(self, form: RideForm) -> HttpResponseRedirect: + cleaned_data = prepare_form_data(form.cleaned_data, self.park) + return self.handle_submission(form, cleaned_data) + + def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]: + """Handle invalid form submission. + + Args: + form: The invalid form + + Returns: + Response with error messages + """ + handle_form_errors(self.request, form) return super().form_invalid(form) - def get_success_url(self): - return reverse('parks:rides:ride_detail', kwargs={ - 'park_slug': self.park.slug, - 'ride_slug': self.object.slug - }) + def get_success_url(self) -> str: + return reverse( + "parks:rides:ride_detail", + kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug}, + ) -class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): + +class RideDetailView( + SlugRedirectMixin, + EditSubmissionMixin, + PhotoSubmissionMixin, + HistoryMixin, + DetailView, +): model = Ride - template_name = 'rides/ride_detail.html' - context_object_name = 'ride' - slug_url_kwarg = 'ride_slug' + template_name = "rides/ride_detail.html" + context_object_name = "ride" + slug_url_kwarg = "ride_slug" def get_object(self, queryset=None): if queryset is None: queryset = self.get_queryset() - park_slug = self.kwargs.get('park_slug') - ride_slug = self.kwargs.get('ride_slug') - # Try to get by current or historical slug - obj, is_old_slug = self.model.get_by_slug(ride_slug) + park_slug = self.kwargs.get("park_slug") + ride_slug = self.kwargs.get("ride_slug") + obj, is_old_slug = self.model.get_by_slug(ride_slug) # type: ignore[attr-defined] if obj.park.slug != park_slug: raise self.model.DoesNotExist("Park slug doesn't match") return obj - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) - if self.object.category == 'RC': - context['coaster_stats'] = RollerCoasterStats.objects.filter(ride=self.object).first() + if self.object.category == "RC": + context["coaster_stats"] = RollerCoasterStats.objects.filter( + ride=self.object + ).first() return context - def get_redirect_url_pattern(self): - return 'parks:rides:ride_detail' + def get_redirect_url_pattern(self) -> str: + return "parks:rides:ride_detail" + + def get_redirect_url_kwargs(self) -> Dict[str, Any]: + return {"park_slug": self.object.park.slug, "ride_slug": self.object.slug} - def get_redirect_url_kwargs(self): - return { - 'park_slug': self.object.park.slug, - 'ride_slug': self.object.slug - } class RideListView(ListView): model = Ride - template_name = 'rides/ride_list.html' - context_object_name = 'rides' + template_name = "rides/ride_list.html" + context_object_name = "rides" - def setup(self, request, *args, **kwargs): + def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: super().setup(request, *args, **kwargs) self.park = None - if 'park_slug' in self.kwargs: - self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + if "park_slug" in self.kwargs: + self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) def get_queryset(self): - queryset = Ride.objects.select_related('park', 'coaster_stats', 'manufacturer').prefetch_related('photos') - - # Filter by park if viewing park-specific rides + queryset = Ride.objects.select_related( + "park", "coaster_stats", "manufacturer" + ).prefetch_related("photos") + if self.park: queryset = queryset.filter(park=self.park) - - search = self.request.GET.get('search', '').strip() or None - category = self.request.GET.get('category', '').strip() or None - status = self.request.GET.get('status', '').strip() or None - manufacturer = self.request.GET.get('manufacturer', '').strip() or None - + + search = self.request.GET.get("search", "").strip() or None + category = self.request.GET.get("category", "").strip() or None + status = self.request.GET.get("status", "").strip() or None + manufacturer = self.request.GET.get("manufacturer", "").strip() or None + if search: if self.park: queryset = queryset.filter(name__icontains=search) else: queryset = queryset.filter( - Q(name__icontains=search) | - Q(park__name__icontains=search) + Q(name__icontains=search) | Q(park__name__icontains=search) ) if category: queryset = queryset.filter(category=category) @@ -351,37 +472,34 @@ class RideListView(ListView): queryset = queryset.filter(status=status) if manufacturer: queryset = queryset.exclude(manufacturer__isnull=True) - + return queryset - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs) -> Dict[str, Any]: context = super().get_context_data(**kwargs) - context['park'] = self.park + context["park"] = self.park - # Get manufacturers for the filter dropdown manufacturer_query = Ride.objects if self.park: manufacturer_query = manufacturer_query.filter(park=self.park) - - context['manufacturers'] = list( + + context["manufacturers"] = list( manufacturer_query.exclude(manufacturer__isnull=True) - .values_list('manufacturer__name', flat=True) - .distinct().order_by('manufacturer__name') + .values_list("manufacturer__name", flat=True) + .distinct() + .order_by("manufacturer__name") ) - # Add current filter values to context - context['current_filters'] = { - 'search': self.request.GET.get('search', ''), - 'category': self.request.GET.get('category', ''), - 'status': self.request.GET.get('status', ''), - 'manufacturer': self.request.GET.get('manufacturer', '') + context["current_filters"] = { + "search": self.request.GET.get("search", ""), + "category": self.request.GET.get("category", ""), + "status": self.request.GET.get("status", ""), + "manufacturer": self.request.GET.get("manufacturer", ""), } - + return context - def get(self, request, *args, **kwargs): - # Check if this is an HTMX request - if request.htmx: - # If it is, return just the rides list partial - self.template_name = 'rides/partials/ride_list.html' + def get(self, request: HttpRequest, *args: Any, **kwargs: Any): + if getattr(request, "htmx", False): # type: ignore[attr-defined] + self.template_name = "rides/partials/ride_list.html" return super().get(request, *args, **kwargs) diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 9dd3d5da..4f32e735 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2225,6 +2225,14 @@ select { z-index: 60; } +.col-span-1 { + grid-column: span 1 / span 1; +} + +.col-span-12 { + grid-column: span 12 / span 12; +} + .col-span-2 { grid-column: span 2 / span 2; } @@ -2237,26 +2245,6 @@ select { grid-column: 1 / -1; } -.col-span-4 { - grid-column: span 4 / span 4; -} - -.col-span-8 { - grid-column: span 8 / span 8; -} - -.col-span-9 { - grid-column: span 9 / span 9; -} - -.col-span-1 { - grid-column: span 1 / span 1; -} - -.col-span-12 { - grid-column: span 12 / span 12; -} - .mx-1 { margin-left: 0.25rem; margin-right: 0.25rem; @@ -2285,6 +2273,10 @@ select { margin-bottom: 0.25rem; } +.mb-10 { + margin-bottom: 2.5rem; +} + .mb-12 { margin-bottom: 3rem; } @@ -2309,6 +2301,10 @@ select { margin-bottom: 2rem; } +.ml-0\.5 { + margin-left: 0.125rem; +} + .ml-1 { margin-left: 0.25rem; } @@ -2321,6 +2317,10 @@ select { margin-left: 1.5rem; } +.mr-0\.5 { + margin-right: 0.125rem; +} + .mr-1 { margin-right: 0.25rem; } @@ -2333,6 +2333,10 @@ select { margin-right: 0.75rem; } +.mt-0\.5 { + margin-top: 0.125rem; +} + .mt-1 { margin-top: 0.25rem; } @@ -2357,38 +2361,6 @@ select { margin-top: auto; } -.mt-0\.5 { - margin-top: 0.125rem; -} - -.mt-8 { - margin-top: 2rem; -} - -.mr-1\.5 { - margin-right: 0.375rem; -} - -.mb-0\.5 { - margin-bottom: 0.125rem; -} - -.ml-0\.5 { - margin-left: 0.125rem; -} - -.mr-0\.5 { - margin-right: 0.125rem; -} - -.mt-1\.5 { - margin-top: 0.375rem; -} - -.mb-10 { - margin-bottom: 2.5rem; -} - .block { display: block; } @@ -2453,18 +2425,14 @@ select { height: 300px; } -.h-full { - height: 100%; -} - -.h-\[340px\] { - height: 340px; -} - .h-auto { height: auto; } +.h-full { + height: 100%; +} + .max-h-60 { max-height: 15rem; } @@ -2473,10 +2441,6 @@ select { max-height: 90vh; } -.max-h-\[340px\] { - max-height: 340px; -} - .min-h-\[calc\(100vh-16rem\)\] { min-height: calc(100vh - 16rem); } @@ -2485,14 +2449,6 @@ select { min-height: 100vh; } -.min-h-0 { - min-height: 0px; -} - -.min-h-\[200px\] { - min-height: 200px; -} - .w-16 { width: 4rem; } @@ -2566,10 +2522,6 @@ select { flex: 1 1 0%; } -.flex-shrink-0 { - flex-shrink: 0; -} - .flex-grow { flex-grow: 1; } @@ -2619,36 +2571,24 @@ select { cursor: pointer; } -.resize { - resize: both; -} - -.auto-rows-fr { - grid-auto-rows: minmax(0, 1fr); -} - -.auto-rows-min { - grid-auto-rows: min-content; -} - .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } +.grid-cols-12 { + grid-template-columns: repeat(12, minmax(0, 1fr)); +} + .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.grid-cols-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.grid-cols-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); +.grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); } .flex-col { @@ -2683,6 +2623,10 @@ select { justify-content: space-between; } +.gap-1 { + gap: 0.25rem; +} + .gap-2 { gap: 0.5rem; } @@ -2695,45 +2639,6 @@ select { gap: 1.5rem; } -.gap-3 { - gap: 0.75rem; -} - -.gap-8 { - gap: 2rem; -} - -.gap-1\.5 { - gap: 0.375rem; -} - -.gap-1 { - gap: 0.25rem; -} - -.gap-x-8 { - -moz-column-gap: 2rem; - column-gap: 2rem; -} - -.gap-y-6 { - row-gap: 1.5rem; -} - -.gap-x-6 { - -moz-column-gap: 1.5rem; - column-gap: 1.5rem; -} - -.gap-y-4 { - row-gap: 1rem; -} - -.gap-x-4 { - -moz-column-gap: 1rem; - column-gap: 1rem; -} - .space-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.5rem * var(--tw-space-x-reverse)); @@ -2784,10 +2689,6 @@ select { overflow: hidden; } -.overflow-y-auto { - overflow-y: auto; -} - .rounded { border-radius: 0.25rem; } @@ -2923,6 +2824,11 @@ select { background-color: rgb(37 99 235 / var(--tw-bg-opacity)); } +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + .bg-gray-200 { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity)); @@ -2991,11 +2897,6 @@ select { background-color: rgb(202 138 4 / var(--tw-bg-opacity)); } -.bg-gray-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -3047,6 +2948,14 @@ select { object-fit: cover; } +.p-0\.5 { + padding: 0.125rem; +} + +.p-1\.5 { + padding: 0.375rem; +} + .p-2 { padding: 0.5rem; } @@ -3067,18 +2976,6 @@ select { padding: 2rem; } -.p-2\.5 { - padding: 0.625rem; -} - -.p-0\.5 { - padding: 0.125rem; -} - -.p-1\.5 { - padding: 0.375rem; -} - .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; @@ -3104,6 +3001,11 @@ select { padding-right: 2rem; } +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; @@ -3144,11 +3046,6 @@ select { padding-bottom: 2rem; } -.py-0\.5 { - padding-top: 0.125rem; - padding-bottom: 0.125rem; -} - .pb-4 { padding-bottom: 1rem; } @@ -3157,10 +3054,6 @@ select { text-align: center; } -.align-middle { - vertical-align: middle; -} - .text-2xl { font-size: 1.5rem; line-height: 2rem; @@ -3196,11 +3089,6 @@ select { line-height: 1rem; } -.text-base { - font-size: 1rem; - line-height: 1.5rem; -} - .font-bold { font-weight: 700; } @@ -3237,6 +3125,11 @@ select { color: rgb(30 64 175 / var(--tw-text-opacity)); } +.text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + .text-gray-300 { --tw-text-opacity: 1; color: rgb(209 213 219 / var(--tw-text-opacity)); @@ -3267,6 +3160,11 @@ select { color: rgb(17 24 39 / var(--tw-text-opacity)); } +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + .text-green-800 { --tw-text-opacity: 1; color: rgb(22 101 52 / var(--tw-text-opacity)); @@ -3297,6 +3195,11 @@ select { color: rgb(153 27 27 / var(--tw-text-opacity)); } +.text-sky-900 { + --tw-text-opacity: 1; + color: rgb(12 74 110 / var(--tw-text-opacity)); +} + .text-transparent { color: transparent; } @@ -3326,21 +3229,6 @@ select { color: rgb(133 77 14 / var(--tw-text-opacity)); } -.text-green-600 { - --tw-text-opacity: 1; - color: rgb(22 163 74 / var(--tw-text-opacity)); -} - -.text-sky-400 { - --tw-text-opacity: 1; - color: rgb(56 189 248 / var(--tw-text-opacity)); -} - -.text-sky-900 { - --tw-text-opacity: 1; - color: rgb(12 74 110 / var(--tw-text-opacity)); -} - .opacity-0 { opacity: 0; } @@ -3430,14 +3318,14 @@ select { transition-duration: 150ms; } -.transition-transform { - transition-property: transform; +.transition-shadow { + transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } -.transition-shadow { - transition-property: box-shadow; +.transition-transform { + transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } @@ -3496,11 +3384,6 @@ select { 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)); } -.hover\:translate-x-2:hover { - --tw-translate-x: 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)); -} - .hover\:scale-105:hover { --tw-scale-x: 1.05; --tw-scale-y: 1.05; @@ -3528,6 +3411,11 @@ select { background-color: rgb(243 244 246 / var(--tw-bg-opacity)); } +.hover\:bg-gray-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + .hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity)); @@ -3562,11 +3450,6 @@ select { background-color: rgb(202 138 4 / var(--tw-bg-opacity)); } -.hover\:bg-gray-200:hover { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity)); -} - .hover\:text-blue-500:hover { --tw-text-opacity: 1; color: rgb(59 130 246 / var(--tw-text-opacity)); @@ -3606,31 +3489,11 @@ select { color: rgb(79 70 229 / 0.8); } -.hover\:text-sky-300:hover { - --tw-text-opacity: 1; - color: rgb(125 211 252 / var(--tw-text-opacity)); -} - -.hover\:text-sky-900:hover { - --tw-text-opacity: 1; - color: rgb(12 74 110 / var(--tw-text-opacity)); -} - -.hover\:text-sky-950:hover { - --tw-text-opacity: 1; - color: rgb(8 47 73 / var(--tw-text-opacity)); -} - .hover\:text-sky-800:hover { --tw-text-opacity: 1; color: rgb(7 89 133 / var(--tw-text-opacity)); } -.hover\:text-blue-800:hover { - --tw-text-opacity: 1; - color: rgb(30 64 175 / var(--tw-text-opacity)); -} - .hover\:underline:hover { text-decoration-line: underline; } @@ -3641,6 +3504,12 @@ select { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.hover\:shadow-xl:hover { + --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); +} + .focus\:border-blue-500:focus { --tw-border-opacity: 1; border-color: rgb(59 130 246 / var(--tw-border-opacity)); @@ -3853,6 +3722,11 @@ select { color: rgb(187 247 208 / var(--tw-text-opacity)); } +.dark\:text-green-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(74 222 128 / var(--tw-text-opacity)); +} + .dark\:text-green-900:is(.dark *) { --tw-text-opacity: 1; color: rgb(20 83 45 / var(--tw-text-opacity)); @@ -3878,6 +3752,11 @@ select { color: rgb(127 29 29 / var(--tw-text-opacity)); } +.dark\:text-sky-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(56 189 248 / var(--tw-text-opacity)); +} + .dark\:text-white:is(.dark *) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -3903,16 +3782,6 @@ select { color: rgb(254 252 232 / var(--tw-text-opacity)); } -.dark\:text-green-400:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(74 222 128 / var(--tw-text-opacity)); -} - -.dark\:text-sky-400:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(56 189 248 / var(--tw-text-opacity)); -} - .dark\:ring-1:is(.dark *) { --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(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -3986,39 +3855,20 @@ select { color: rgb(79 70 229 / var(--tw-text-opacity)); } -.dark\:hover\:text-sky-400:hover:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(56 189 248 / var(--tw-text-opacity)); -} - -.dark\:hover\:text-sky-600:hover:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(2 132 199 / var(--tw-text-opacity)); -} - -.dark\:hover\:text-sky-200:hover:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(186 230 253 / var(--tw-text-opacity)); -} - .dark\:hover\:text-sky-300:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(125 211 252 / var(--tw-text-opacity)); } @media (min-width: 640px) { - .sm\:col-span-2 { - grid-column: span 2 / span 2; + .sm\:col-span-3 { + grid-column: span 3 / span 3; } .sm\:col-span-4 { grid-column: span 4 / span 4; } - .sm\:col-span-3 { - grid-column: span 3 / span 3; - } - .sm\:col-span-8 { grid-column: span 8 / span 8; } @@ -4027,68 +3877,20 @@ select { grid-column: span 9 / span 9; } - .sm\:mb-8 { - margin-bottom: 2rem; - } - .sm\:mb-16 { margin-bottom: 4rem; } - .sm\:flex { - display: flex; - } - - .sm\:h-\[340px\] { - height: 340px; - } - - .sm\:h-\[300px\] { - height: 300px; - } - - .sm\:h-\[200px\] { - height: 200px; - } - - .sm\:h-\[160px\] { - height: 160px; - } - - .sm\:h-\[140px\] { - height: 140px; - } - - .sm\:h-auto { - height: auto; - } - - .sm\:grid-cols-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .sm\:grid-cols-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .sm\:grid-cols-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .sm\:grid-cols-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - .sm\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } - .sm\:flex-col { - flex-direction: column; + .sm\:grid-cols-12 { + grid-template-columns: repeat(12, minmax(0, 1fr)); } - .sm\:gap-4 { - gap: 1rem; + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); } .sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) { @@ -4103,6 +3905,11 @@ select { margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); } + .sm\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + .sm\:text-3xl { font-size: 1.875rem; line-height: 2.25rem; @@ -4118,52 +3925,18 @@ select { line-height: 1.25rem; } - .sm\:text-4xl { - font-size: 2.25rem; - line-height: 2.5rem; - } - - .sm\:text-lg { - font-size: 1.125rem; - line-height: 1.75rem; - } - .sm\:text-xs { font-size: 0.75rem; line-height: 1rem; } - .sm\:text-2xl { - font-size: 1.5rem; - line-height: 2rem; + .sm\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; } } @media (min-width: 768px) { - .md\:col-span-2 { - grid-column: span 2 / span 2; - } - - .md\:col-span-3 { - grid-column: span 3 / span 3; - } - - .md\:col-span-9 { - grid-column: span 9 / span 9; - } - - .md\:col-span-4 { - grid-column: span 4 / span 4; - } - - .md\:col-span-8 { - grid-column: span 8 / span 8; - } - - .md\:mt-0 { - margin-top: 0px; - } - .md\:mb-8 { margin-bottom: 2rem; } @@ -4184,14 +3957,6 @@ select { grid-template-columns: repeat(4, minmax(0, 1fr)); } - .md\:grid-cols-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .md\:grid-cols-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - .md\:flex-row { flex-direction: row; } @@ -4200,10 +3965,6 @@ select { align-items: center; } - .md\:justify-between { - justify-content: space-between; - } - .md\:text-2xl { font-size: 1.5rem; line-height: 2rem; @@ -4224,38 +3985,6 @@ select { grid-column: span 2 / span 2; } - .lg\:col-span-4 { - grid-column: span 4 / span 4; - } - - .lg\:col-span-8 { - grid-column: span 8 / span 8; - } - - .lg\:col-span-3 { - grid-column: span 3 / span 3; - } - - .lg\:col-span-5 { - grid-column: span 5 / span 5; - } - - .lg\:mb-0 { - margin-bottom: 0px; - } - - .lg\:mr-6 { - margin-right: 1.5rem; - } - - .lg\:ml-8 { - margin-left: 2rem; - } - - .lg\:mt-0 { - margin-top: 0px; - } - .lg\:flex { display: flex; } @@ -4264,18 +3993,6 @@ select { display: none; } - .lg\:w-1\/3 { - width: 33.333333%; - } - - .lg\:w-1\/2 { - width: 50%; - } - - .lg\:flex-1 { - flex: 1 1 0%; - } - .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -4284,38 +4001,8 @@ select { grid-template-columns: repeat(4, minmax(0, 1fr)); } - .lg\:grid-cols-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .lg\:grid-cols-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .lg\:grid-cols-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .lg\:flex-row { - flex-direction: row; - } - - .lg\:items-start { - align-items: flex-start; - } - - .lg\:justify-between { - justify-content: space-between; - } - .lg\:text-6xl { font-size: 3.75rem; line-height: 1; } } - -@media (min-width: 1280px) { - .xl\:grid-cols-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} diff --git a/static/js/photo-gallery.js b/static/js/photo-gallery.js new file mode 100644 index 00000000..2705dcab --- /dev/null +++ b/static/js/photo-gallery.js @@ -0,0 +1,91 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({ + photos, + fullscreenPhoto: null, + uploading: false, + uploadProgress: 0, + error: null, + showSuccess: false, + + showFullscreen(photo) { + this.fullscreenPhoto = photo; + }, + + async handleFileSelect(event) { + const files = Array.from(event.target.files); + if (!files.length) { + return; + } + + this.uploading = true; + this.uploadProgress = 0; + this.error = null; + this.showSuccess = false; + + const totalFiles = files.length; + let completedFiles = 0; + + for (const file of files) { + const formData = new FormData(); + formData.append('image', file); + formData.append('app_label', contentType.split('.')[0]); + formData.append('model', contentType.split('.')[1]); + formData.append('object_id', objectId); + + try { + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + }, + body: formData + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Upload failed'); + } + + const photo = await response.json(); + this.photos.push(photo); + completedFiles++; + this.uploadProgress = (completedFiles / totalFiles) * 100; + } catch (err) { + this.error = err.message || 'Failed to upload photo. Please try again.'; + console.error('Upload error:', err); + break; + } + } + + this.uploading = false; + event.target.value = ''; // Reset file input + + if (!this.error) { + this.showSuccess = true; + setTimeout(() => { + this.showSuccess = false; + }, 3000); + } + }, + + async sharePhoto(photo) { + if (navigator.share) { + try { + await navigator.share({ + title: photo.caption || 'Shared photo', + url: photo.url + }); + } catch (err) { + if (err.name !== 'AbortError') { + console.error('Error sharing:', err); + } + } + } else { + // Fallback: copy URL to clipboard + navigator.clipboard.writeText(photo.url) + .then(() => alert('Photo URL copied to clipboard!')) + .catch(err => console.error('Error copying to clipboard:', err)); + } + } + })); +}); diff --git a/templates/home.html b/templates/home.html index dbe3980f..07f668da 100644 --- a/templates/home.html +++ b/templates/home.html @@ -29,88 +29,167 @@
-
+
{{ stats.total_parks }}
Theme Parks
-
+ -
+
{{ stats.total_rides }}
Attractions
-
+ -
+
{{ stats.total_roller_coasters }}
Roller Coasters
-
+
-
- +
+

- Popular Parks + Trending Parks

- {% for park in popular_parks %} - -
- {{ park.name }} -
-
- {{ park.location }} -
- {% if park.average_rating %} -
- + {% endblock %} diff --git a/templates/media/partials/photo_display.html b/templates/media/partials/photo_display.html index c18d24c4..e7d18563 100644 --- a/templates/media/partials/photo_display.html +++ b/templates/media/partials/photo_display.html @@ -118,96 +118,3 @@
- - - diff --git a/templates/parks/park_detail.html b/templates/parks/park_detail.html index df15234c..9f7627f9 100644 --- a/templates/parks/park_detail.html +++ b/templates/parks/park_detail.html @@ -37,20 +37,20 @@ {% endif %} -
+
-
+

{{ park.name }}

{% if park.formatted_location %} -
+

{{ park.formatted_location }}

{% endif %} -
- + - + + {{ park.average_rating|floatformat:1 }}/10 {% endif %} @@ -67,36 +67,36 @@
-
+
-
+
{% if park.total_rides %} + class="flex flex-col items-center justify-center p-4 text-center transition-transform bg-white rounded-lg shadow-lg hover:scale-[1.02] dark:bg-gray-800">
Total Rides
-
{{ park.total_rides }}
+
{{ park.total_rides }}
{% endif %} {% if park.total_roller_coasters %} -
+
Roller Coasters
-
{{ park.total_roller_coasters }}
+
{{ park.total_roller_coasters }}
{% endif %}
-
+
{% if park.owner %} -
- -
Owner
+
+ +
Owner
+ class="text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> {{ park.owner.name }}
@@ -104,23 +104,23 @@ {% endif %} {% if park.opening_date %} -
- -
Opened
-
{{ park.opening_date }}
+
+ +
Opened
+
{{ park.opening_date }}
{% endif %} {% if park.website %} -
- -
Website
+
+ +
Website
Visit - +
@@ -129,7 +129,7 @@
- + {% if park.photos.exists %}

Photos

@@ -225,6 +225,7 @@
+
{% if perms.media.add_photo %} @@ -252,16 +253,20 @@
{% endif %} -{% if park.latitude and park.longitude %} - {% block extra_js %} - - - - {% endblock %} -{% endif %} - +{% endblock %} + +{% block extra_js %} + + + + +{% if park.latitude and park.longitude %} + + + +{% endif %} {% endblock %} diff --git a/templates/rides/ride_detail.html b/templates/rides/ride_detail.html index 47741f94..7392df59 100644 --- a/templates/rides/ride_detail.html +++ b/templates/rides/ride_detail.html @@ -22,12 +22,12 @@ {% endif %} -
+
-
+

{{ ride.name }}

-
+
at {{ ride.park.name }} @@ -36,20 +36,20 @@ {% endif %}
-
- + {{ ride.get_status_display }} - + {{ ride.get_category_display }} {% if ride.average_rating %} - - + + {{ ride.average_rating|floatformat:1 }}/10 {% endif %} @@ -57,46 +57,46 @@
-
+
-
+
{% if coaster_stats %} {% if coaster_stats.height_ft %} -
+
Height
-
{{ coaster_stats.height_ft }} ft
+
{{ coaster_stats.height_ft }} ft
{% endif %} {% if coaster_stats.speed_mph %} -
+
Speed
-
{{ coaster_stats.speed_mph }} mph
+
{{ coaster_stats.speed_mph }} mph
{% endif %} {% if coaster_stats.inversions %} -
+
Inversions
-
{{ coaster_stats.inversions }}
+
{{ coaster_stats.inversions }}
{% endif %} {% if coaster_stats.length_ft %} -
+
Length
-
{{ coaster_stats.length_ft }} ft
+
{{ coaster_stats.length_ft }} ft
{% endif %} {% endif %}
-
+
{% if ride.manufacturer %} -
- -
Manufacturer
+
+ +
Manufacturer
+ class="text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> {{ ride.manufacturer.name }}
@@ -104,12 +104,12 @@ {% endif %} {% if ride.designer %} -
- -
Designer
+
+ +
Designer
+ class="text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> {{ ride.designer.name }}
@@ -117,49 +117,49 @@ {% endif %} {% if coaster_stats.roller_coaster_type %} -
- -
Coaster Type
-
{{ coaster_stats.get_roller_coaster_type_display }}
+
+ +
Coaster Type
+
{{ coaster_stats.get_roller_coaster_type_display }}
{% endif %} {% if coaster_stats.track_material %} -
- -
Track Material
-
{{ coaster_stats.get_track_material_display }}
+
+ +
Track Material
+
{{ coaster_stats.get_track_material_display }}
{% endif %} {% if ride.opening_date %} -
- -
Opened
-
{{ ride.opening_date }}
+
+ +
Opened
+
{{ ride.opening_date }}
{% endif %} {% if ride.capacity_per_hour %} -
- -
Capacity
-
{{ ride.capacity_per_hour }}/hr
+
+ +
Capacity
+
{{ ride.capacity_per_hour }}/hr
{% endif %} {% if coaster_stats.launch_type %} -
- -
Launch Type
-
{{ coaster_stats.get_launch_type_display }}
+
+ +
Launch Type
+
{{ coaster_stats.get_launch_type_display }}
{% endif %}
- + {% if ride.photos.exists %}

Photos

@@ -486,3 +486,7 @@
{% endif %} {% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/thrillwiki/__pycache__/settings.cpython-312.pyc b/thrillwiki/__pycache__/settings.cpython-312.pyc index d24daa64..67b8dc4f 100644 Binary files a/thrillwiki/__pycache__/settings.cpython-312.pyc and b/thrillwiki/__pycache__/settings.cpython-312.pyc differ diff --git a/thrillwiki/__pycache__/views.cpython-312.pyc b/thrillwiki/__pycache__/views.cpython-312.pyc index a666c36c..d80cadf1 100644 Binary files a/thrillwiki/__pycache__/views.cpython-312.pyc and b/thrillwiki/__pycache__/views.cpython-312.pyc differ diff --git a/thrillwiki/settings.py b/thrillwiki/settings.py index fb256be9..07963d12 100644 --- a/thrillwiki/settings.py +++ b/thrillwiki/settings.py @@ -1,12 +1,12 @@ -# Django settings for thrillwiki project. +""" +Django settings for thrillwiki project. +""" from pathlib import Path import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - -# SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^" # SECURITY WARNING: don't run with debug turned on in production! @@ -46,6 +46,7 @@ INSTALLED_APPS = [ "moderation", "history_tracking", "designers", + "analytics", ] MIDDLEWARE = [ @@ -62,6 +63,7 @@ MIDDLEWARE = [ "django.middleware.cache.FetchFromCacheMiddleware", "simple_history.middleware.HistoryRequestMiddleware", "django_htmx.middleware.HtmxMiddleware", + "analytics.middleware.PageViewMiddleware", # Add our page view tracking ] ROOT_URLCONF = "thrillwiki.urls" diff --git a/thrillwiki/views.py b/thrillwiki/views.py index 1a431453..514ee2e2 100644 --- a/thrillwiki/views.py +++ b/thrillwiki/views.py @@ -1,10 +1,14 @@ from django.shortcuts import render from django.views.generic import TemplateView -from django.db.models import Count, Q +from django.db.models import Count, Q, Value, CharField +from django.db.models.functions import Concat +from django.core.cache import cache from parks.models import Park from rides.models import Ride from companies.models import Company, Manufacturer +from analytics.models import PageView from django.conf import settings +import random import os @@ -27,15 +31,60 @@ class HomeView(TemplateView): 'total_roller_coasters': Ride.objects.filter(category='RC').count(), } - # Get popular parks (based on average rating) - context['popular_parks'] = Park.objects.exclude( + # Try to get trending items from cache first + trending_parks = cache.get('trending_parks') + trending_rides = cache.get('trending_rides') + + # If not in cache, get them directly and cache them + if trending_parks is None: + try: + trending_parks = list(PageView.get_trending_items(Park, hours=24, limit=10)) + if trending_parks: + cache.set('trending_parks', trending_parks, 3600) # Cache for 1 hour + else: + # Fallback to highest rated parks if no trending data + trending_parks = Park.objects.exclude( + average_rating__isnull=True + ).order_by('-average_rating')[:10] + except Exception: + # Fallback to highest rated parks if trending calculation fails + trending_parks = Park.objects.exclude( + average_rating__isnull=True + ).order_by('-average_rating')[:10] + + if trending_rides is None: + try: + trending_rides = list(PageView.get_trending_items(Ride, hours=24, limit=10)) + if trending_rides: + cache.set('trending_rides', trending_rides, 3600) # Cache for 1 hour + else: + # Fallback to highest rated rides if no trending data + trending_rides = Ride.objects.exclude( + average_rating__isnull=True + ).order_by('-average_rating')[:10] + except Exception: + # Fallback to highest rated rides if trending calculation fails + trending_rides = Ride.objects.exclude( + average_rating__isnull=True + ).order_by('-average_rating')[:10] + + # Get highest rated items (mix of parks and rides) + highest_rated_parks = list(Park.objects.exclude( average_rating__isnull=True - ).order_by('-average_rating')[:5] + ).order_by('-average_rating')[:20]) # Get more items to randomly select from - # Get popular rides (based on average rating) - context['popular_rides'] = Ride.objects.exclude( + highest_rated_rides = list(Ride.objects.exclude( average_rating__isnull=True - ).order_by('-average_rating')[:5] + ).order_by('-average_rating')[:20]) # Get more items to randomly select from + + # Combine and shuffle highest rated items + all_highest_rated = highest_rated_parks + highest_rated_rides + random.shuffle(all_highest_rated) + + # Keep the same context variable names for template compatibility + context['popular_parks'] = trending_parks + context['popular_rides'] = trending_rides + context['highest_rated'] = all_highest_rated[:10] # Take first 10 after shuffling return context @@ -76,6 +125,7 @@ class SearchView(TemplateView): ).prefetch_related('rides')[:10] return context + def environment_and_settings_view(request): # Get all environment variables env_vars = dict(os***REMOVED***iron) @@ -86,4 +136,4 @@ def environment_and_settings_view(request): return render(request, 'environment_and_settings.html', { 'env_vars': env_vars, 'settings_vars': settings_vars - }) \ No newline at end of file + })