feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -42,9 +42,7 @@ logger = logging.getLogger(__name__)
# Constants
PARK_DETAIL_URL = "parks:park_detail"
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 (*)."
)
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"
@@ -87,18 +85,10 @@ def normalize_osm_result(result: dict) -> dict:
neighborhood = address.get("neighbourhood", "")
# Build city from available components
city = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or ""
)
city = address.get("city") or address.get("town") or address.get("village") or address.get("municipality") or ""
# Get detailed state/region information
state = (
address.get("state") or address.get("province") or address.get("region") or ""
)
state = address.get("state") or address.get("province") or address.get("region") or ""
# Get postal code with fallbacks
postal_code = address.get("postcode") or address.get("postal_code") or ""
@@ -170,9 +160,7 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
park = Park.objects.get(id=park_id)
areas = park.areas.all()
options = ['<option value="">No specific area</option>']
options.extend(
[f'<option value="{area.id}">{area.name}</option>' for area in areas]
)
options.extend([f'<option value="{area.id}">{area.name}</option>' for area in areas])
return HttpResponse("\n".join(options))
except Park.DoesNotExist:
return HttpResponse('<option value="">Invalid park selected</option>')
@@ -201,11 +189,7 @@ def location_search(request: HttpRequest) -> JsonResponse:
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
if r["lat"] is not None and r["lon"] is not None
]
valid_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": []})
@@ -226,13 +210,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",
@@ -306,9 +286,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
try:
# Initialize filterset if not exists
if not hasattr(self, "filterset"):
self.filterset = self.filter_class(
self.request.GET, queryset=self.model.objects.none()
)
self.filterset = self.filter_class(self.request.GET, queryset=self.model.objects.none())
context = super().get_context_data(**kwargs)
@@ -323,20 +301,14 @@ class ParkListView(HTMXFilterableMixin, ListView):
"search_query": self.request.GET.get("search", ""),
"filter_counts": filter_counts,
"popular_filters": popular_filters,
"total_results": (
context.get("paginator").count
if context.get("paginator")
else 0
),
"total_results": (context.get("paginator").count if context.get("paginator") else 0),
}
)
# Add filter suggestions for search queries
search_query = self.request.GET.get("search", "")
if search_query:
context["filter_suggestions"] = (
self.filter_service.get_filter_suggestions(search_query)
)
context["filter_suggestions"] = self.filter_service.get_filter_suggestions(search_query)
return context
@@ -353,9 +325,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
messages.error(self.request, f"Error applying filters: {str(e)}")
# Ensure filterset exists in error case
if not hasattr(self, "filterset"):
self.filterset = self.filter_class(
self.request.GET, queryset=self.model.objects.none()
)
self.filterset = self.filter_class(self.request.GET, queryset=self.model.objects.none())
return {
"filter": self.filterset,
"error": "Unable to apply filters. Please try adjusting your criteria.",
@@ -427,9 +397,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
return urlencode(url_params)
def _get_pagination_urls(
self, page_obj, filter_params: dict[str, Any]
) -> dict[str, str]:
def _get_pagination_urls(self, page_obj, filter_params: dict[str, Any]) -> dict[str, str]:
"""Generate pagination URLs that preserve filter state."""
base_query = self._build_filter_query_string(filter_params)
@@ -476,9 +444,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
# Get current view mode from request
current_view_mode = request.GET.get("view_mode", "grid")
park_filter = ParkFilter(
{"search": search_query}, queryset=get_base_park_queryset()
)
park_filter = ParkFilter({"search": search_query}, queryset=get_base_park_queryset())
parks = park_filter.qs
if request.GET.get("quick_search"):
@@ -747,10 +713,7 @@ def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
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
)
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
@@ -762,18 +725,14 @@ def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
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}
)
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"]
)
total_miles += haversine_miles(a["latitude"], a["longitude"], b["latitude"], b["longitude"])
except Exception as e:
log_exception(
logger,
@@ -807,9 +766,7 @@ def htmx_optimize_route(request: HttpRequest) -> HttpResponse:
"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
)
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}})
@@ -843,9 +800,7 @@ def htmx_save_trip(request: HttpRequest) -> HttpResponse:
# attempt to associate parks if the Trip model supports it
with contextlib.suppress(Exception):
trip.parks.set([p.id for p in parks])
trips = list(
Trip.objects.filter(owner=request.user).order_by("-created_at")[:10]
)
trips = list(Trip.objects.filter(owner=request.user).order_by("-created_at")[:10])
except Exception:
trips = []
@@ -892,14 +847,10 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
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
)
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
)
form.cleaned_data["longitude"] = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
@@ -942,8 +893,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
)
messages.success(
self.request,
f"Successfully created {self.object.name}. "
f"Added {service_result['uploaded_count']} photo(s).",
f"Successfully created {self.object.name}. " f"Added {service_result['uploaded_count']} photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
@@ -960,8 +910,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
)
messages.success(
self.request,
"Your park submission has been sent for review. "
"You will be notified when it is approved.",
"Your park submission has been sent for review. " "You will be notified when it is approved.",
)
return HttpResponseRedirect(reverse("parks:park_list"))
@@ -1016,14 +965,10 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
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
)
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
)
form.cleaned_data["longitude"] = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
@@ -1068,8 +1013,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
)
messages.success(
self.request,
f"Successfully updated {self.object.name}. "
f"Added {service_result['uploaded_count']} new photo(s).",
f"Successfully updated {self.object.name}. " f"Added {service_result['uploaded_count']} new photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
@@ -1090,9 +1034,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
f"Your changes to {self.object.name} have been sent for review. "
"You will be notified when they are approved.",
)
return HttpResponseRedirect(
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
)
return HttpResponseRedirect(reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug}))
elif service_result["status"] == "failed":
messages.error(
@@ -1143,11 +1085,7 @@ class ParkDetailView(
def get_queryset(self) -> QuerySet[Park]:
return cast(
QuerySet[Park],
super()
.get_queryset()
.prefetch_related(
"rides", "rides__manufacturer", "photos", "areas", "location"
),
super().get_queryset().prefetch_related("rides", "rides__manufacturer", "photos", "areas", "location"),
)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]: