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,10 +1,11 @@
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 typing import Any, Dict, Optional, Tuple, Union, cast, Type
from django.views.generic import DetailView, ListView, CreateView, UpdateView, RedirectView
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
from django.db.models import Q, Model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import (
@@ -17,7 +18,11 @@ from django.http import (
from django.db.models import Count
from django.core.files.uploadedfile import UploadedFile
from django.forms import ModelForm
from .models import Ride, RollerCoasterStats
from django.db.models.query import QuerySet
from simple_history.models import HistoricalRecords
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from .models import Ride, RollerCoasterStats, RideModel, CATEGORY_CHOICES
from .forms import RideForm
from parks.models import Park
from core.views import SlugRedirectMixin
@@ -25,481 +30,328 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
from moderation.models import EditSubmission
from media.models import Photo
from accounts.models import User
from companies.models import Manufacturer, Designer
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"
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")
)
return {category_name: rides} if rides.exists() else {}
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
return context
class ParkSingleCategoryListView(ListView):
model = Ride
template_name = "rides/ride_category_list.html"
context_object_name = "categories"
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"])
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")
)
return {category_name: rides} if rides.exists() else {}
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
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
return context
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
"""Show roller coaster specific fields based on category selection"""
category = request.GET.get('category')
if category != 'RC': # Only show for roller coasters
return HttpResponse('')
return render(request, "rides/partials/coaster_fields.html")
class RideCreateView(LoginRequiredMixin, CreateView):
"""View for creating a new ride"""
model = Ride
form_class = RideForm
template_name = "rides/ride_form.html"
template_name = 'rides/ride_form.html'
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"])
def get_success_url(self):
"""Get URL to redirect to after successful creation"""
if hasattr(self, 'park'):
return reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
})
return reverse('rides:ride_detail', kwargs={'ride_slug': self.object.slug})
def get_form_kwargs(self) -> Dict[str, Any]:
def get_form_kwargs(self):
"""Pass park to the form"""
kwargs = super().get_form_kwargs()
kwargs["park"] = self.park
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
kwargs['park'] = self.park
return kwargs
def handle_submission(
self, form: RideForm, cleaned_data: Dict[str, Any]
) -> HttpResponseRedirect:
"""Handle the form submission.
Args:
form: The form to process
cleaned_data: The cleaned form data
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).",
)
return HttpResponseRedirect(self.get_success_url())
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.",
)
return HttpResponseRedirect(
reverse("parks:rides:ride_list", kwargs={"park_slug": self.park.slug})
)
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) -> str:
return reverse(
"parks:rides:ride_detail",
kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug},
)
def get_context_data(self, **kwargs) -> Dict[str, Any]:
def get_context_data(self, **kwargs):
"""Add park and park_slug to context"""
context = super().get_context_data(**kwargs)
context["park"] = self.park
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.park.slug
context['is_edit'] = False
return context
class RideUpdateView(LoginRequiredMixin, UpdateView):
model = Ride
form_class = RideForm
template_name = "rides/ride_form.html"
slug_url_kwarg = "ride_slug"
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"])
def get_form_kwargs(self) -> Dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs["park"] = self.park
return kwargs
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["park"] = self.park
context["is_edit"] = True
return context
def handle_submission(
self, form: RideForm, cleaned_data: Dict[str, Any]
) -> HttpResponseRedirect:
"""Handle the form submission.
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 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).",
)
return HttpResponseRedirect(self.get_success_url())
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.",
)
return HttpResponseRedirect(
reverse(
"parks:rides:ride_detail",
kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug},
def form_valid(self, form):
"""Handle form submission including new items"""
# Check for new manufacturer
manufacturer_name = form.cleaned_data.get('manufacturer_search')
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
# Create submission for new manufacturer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type="CREATE",
changes={"name": manufacturer_name},
)
)
def form_valid(self, form: RideForm) -> HttpResponseRedirect:
cleaned_data = prepare_form_data(form.cleaned_data, self.park)
return self.handle_submission(form, cleaned_data)
# Check for new designer
designer_name = form.cleaned_data.get('designer_search')
if designer_name and not form.cleaned_data.get('designer'):
# Create submission for new designer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Designer),
submission_type="CREATE",
changes={"name": designer_name},
)
def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]:
"""Handle invalid form submission.
# Check for new ride model
ride_model_name = form.cleaned_data.get('ride_model_search')
manufacturer = form.cleaned_data.get('manufacturer')
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
# Create submission for new ride model
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(RideModel),
submission_type="CREATE",
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id
},
)
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) -> str:
return reverse(
"parks:rides:ride_detail",
kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug},
)
return super().form_valid(form)
class RideDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView,
):
class RideDetailView(DetailView):
"""View for displaying ride details"""
model = Ride
template_name = "rides/ride_detail.html"
context_object_name = "ride"
slug_url_kwarg = "ride_slug"
template_name = 'rides/ride_detail.html'
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")
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_queryset(self):
"""Get ride for the specific park if park_slug is provided"""
queryset = Ride.objects.all().select_related(
'park',
'ride_model',
'ride_model__manufacturer'
).prefetch_related('photos')
def get_context_data(self, **kwargs) -> Dict[str, Any]:
if 'park_slug' in self.kwargs:
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
return queryset
def get_context_data(self, **kwargs):
"""Add park_slug to context if it exists"""
context = super().get_context_data(**kwargs)
if self.object.category == "RC":
context["coaster_stats"] = RollerCoasterStats.objects.filter(
ride=self.object
).first()
if 'park_slug' in self.kwargs:
context['park_slug'] = self.kwargs['park_slug']
return context
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}
class RideUpdateView(LoginRequiredMixin, EditSubmissionMixin, UpdateView):
"""View for updating an existing ride"""
model = Ride
form_class = RideForm
template_name = 'rides/ride_form.html'
slug_url_kwarg = 'ride_slug'
def get_success_url(self):
"""Get URL to redirect to after successful update"""
if hasattr(self, 'park'):
return reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
})
return reverse('rides:ride_detail', kwargs={'ride_slug': self.object.slug})
def get_queryset(self):
"""Get ride for the specific park if park_slug is provided"""
queryset = Ride.objects.all()
if 'park_slug' in self.kwargs:
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
return queryset
def get_form_kwargs(self):
"""Pass park to the form"""
kwargs = super().get_form_kwargs()
# For park-specific URLs, use the park from the URL
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
kwargs['park'] = self.park
# For global URLs, use the ride's park
else:
self.park = self.get_object().park
kwargs['park'] = self.park
return kwargs
def get_context_data(self, **kwargs):
"""Add park and park_slug to context"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.park.slug
context['is_edit'] = True
return context
def form_valid(self, form):
"""Handle form submission including new items"""
# Check for new manufacturer
manufacturer_name = form.cleaned_data.get('manufacturer_search')
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
# Create submission for new manufacturer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type="CREATE",
changes={"name": manufacturer_name},
)
# Check for new designer
designer_name = form.cleaned_data.get('designer_search')
if designer_name and not form.cleaned_data.get('designer'):
# Create submission for new designer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Designer),
submission_type="CREATE",
changes={"name": designer_name},
)
# Check for new ride model
ride_model_name = form.cleaned_data.get('ride_model_search')
manufacturer = form.cleaned_data.get('manufacturer')
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
# Create submission for new ride model
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(RideModel),
submission_type="CREATE",
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id
},
)
return super().form_valid(form)
class RideListView(ListView):
"""View for displaying a list of rides"""
model = Ride
template_name = "rides/ride_list.html"
context_object_name = "rides"
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"])
template_name = 'rides/ride_list.html'
context_object_name = 'rides'
def get_queryset(self):
queryset = Ride.objects.select_related(
"park", "coaster_stats", "manufacturer"
).prefetch_related("photos")
"""Get all rides or filter by park if park_slug is provided"""
queryset = Ride.objects.all().select_related(
'park',
'ride_model',
'ride_model__manufacturer'
).prefetch_related('photos')
if self.park:
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
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
if search:
if self.park:
queryset = queryset.filter(name__icontains=search)
else:
queryset = queryset.filter(
Q(name__icontains=search) | Q(park__name__icontains=search)
)
if category:
queryset = queryset.filter(category=category)
if status:
queryset = queryset.filter(status=status)
if manufacturer:
queryset = queryset.exclude(manufacturer__isnull=True)
return queryset
def get_context_data(self, **kwargs) -> Dict[str, Any]:
def get_context_data(self, **kwargs):
"""Add park to context if park_slug is provided"""
context = super().get_context_data(**kwargs)
context["park"] = self.park
manufacturer_query = Ride.objects
if self.park:
manufacturer_query = manufacturer_query.filter(park=self.park)
context["manufacturers"] = list(
manufacturer_query.exclude(manufacturer__isnull=True)
.values_list("manufacturer__name", flat=True)
.distinct()
.order_by("manufacturer__name")
)
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", ""),
}
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
return context
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)
class SingleCategoryListView(ListView):
"""View for displaying rides of a specific category"""
model = Ride
template_name = 'rides/park_category_list.html'
context_object_name = 'rides'
def get_queryset(self):
"""Get rides filtered by category and optionally by park"""
category = self.kwargs.get('category')
queryset = Ride.objects.filter(
category=category
).select_related(
'park',
'ride_model',
'ride_model__manufacturer'
)
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
queryset = queryset.filter(park=self.park)
return queryset
def get_context_data(self, **kwargs):
"""Add park and category information to context"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
context['category'] = dict(CATEGORY_CHOICES).get(self.kwargs['category'])
return context
# Alias for parks app to maintain backward compatibility
ParkSingleCategoryListView = SingleCategoryListView
def is_privileged_user(user: Any) -> bool:
"""Check if user has privileged access"""
return bool(user and hasattr(user, 'is_staff') and (user.is_staff or user.is_superuser))
@login_required
def search_manufacturers(request: HttpRequest) -> HttpResponse:
"""Search manufacturers and return results for HTMX"""
query = request.GET.get("q", "").strip()
# Show all manufacturers on click, filter on input
manufacturers = Manufacturer.objects.all().order_by("name")
if query:
manufacturers = manufacturers.filter(name__icontains=query)
manufacturers = manufacturers[:10]
return render(
request,
"rides/partials/manufacturer_search_results.html",
{"manufacturers": manufacturers, "search_term": query},
)
@login_required
def search_designers(request: HttpRequest) -> HttpResponse:
"""Search designers and return results for HTMX"""
query = request.GET.get("q", "").strip()
# Show all designers on click, filter on input
designers = Designer.objects.all().order_by("name")
if query:
designers = designers.filter(name__icontains=query)
designers = designers[:10]
return render(
request,
"rides/partials/designer_search_results.html",
{"designers": designers, "search_term": query},
)
@login_required
def search_ride_models(request: HttpRequest) -> HttpResponse:
"""Search ride models and return results for HTMX"""
query = request.GET.get("q", "").strip()
manufacturer_id = request.GET.get("manufacturer")
# Show all ride models on click, filter on input
ride_models = RideModel.objects.select_related("manufacturer").order_by("name")
if query:
ride_models = ride_models.filter(name__icontains=query)
if manufacturer_id:
ride_models = ride_models.filter(manufacturer_id=manufacturer_id)
ride_models = ride_models[:10]
return render(
request,
"rides/partials/ride_model_search_results.html",
{"ride_models": ride_models, "search_term": query, "manufacturer_id": manufacturer_id},
)