Enhance moderation dashboard UI and UX:

- Add HTMX-powered filtering with instant updates
- Add smooth transitions and loading states
- Improve visual hierarchy and styling
- Add review notes functionality
- Add confirmation dialogs for actions
- Make navigation sticky
- Add hover effects and visual feedback
- Improve dark mode support
This commit is contained in:
pacnpal
2024-11-13 14:38:38 +00:00
parent d2c9d02523
commit 9ee380c3ea
98 changed files with 5073 additions and 3040 deletions

View File

@@ -1,13 +1,15 @@
from decimal import Decimal, ROUND_DOWN, InvalidOperation
from typing import Any, Optional, cast, Type
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
from django.db.models import Q, Avg, Count, QuerySet, Model
from django.core.exceptions import ObjectDoesNotExist
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
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
import requests
from .models import Park, ParkArea
from .forms import ParkForm
@@ -17,11 +19,49 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
from moderation.models import EditSubmission
from media.models import Photo
from location.models import Location
from reviews.models import Review # Import the Review model
from analytics.models import PageView # Import PageView for tracking views
from reviews.models import Review
from analytics.models import PageView
def location_search(request):
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')
if not park_id:
return HttpResponse('<option value="">Select a park first</option>')
try:
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
])
return HttpResponse('\n'.join(options))
except Park.DoesNotExist:
return HttpResponse('<option value="">Invalid park selected</option>')
def search_parks(request: HttpRequest) -> HttpResponse:
"""Search parks and return results for HTMX"""
query = request.GET.get('q', '').strip()
# If no query, show first 10 parks
if not query:
parks = Park.objects.all().order_by('name')[:10]
else:
parks = Park.objects.filter(name__icontains=query).order_by('name')[:10]
return render(request, "parks/partials/park_search_results.html", {"parks": parks})
def location_search(request: HttpRequest) -> JsonResponse:
"""Search for locations using OpenStreetMap Nominatim API"""
query = request.GET.get("q", "")
if not query:
@@ -34,8 +74,8 @@ def location_search(request):
"q": query,
"format": "json",
"addressdetails": 1,
"namedetails": 1, # Include name tags
"accept-language": "en", # Prefer English results
"namedetails": 1,
"accept-language": "en",
"limit": 10,
},
headers={"User-Agent": "ThrillWiki/1.0"},
@@ -43,16 +83,18 @@ def location_search(request):
if response.status_code == 200:
results = response.json()
# Normalize each result
normalized_results = [normalize_osm_result(result) for result in results]
# Filter out any results with invalid coordinates
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": []})
def reverse_geocode(request):
def reverse_geocode(request: HttpRequest) -> JsonResponse:
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
try:
lat = Decimal(request.GET.get("lat", ""))
@@ -63,17 +105,18 @@ def reverse_geocode(request):
if not lat or not lon:
return JsonResponse({"error": "Missing coordinates"}, status=400)
# Normalize coordinates before geocoding
lat = lat.quantize(Decimal('0.000001'), rounding=ROUND_DOWN)
lon = lon.quantize(Decimal('0.000001'), rounding=ROUND_DOWN)
lat = lat.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
# Validate ranges
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
)
# Call Nominatim API
response = requests.get(
"https://nominatim.openstreetmap.org/reverse",
params={
@@ -81,30 +124,36 @@ def reverse_geocode(request):
"lon": str(lon),
"format": "json",
"addressdetails": 1,
"namedetails": 1, # Include name tags
"accept-language": "en", # Prefer English results
"namedetails": 1,
"accept-language": "en",
},
headers={"User-Agent": "ThrillWiki/1.0"},
)
if response.status_code == 200:
result = response.json()
# Normalize the result
normalized_result = normalize_osm_result(result)
if normalized_result['lat'] is None or normalized_result['lon'] is None:
if normalized_result["lat"] is None or normalized_result["lon"] is None:
return JsonResponse({"error": "Invalid coordinates"}, status=400)
return JsonResponse(normalized_result)
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(ListView):
model = Park
template_name = "parks/park_list.html"
context_object_name = "parks"
def get_queryset(self):
queryset = Park.objects.select_related("owner").prefetch_related("photos", "location")
def get_queryset(self) -> QuerySet[Park]:
queryset = Park.objects.select_related("owner").prefetch_related(
"photos", "location"
)
search = self.request.GET.get("search", "").strip()
country = self.request.GET.get("country", "").strip()
@@ -114,10 +163,10 @@ class ParkListView(ListView):
if search:
queryset = queryset.filter(
Q(name__icontains=search) |
Q(location__city__icontains=search) |
Q(location__state__icontains=search) |
Q(location__country__icontains=search)
Q(name__icontains=search)
| Q(location__city__icontains=search)
| Q(location__state__icontains=search)
| Q(location__country__icontains=search)
)
if country:
@@ -132,16 +181,14 @@ class ParkListView(ListView):
if statuses:
queryset = queryset.filter(status__in=statuses)
# Annotate with ride count, coaster count, and average review rating
queryset = queryset.annotate(
ride_count=Count('rides'),
coaster_count=Count('rides', filter=Q(rides__type='coaster')),
average_rating=Avg('reviews__rating')
total_rides=Count("rides"),
total_coasters=Count("rides", filter=Q(rides__category="RC")),
)
return queryset.distinct()
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["current_filters"] = {
"search": self.request.GET.get("search", ""),
@@ -152,10 +199,8 @@ class ParkListView(ListView):
}
return context
def get(self, request, *args, **kwargs):
# Check if this is an HTMX request
if hasattr(request, 'htmx') and getattr(request, 'htmx', False):
# If it is, return just the parks list partial
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if hasattr(request, "htmx") and getattr(request, "htmx", False):
self.template_name = "parks/partials/park_list.html"
return super().get(request, *args, **kwargs)
@@ -171,44 +216,43 @@ class ParkDetailView(
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset=None):
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)
# Try to get by current or historical slug
return Park.get_by_slug(slug)[0]
if slug is None:
raise ObjectDoesNotExist("No slug provided")
park, _ = Park.get_by_slug(slug)
return park
def get_queryset(self):
return super().get_queryset().prefetch_related(
'rides',
'rides__manufacturer',
'photos',
'areas',
'location'
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):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["areas"] = self.object.areas.all()
# Get rides ordered by status (operating first) and name
context["rides"] = self.object.rides.all().order_by(
'-status', # OPERATING will come before others
'name'
)
# Check if the user has reviewed the park
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=self.object.id
object_id=park.id,
).exists()
else:
context["has_reviewed"] = False
return context
def get_redirect_url_pattern(self):
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
@@ -217,38 +261,36 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
form_class = ParkForm
template_name = "parks/park_form.html"
def prepare_changes_data(self, cleaned_data):
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get("owner"):
data["owner"] = data["owner"].id
# Convert dates to ISO format strings
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
# Convert Decimal fields to strings
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):
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):
# Normalize coordinates before saving
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
@@ -258,8 +300,9 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
source=self.request.POST.get("source", ""),
)
# If user is moderator or above, auto-approve
if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
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
@@ -267,23 +310,23 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
submission.handled_by = self.request.user
submission.save()
# Create Location record
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
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',
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", "")
postal_code=form.cleaned_data.get("postal_code", ""),
)
# Handle photo uploads
photos = self.request.FILES.getlist("photos")
for photo_file in photos:
try:
@@ -319,7 +362,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
)
return HttpResponseRedirect(reverse("parks:park_list"))
def form_invalid(self, form):
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
@@ -329,7 +372,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get_success_url(self):
def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
@@ -338,43 +381,41 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
form_class = ParkForm
template_name = "parks/park_form.html"
def get_context_data(self, **kwargs):
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):
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get("owner"):
data["owner"] = data["owner"].id
# Convert dates to ISO format strings
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
# Convert Decimal fields to strings
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):
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):
# Normalize coordinates before saving
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
@@ -385,25 +426,25 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
source=self.request.POST.get("source", ""),
)
# If user is moderator or above, auto-approve
if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
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()
# Update or create Location record
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", "")
"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():
@@ -415,10 +456,9 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
Location.objects.create(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
**location_data
**location_data,
)
# Handle photo uploads
photos = self.request.FILES.getlist("photos")
uploaded_count = 0
for photo_file in photos:
@@ -458,7 +498,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
)
def form_invalid(self, form):
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
@@ -468,7 +508,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get_success_url(self):
def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
@@ -484,23 +524,25 @@ class ParkAreaDetailView(
context_object_name = "area"
slug_url_kwarg = "area_slug"
def get_object(self, queryset=None):
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")
# Try to get by current or historical slug
obj, is_old_slug = ParkArea.get_by_slug(area_slug)
if obj.park.slug != park_slug:
raise self.model.DoesNotExist("Park slug doesn't match")
return obj
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):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
return context
def get_redirect_url_pattern(self):
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
def get_redirect_url_kwargs(self):
return {"park_slug": self.object.park.slug, "area_slug": self.object.slug}
def get_redirect_url_kwargs(self) -> dict[str, str]:
area = cast(ParkArea, self.object)
return {"park_slug": area.park.slug, "area_slug": area.slug}