-{% for park in object_list|default:parks %}
- {% include "parks/partials/park_card.html" with park=park view_mode=view_mode %}
-{% empty %}
-
- No parks found matching your search.
-
-{% endfor %}
+
+ {% for park in object_list|default:parks %}
+
+
+
+
+
+ {% if park.photos.exists %}
+
+ {% else %}
+
+ {{ park.name|first|upper }}
+
+ {% endif %}
+
+
+
+
+ {{ park.name }}
+
+
+
+ {% with location=park.location.first %}
+ {% if location %}
+ {{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %}
+ {% else %}
+ Location unknown
+ {% endif %}
+ {% endwith %}
+
+
+
+
+ {{ park.get_status_display }}
+
+
+ {% if park.opening_date %}
+
+ Opened {{ park.opening_date|date:"Y" }}
+
+ {% endif %}
+
+ {% if park.current_ride_count %}
+
+ {{ park.current_ride_count }} ride{{ park.current_ride_count|pluralize }}
+
+ {% endif %}
+
+ {% if park.current_coaster_count %}
+
+ {{ park.current_coaster_count }} coaster{{ park.current_coaster_count|pluralize }}
+
+ {% endif %}
+
+
+
+ {% empty %}
+
+ {% if search_query %}
+ No parks found matching "{{ search_query }}". Try adjusting your search terms.
+ {% else %}
+ No parks found matching your criteria. Try adjusting your filters.
+ {% endif %}
+ {% if user.is_authenticated %}
+ You can also
add a new park .
+ {% endif %}
+
+ {% endfor %}
{% endif %}
\ No newline at end of file
diff --git a/parks/views.py b/parks/views.py
index 13db9ad6..dff57dcc 100644
--- a/parks/views.py
+++ b/parks/views.py
@@ -1,36 +1,55 @@
-from decimal import Decimal, ROUND_DOWN, InvalidOperation
-from typing import Any, Optional, cast, Type
+from decimal import Decimal, ROUND_DOWN
+from typing import Any, Optional, cast, Literal
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404, render
-from django.core.serializers.json import DjangoJSONEncoder
from django.urls import reverse
-from django.db.models import Q, Avg, Count, QuerySet, Model
-from search.mixins import HTMXFilterableMixin
-from .filters import ParkFilter
-from django.core.exceptions import ObjectDoesNotExist
+from django.db.models import Q, Count, QuerySet
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, HttpResponse, HttpRequest
-import requests
+from django.core.exceptions import ObjectDoesNotExist
+from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
from .models import Park, ParkArea
from .forms import ParkForm
-from .location_utils import normalize_coordinate, normalize_osm_result
+from .filters import ParkFilter
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
from media.models import Photo
from location.models import Location
from reviews.models import Review
-from analytics.models import PageView
+from search.mixins import HTMXFilterableMixin
+ViewMode = Literal["grid", "list"]
+
+
+def get_view_mode(request: HttpRequest) -> ViewMode:
+ """Get the current view mode from request, defaulting to grid"""
+ view_mode = request.GET.get('view_mode', 'grid')
+ return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
+
+
+def get_base_park_queryset() -> QuerySet[Park]:
+ """Get base queryset with all needed annotations and prefetches"""
+ return (
+ Park.objects.select_related('owner')
+ .prefetch_related('location', 'photos', 'rides')
+ .annotate(
+ current_ride_count=Count('rides', distinct=True),
+ current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
+ )
+ .order_by('name')
+ )
+
+def add_park_button(request: HttpRequest) -> HttpResponse:
+ """Return the add park button partial template"""
+ return render(request, "parks/partials/add_park_button.html")
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
"""Return the park actions partial template"""
park = get_object_or_404(Park, slug=slug)
return render(request, "parks/partials/park_actions.html", {"park": park})
-
def get_park_areas(request: HttpRequest) -> HttpResponse:
"""Return park areas as options for a select element"""
park_id = request.GET.get('park')
@@ -49,53 +68,12 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
except Park.DoesNotExist:
return HttpResponse('
Invalid park selected ')
-
-def search_parks(request: HttpRequest) -> HttpResponse:
- """Search parks and return results for quick searches with auto-suggestions"""
- try:
- search_query = request.GET.get('search', '').strip()
- if not search_query:
- return HttpResponse('') # Keep empty string for clearing search results
-
- queryset = (
- Park.objects.select_related('owner')
- .prefetch_related('location', 'photos')
- .annotate(
- ride_count=Count('rides'),
- coaster_count=Count('rides', filter=Q(rides__category="RC"))
- )
- .order_by('name')
- )
-
- # Use our existing filter but with search-specific configuration
- park_filter = ParkFilter({
- 'search': search_query
- }, queryset=queryset)
-
- parks = park_filter.qs[:8] # Limit to 8 suggestions
-
- response = render(request, "parks/park_list.html", {
- "parks": parks
- })
- response['HX-Trigger'] = 'searchComplete'
- return response
-
- except Exception as e:
- response = render(request, "parks/park_list.html", {
- "parks": [],
- "error": f"Error performing search: {str(e)}"
- })
- response['HX-Trigger'] = 'searchError'
- return response
-
-
def location_search(request: HttpRequest) -> JsonResponse:
"""Search for locations using OpenStreetMap Nominatim API"""
query = request.GET.get("q", "")
if not query:
return JsonResponse({"results": []})
- # Call Nominatim API
response = requests.get(
"https://nominatim.openstreetmap.org/search",
params={
@@ -107,21 +85,20 @@ def location_search(request: HttpRequest) -> JsonResponse:
"limit": 10,
},
headers={"User-Agent": "ThrillWiki/1.0"},
- timeout=60)
+ timeout=60
+ )
if response.status_code == 200:
results = response.json()
normalized_results = [normalize_osm_result(result) for result in results]
valid_results = [
- r
- for r in normalized_results
+ r for r in normalized_results
if r["lat"] is not None and r["lon"] is not None
]
return JsonResponse({"results": valid_results})
return JsonResponse({"results": []})
-
def reverse_geocode(request: HttpRequest) -> JsonResponse:
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
try:
@@ -137,13 +114,9 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
if lat < -90 or lat > 90:
- return JsonResponse(
- {"error": "Latitude must be between -90 and 90"}, status=400
- )
+ return JsonResponse({"error": "Latitude must be between -90 and 90"}, status=400)
if lon < -180 or lon > 180:
- return JsonResponse(
- {"error": "Longitude must be between -180 and 180"}, status=400
- )
+ return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400)
response = requests.get(
"https://nominatim.openstreetmap.org/reverse",
@@ -156,7 +129,8 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
"accept-language": "en",
},
headers={"User-Agent": "ThrillWiki/1.0"},
- timeout=60)
+ timeout=60
+ )
if response.status_code == 200:
result = response.json()
@@ -168,111 +142,109 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
return JsonResponse({"error": "Geocoding failed"}, status=500)
-def add_park_button(request: HttpRequest) -> HttpResponse:
- """Return the add park button partial template"""
- return render(request, "parks/partials/add_park_button.html")
-
-
class ParkListView(HTMXFilterableMixin, ListView):
model = Park
template_name = "parks/park_list.html"
context_object_name = "parks"
filter_class = ParkFilter
paginate_by = 20
-
+
def get_template_names(self) -> list[str]:
- """Override to use same template for HTMX and regular requests"""
+ """Return park_list_item.html for HTMX requests"""
if self.request.htmx:
return ["parks/partials/park_list_item.html"]
return [self.template_name]
+ def get_view_mode(self) -> ViewMode:
+ """Get the current view mode (grid or list)"""
+ return get_view_mode(self.request)
+
def get_queryset(self) -> QuerySet[Park]:
+ """Get base queryset with annotations and apply filters"""
try:
- return (
- super()
- .get_queryset()
- .select_related("owner")
- .prefetch_related(
- "photos",
- "location",
- "rides",
- "rides__manufacturer",
- "areas"
- )
- .annotate(
- total_rides=Count("rides"),
- total_coasters=Count("rides", filter=Q(rides__category="RC")),
- )
- .order_by("name") # Ensure consistent ordering for pagination
- )
+ queryset = get_base_park_queryset()
except Exception as e:
messages.error(self.request, f"Error loading parks: {str(e)}")
- return Park.objects.none()
+ queryset = self.model.objects.none()
+
+ # Always initialize filterset, even if queryset failed
+ self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset)
+ return self.filterset.qs
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ """Add view_mode and other context data"""
try:
+ # Initialize filterset even if queryset fails
+ if not hasattr(self, 'filterset'):
+ self.filterset = self.get_filter_class()(
+ self.request.GET,
+ queryset=self.model.objects.none()
+ )
+
context = super().get_context_data(**kwargs)
- context['results_template'] = "parks/partials/park_list_item.html"
+ context.update({
+ 'view_mode': self.get_view_mode(),
+ 'is_search': bool(self.request.GET.get('search')),
+ 'search_query': self.request.GET.get('search', '')
+ })
return context
except Exception as e:
messages.error(self.request, f"Error applying filters: {str(e)}")
+ # Ensure filterset exists in error case
+ if not hasattr(self, 'filterset'):
+ self.filterset = self.get_filter_class()(
+ self.request.GET,
+ queryset=self.model.objects.none()
+ )
return {
- "filter": self.filterset,
- "error": "Unable to apply filters. Please try adjusting your criteria.",
- "results_template": "parks/partials/park_list_item.html"
+ 'filter': self.filterset,
+ 'error': "Unable to apply filters. Please try adjusting your criteria.",
+ 'view_mode': self.get_view_mode(),
+ 'is_search': bool(self.request.GET.get('search')),
+ 'search_query': self.request.GET.get('search', '')
}
-class ParkDetailView(
- SlugRedirectMixin,
- EditSubmissionMixin,
- PhotoSubmissionMixin,
- HistoryMixin,
- DetailView,
-):
- model = Park
- template_name = "parks/park_detail.html"
- context_object_name = "park"
+def search_parks(request: HttpRequest) -> HttpResponse:
+ """Search parks and return results using park_list_item.html"""
+ try:
+ search_query = request.GET.get('search', '').strip()
+ if not search_query:
+ return HttpResponse('')
- def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
- if queryset is None:
- queryset = self.get_queryset()
- slug = self.kwargs.get(self.slug_url_kwarg)
- if slug is None:
- raise ObjectDoesNotExist("No slug provided")
- park, _ = Park.get_by_slug(slug)
- return park
+ park_filter = ParkFilter({
+ 'search': search_query
+ }, queryset=get_base_park_queryset())
- def get_queryset(self) -> QuerySet[Park]:
- return cast(
- QuerySet[Park],
- super()
- .get_queryset()
- .prefetch_related(
- "rides", "rides__manufacturer", "photos", "areas", "location"
- ),
+ parks = park_filter.qs
+ if request.GET.get('quick_search'):
+ parks = parks[:8] # Limit quick search results
+
+ response = render(
+ request,
+ "parks/partials/park_list_item.html",
+ {
+ "parks": parks,
+ "view_mode": get_view_mode(request),
+ "search_query": search_query,
+ "is_search": True
+ }
)
+ response['HX-Trigger'] = 'searchComplete'
+ return response
- def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
- context = super().get_context_data(**kwargs)
- park = cast(Park, self.object)
- context["areas"] = park.areas.all()
- context["rides"] = park.rides.all().order_by("-status", "name")
-
- if self.request.user.is_authenticated:
- context["has_reviewed"] = Review.objects.filter(
- user=self.request.user,
- content_type=ContentType.objects.get_for_model(Park),
- object_id=park.id,
- ).exists()
- else:
- context["has_reviewed"] = False
-
- return context
-
- def get_redirect_url_pattern(self) -> str:
- return "parks:park_detail"
-
+ except Exception as e:
+ response = render(
+ request,
+ "parks/partials/park_list_item.html",
+ {
+ "parks": [],
+ "error": f"Error performing search: {str(e)}",
+ "is_search": True
+ }
+ )
+ response['HX-Trigger'] = 'searchError'
+ return response
class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park
@@ -346,6 +318,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
)
photos = self.request.FILES.getlist("photos")
+ uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
@@ -354,6 +327,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
)
+ uploaded_count += 1
except Exception as e:
messages.error(
self.request,
@@ -363,7 +337,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
messages.success(
self.request,
f"Successfully created {self.object.name}. "
- f"Added {len(photos)} photo(s).",
+ f"Added {uploaded_count} photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
except Exception as e:
@@ -530,6 +504,327 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
+class ParkDetailView(
+ SlugRedirectMixin,
+ EditSubmissionMixin,
+ PhotoSubmissionMixin,
+ HistoryMixin,
+ DetailView
+):
+ model = Park
+ template_name = "parks/park_detail.html"
+ context_object_name = "park"
+
+ def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
+ if queryset is None:
+ queryset = self.get_queryset()
+ slug = self.kwargs.get(self.slug_url_kwarg)
+ if slug is None:
+ raise ObjectDoesNotExist("No slug provided")
+ park, _ = Park.get_by_slug(slug)
+ return park
+
+ def get_queryset(self) -> QuerySet[Park]:
+ return cast(
+ QuerySet[Park],
+ super()
+ .get_queryset()
+ .prefetch_related(
+ "rides",
+ "rides__manufacturer",
+ "photos",
+ "areas",
+ "location"
+ ),
+ )
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+ park = cast(Park, self.object)
+ context["areas"] = park.areas.all()
+ context["rides"] = park.rides.all().order_by("-status", "name")
+
+ if self.request.user.is_authenticated:
+ context["has_reviewed"] = Review.objects.filter(
+ user=self.request.user,
+ content_type=ContentType.objects.get_for_model(Park),
+ object_id=park.id,
+ ).exists()
+ else:
+ context["has_reviewed"] = False
+
+ return context
+
+ def get_redirect_url_pattern(self) -> str:
+ return "parks:park_detail"
+
+
+class ParkAreaDetailView(
+ SlugRedirectMixin,
+ EditSubmissionMixin,
+ PhotoSubmissionMixin,
+ HistoryMixin,
+ DetailView
+):
+ model = ParkArea
+ template_name = "parks/area_detail.html"
+ context_object_name = "area"
+ slug_url_kwarg = "area_slug"
+
+ def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea:
+ if queryset is None:
+ queryset = self.get_queryset()
+ park_slug = self.kwargs.get("park_slug")
+ area_slug = self.kwargs.get("area_slug")
+ if park_slug is None or area_slug is None:
+ raise ObjectDoesNotExist("Missing slug")
+ area, _ = ParkArea.get_by_slug(area_slug)
+ if area.park.slug != park_slug:
+ raise ObjectDoesNotExist("Park slug doesn't match")
+ return area
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+ return context
+
+ def get_redirect_url_pattern(self) -> str:
+ return "parks:park_detail"
+
+ def get_redirect_url_kwargs(self) -> dict[str, str]:
+ area = cast(ParkArea, self.object)
+ return {"park_slug": area.park.slug, "area_slug": area.slug}
+
+ def form_valid(self, form: ParkForm) -> HttpResponse:
+ self.normalize_coordinates(form)
+ changes = self.prepare_changes_data(form.cleaned_data)
+
+ submission = EditSubmission.objects.create(
+ user=self.request.user,
+ content_type=ContentType.objects.get_for_model(Park),
+ submission_type="CREATE",
+ changes=changes,
+ reason=self.request.POST.get("reason", ""),
+ source=self.request.POST.get("source", ""),
+ )
+
+ if hasattr(self.request.user, "role") and getattr(
+ self.request.user, "role", None
+ ) 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()
+
+ if form.cleaned_data.get("latitude") and form.cleaned_data.get(
+ "longitude"
+ ):
+ Location.objects.create(
+ content_type=ContentType.objects.get_for_model(Park),
+ object_id=self.object.id,
+ name=self.object.name,
+ location_type="park",
+ latitude=form.cleaned_data["latitude"],
+ longitude=form.cleaned_data["longitude"],
+ street_address=form.cleaned_data.get(
+ "street_address", ""),
+ city=form.cleaned_data.get("city", ""),
+ state=form.cleaned_data.get("state", ""),
+ country=form.cleaned_data.get("country", ""),
+ postal_code=form.cleaned_data.get("postal_code", ""),
+ )
+
+ 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(
+ Park),
+ 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)}",
+ )
+
+ messages.success(
+ self.request,
+ f"Successfully created {self.object.name}. "
+ f"Added {uploaded_count} photo(s).",
+ )
+ return HttpResponseRedirect(self.get_success_url())
+ except Exception as e:
+ messages.error(
+ self.request,
+ f"Error creating park: {str(e)}. Please check your input and try again.",
+ )
+ return self.form_invalid(form)
+
+ messages.success(
+ self.request,
+ "Your park submission has been sent for review. "
+ "You will be notified when it is approved.",
+ )
+ return HttpResponseRedirect(reverse("parks:park_list"))
+
+ def form_invalid(self, form: ParkForm) -> HttpResponse:
+ 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}")
+ return super().form_invalid(form)
+
+ def get_success_url(self) -> str:
+ return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
+
+
+class ParkUpdateView(LoginRequiredMixin, UpdateView):
+ model = Park
+ form_class = ParkForm
+ template_name = "parks/park_form.html"
+
+ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+ context = super().get_context_data(**kwargs)
+ context["is_edit"] = True
+ return context
+
+ def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
+ data = cleaned_data.copy()
+ if data.get("owner"):
+ data["owner"] = data["owner"].id
+ if data.get("opening_date"):
+ data["opening_date"] = data["opening_date"].isoformat()
+ if data.get("closing_date"):
+ data["closing_date"] = data["closing_date"].isoformat()
+ decimal_fields = ["latitude", "longitude",
+ "size_acres", "average_rating"]
+ for field in decimal_fields:
+ if data.get(field):
+ data[field] = str(data[field])
+ return data
+
+ def normalize_coordinates(self, form: ParkForm) -> None:
+ if form.cleaned_data.get("latitude"):
+ lat = Decimal(str(form.cleaned_data["latitude"]))
+ form.cleaned_data["latitude"] = lat.quantize(
+ Decimal("0.000001"), rounding=ROUND_DOWN
+ )
+ if form.cleaned_data.get("longitude"):
+ lon = Decimal(str(form.cleaned_data["longitude"]))
+ form.cleaned_data["longitude"] = lon.quantize(
+ Decimal("0.000001"), rounding=ROUND_DOWN
+ )
+
+ def form_valid(self, form: ParkForm) -> HttpResponse:
+ self.normalize_coordinates(form)
+ changes = self.prepare_changes_data(form.cleaned_data)
+
+ submission = EditSubmission.objects.create(
+ user=self.request.user,
+ content_type=ContentType.objects.get_for_model(Park),
+ object_id=self.object.id,
+ submission_type="EDIT",
+ changes=changes,
+ reason=self.request.POST.get("reason", ""),
+ source=self.request.POST.get("source", ""),
+ )
+
+ if hasattr(self.request.user, "role") and getattr(
+ self.request.user, "role", None
+ ) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
+ try:
+ self.object = form.save()
+ submission.status = "APPROVED"
+ submission.handled_by = self.request.user
+ submission.save()
+
+ location_data = {
+ "name": self.object.name,
+ "location_type": "park",
+ "latitude": form.cleaned_data.get("latitude"),
+ "longitude": form.cleaned_data.get("longitude"),
+ "street_address": form.cleaned_data.get("street_address", ""),
+ "city": form.cleaned_data.get("city", ""),
+ "state": form.cleaned_data.get("state", ""),
+ "country": form.cleaned_data.get("country", ""),
+ "postal_code": form.cleaned_data.get("postal_code", ""),
+ }
+
+ if self.object.location.exists():
+ location = self.object.location.first()
+ for key, value in location_data.items():
+ setattr(location, key, value)
+ location.save()
+ else:
+ Location.objects.create(
+ content_type=ContentType.objects.get_for_model(Park),
+ object_id=self.object.id,
+ **location_data,
+ )
+
+ 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(
+ Park),
+ 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)}",
+ )
+
+ messages.success(
+ self.request,
+ f"Successfully updated {self.object.name}. "
+ f"Added {uploaded_count} new photo(s).",
+ )
+ return HttpResponseRedirect(self.get_success_url())
+ except Exception as e:
+ messages.error(
+ self.request,
+ f"Error updating park: {str(e)}. Please check your input and try again.",
+ )
+ return 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.",
+ )
+ return HttpResponseRedirect(
+ reverse("parks:park_detail", kwargs={"slug": self.object.slug})
+ )
+
+ def form_invalid(self, form: ParkForm) -> HttpResponse:
+ 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}")
+ return super().form_invalid(form)
+
+ def get_success_url(self) -> str:
+ return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
+
+
class ParkAreaDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
diff --git a/static/css/tailwind.css b/static/css/tailwind.css
index dab10d4b..1f4612e1 100644
--- a/static/css/tailwind.css
+++ b/static/css/tailwind.css
@@ -2181,6 +2181,18 @@ select {
justify-content: center;
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
.visible {
visibility: visible;
}
@@ -2457,6 +2469,10 @@ select {
display: none;
}
+.h-10 {
+ height: 2.5rem;
+}
+
.h-16 {
height: 4rem;
}
@@ -2485,6 +2501,10 @@ select {
height: 1.25rem;
}
+.h-6 {
+ height: 1.5rem;
+}
+
.h-8 {
height: 2rem;
}
@@ -2533,6 +2553,10 @@ select {
width: 1.25rem;
}
+.w-6 {
+ width: 1.5rem;
+}
+
.w-64 {
width: 16rem;
}
@@ -2646,6 +2670,16 @@ 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));
}
+@keyframes pulse {
+ 50% {
+ opacity: .5;
+ }
+}
+
+.animate-pulse {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
@keyframes spin {
to {
transform: rotate(360deg);
@@ -3000,10 +3034,6 @@ select {
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
-.bg-gray-900\/80 {
- background-color: rgb(17 24 39 / 0.8);
-}
-
.bg-green-100 {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
@@ -3244,6 +3274,10 @@ select {
padding-bottom: 1rem;
}
+.pt-2 {
+ padding-top: 0.5rem;
+}
+
.text-left {
text-align: left;
}
@@ -3335,6 +3369,11 @@ select {
color: rgb(37 99 235 / var(--tw-text-opacity));
}
+.text-blue-700 {
+ --tw-text-opacity: 1;
+ color: rgb(29 78 216 / var(--tw-text-opacity));
+}
+
.text-blue-800 {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
@@ -3405,6 +3444,11 @@ select {
color: rgb(79 70 229 / var(--tw-text-opacity));
}
+.text-red-100 {
+ --tw-text-opacity: 1;
+ color: rgb(254 226 226 / var(--tw-text-opacity));
+}
+
.text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
@@ -3507,6 +3551,11 @@ select {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
+.outline-none {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+}
+
.ring-2 {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
@@ -3522,6 +3571,19 @@ select {
--tw-ring-color: rgb(79 70 229 / 0.2);
}
+.ring-offset-2 {
+ --tw-ring-offset-width: 2px;
+}
+
+.ring-offset-white {
+ --tw-ring-offset-color: #fff;
+}
+
+.blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
+}
+
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
@@ -3796,6 +3858,11 @@ select {
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
+.focus\:bg-gray-100:focus {
+ --tw-bg-opacity: 1;
+ background-color: rgb(243 244 246 / var(--tw-bg-opacity));
+}
+
.focus\:underline:focus {
text-decoration-line: underline;
}
@@ -3824,6 +3891,10 @@ select {
--tw-ring-offset-width: 2px;
}
+.active\:transform:active {
+ 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));
+}
+
.disabled\:opacity-50:disabled {
opacity: 0.5;
}
@@ -3930,6 +4001,10 @@ select {
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
+.dark\:bg-red-900\/40:is(.dark *) {
+ background-color: rgb(127 29 29 / 0.4);
+}
+
.dark\:bg-yellow-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
@@ -3968,6 +4043,11 @@ select {
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
}
+.dark\:text-blue-100:is(.dark *) {
+ --tw-text-opacity: 1;
+ color: rgb(219 234 254 / var(--tw-text-opacity));
+}
+
.dark\:text-blue-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
@@ -4190,6 +4270,11 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
+.dark\:focus\:bg-gray-700:focus:is(.dark *) {
+ --tw-bg-opacity: 1;
+ background-color: rgb(55 65 81 / var(--tw-bg-opacity));
+}
+
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;
@@ -4297,10 +4382,26 @@ select {
grid-column: span 2 / span 2;
}
+ .md\:col-span-3 {
+ grid-column: span 3 / span 3;
+ }
+
.md\:mb-8 {
margin-bottom: 2rem;
}
+ .md\:block {
+ display: block;
+ }
+
+ .md\:grid {
+ display: grid;
+ }
+
+ .md\:hidden {
+ display: none;
+ }
+
.md\:h-\[140px\] {
height: 140px;
}