feat: Implement ride management views and utility functions

- Added functions for checking user privileges, handling photo uploads, preparing form data, and managing form errors.
- Created views for listing, creating, updating, and displaying rides, including category-specific views.
- Integrated submission handling for ride changes and improved user feedback through messages.
- Enhanced data handling with appropriate context and queryset management for better performance and usability.
This commit is contained in:
pacnpal
2024-11-04 05:25:53 +00:00
parent ae913e757a
commit 01e0a609d2
29 changed files with 1087 additions and 925 deletions

1
analytics/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'analytics.apps.AnalyticsConfig'

3
analytics/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
analytics/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'analytics'

View File

@@ -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.'
)
)

39
analytics/middleware.py Normal file
View File

@@ -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

View File

@@ -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",
),
],
},
),
]

View File

57
analytics/models.py Normal file
View File

@@ -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()

3
analytics/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
analytics/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

0
db.sqlite3 Normal file
View File

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -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}"

View File

@@ -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")
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")
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
def handle_submission(
self, form: RideForm, cleaned_data: Dict[str, Any]
) -> HttpResponseRedirect:
"""Handle the form submission.
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
Args:
form: The form to process
cleaned_data: The cleaned form data
# 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()
# 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')
queryset = Ride.objects.select_related(
"park", "coaster_stats", "manufacturer"
).prefetch_related("photos")
# Filter by park if viewing park-specific rides
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)
@@ -354,34 +475,31 @@ class RideListView(ListView):
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)

View File

@@ -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));
}
}

View File

@@ -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));
}
}
}));
});

View File

@@ -29,88 +29,167 @@
<!-- Stats Section -->
<div class="grid grid-cols-1 gap-6 mb-12 md:grid-cols-3">
<!-- Total Parks -->
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<a href="{% url 'parks:park_list' %}"
class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
{{ stats.total_parks }}
</div>
<div class="text-xl text-gray-600 dark:text-gray-300">
Theme Parks
</div>
</div>
</a>
<!-- Total Attractions -->
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<a href="{% url 'rides:all_rides' %}"
class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
{{ stats.total_rides }}
</div>
<div class="text-xl text-gray-600 dark:text-gray-300">
Attractions
</div>
</div>
</a>
<!-- Total Roller Coasters -->
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<a href="{% url 'rides:roller_coasters' %}"
class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
{{ stats.total_roller_coasters }}
</div>
<div class="text-xl text-gray-600 dark:text-gray-300">
Roller Coasters
</div>
</div>
</a>
</div>
<!-- Featured Content -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Popular Parks -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<!-- Trending Parks -->
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
Popular Parks
Trending Parks
</h2>
<div class="space-y-4">
{% for park in popular_parks %}
<a href="{% url 'parks:park_detail' park.slug %}"
class="block p-4 mb-4 transition-all rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 hover:translate-x-2">
<div class="text-lg font-semibold text-blue-600 dark:text-blue-400">
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
{% if park.photos.first %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ park.photos.first.image.url }}') center/cover no-repeat;"
{% else %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
{% endif %}>
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
<div class="text-lg font-semibold">
{{ park.name }}
</div>
<div class="text-gray-600 dark:text-gray-300">
<div class="text-sm text-gray-200">
{{ park.location }}
</div>
{% if park.average_rating %}
<div class="flex items-center mt-1 text-yellow-500">
<div class="flex items-center mt-1 text-yellow-400">
<span class="mr-1"></span>
<span>{{ park.average_rating|floatformat:1 }}/10</span>
</div>
{% endif %}
</div>
</a>
{% empty %}
<p class="text-gray-600 dark:text-gray-400">No popular parks found.</p>
<p class="text-gray-600 dark:text-gray-400">No trending parks found.</p>
{% endfor %}
</div>
</div>
<!-- Popular Rides -->
<!-- Trending Rides -->
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
Popular Rides
Trending Rides
</h2>
<div class="space-y-4">
{% for ride in popular_rides %}
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
class="block p-4 mb-4 transition-all rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 hover:translate-x-2">
<div class="text-lg font-semibold text-blue-600 dark:text-blue-400">
<a href="{% url 'rides:ride_detail' ride.slug %}"
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
{% if ride.photos.first %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ ride.photos.first.image.url }}') center/cover no-repeat;"
{% else %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
{% endif %}>
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
<div class="text-lg font-semibold">
{{ ride.name }}
</div>
<div class="text-gray-600 dark:text-gray-300">
<div class="text-sm text-gray-200">
at {{ ride.park.name }}
</div>
{% if ride.average_rating %}
<div class="flex items-center mt-1 text-yellow-500">
<div class="flex items-center mt-1 text-yellow-400">
<span class="mr-1"></span>
<span>{{ ride.average_rating|floatformat:1 }}/10</span>
</div>
{% endif %}
</div>
</a>
{% empty %}
<p class="text-gray-600 dark:text-gray-400">No popular rides found.</p>
<p class="text-gray-600 dark:text-gray-400">No trending rides found.</p>
{% endfor %}
</div>
</div>
<!-- Highest Rated -->
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
Highest Rated
</h2>
<div class="space-y-4">
{% for item in highest_rated %}
{% if item.park %}
<!-- This is a ride -->
<a href="{% url 'rides:ride_detail' item.slug %}"
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
{% if item.photos.first %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ item.photos.first.image.url }}') center/cover no-repeat;"
{% else %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
{% endif %}>
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
<div class="text-lg font-semibold">
{{ item.name }}
</div>
<div class="text-sm text-gray-200">
at {{ item.park.name }}
</div>
<div class="flex items-center mt-1 text-yellow-400">
<span class="mr-1"></span>
<span>{{ item.average_rating|floatformat:1 }}/10</span>
</div>
</div>
</a>
{% else %}
<!-- This is a park -->
<a href="{% url 'parks:park_detail' item.slug %}"
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
{% if item.photos.first %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ item.photos.first.image.url }}') center/cover no-repeat;"
{% else %}
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
{% endif %}>
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
<div class="text-lg font-semibold">
{{ item.name }}
</div>
<div class="text-sm text-gray-200">
{{ item.location }}
</div>
<div class="flex items-center mt-1 text-yellow-400">
<span class="mr-1"></span>
<span>{{ item.average_rating|floatformat:1 }}/10</span>
</div>
</div>
</a>
{% endif %}
{% empty %}
<p class="text-gray-600 dark:text-gray-400">No rated items found.</p>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -118,96 +118,3 @@
</div>
</div>
</div>
<!-- AlpineJS Component Script -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('photoDisplay', ({ photos, contentType, objectId, csrfToken, uploadUrl }) => ({
photos,
fullscreenPhoto: null,
uploading: false,
uploadProgress: 0,
error: null,
showSuccess: false,
showFullscreen(photo) {
this.fullscreenPhoto = photo;
},
async handleFileSelect(event) {
const files = Array.from(event.target.files);
if (!files.length) return;
this.uploading = true;
this.uploadProgress = 0;
this.error = null;
this.showSuccess = false;
const totalFiles = files.length;
let completedFiles = 0;
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('app_label', contentType.split('.')[0]);
formData.append('model', contentType.split('.')[1]);
formData.append('object_id', objectId);
try {
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
},
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
}
const photo = await response.json();
this.photos.push(photo);
completedFiles++;
this.uploadProgress = (completedFiles / totalFiles) * 100;
} catch (err) {
this.error = err.message || 'Failed to upload photo. Please try again.';
console.error('Upload error:', err);
break;
}
}
this.uploading = false;
event.target.value = ''; // Reset file input
if (!this.error) {
this.showSuccess = true;
setTimeout(() => {
this.showSuccess = false;
}, 3000);
}
},
async sharePhoto(photo) {
if (navigator.share) {
try {
await navigator.share({
title: photo.caption || 'Shared photo',
url: photo.url
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
// Fallback: copy URL to clipboard
navigator.clipboard.writeText(photo.url)
.then(() => alert('Photo URL copied to clipboard!'))
.catch(err => console.error('Error copying to clipboard:', err));
}
}
}));
});
</script>

View File

@@ -37,20 +37,20 @@
{% endif %}
<!-- Header Grid -->
<div class="grid gap-2 mb-12 sm:mb-16 md:mb-8 grid-cols-1 sm:grid-cols-12 h-auto md:h-[140px]">
<div class="grid grid-cols-1 gap-4 mb-8 sm:grid-cols-12">
<!-- Park Info Card -->
<div class="flex flex-col items-center justify-center h-full col-span-1 p-2 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
<div class="flex flex-col items-center justify-center h-full col-span-1 p-4 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
<h1 class="text-2xl font-bold leading-tight text-gray-900 sm:text-3xl dark:text-white">{{ park.name }}</h1>
{% if park.formatted_location %}
<div class="flex items-center justify-center mt-0.5 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
<i class="mr-1 fas fa-map-marker-alt"></i>
<p>{{ park.formatted_location }}</p>
</div>
{% endif %}
<div class="flex flex-wrap items-center justify-center gap-1 mt-1">
<span class="status-badge text-xs sm:text-sm font-medium py-0.5 {% if park.status == 'OPERATING' %}status-operating
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<span class="status-badge text-xs sm:text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
@@ -58,8 +58,8 @@
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="flex items-center text-xs font-medium text-yellow-800 bg-yellow-100 sm:text-sm status-badge py-0.5 dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-0.5 text-yellow-500 dark:text-yellow-200"></span>
<span class="flex items-center px-3 py-1 text-xs font-medium text-yellow-800 bg-yellow-100 sm:text-sm status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
@@ -67,36 +67,36 @@
</div>
<!-- Stats and Quick Facts -->
<div class="grid h-full grid-cols-12 col-span-1 gap-2 sm:col-span-9">
<div class="grid h-full grid-cols-12 col-span-1 gap-4 sm:col-span-9">
<!-- Stats Column -->
<div class="grid-cols-2 col-span-12 gap-2 text-sky-400grid sm:grid-cols-1 md:grid-cols-2 sm:col-span-4">
<div class="grid grid-cols-2 col-span-12 gap-4 sm:col-span-4">
<!-- Total Rides Card -->
{% if park.total_rides %}
<a href="{% url 'parks:rides:ride_list' park.slug %}"
class="flex flex-col items-center justify-center p-2 text-center transition-transform bg-white rounded-lg shadow-lg hover:scale-[1.02] dark:bg-gray-800">
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">
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Total Rides</dt>
<dd class="mt-0.5 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl dark:text-sky-400 dark:hover:text-sky-300">{{ park.total_rides }}</dd>
<dd class="mt-2 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl dark:text-sky-400 dark:hover:text-sky-300">{{ park.total_rides }}</dd>
</a>
{% endif %}
<!-- Total Roller Coasters Card -->
{% if park.total_roller_coasters %}
<div class="flex flex-col items-center justify-center p-2 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Roller Coasters</dt>
<dd class="mt-0.5 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl dark:text-sky-400 dark:hover:text-sky-300">{{ park.total_roller_coasters }}</dd>
<dd class="mt-2 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl dark:text-sky-400 dark:hover:text-sky-300">{{ park.total_roller_coasters }}</dd>
</div>
{% endif %}
</div>
<!-- Quick Facts Grid -->
<div class="grid h-full grid-cols-3 col-span-12 gap-1 p-1.5 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
<div class="grid h-full grid-cols-3 col-span-12 gap-2 p-4 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
{% if park.owner %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-building dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Owner</dt>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-building dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Owner</dt>
<dd>
<a href="{% url 'companies:company_detail' park.owner.slug %}"
class="text-blue-600 text-2xs sm:text-xs hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
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 }}
</a>
</dd>
@@ -104,23 +104,23 @@
{% endif %}
{% if park.opening_date %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-calendar-alt dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Opened</dt>
<dd class="text-gray-900 text-2xs sm:text-xs dark:text-white">{{ park.opening_date }}</dd>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-calendar-alt dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Opened</dt>
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ park.opening_date }}</dd>
</div>
{% endif %}
{% if park.website %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-globe dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Website</dt>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-globe dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Website</dt>
<dd>
<a href="{{ park.website }}"
class="inline-flex items-center text-blue-600 text-2xs sm:text-xs hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
class="inline-flex items-center text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
target="_blank" rel="noopener noreferrer">
Visit
<i class="ml-0.5 text-2xs fas fa-external-link-alt"></i>
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
</a>
</dd>
</div>
@@ -129,7 +129,7 @@
</div>
</div>
<!-- Photos Section -->
<!-- Rest of the content remains unchanged -->
{% if park.photos.exists %}
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
@@ -225,6 +225,7 @@
</div>
</div>
</div>
</div>
<!-- Photo Upload Modal -->
{% if perms.media.add_photo %}
@@ -252,8 +253,14 @@
</div>
{% endif %}
{% if park.latitude and park.longitude %}
{% endblock %}
{% block extra_js %}
<!-- Photo Gallery Script -->
<script src="{% static 'js/photo-gallery.js' %}"></script>
<!-- Map Script (if location exists) -->
{% if park.latitude and park.longitude %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{% static 'js/park-map.js' %}"></script>
<script>
@@ -261,7 +268,5 @@
initParkMap({{ park.latitude }}, {{ park.longitude }}, "{{ park.name }}");
});
</script>
{% endblock %}
{% endif %}
{% endblock %}

View File

@@ -22,12 +22,12 @@
{% endif %}
<!-- Header Grid -->
<div class="grid grid-cols-1 gap-2 mb-8 sm:grid-cols-12">
<div class="grid grid-cols-1 gap-4 mb-8 sm:grid-cols-12">
<!-- Ride Info Card -->
<div class="flex flex-col items-center justify-center h-full col-span-1 p-2 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
<div class="flex flex-col items-center justify-center h-full col-span-1 p-4 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
<h1 class="text-2xl font-bold leading-tight text-gray-900 sm:text-3xl dark:text-white">{{ ride.name }}</h1>
<div class="flex items-center justify-center mt-0.5 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
@@ -36,20 +36,20 @@
{% endif %}
</div>
<div class="flex flex-wrap items-center justify-center gap-1 mt-1">
<span class="status-badge text-xs sm:text-sm font-medium py-0.5 {% if ride.status == 'OPERATING' %}status-operating
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
<span class="px-3 py-1 text-xs font-medium status-badge sm:text-sm {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
<span class="text-blue-800 bg-blue-100 status-badge text-xs sm:text-sm font-medium py-0.5 dark:bg-blue-700 dark:text-blue-50">
<span class="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 status-badge sm:text-sm dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
<span class="flex items-center text-xs font-medium text-yellow-800 bg-yellow-100 sm:text-sm status-badge py-0.5 dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-0.5 text-yellow-500 dark:text-yellow-200"></span>
<span class="flex items-center px-3 py-1 text-xs font-medium text-yellow-800 bg-yellow-100 status-badge sm:text-sm dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
@@ -57,46 +57,46 @@
</div>
<!-- Stats and Quick Facts -->
<div class="grid h-full grid-cols-12 col-span-1 gap-2 sm:col-span-9">
<div class="grid h-full grid-cols-12 col-span-1 gap-4 sm:col-span-9">
<!-- Stats Column -->
<div class="grid grid-cols-2 col-span-12 gap-2 sm:col-span-4">
<div class="grid grid-cols-2 col-span-12 gap-4 sm:col-span-4">
{% if coaster_stats %}
{% if coaster_stats.height_ft %}
<div class="flex flex-col items-center justify-center p-2 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Height</dt>
<dd class="mt-0.5 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.height_ft }} ft</dd>
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.height_ft }} ft</dd>
</div>
{% endif %}
{% if coaster_stats.speed_mph %}
<div class="flex flex-col items-center justify-center p-2 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Speed</dt>
<dd class="mt-0.5 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.speed_mph }} mph</dd>
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.speed_mph }} mph</dd>
</div>
{% endif %}
{% if coaster_stats.inversions %}
<div class="flex flex-col items-center justify-center p-2 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Inversions</dt>
<dd class="mt-0.5 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.inversions }}</dd>
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.inversions }}</dd>
</div>
{% endif %}
{% if coaster_stats.length_ft %}
<div class="flex flex-col items-center justify-center p-2 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Length</dt>
<dd class="mt-0.5 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.length_ft }} ft</dd>
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.length_ft }} ft</dd>
</div>
{% endif %}
{% endif %}
</div>
<!-- Quick Facts Grid -->
<div class="grid h-full grid-cols-3 col-span-12 gap-1 p-1.5 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
<div class="grid h-full grid-cols-3 col-span-12 gap-2 p-4 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
{% if ride.manufacturer %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-industry dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Manufacturer</dt>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-industry dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Manufacturer</dt>
<dd>
<a href="{% url 'companies:manufacturer_detail' ride.manufacturer.slug %}"
class="text-blue-600 text-2xs sm:text-xs hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
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 }}
</a>
</dd>
@@ -104,12 +104,12 @@
{% endif %}
{% if ride.designer %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-drafting-compass dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Designer</dt>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-drafting-compass dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Designer</dt>
<dd>
<a href="{% url 'designers:designer_detail' ride.designer.slug %}"
class="text-blue-600 text-2xs sm:text-xs hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
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 }}
</a>
</dd>
@@ -117,49 +117,49 @@
{% endif %}
{% if coaster_stats.roller_coaster_type %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-train dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Coaster Type</dt>
<dd class="text-gray-900 text-2xs sm:text-xs dark:text-white">{{ coaster_stats.get_roller_coaster_type_display }}</dd>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-train dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Coaster Type</dt>
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ coaster_stats.get_roller_coaster_type_display }}</dd>
</div>
{% endif %}
{% if coaster_stats.track_material %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-layer-group dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Track Material</dt>
<dd class="text-gray-900 text-2xs sm:text-xs dark:text-white">{{ coaster_stats.get_track_material_display }}</dd>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-layer-group dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Track Material</dt>
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ coaster_stats.get_track_material_display }}</dd>
</div>
{% endif %}
{% if ride.opening_date %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-calendar-alt dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Opened</dt>
<dd class="text-gray-900 text-2xs sm:text-xs dark:text-white">{{ ride.opening_date }}</dd>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-calendar-alt dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Opened</dt>
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ ride.opening_date }}</dd>
</div>
{% endif %}
{% if ride.capacity_per_hour %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-users dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Capacity</dt>
<dd class="text-gray-900 text-2xs sm:text-xs dark:text-white">{{ ride.capacity_per_hour }}/hr</dd>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-users dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Capacity</dt>
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ ride.capacity_per_hour }}/hr</dd>
</div>
{% endif %}
{% if coaster_stats.launch_type %}
<div class="flex flex-col items-center justify-center text-center p-0.5">
<i class="text-sm text-blue-600 sm:text-base fas fa-rocket dark:text-blue-400"></i>
<dt class="font-medium text-gray-500 text-2xs dark:text-gray-400">Launch Type</dt>
<dd class="text-gray-900 text-2xs sm:text-xs dark:text-white">{{ coaster_stats.get_launch_type_display }}</dd>
<div class="flex flex-col items-center justify-center p-2 text-center">
<i class="text-lg text-blue-600 sm:text-xl fas fa-rocket dark:text-blue-400"></i>
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Launch Type</dt>
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ coaster_stats.get_launch_type_display }}</dd>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Photos Section -->
<!-- Rest of the content remains unchanged -->
{% if ride.photos.exists %}
<div class="p-6 mb-8 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
@@ -486,3 +486,7 @@
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/photo-gallery.js' %}"></script>
{% endblock %}

View File

@@ -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"

View File

@@ -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(
average_rating__isnull=True
).order_by('-average_rating')[:5]
# Try to get trending items from cache first
trending_parks = cache.get('trending_parks')
trending_rides = cache.get('trending_rides')
# Get popular rides (based on average rating)
context['popular_rides'] = Ride.objects.exclude(
# 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')[:5]
).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')[:20]) # Get more items to randomly select from
highest_rated_rides = list(Ride.objects.exclude(
average_rating__isnull=True
).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)