feat: Add detailed park and ride pages with HTMX integration

- Implemented park detail page with dynamic content loading for rides and weather.
- Created park list page with filters and search functionality.
- Developed ride detail page showcasing ride stats, reviews, and similar rides.
- Added ride list page with filtering options and dynamic loading.
- Introduced search results page with tabs for parks, rides, and users.
- Added HTMX tests for global search functionality.
This commit is contained in:
pacnpal
2025-12-19 19:53:20 -05:00
parent bf04e4d854
commit b9063ff4f8
154 changed files with 4536 additions and 2570 deletions

View File

@@ -31,6 +31,10 @@ from django.views.generic import DetailView, ListView, CreateView, UpdateView
import requests
from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal, Dict
from django.views.decorators.http import require_POST
from django.template.loader import render_to_string
import json
# Constants
PARK_DETAIL_URL = "parks:park_detail"
@@ -38,6 +42,9 @@ PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
REQUIRED_FIELDS_ERROR = (
"Please correct the errors below. Required fields are marked with an asterisk (*)."
)
TRIP_PARKS_TEMPLATE = "parks/partials/trip_parks_list.html"
TRIP_SUMMARY_TEMPLATE = "parks/partials/trip_summary.html"
SAVED_TRIPS_TEMPLATE = "parks/partials/saved_trips.html"
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
@@ -461,6 +468,250 @@ def search_parks(request: HttpRequest) -> HttpResponse:
return response
# --------------------
# HTMX roadtrip helpers
# --------------------
def htmx_saved_trips(request: HttpRequest) -> HttpResponse:
"""Return a partial with the user's saved trips (stubbed)."""
trips = []
if request.user.is_authenticated:
try:
from .models import Trip # type: ignore
qs = Trip.objects.filter(owner=request.user).order_by("-created_at")
trips = list(qs[:10])
except Exception:
trips = []
return render(request, SAVED_TRIPS_TEMPLATE, {"trips": trips})
def _get_session_trip(request: HttpRequest) -> list:
raw = request.session.get("trip_parks", [])
try:
return [int(x) for x in raw]
except Exception:
return []
def _save_session_trip(request: HttpRequest, trip_list: list) -> None:
request.session["trip_parks"] = [int(x) for x in trip_list]
request.session.modified = True
@require_POST
def htmx_add_park_to_trip(request: HttpRequest) -> HttpResponse:
"""Add a park id to `request.session['trip_parks']` and return the full trip list partial."""
park_id = request.POST.get("park_id")
if not park_id:
try:
payload = json.loads(request.body.decode("utf-8"))
park_id = payload.get("park_id")
except Exception:
park_id = None
if not park_id:
return HttpResponse("", status=400)
try:
pid = int(park_id)
except Exception:
return HttpResponse("", status=400)
trip = _get_session_trip(request)
if pid not in trip:
trip.append(pid)
_save_session_trip(request, trip)
# Build ordered Park queryset preserving session order
parks = []
for tid in _get_session_trip(request):
try:
parks.append(Park.objects.get(id=tid))
except Park.DoesNotExist:
continue
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
return resp
@require_POST
def htmx_remove_park_from_trip(request: HttpRequest) -> HttpResponse:
"""Remove a park id from `request.session['trip_parks']` and return the updated trip list partial."""
park_id = request.POST.get("park_id")
if not park_id:
try:
payload = json.loads(request.body.decode("utf-8"))
park_id = payload.get("park_id")
except Exception:
park_id = None
if not park_id:
return HttpResponse("", status=400)
try:
pid = int(park_id)
except Exception:
return HttpResponse("", status=400)
trip = _get_session_trip(request)
if pid in trip:
trip = [t for t in trip if t != pid]
_save_session_trip(request, trip)
parks = []
for tid in _get_session_trip(request):
try:
parks.append(Park.objects.get(id=tid))
except Park.DoesNotExist:
continue
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"tripUpdated": True})
return resp
@require_POST
def htmx_reorder_parks(request: HttpRequest) -> HttpResponse:
"""Accept an ordered list of park ids and persist it to the session, returning the updated list partial."""
order = []
try:
payload = json.loads(request.body.decode("utf-8"))
order = payload.get("order", [])
except Exception:
order = request.POST.getlist("order[]")
# Normalize to ints
clean_order = []
for item in order:
try:
clean_order.append(int(item))
except Exception:
continue
_save_session_trip(request, clean_order)
parks = []
for tid in _get_session_trip(request):
try:
parks.append(Park.objects.get(id=tid))
except Park.DoesNotExist:
continue
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": parks}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"tripReordered": True})
return resp
@require_POST
def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
"""Compute a simple trip summary from session parks and return the summary partial."""
parks = []
for tid in _get_session_trip(request):
try:
parks.append(Park.objects.get(id=tid))
except Park.DoesNotExist:
continue
# Helper: haversine distance (miles)
import math
def haversine_miles(lat1, lon1, lat2, lon2):
# convert decimal degrees to radians
rlat1, rlon1, rlat2, rlon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat = rlat2 - rlat1
dlon = rlon2 - rlon1
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
c = 2 * math.asin(min(1, math.sqrt(a)))
miles = 3958.8 * c
return miles
total_miles = 0.0
waypoints = []
for p in parks:
loc = getattr(p, "location", None)
lat = getattr(loc, "latitude", None) if loc else None
lon = getattr(loc, "longitude", None) if loc else None
if lat is not None and lon is not None:
waypoints.append({"id": p.id, "name": p.name, "latitude": lat, "longitude": lon})
# sum straight-line distances between consecutive waypoints
for i in range(len(waypoints) - 1):
a = waypoints[i]
b = waypoints[i + 1]
try:
total_miles += haversine_miles(a["latitude"], a["longitude"], b["latitude"], b["longitude"])
except Exception:
continue
# Estimate drive time assuming average speed of 60 mph
total_hours = total_miles / 60.0 if total_miles else 0.0
summary = {
"total_distance": f"{int(round(total_miles))} mi",
"total_time": f"{total_hours:.1f} hrs",
"total_parks": len(parks),
"total_rides": sum(getattr(p, "ride_count", 0) or 0 for p in parks),
}
html = render_to_string(TRIP_SUMMARY_TEMPLATE, {"summary": summary}, request=request)
resp = HttpResponse(html)
# Include waypoints payload in HX-Trigger so client can render route on the map
resp["HX-Trigger"] = json.dumps({"tripOptimized": {"parks": waypoints}})
return resp
@require_POST
def htmx_calculate_route(request: HttpRequest) -> HttpResponse:
"""Alias for optimize route for now — returns trip summary partial."""
return htmx_optimize_route(request)
@require_POST
def htmx_save_trip(request: HttpRequest) -> HttpResponse:
"""Save the current session trip to a Trip model (if present) and return saved trips partial."""
name = request.POST.get("name") or "My Trip"
parks = []
for tid in _get_session_trip(request):
try:
parks.append(Park.objects.get(id=tid))
except Park.DoesNotExist:
continue
trips = []
if request.user.is_authenticated:
try:
from .models import Trip # type: ignore
trip = Trip.objects.create(owner=request.user, name=name)
# attempt to associate parks if the Trip model supports it
try:
trip.parks.set([p.id for p in parks])
except Exception:
pass
trips = list(Trip.objects.filter(owner=request.user).order_by("-created_at")[:10])
except Exception:
trips = []
html = render_to_string(SAVED_TRIPS_TEMPLATE, {"trips": trips}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"tripSaved": True})
return resp
@require_POST
def htmx_clear_trip(request: HttpRequest) -> HttpResponse:
"""Clear the current session trip and return an empty trip list partial."""
_save_session_trip(request, [])
html = render_to_string(TRIP_PARKS_TEMPLATE, {"trip_parks": []}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"tripCleared": True})
return resp
class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park
form_class = ParkForm
@@ -517,7 +768,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
# Create or update ParkLocation
park_location, created = ParkLocation.objects.get_or_create(
park_location, _ = ParkLocation.objects.get_or_create(
park=self.object,
defaults={
"street_address": form.cleaned_data.get("street_address", ""),