From 1acfe9d29e44f3d9c0ee83a3cdf6e65f3d6fe0bb Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Wed, 13 Nov 2024 18:38:03 +0000 Subject: [PATCH] Improve moderation dashboard UI and functionality - Add status tabs (Pending, Approved, Rejected, Escalated) - Implement HTMX for smooth tab switching and status changes - Add proper permissions for escalated submissions - Change Status filter to Submission Type (Text/Photo) - Move navigation into dashboard content - Fix tab menu visibility and transitions - Add contextual loading indicator - Update styling to match dark theme - Ensure consistent styling across components --- moderation/models.py | 51 ++++++++-- moderation/urls.py | 22 ++-- static/css/tailwind.css | 220 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 14 deletions(-) diff --git a/moderation/models.py b/moderation/models.py index 5fd2e2b7..8c3aafdd 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -8,6 +8,7 @@ from django.apps import apps from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.models import AnonymousUser +from django.utils.text import slugify UserType = Union[AbstractBaseUser, AnonymousUser] @@ -102,21 +103,56 @@ class EditSubmission(models.Model): return resolved_data + def _prepare_model_data(self, data: Dict[str, Any], model_class: Type[models.Model]) -> Dict[str, Any]: + """Prepare data for model creation/update by filtering out auto-generated fields""" + prepared_data = data.copy() + + # Remove fields that are auto-generated or handled by the model's save method + auto_fields = {'created_at', 'updated_at', 'slug'} + for field in auto_fields: + prepared_data.pop(field, None) + + # Set default values for required fields if not provided + for field in model_class._meta.fields: + if not field.auto_created and not field.blank and not field.null: + if field.name not in prepared_data and field.has_default(): + prepared_data[field.name] = field.get_default() + + return prepared_data + + def _check_duplicate_name(self, model_class: Type[models.Model], name: str) -> Optional[models.Model]: + """Check if an object with the same name already exists""" + try: + return model_class.objects.filter(name=name).first() + except: + return None + def approve(self, user: UserType) -> Optional[models.Model]: """Approve the submission and apply the changes""" - self.status = "APPROVED" - self.handled_by = user # type: ignore - self.handled_at = timezone.now() - if not (model_class := self.content_type.model_class()): raise ValueError("Could not resolve model class") try: resolved_data = self._resolve_foreign_keys(self.changes) + prepared_data = self._prepare_model_data(resolved_data, model_class) + + # For CREATE submissions, check for duplicates by name + if self.submission_type == "CREATE" and "name" in prepared_data: + if existing_obj := self._check_duplicate_name(model_class, prepared_data["name"]): + self.status = "REJECTED" + self.handled_by = user # type: ignore + self.handled_at = timezone.now() + self.notes = f"A {model_class.__name__} with the name '{prepared_data['name']}' already exists (ID: {existing_obj.id})" + self.save() + raise ValueError(self.notes) + + self.status = "APPROVED" + self.handled_by = user # type: ignore + self.handled_at = timezone.now() if self.submission_type == "CREATE": # Create new object - obj = model_class(**resolved_data) + obj = model_class(**prepared_data) obj.save() # Update object_id after creation self.object_id = getattr(obj, "id", None) @@ -124,13 +160,16 @@ class EditSubmission(models.Model): # Apply changes to existing object if not (obj := self.content_object): raise ValueError("Content object not found") - for field, value in resolved_data.items(): + for field, value in prepared_data.items(): setattr(obj, field, value) obj.save() self.save() return obj except Exception as e: + if self.status != "REJECTED": # Don't override if already rejected due to duplicate + self.status = "NEW" # Reset status if approval failed + self.save() raise ValueError(f"Error approving submission: {str(e)}") from e def reject(self, user: UserType) -> None: diff --git a/moderation/urls.py b/moderation/urls.py index df2bea3e..8cb92434 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -1,21 +1,27 @@ from django.urls import path +from django.shortcuts import redirect +from django.urls import reverse_lazy from . import views app_name = 'moderation' +def redirect_to_dashboard(request): + return redirect(reverse_lazy('moderation:dashboard')) + urlpatterns = [ - # Dashboard - path('', views.DashboardView.as_view(), name='dashboard'), + # Root URL redirects to dashboard + path('', redirect_to_dashboard), + + # Dashboard and Submissions + path('dashboard/', views.DashboardView.as_view(), name='dashboard'), path('submissions/', views.submission_list, name='submission_list'), - # Edit Submissions - path('edits/', views.EditSubmissionListView.as_view(), name='edit_submissions'), - path('edits//approve/', views.approve_submission, name='approve_submission'), - path('edits//reject/', views.reject_submission, name='reject_submission'), - path('edits//escalate/', views.escalate_submission, name='escalate_submission'), + # Submission Actions + path('submissions//approve/', views.approve_submission, name='approve_submission'), + path('submissions//reject/', views.reject_submission, name='reject_submission'), + path('submissions//escalate/', views.escalate_submission, name='escalate_submission'), # Photo Submissions - path('photos/', views.PhotoSubmissionListView.as_view(), name='photo_submissions'), path('photos//approve/', views.approve_photo, name='approve_photo'), path('photos//reject/', views.reject_photo, name='reject_photo'), ] diff --git a/static/css/tailwind.css b/static/css/tailwind.css index bc91f780..efaa71f8 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2249,6 +2249,10 @@ select { z-index: 60; } +.z-10 { + z-index: 10; +} + .col-span-1 { grid-column: span 1 / span 1; } @@ -2294,6 +2298,21 @@ select { margin-right: auto; } +.mx-2\.5 { + margin-left: 0.625rem; + margin-right: 0.625rem; +} + +.-mx-1\.5 { + margin-left: -0.375rem; + margin-right: -0.375rem; +} + +.-my-1\.5 { + margin-top: -0.375rem; + margin-bottom: -0.375rem; +} + .-mb-px { margin-bottom: -1px; } @@ -2386,6 +2405,30 @@ select { margin-top: auto; } +.mr-2\.5 { + margin-right: 0.625rem; +} + +.ml-1\.5 { + margin-left: 0.375rem; +} + +.mr-1\.5 { + margin-right: 0.375rem; +} + +.mt-1\.5 { + margin-top: 0.375rem; +} + +.ml-3 { + margin-left: 0.75rem; +} + +.ml-auto { + margin-left: auto; +} + .block { display: block; } @@ -2519,6 +2562,10 @@ select { min-width: 200px; } +.min-w-\[120px\] { + min-width: 120px; +} + .max-w-2xl { max-width: 42rem; } @@ -2551,10 +2598,22 @@ select { max-width: none; } +.max-w-xs { + max-width: 20rem; +} + +.max-w-6xl { + max-width: 72rem; +} + .flex-1 { flex: 1 1 0%; } +.flex-shrink-0 { + flex-shrink: 0; +} + .flex-grow { flex-grow: 1; } @@ -2574,6 +2633,21 @@ 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)); } +.translate-y-full { + --tw-translate-y: 100%; + 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)); +} + +.translate-x-0 { + --tw-translate-x: 0px; + 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)); +} + +.translate-x-4 { + --tw-translate-x: 1rem; + 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)); +} + .scale-100 { --tw-scale-x: 1; --tw-scale-y: 1; @@ -2586,6 +2660,12 @@ 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)); } +.scale-\[1\.01\] { + --tw-scale-x: 1.01; + --tw-scale-y: 1.01; + 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)); +} + .transform { 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)); } @@ -2734,6 +2814,12 @@ select { margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); } +.space-x-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1.5rem * var(--tw-space-x-reverse)); + margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); +} + .overflow-auto { overflow: auto; } @@ -2868,6 +2954,16 @@ select { border-color: transparent; } +.border-gray-800 { + --tw-border-opacity: 1; + border-color: rgb(31 41 55 / var(--tw-border-opacity)); +} + +.border-gray-700 { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity)); +} + .border-t-transparent { border-top-color: transparent; } @@ -2988,6 +3084,32 @@ select { background-color: rgb(202 138 4 / var(--tw-bg-opacity)); } +.bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); +} + +.bg-blue-900\/40 { + background-color: rgb(30 58 138 / 0.4); +} + +.bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); +} + +.bg-green-900\/40 { + background-color: rgb(20 83 45 / 0.4); +} + +.bg-blue-900\/30 { + background-color: rgb(30 58 138 / 0.3); +} + +.bg-gray-900\/80 { + background-color: rgb(17 24 39 / 0.8); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -3145,6 +3267,16 @@ select { padding-bottom: 2rem; } +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + .pb-4 { padding-bottom: 1rem; } @@ -3192,6 +3324,11 @@ select { line-height: 1rem; } +.text-5xl { + font-size: 3rem; + line-height: 1; +} + .font-bold { font-weight: 700; } @@ -3275,6 +3412,11 @@ select { color: rgb(17 24 39 / var(--tw-text-opacity)); } +.text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); +} + .text-green-600 { --tw-text-opacity: 1; color: rgb(22 163 74 / var(--tw-text-opacity)); @@ -3359,6 +3501,26 @@ select { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.text-blue-400 { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); +} + +.text-green-400 { + --tw-text-opacity: 1; + color: rgb(74 222 128 / var(--tw-text-opacity)); +} + +.text-blue-200 { + --tw-text-opacity: 1; + color: rgb(191 219 254 / var(--tw-text-opacity)); +} + +.text-blue-300 { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity)); +} + .opacity-0 { opacity: 0; } @@ -3532,6 +3694,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)); } +.hover\:scale-\[1\.01\]:hover { + --tw-scale-x: 1.01; + --tw-scale-y: 1.01; + 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)); +} + +.hover\:transform:hover { + 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)); +} + .hover\:border-gray-300:hover { --tw-border-opacity: 1; border-color: rgb(209 213 219 / var(--tw-border-opacity)); @@ -3640,6 +3812,16 @@ select { color: rgb(7 89 133 / var(--tw-text-opacity)); } +.hover\:text-gray-900:hover { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.hover\:text-blue-300:hover { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -3685,6 +3867,11 @@ select { --tw-ring-color: rgb(79 70 229 / 0.5); } +.focus\:ring-gray-300:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + .focus\:ring-offset-2:focus { --tw-ring-offset-width: 2px; } @@ -3822,6 +4009,24 @@ select { background-color: rgb(113 63 18 / 0.5); } +.dark\:bg-blue-900\/60:is(.dark *) { + background-color: rgb(30 58 138 / 0.6); +} + +.dark\:bg-green-800:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(22 101 52 / var(--tw-bg-opacity)); +} + +.dark\:bg-green-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(20 83 45 / var(--tw-bg-opacity)); +} + +.dark\:bg-blue-900\/30:is(.dark *) { + background-color: rgb(30 58 138 / 0.3); +} + .dark\:from-gray-950:is(.dark *) { --tw-gradient-from: #030712 var(--tw-gradient-from-position); --tw-gradient-to: rgb(3 7 18 / 0) var(--tw-gradient-to-position); @@ -3952,6 +4157,16 @@ select { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.dark\:text-blue-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity)); +} + +.dark\:text-green-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(187 247 208 / var(--tw-text-opacity)); +} + .dark\:ring-1:is(.dark *) { --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(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -4045,6 +4260,11 @@ select { color: rgb(125 211 252 / var(--tw-text-opacity)); } +.dark\:hover\:text-white:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + @media (min-width: 640px) { .sm\:col-span-3 { grid-column: span 3 / span 3;