From 6880f36b996bd49891d540ef6bb49b58d4dd14d7 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:29:16 -0400 Subject: [PATCH] good stuff --- cookiejar | 2 - moderation/__init__.py | 0 moderation/admin.py | 78 ++++++ moderation/apps.py | 6 + moderation/context_processors.py | 16 ++ moderation/migrations/0001_initial.py | 183 ++++++++++++ ...editsubmission_submission_type_and_more.py | 46 +++ moderation/migrations/__init__.py | 0 moderation/mixins.py | 205 ++++++++++++++ moderation/models.py | 262 ++++++++++++++++++ moderation/tests.py | 3 + moderation/urls.py | 16 ++ moderation/views.py | 100 +++++++ parks/__pycache__/models.cpython-312.pyc | Bin 6633 -> 6657 bytes parks/__pycache__/urls.cpython-312.pyc | Bin 747 -> 880 bytes parks/__pycache__/views.cpython-312.pyc | Bin 6535 -> 8966 bytes ...historicalpark_status_alter_park_status.py | 45 +++ parks/models.py | 1 + parks/urls.py | 1 + parks/views.py | 46 ++- rides/__pycache__/urls.cpython-312.pyc | Bin 596 -> 729 bytes rides/__pycache__/views.cpython-312.pyc | Bin 5863 -> 8661 bytes rides/urls.py | 1 + rides/views.py | 52 +++- static/css/inline-edit.css | 187 +++++++++++++ static/css/tailwind.css | 156 ++++++++++- static/js/inline-edit.js | 262 ++++++++++++++++++ templates/accounts/turnstile_widget.html | 6 +- templates/base/base.html | 121 ++++---- templates/moderation/admin/base.html | 159 +++++++++++ .../admin/edit_submission_list.html | 142 ++++++++++ .../admin/photo_submission_list.html | 104 +++++++ templates/parks/park_detail.html | 201 +++++++++----- templates/parks/park_form.html | 92 ++++++ templates/parks/park_list.html | 128 +++++++-- templates/rides/ride_detail.html | 230 ++++++++++----- templates/rides/ride_form.html | 92 ++++++ templates/rides/ride_list.html | 139 +++++++--- .../__pycache__/settings.cpython-312.pyc | Bin 5585 -> 5646 bytes thrillwiki/__pycache__/urls.cpython-312.pyc | Bin 2651 -> 2758 bytes thrillwiki/settings.py | 12 +- thrillwiki/urls.py | 3 + 42 files changed, 2835 insertions(+), 262 deletions(-) create mode 100644 moderation/__init__.py create mode 100644 moderation/admin.py create mode 100644 moderation/apps.py create mode 100644 moderation/context_processors.py create mode 100644 moderation/migrations/0001_initial.py create mode 100644 moderation/migrations/0002_editsubmission_submission_type_and_more.py create mode 100644 moderation/migrations/__init__.py create mode 100644 moderation/mixins.py create mode 100644 moderation/models.py create mode 100644 moderation/tests.py create mode 100644 moderation/urls.py create mode 100644 moderation/views.py create mode 100644 parks/migrations/0003_alter_historicalpark_status_alter_park_status.py create mode 100644 static/css/inline-edit.css create mode 100644 static/js/inline-edit.js create mode 100644 templates/moderation/admin/base.html create mode 100644 templates/moderation/admin/edit_submission_list.html create mode 100644 templates/moderation/admin/photo_submission_list.html create mode 100644 templates/parks/park_form.html create mode 100644 templates/rides/ride_form.html diff --git a/cookiejar b/cookiejar index 5baf8625..c31d9899 100644 --- a/cookiejar +++ b/cookiejar @@ -2,5 +2,3 @@ # https://curl.se/docs/http-cookies.html # This file was generated by libcurl! Edit at your own risk. -#HttpOnly_localhost FALSE / FALSE 1731262680 sessionid angfimq8dc7q8qmn064ud2s2svheq5es -localhost FALSE / FALSE 1761502680 csrftoken 8C8T7QuLCNRoSYeothorKYe6PYadNtOO diff --git a/moderation/__init__.py b/moderation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/moderation/admin.py b/moderation/admin.py new file mode 100644 index 00000000..745dd646 --- /dev/null +++ b/moderation/admin.py @@ -0,0 +1,78 @@ +from django.contrib import admin +from django.contrib.admin import AdminSite +from django.utils.html import format_html +from django.urls import reverse +from .models import EditSubmission, PhotoSubmission + +class ModerationAdminSite(AdminSite): + site_header = 'ThrillWiki Moderation' + site_title = 'ThrillWiki Moderation' + index_title = 'Moderation Dashboard' + + def has_permission(self, request): + """Only allow moderators and above to access this admin site""" + return request.user.is_authenticated and request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + +moderation_site = ModerationAdminSite(name='moderation') + +class EditSubmissionAdmin(admin.ModelAdmin): + list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'submitted_at', 'reviewed_by'] + list_filter = ['status', 'content_type', 'submitted_at'] + search_fields = ['user__username', 'reason', 'source', 'review_notes'] + readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'submitted_at'] + + def user_link(self, obj): + url = reverse('admin:accounts_user_change', args=[obj.user.id]) + return format_html('{}', url, obj.user.username) + user_link.short_description = 'User' + + def content_link(self, obj): + if hasattr(obj.content_object, 'get_absolute_url'): + url = obj.content_object.get_absolute_url() + return format_html('{}', url, str(obj.content_object)) + return str(obj.content_object) + content_link.short_description = 'Content' + + def save_model(self, request, obj, form, change): + if 'status' in form.changed_data: + if obj.status == 'APPROVED': + obj.approve(request.user, obj.review_notes) + elif obj.status == 'REJECTED': + obj.reject(request.user, obj.review_notes) + super().save_model(request, obj, form, change) + +class PhotoSubmissionAdmin(admin.ModelAdmin): + list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'submitted_at', 'reviewed_by'] + list_filter = ['status', 'content_type', 'submitted_at'] + search_fields = ['user__username', 'caption', 'review_notes'] + readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'submitted_at'] + + def user_link(self, obj): + url = reverse('admin:accounts_user_change', args=[obj.user.id]) + return format_html('{}', url, obj.user.username) + user_link.short_description = 'User' + + def content_link(self, obj): + if hasattr(obj.content_object, 'get_absolute_url'): + url = obj.content_object.get_absolute_url() + return format_html('{}', url, str(obj.content_object)) + return str(obj.content_object) + content_link.short_description = 'Content' + + def photo_preview(self, obj): + if obj.photo: + return format_html('', obj.photo.url) + return '' + photo_preview.short_description = 'Photo Preview' + + def save_model(self, request, obj, form, change): + if 'status' in form.changed_data: + if obj.status == 'APPROVED': + obj.approve(request.user, obj.review_notes) + elif obj.status == 'REJECTED': + obj.reject(request.user, obj.review_notes) + super().save_model(request, obj, form, change) + +# Register with moderation site only +moderation_site.register(EditSubmission, EditSubmissionAdmin) +moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin) diff --git a/moderation/apps.py b/moderation/apps.py new file mode 100644 index 00000000..781aa562 --- /dev/null +++ b/moderation/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class ModerationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'moderation' + verbose_name = 'Content Moderation' diff --git a/moderation/context_processors.py b/moderation/context_processors.py new file mode 100644 index 00000000..9a742e41 --- /dev/null +++ b/moderation/context_processors.py @@ -0,0 +1,16 @@ +def moderation_access(request): + """Add moderation access check to template context""" + context = { + 'has_moderation_access': False, + 'has_admin_access': False, + 'has_superuser_access': False, + 'user_role': None + } + + if request.user.is_authenticated: + context['user_role'] = request.user.role + context['has_moderation_access'] = request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + context['has_admin_access'] = request.user.role in ['ADMIN', 'SUPERUSER'] + context['has_superuser_access'] = request.user.role == 'SUPERUSER' + + return context diff --git a/moderation/migrations/0001_initial.py b/moderation/migrations/0001_initial.py new file mode 100644 index 00000000..7d6a96ba --- /dev/null +++ b/moderation/migrations/0001_initial.py @@ -0,0 +1,183 @@ +# Generated by Django 5.1.2 on 2024-10-30 00:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EditSubmission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.PositiveIntegerField()), + ( + "changes", + models.JSONField( + help_text="JSON representation of the changes made" + ), + ), + ("reason", models.TextField(help_text="Why this edit is needed")), + ( + "source", + models.TextField( + blank=True, + help_text="Source of information for this edit (if applicable)", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("AUTO_APPROVED", "Auto Approved"), + ], + default="PENDING", + max_length=20, + ), + ), + ("submitted_at", models.DateTimeField(auto_now_add=True)), + ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ( + "review_notes", + models.TextField( + blank=True, + help_text="Notes from the moderator about this submission", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "reviewed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reviewed_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="edit_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-submitted_at"], + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="moderation__content_922d2b_idx", + ), + models.Index( + fields=["status"], name="moderation__status_e4eb2b_idx" + ), + ], + }, + ), + migrations.CreateModel( + name="PhotoSubmission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.PositiveIntegerField()), + ("photo", models.ImageField(upload_to="submissions/photos/")), + ("caption", models.CharField(blank=True, max_length=255)), + ("date_taken", models.DateField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("AUTO_APPROVED", "Auto Approved"), + ], + default="PENDING", + max_length=20, + ), + ), + ("submitted_at", models.DateTimeField(auto_now_add=True)), + ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ( + "review_notes", + models.TextField( + blank=True, + help_text="Notes from the moderator about this photo submission", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "reviewed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reviewed_photos", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="photo_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-submitted_at"], + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="moderation__content_7a7bc1_idx", + ), + models.Index( + fields=["status"], name="moderation__status_7a1914_idx" + ), + ], + }, + ), + ] diff --git a/moderation/migrations/0002_editsubmission_submission_type_and_more.py b/moderation/migrations/0002_editsubmission_submission_type_and_more.py new file mode 100644 index 00000000..c2a2acd5 --- /dev/null +++ b/moderation/migrations/0002_editsubmission_submission_type_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.2 on 2024-10-30 01:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("moderation", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="editsubmission", + name="submission_type", + field=models.CharField( + choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")], + default="EDIT", + max_length=10, + ), + ), + migrations.AlterField( + model_name="editsubmission", + name="changes", + field=models.JSONField( + help_text="JSON representation of the changes or new object data" + ), + ), + migrations.AlterField( + model_name="editsubmission", + name="object_id", + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name="editsubmission", + name="reason", + field=models.TextField(help_text="Why this edit/addition is needed"), + ), + migrations.AlterField( + model_name="editsubmission", + name="source", + field=models.TextField( + blank=True, help_text="Source of information (if applicable)" + ), + ), + ] diff --git a/moderation/migrations/__init__.py b/moderation/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/moderation/mixins.py b/moderation/mixins.py new file mode 100644 index 00000000..28008ff5 --- /dev/null +++ b/moderation/mixins.py @@ -0,0 +1,205 @@ +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.contenttypes.models import ContentType +from django.http import JsonResponse, HttpResponseForbidden +from django.core.exceptions import PermissionDenied +from django.utils import timezone +import json +from .models import EditSubmission, PhotoSubmission + +class EditSubmissionMixin(LoginRequiredMixin): + """ + Mixin for handling edit submissions with proper moderation. + """ + def handle_edit_submission(self, request, changes, reason='', source='', submission_type='EDIT'): + """ + Handle an edit submission based on user's role. + + Args: + request: The HTTP request + changes: Dict of field changes {field_name: new_value} + reason: Why this edit is needed + source: Source of information (optional) + submission_type: 'EDIT' or 'CREATE' + + Returns: + JsonResponse with status and message + """ + if not request.user.is_authenticated: + return JsonResponse({ + 'status': 'error', + 'message': 'You must be logged in to make edits.' + }, status=403) + + content_type = ContentType.objects.get_for_model(self.model) + + # Create the submission + submission = EditSubmission( + user=request.user, + content_type=content_type, + submission_type=submission_type, + changes=changes, + reason=reason, + source=source + ) + + # For edits, set the object_id + if submission_type == 'EDIT': + obj = self.get_object() + submission.object_id = obj.id + + # Auto-approve for moderators and above + if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + obj = submission.auto_approve() + return JsonResponse({ + 'status': 'success', + 'message': 'Changes saved successfully.', + 'auto_approved': True, + 'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None + }) + + # Submit for approval for regular users + submission.save() + return JsonResponse({ + 'status': 'success', + 'message': 'Your changes have been submitted for approval.', + 'auto_approved': False + }) + + def post(self, request, *args, **kwargs): + """Handle POST requests for editing""" + try: + data = json.loads(request.body) + changes = data.get('changes', {}) + reason = data.get('reason', '') + source = data.get('source', '') + submission_type = data.get('submission_type', 'EDIT') + + if not changes: + return JsonResponse({ + 'status': 'error', + 'message': 'No changes provided.' + }, status=400) + + if not reason and request.user.role == 'USER': + return JsonResponse({ + 'status': 'error', + 'message': 'Please provide a reason for your changes.' + }, status=400) + + return self.handle_edit_submission( + request, changes, reason, source, submission_type + ) + + except json.JSONDecodeError: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid JSON data.' + }, status=400) + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=500) + +class PhotoSubmissionMixin(LoginRequiredMixin): + """ + Mixin for handling photo submissions with proper moderation. + """ + def handle_photo_submission(self, request): + """Handle a photo submission based on user's role""" + if not request.FILES.get('photo'): + return JsonResponse({ + 'status': 'error', + 'message': 'No photo provided.' + }, status=400) + + obj = self.get_object() + content_type = ContentType.objects.get_for_model(obj) + + submission = PhotoSubmission( + user=request.user, + content_type=content_type, + object_id=obj.id, + photo=request.FILES['photo'], + caption=request.POST.get('caption', ''), + date_taken=request.POST.get('date_taken') + ) + + # Auto-approve for moderators and above + if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + submission.auto_approve() + return JsonResponse({ + 'status': 'success', + 'message': 'Photo uploaded successfully.', + 'auto_approved': True + }) + + # Submit for approval for regular users + submission.save() + return JsonResponse({ + 'status': 'success', + 'message': 'Your photo has been submitted for approval.', + 'auto_approved': False + }) + +class ModeratorRequiredMixin(UserPassesTestMixin): + """Require moderator or higher role for access""" + def test_func(self): + return ( + self.request.user.is_authenticated and + self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + ) + + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return super().handle_no_permission() + return HttpResponseForbidden("You must be a moderator to access this page.") + +class AdminRequiredMixin(UserPassesTestMixin): + """Require admin or superuser role for access""" + def test_func(self): + return ( + self.request.user.is_authenticated and + self.request.user.role in ['ADMIN', 'SUPERUSER'] + ) + + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return super().handle_no_permission() + return HttpResponseForbidden("You must be an admin to access this page.") + +class InlineEditMixin: + """Add inline editing context to views""" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.user.is_authenticated: + context['can_edit'] = True + context['can_auto_approve'] = self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + if hasattr(self, 'get_object'): + obj = self.get_object() + context['pending_edits'] = EditSubmission.objects.filter( + content_type=ContentType.objects.get_for_model(obj), + object_id=obj.id, + status='PENDING' + ).select_related('user').order_by('-submitted_at') + return context + +class HistoryMixin: + """Add edit history context to views""" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + obj = self.get_object() + + # Get historical records + context['history'] = obj.history.all().select_related('history_user') + + # Get related edit submissions + content_type = ContentType.objects.get_for_model(obj) + context['edit_submissions'] = EditSubmission.objects.filter( + content_type=content_type, + object_id=obj.id + ).exclude( + status='PENDING' + ).select_related('user', 'reviewed_by').order_by('-submitted_at') + + return context diff --git a/moderation/models.py b/moderation/models.py new file mode 100644 index 00000000..67440d0b --- /dev/null +++ b/moderation/models.py @@ -0,0 +1,262 @@ +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.conf import settings +from django.utils import timezone +from django.apps import apps + +class EditSubmission(models.Model): + STATUS_CHOICES = [ + ('PENDING', 'Pending'), + ('APPROVED', 'Approved'), + ('REJECTED', 'Rejected'), + ('AUTO_APPROVED', 'Auto Approved'), + ] + + SUBMISSION_TYPE_CHOICES = [ + ('EDIT', 'Edit Existing'), + ('CREATE', 'Create New'), + ] + + # Who submitted the edit + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='edit_submissions' + ) + + # What is being edited (Park or Ride) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(null=True, blank=True) # Null for new objects + content_object = GenericForeignKey('content_type', 'object_id') + + # Type of submission + submission_type = models.CharField( + max_length=10, + choices=SUBMISSION_TYPE_CHOICES, + default='EDIT' + ) + + # The actual changes/data + changes = models.JSONField( + help_text='JSON representation of the changes or new object data' + ) + + # Metadata + reason = models.TextField( + help_text='Why this edit/addition is needed' + ) + source = models.TextField( + blank=True, + help_text='Source of information (if applicable)' + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='PENDING' + ) + submitted_at = models.DateTimeField(auto_now_add=True) + + # Review details + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reviewed_submissions' + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + review_notes = models.TextField( + blank=True, + help_text='Notes from the moderator about this submission' + ) + + class Meta: + ordering = ['-submitted_at'] + indexes = [ + models.Index(fields=['content_type', 'object_id']), + models.Index(fields=['status']), + ] + + def __str__(self): + action = "creation" if self.submission_type == 'CREATE' else "edit" + target = self.content_object or self.content_type.model_class().__name__ + return f"{action} by {self.user.username} on {target}" + + def _resolve_foreign_keys(self, data): + """Convert foreign key IDs to model instances""" + model_class = self.content_type.model_class() + resolved_data = data.copy() + + for field_name, value in data.items(): + field = model_class._meta.get_field(field_name) + if isinstance(field, models.ForeignKey) and value is not None: + related_model = field.related_model + resolved_data[field_name] = related_model.objects.get(id=value) + + return resolved_data + + def approve(self, moderator, notes=''): + """Approve the submission and apply the changes""" + self.status = 'APPROVED' + self.reviewed_by = moderator + self.reviewed_at = timezone.now() + self.review_notes = notes + + model_class = self.content_type.model_class() + resolved_data = self._resolve_foreign_keys(self.changes) + + if self.submission_type == 'CREATE': + # Create new object + obj = model_class(**resolved_data) + obj.save() + # Update object_id after creation + self.object_id = obj.id + else: + # Apply changes to existing object + obj = self.content_object + for field, value in resolved_data.items(): + setattr(obj, field, value) + obj.save() + + self.save() + return obj + + def reject(self, moderator, notes): + """Reject the submission""" + self.status = 'REJECTED' + self.reviewed_by = moderator + self.reviewed_at = timezone.now() + self.review_notes = notes + self.save() + + def auto_approve(self): + """Auto-approve the submission (for moderators/admins)""" + self.status = 'AUTO_APPROVED' + self.reviewed_by = self.user + self.reviewed_at = timezone.now() + + model_class = self.content_type.model_class() + resolved_data = self._resolve_foreign_keys(self.changes) + + if self.submission_type == 'CREATE': + # Create new object + obj = model_class(**resolved_data) + obj.save() + # Update object_id after creation + self.object_id = obj.id + else: + # Apply changes to existing object + obj = self.content_object + for field, value in resolved_data.items(): + setattr(obj, field, value) + obj.save() + + self.save() + return obj + +class PhotoSubmission(models.Model): + STATUS_CHOICES = [ + ('PENDING', 'Pending'), + ('APPROVED', 'Approved'), + ('REJECTED', 'Rejected'), + ('AUTO_APPROVED', 'Auto Approved'), + ] + + # Who submitted the photo + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='photo_submissions' + ) + + # What the photo is for (Park or Ride) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + # The photo itself + photo = models.ImageField(upload_to='submissions/photos/') + caption = models.CharField(max_length=255, blank=True) + date_taken = models.DateField(null=True, blank=True) + + # Metadata + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='PENDING' + ) + submitted_at = models.DateTimeField(auto_now_add=True) + + # Review details + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reviewed_photos' + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + review_notes = models.TextField( + blank=True, + help_text='Notes from the moderator about this photo submission' + ) + + class Meta: + ordering = ['-submitted_at'] + indexes = [ + models.Index(fields=['content_type', 'object_id']), + models.Index(fields=['status']), + ] + + def __str__(self): + return f"Photo submission by {self.user.username} for {self.content_object}" + + def approve(self, moderator, notes=''): + """Approve the photo submission""" + from media.models import Photo + + self.status = 'APPROVED' + self.reviewed_by = moderator + self.reviewed_at = timezone.now() + self.review_notes = notes + + # Create the approved photo + Photo.objects.create( + user=self.user, + content_type=self.content_type, + object_id=self.object_id, + image=self.photo, + caption=self.caption, + date_taken=self.date_taken + ) + + self.save() + + def reject(self, moderator, notes): + """Reject the photo submission""" + self.status = 'REJECTED' + self.reviewed_by = moderator + self.reviewed_at = timezone.now() + self.review_notes = notes + self.save() + + def auto_approve(self): + """Auto-approve the photo submission (for moderators/admins)""" + from media.models import Photo + + self.status = 'AUTO_APPROVED' + self.reviewed_by = self.user + self.reviewed_at = timezone.now() + + # Create the approved photo + Photo.objects.create( + user=self.user, + content_type=self.content_type, + object_id=self.object_id, + image=self.photo, + caption=self.caption, + date_taken=self.date_taken + ) + + self.save() diff --git a/moderation/tests.py b/moderation/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/moderation/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/moderation/urls.py b/moderation/urls.py new file mode 100644 index 00000000..b4d320b0 --- /dev/null +++ b/moderation/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from .admin import moderation_site +from .views import EditSubmissionListView, PhotoSubmissionListView + +app_name = 'moderation' + +urlpatterns = [ + # Custom moderation views + path('submissions/', include([ + path('edits/', EditSubmissionListView.as_view(), name='edit_submissions'), + path('photos/', PhotoSubmissionListView.as_view(), name='photo_submissions'), + ])), + + # Admin site URLs + path('admin/', moderation_site.urls), +] diff --git a/moderation/views.py b/moderation/views.py new file mode 100644 index 00000000..9c2d6a6b --- /dev/null +++ b/moderation/views.py @@ -0,0 +1,100 @@ +from django.views.generic import ListView +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from .models import EditSubmission, PhotoSubmission +from .mixins import ModeratorRequiredMixin + +class EditSubmissionListView(ModeratorRequiredMixin, ListView): + model = EditSubmission + template_name = 'moderation/admin/edit_submission_list.html' + context_object_name = 'submissions' + paginate_by = 20 + + def get_queryset(self): + queryset = super().get_queryset().select_related( + 'user', 'reviewed_by', 'content_type' + ).order_by('-submitted_at') + + # Filter by status + status = self.request.GET.get('status') + if status: + queryset = queryset.filter(status=status) + + # Filter by submission type + submission_type = self.request.GET.get('type') + if submission_type: + queryset = queryset.filter(submission_type=submission_type) + + return queryset + + def post(self, request, *args, **kwargs): + submission_id = request.POST.get('submission_id') + action = request.POST.get('action') + review_notes = request.POST.get('review_notes', '') + + submission = get_object_or_404(EditSubmission, id=submission_id) + + if action == 'approve': + obj = submission.approve(request.user, review_notes) + message = 'New addition approved successfully.' if submission.submission_type == 'CREATE' else 'Changes approved successfully.' + return JsonResponse({ + 'status': 'success', + 'message': message, + 'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None + }) + elif action == 'reject': + submission.reject(request.user, review_notes) + message = 'New addition rejected.' if submission.submission_type == 'CREATE' else 'Changes rejected.' + return JsonResponse({ + 'status': 'success', + 'message': message + }) + + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid action.' + }, status=400) + +class PhotoSubmissionListView(ModeratorRequiredMixin, ListView): + model = PhotoSubmission + template_name = 'moderation/admin/photo_submission_list.html' + context_object_name = 'submissions' + paginate_by = 20 + + def get_queryset(self): + queryset = super().get_queryset().select_related( + 'user', 'reviewed_by', 'content_type' + ).order_by('-submitted_at') + + status = self.request.GET.get('status') + if status: + queryset = queryset.filter(status=status) + + return queryset + + def post(self, request, *args, **kwargs): + submission_id = request.POST.get('submission_id') + action = request.POST.get('action') + review_notes = request.POST.get('review_notes', '') + + submission = get_object_or_404(PhotoSubmission, id=submission_id) + + if action == 'approve': + submission.approve(request.user, review_notes) + return JsonResponse({ + 'status': 'success', + 'message': 'Photo approved successfully.' + }) + elif action == 'reject': + submission.reject(request.user, review_notes) + return JsonResponse({ + 'status': 'success', + 'message': 'Photo rejected successfully.' + }) + + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid action.' + }, status=400) diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index da0e11bdf505fbae300d75ebb63b5659cdf4d5a9..d7d1fbcede6e37388fc39d069c12b942be40aa9d 100644 GIT binary patch delta 1223 zcmZva%TE(g6o-3Dr!SabpkT{G3mBk25G|-cL7D=BK~$(3qj4PDv8@g*o@v1njF7mX z8zH%IVWKfnH(e@!fN|}{$3mTzi7R3>CO#4q6Fui5@e!N!%em)q?>TdRtHFg1`H3vM zE%Xh__AAeY*ho%?Ke+XGh9H9k0cI1-Ky4xp*&nV6c@;;TO}xyVfSqtE-oYJcVpldp)6W|W4}R!C znhr%VvY;rlWk*!F*!JtxgCU~~F>iTQUb&Pnn(fHU6k-9J(mCp4?uUD zl98Lz0Nv`eRly8O0K_B&HkC zOC@6nqXgmiW1sxFyOBj!zPMkqmQi%#r^@8gS;&i*@zZos%_*v}iW)5WRJNTR=6?A? zX&VK1659Czcsshf_m(bF#&n}>@wm2{0jr9+Cot z2>l4dhQNi*3uUdXU8c2%akMsCeVS4S2(3GGt(KG%*1oL)(4CHj<2zGj4}V>o~2MB^w~o8=d;#k7GaCiOLK*}FBY=J#(x6M CxEx>r delta 1222 zcmZva-%k`*6vy}O&w*tC-Gv3Dbpr-m<%cU)NdeOpL2Hy)G4)5(aoimc9d`8GS;?YC znvh0b3dw!#Q=jC)CJixt>0e-9n)E@P*VYGY(o|y;`_}WlDjMS^`H$TAW?Fr?27BJhG`3uke&h!Yghd(?Q61OQScOAHU1$68gXM2<0LgRgP2W@ExEB)1loYDz&4-@*skhR zUw)-qiC*K$>Mka7F?z3CpDh;|{QH?IrUea^FQO5IDf$KOce zVam_nL?XUIdl_#Mp7A3-y zz)42#(+?*EmC-ps_jw+c)}RIJB47hpXpp!wT9_Oy92_G(83%zWU>Ya`9&imn$8;Tt z10RtTNd&Z%d{`C6`%0RF6Zjf|KN%|5^7QqB=2W^>?mET3Aa=|vIj*iyXMeEYz!;wO vN)^}HPv5GaTRXNN6C!(e@AB}{@XHYFmlwiaVpQC{v3zUk*1sXxS{?rb+v^NJ diff --git a/parks/__pycache__/urls.cpython-312.pyc b/parks/__pycache__/urls.cpython-312.pyc index 835963909bc44a3a063e27c513d9e71d77e9ee12..47b164f7e4e3220ed35c281651d10ca61fee7b23 100644 GIT binary patch delta 228 zcmaFO`hiX3G%qg~0}x2IDW-2?WMFs<;=lk0l<`@QaiYd9mQ>~}k%@8ot~_fvRx?AS zm>5zSQ+RgUcdD ZJ7ll0xKCcqWXjFS%FoouT_g@v4FH%gFQfnf delta 121 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 HkGkhSKbW^= diff --git a/parks/migrations/0003_alter_historicalpark_status_alter_park_status.py b/parks/migrations/0003_alter_historicalpark_status_alter_park_status.py new file mode 100644 index 00000000..051d5cec --- /dev/null +++ b/parks/migrations/0003_alter_historicalpark_status_alter_park_status.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.2 on 2024-10-30 01:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parks", "0002_add_country_field"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalpark", + name="status", + field=models.CharField( + choices=[ + ("OPERATING", "Operating"), + ("CLOSED_TEMP", "Temporarily Closed"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ], + default="OPERATING", + max_length=20, + ), + ), + migrations.AlterField( + model_name="park", + name="status", + field=models.CharField( + choices=[ + ("OPERATING", "Operating"), + ("CLOSED_TEMP", "Temporarily Closed"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ], + default="OPERATING", + max_length=20, + ), + ), + ] diff --git a/parks/models.py b/parks/models.py index 6dbfcc60..3489c709 100644 --- a/parks/models.py +++ b/parks/models.py @@ -11,6 +11,7 @@ class Park(models.Model): ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), + ('RELOCATED', 'Relocated'), # Added to match Ride model ] name = models.CharField(max_length=255) diff --git a/parks/urls.py b/parks/urls.py index 570cc0dc..62bbe7b9 100644 --- a/parks/urls.py +++ b/parks/urls.py @@ -6,6 +6,7 @@ app_name = 'parks' urlpatterns = [ path('', views.ParkListView.as_view(), name='park_list'), + path('create/', views.ParkCreateView.as_view(), name='park_create'), path('/', views.ParkDetailView.as_view(), name='park_detail'), path('//', RideDetailView.as_view(), name='ride_detail'), ] diff --git a/parks/views.py b/parks/views.py index 2f50be63..15165f54 100644 --- a/parks/views.py +++ b/parks/views.py @@ -1,12 +1,48 @@ -from django.views.generic import DetailView, ListView +from django.views.generic import DetailView, ListView, CreateView from django.shortcuts import get_object_or_404 from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.http import JsonResponse, HttpResponseRedirect from .models import Park, ParkArea from rides.models import Ride from core.views import SlugRedirectMixin +from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin +from moderation.models import EditSubmission -class ParkDetailView(SlugRedirectMixin, DetailView): +class ParkCreateView(LoginRequiredMixin, CreateView): + model = Park + template_name = 'parks/park_form.html' + fields = ['name', 'location', 'country', 'description', 'owner', 'status', + 'opening_date', 'closing_date', 'operating_season', 'size_acres', 'website'] + + def form_valid(self, form): + # If user is moderator or above, save directly + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + return HttpResponseRedirect(self.get_success_url()) + + # Otherwise, create a submission + cleaned_data = form.cleaned_data.copy() + # Convert model instances to IDs for JSON serialization + if cleaned_data.get('owner'): + cleaned_data['owner'] = cleaned_data['owner'].id + + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Park), + submission_type='CREATE', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + return HttpResponseRedirect(reverse('parks:park_list')) + + def get_success_url(self): + return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) + +class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView): model = Park template_name = 'parks/park_detail.html' context_object_name = 'park' @@ -27,9 +63,9 @@ class ParkDetailView(SlugRedirectMixin, DetailView): return context def get_redirect_url_pattern(self): - return 'park_detail' + return 'parks:park_detail' -class ParkAreaDetailView(SlugRedirectMixin, DetailView): +class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView): model = ParkArea template_name = 'parks/area_detail.html' context_object_name = 'area' @@ -54,7 +90,7 @@ class ParkAreaDetailView(SlugRedirectMixin, DetailView): return context def get_redirect_url_pattern(self): - return 'area_detail' + return 'parks:area_detail' def get_redirect_url_kwargs(self): return { diff --git a/rides/__pycache__/urls.cpython-312.pyc b/rides/__pycache__/urls.cpython-312.pyc index d6b81071518e9e86832a117be0a35db54e542bb1..e77dfb0c1c80ccc57bee1cd18484cc3a2f39385c 100644 GIT binary patch delta 183 zcmcb@a+6j4G%qg~0}x2JDW*F!GB7*_abSQQ%J}?YqWVfMCWchT6s}a}EP;vB^o6c_?a5H Ii$s9R0iB8~(f|Me delta 105 zcmcb~dWA*(G%qg~0}%Z9FQ5JkNIwQ~V1NzE_`GJK`bv&;rYMd|E=}&qGK?0JvlxvS qIVaC$be{ZyF`G*SsE`qeivuU8G8s?a%p}dt%FNHy$Xz4^6a@g$Llxry diff --git a/rides/__pycache__/views.cpython-312.pyc b/rides/__pycache__/views.cpython-312.pyc index 1ee394deef6b0cf73e35a6ecb8a9b15ffc3d16e0..8205004c2168186a489c3d58ea117e130cac7357 100644 GIT binary patch delta 3892 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 Xw!BGzy?InPBs}@S(_a$!;m7(P7>x>& delta 1515 zcmZWpO>7%Q6rQoy>;Jzvf2B^cF-e+Dg9D-|p+Fnb{747_ttvo9$lcl#$L(w!X4Y+k zgqj2D35klKwBo{r15uATA#vu?3kV^hK~_OjLR?U#rkr_i>>>~=?YD2fdGF18^WNJZ zPyhbZwY%wbg240XlG8YwA>=n)Jbvt+@bGgGZjm~vQ%-zZp?;_mqPPz6uy0i?BZqm! zw=1@hEgtn_m6(wuJnknd36Sl2lqdaECFQ3pX|s1e#xs7lk~NDs&-wXEo{|t*BlX0G zq@HAXM(fFCxMT>CR|o0YbK1Ui#S;yw!wnYbh~KI+u9F~F zFYFjSZJ~*_(1}hhx0qaS1uIwKIM)W2@eLh&ug%1cWPvC^WZ;-*9JLf%Q7SV@bL#8N z_0U72Ud}%1&%wS<*IJ$on5fFY3*?@9EmvI7cI(1LRtt2j<^`^sxFhcb=BiQhs7wCr$Qsb4y|hl=i4GlN4k} zIO>D^YHl2483EWOKV^%eq<+ZXm~lhBRDgHG!iA4GgzyByvjG42xZbx+C)C}(`FIIv zm#({M#gmi6*lA)2K&J!dZ*xeiy5;#y9Vtwm!b8P0!ZC#7My*<{aZkz`Bvzz1a#{ zaF>#KqPD4tm_a2Q>(G|!%b~d#5-t^M>gS;`+oW*xli|s#jtT}vmVpkht(+5sXcm!y zXl{#HkVOoMD7#wY?K*SqZgEC%X9WShiZz7O01`*dF;6$KeziM1O6T^!7+$8dw15B5 z8zK6NnlH{BHP)+W_+J;U^*_hzQ zi6{d|96X3=w;R&5%8Zogs(NPRPv|CR^!Aw(sA(LdjT`Cah@P~C10)7e!PE@93DfKO zLjCu1XJHa?NO4@tFkkPg_}HOn+-!-U)()hIsDx80U~P1= zx7ik4il{o}3|=!8w$1NBcH=YinZlogEcf^n8YgNk!OE8*O)1hqbo+J@B`i-j76&99 zdlBr~oShUKu*9L1nErz>rSu+|?~rnboam6T4jG-fM`k-@q(i0;tWlcyZeZ%G!~p^6 RV397+`5Oz52z-q1zX2WrXm9`k diff --git a/rides/urls.py b/rides/urls.py index ccabdb95..0f21d5e2 100644 --- a/rides/urls.py +++ b/rides/urls.py @@ -5,5 +5,6 @@ app_name = 'rides' urlpatterns = [ path('', views.RideListView.as_view(), name='ride_list'), + path('create/', views.RideCreateView.as_view(), name='ride_create'), path('//', views.RideDetailView.as_view(), name='ride_detail'), ] diff --git a/rides/views.py b/rides/views.py index c0216a69..c8ca8366 100644 --- a/rides/views.py +++ b/rides/views.py @@ -1,13 +1,57 @@ -from django.views.generic import DetailView, ListView +from django.views.generic import DetailView, ListView, CreateView from django.shortcuts import get_object_or_404 from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse from django.db.models import Q +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.http import JsonResponse, HttpResponseRedirect from .models import Ride, RollerCoasterStats from parks.models import Park from core.views import SlugRedirectMixin +from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin +from moderation.models import EditSubmission -class RideDetailView(SlugRedirectMixin, DetailView): +class RideCreateView(LoginRequiredMixin, CreateView): + model = Ride + template_name = 'rides/ride_form.html' + fields = ['name', 'park', 'park_area', 'category', 'manufacturer', 'model_name', 'status', + 'opening_date', 'closing_date', 'status_since', 'min_height_in', 'max_height_in', + 'accessibility_options', 'capacity_per_hour', 'ride_duration_seconds', 'description'] + + def form_valid(self, form): + # If user is moderator or above, save directly + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + return HttpResponseRedirect(self.get_success_url()) + + # Otherwise, create a submission + cleaned_data = form.cleaned_data.copy() + # Convert model instances to IDs for JSON serialization + if cleaned_data.get('park'): + cleaned_data['park'] = cleaned_data['park'].id + if cleaned_data.get('park_area'): + cleaned_data['park_area'] = cleaned_data['park_area'].id + if cleaned_data.get('manufacturer'): + cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id + + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Ride), + submission_type='CREATE', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + return HttpResponseRedirect(reverse('rides:ride_list')) + + def get_success_url(self): + return reverse('rides:ride_detail', kwargs={ + 'park_slug': self.object.park.slug, + 'ride_slug': self.object.slug + }) + +class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView): model = Ride template_name = 'rides/ride_detail.html' context_object_name = 'ride' @@ -31,7 +75,7 @@ class RideDetailView(SlugRedirectMixin, DetailView): return context def get_redirect_url_pattern(self): - return 'ride_detail' + return 'rides:ride_detail' def get_redirect_url_kwargs(self): return { @@ -88,8 +132,6 @@ class RideListView(ListView): return context - - def get(self, request, *args, **kwargs): # Check if this is an HTMX request if request.htmx: diff --git a/static/css/inline-edit.css b/static/css/inline-edit.css new file mode 100644 index 00000000..a0b77294 --- /dev/null +++ b/static/css/inline-edit.css @@ -0,0 +1,187 @@ +/* Inline editing styles */ +.editable-container { + position: relative; +} + +[data-editable] { + position: relative; + padding: 0.25rem; + border-radius: 0.25rem; + transition: background-color 0.2s; +} + +[data-editable]:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.dark [data-editable]:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +[data-edit-button] { + opacity: 0; + position: absolute; + right: 0.5rem; + top: 0.5rem; + transition: opacity 0.2s; + padding: 0.5rem; + border-radius: 0.375rem; + background-color: rgba(255, 255, 255, 0.9); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.dark [data-edit-button] { + background-color: rgba(31, 41, 55, 0.9); +} + +.editable-container:hover [data-edit-button] { + opacity: 1; +} + +.form-input, .form-textarea, .form-select { + width: 100%; + padding: 0.5rem; + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + background-color: white; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.dark .form-input, .dark .form-textarea, .dark .form-select { + background-color: #1f2937; + border-color: #374151; + color: white; +} + +.form-input:focus, .form-textarea:focus, .form-select:focus { + outline: none; + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.dark .form-input:focus, .dark .form-textarea:focus, .dark .form-select:focus { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +/* Notifications */ +.notification { + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 1rem; + border-radius: 0.5rem; + color: white; + max-width: 24rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + z-index: 50; + animation: slide-in 0.3s ease-out; +} + +.notification-success { + background-color: #059669; +} + +.dark .notification-success { + background-color: #047857; +} + +.notification-error { + background-color: #dc2626; +} + +.dark .notification-error { + background-color: #b91c1c; +} + +.notification-info { + background-color: #3b82f6; +} + +.dark .notification-info { + background-color: #2563eb; +} + +@keyframes slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Add/Edit Form Styles */ +.form-section { + @apply space-y-6; +} + +.form-group { + @apply space-y-2; +} + +.form-label { + @apply block text-sm font-medium text-gray-700 dark:text-gray-300; +} + +.form-error { + @apply mt-1 text-sm text-red-600 dark:text-red-400; +} + +.form-help { + @apply mt-1 text-sm text-gray-500 dark:text-gray-400; +} + +/* Button Styles */ +.btn { + @apply inline-flex items-center justify-center px-4 py-2 font-medium transition-colors rounded-lg; +} + +.btn-primary { + @apply text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600; +} + +.btn-secondary { + @apply text-gray-700 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500; +} + +.btn-danger { + @apply text-white bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600; +} + +/* Status Badges */ +.status-badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; +} + +.status-operating { + @apply text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-200; +} + +.status-closed { + @apply text-red-800 bg-red-100 dark:bg-red-900 dark:text-red-200; +} + +.status-construction { + @apply text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-200; +} + +/* Navigation Links */ +.nav-link { + @apply flex items-center px-3 py-2 text-gray-700 transition-colors rounded-lg dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700; +} + +.nav-link i { + @apply mr-2; +} + +/* Menu Items */ +.menu-item { + @apply flex items-center w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700; +} + +.menu-item i { + @apply mr-3; +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 74be8af1..7140f1a6 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2391,6 +2391,10 @@ select { position: relative; } +.right-0 { + right: 0px; +} + .top-1\/2 { top: 50%; } @@ -2399,6 +2403,10 @@ select { grid-column: span 2 / span 2; } +.col-span-3 { + grid-column: span 3 / span 3; +} + .col-span-full { grid-column: 1 / -1; } @@ -2430,6 +2438,10 @@ select { margin-bottom: 0.5rem; } +.mb-3 { + margin-bottom: 0.75rem; +} + .mb-4 { margin-bottom: 1rem; } @@ -2514,6 +2526,10 @@ select { display: none; } +.h-16 { + height: 4rem; +} + .h-24 { height: 6rem; } @@ -2558,6 +2574,10 @@ select { width: 1rem; } +.w-48 { + width: 12rem; +} + .w-5 { width: 1.25rem; } @@ -2630,6 +2650,10 @@ select { align-items: center; } +.justify-end { + justify-content: flex-end; +} + .justify-center { justify-content: center; } @@ -2726,6 +2750,16 @@ select { border-radius: 0.75rem; } +.rounded-l-lg { + border-top-left-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; +} + +.rounded-r-lg { + border-top-right-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; +} + .border { border-width: 1px; } @@ -2805,6 +2839,11 @@ select { background-color: rgb(219 234 254 / 0.9); } +.bg-blue-50 { + --tw-bg-opacity: 1; + background-color: rgb(239 246 255 / var(--tw-bg-opacity)); +} + .bg-blue-600 { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity)); @@ -2834,6 +2873,11 @@ select { background-color: rgb(220 252 231 / 0.9); } +.bg-green-600 { + --tw-bg-opacity: 1; + background-color: rgb(22 163 74 / var(--tw-bg-opacity)); +} + .bg-primary\/10 { background-color: rgb(79 70 229 / 0.1); } @@ -2847,6 +2891,11 @@ select { background-color: rgb(254 226 226 / 0.9); } +.bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + .bg-white { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity)); @@ -2894,14 +2943,14 @@ select { --tw-gradient-stops: var(--tw-gradient-from), #eff6ff var(--tw-gradient-via-position), var(--tw-gradient-to); } -.to-indigo-50 { - --tw-gradient-to: #eef2ff var(--tw-gradient-to-position); -} - .to-secondary { --tw-gradient-to: #e11d48 var(--tw-gradient-to-position); } +.to-indigo-50 { + --tw-gradient-to: #eef2ff var(--tw-gradient-to-position); +} + .bg-clip-text { -webkit-background-clip: text; background-clip: text; @@ -3073,6 +3122,11 @@ select { color: rgb(37 99 235 / var(--tw-text-opacity)); } +.text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); +} + .text-blue-800 { --tw-text-opacity: 1; color: rgb(30 64 175 / var(--tw-text-opacity)); @@ -3329,15 +3383,30 @@ select { background-color: rgb(229 231 235 / var(--tw-bg-opacity)); } +.hover\:bg-gray-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + .hover\:bg-gray-50:hover { --tw-bg-opacity: 1; background-color: rgb(249 250 251 / var(--tw-bg-opacity)); } +.hover\:bg-green-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(21 128 61 / var(--tw-bg-opacity)); +} + .hover\:bg-primary\/10:hover { background-color: rgb(79 70 229 / 0.1); } +.hover\:bg-red-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(185 28 28 / var(--tw-bg-opacity)); +} + .hover\:from-primary\/90:hover { --tw-gradient-from: rgb(79 70 229 / 0.9) var(--tw-gradient-from-position); --tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position); @@ -3427,6 +3496,11 @@ select { background-color: rgb(96 165 250 / 0.3); } +.dark\:bg-blue-500:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + .dark\:bg-blue-700:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity)); @@ -3436,6 +3510,15 @@ select { background-color: rgb(30 64 175 / 0.3); } +.dark\:bg-blue-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(30 58 138 / var(--tw-bg-opacity)); +} + +.dark\:bg-blue-900\/50:is(.dark *) { + background-color: rgb(30 58 138 / 0.5); +} + .dark\:bg-gray-600:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(75 85 99 / var(--tw-bg-opacity)); @@ -3463,6 +3546,11 @@ select { background-color: rgb(31 41 55 / 0.9); } +.dark\:bg-green-500:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(34 197 94 / var(--tw-bg-opacity)); +} + .dark\:bg-green-700:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(21 128 61 / var(--tw-bg-opacity)); @@ -3472,6 +3560,16 @@ select { background-color: rgb(22 101 52 / 0.3); } +.dark\:bg-green-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(20 83 45 / var(--tw-bg-opacity)); +} + +.dark\:bg-red-500:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + .dark\:bg-red-700:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(185 28 28 / var(--tw-bg-opacity)); @@ -3481,6 +3579,11 @@ select { background-color: rgb(153 27 27 / 0.3); } +.dark\:bg-red-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(127 29 29 / var(--tw-bg-opacity)); +} + .dark\:bg-yellow-400\/30:is(.dark *) { background-color: rgb(250 204 21 / 0.3); } @@ -3494,6 +3597,11 @@ select { background-color: rgb(133 77 14 / 0.3); } +.dark\:bg-yellow-900:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(113 63 18 / var(--tw-bg-opacity)); +} + .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); @@ -3549,6 +3657,11 @@ select { color: rgb(220 252 231 / var(--tw-text-opacity)); } +.dark\:text-green-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(187 247 208 / var(--tw-text-opacity)); +} + .dark\:text-primary:is(.dark *) { --tw-text-opacity: 1; color: rgb(79 70 229 / var(--tw-text-opacity)); @@ -3559,6 +3672,16 @@ select { color: rgb(254 226 226 / var(--tw-text-opacity)); } +.dark\:text-red-200:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(254 202 202 / var(--tw-text-opacity)); +} + +.dark\:text-red-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + .dark\:text-white:is(.dark *) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -3598,20 +3721,45 @@ select { --tw-ring-color: rgb(250 204 21 / 0.3); } +.dark\:hover\:bg-blue-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.dark\:hover\:bg-gray-500:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + .dark\:hover\:bg-gray-700:hover:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity)); } +.dark\:hover\:bg-green-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(22 163 74 / var(--tw-bg-opacity)); +} + .dark\:hover\:bg-primary\/20:hover:is(.dark *) { background-color: rgb(79 70 229 / 0.2); } +.dark\:hover\:bg-red-600:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + .dark\:hover\:text-blue-300:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(147 197 253 / var(--tw-text-opacity)); } +.dark\:hover\:text-blue-400:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); +} + .dark\:hover\:text-primary:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(79 70 229 / var(--tw-text-opacity)); diff --git a/static/js/inline-edit.js b/static/js/inline-edit.js new file mode 100644 index 00000000..65e2dca3 --- /dev/null +++ b/static/js/inline-edit.js @@ -0,0 +1,262 @@ +document.addEventListener('DOMContentLoaded', function() { + // Handle edit button clicks + document.querySelectorAll('[data-edit-button]').forEach(button => { + button.addEventListener('click', function() { + const contentId = this.dataset.contentId; + const contentType = this.dataset.contentType; + const editableFields = document.querySelectorAll(`[data-editable][data-content-id="${contentId}"]`); + + // Toggle edit mode + editableFields.forEach(field => { + const currentValue = field.textContent.trim(); + const fieldName = field.dataset.fieldName; + const fieldType = field.dataset.fieldType || 'text'; + + // Create input field + let input; + if (fieldType === 'textarea') { + input = document.createElement('textarea'); + input.value = currentValue; + input.rows = 4; + } else if (fieldType === 'select') { + input = document.createElement('select'); + // Get options from data attribute + const options = JSON.parse(field.dataset.options || '[]'); + options.forEach(option => { + const optionEl = document.createElement('option'); + optionEl.value = option.value; + optionEl.textContent = option.label; + optionEl.selected = option.value === currentValue; + input.appendChild(optionEl); + }); + } else if (fieldType === 'date') { + input = document.createElement('input'); + input.type = 'date'; + input.value = currentValue; + } else if (fieldType === 'number') { + input = document.createElement('input'); + input.type = 'number'; + input.value = currentValue; + if (field.dataset.min) input.min = field.dataset.min; + if (field.dataset.max) input.max = field.dataset.max; + if (field.dataset.step) input.step = field.dataset.step; + } else { + input = document.createElement('input'); + input.type = fieldType; + input.value = currentValue; + } + + input.className = 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'; + input.dataset.originalValue = currentValue; + input.dataset.fieldName = fieldName; + + // Replace content with input + field.textContent = ''; + field.appendChild(input); + }); + + // Show save/cancel buttons + const actionButtons = document.createElement('div'); + actionButtons.className = 'flex gap-2 mt-2'; + actionButtons.innerHTML = ` + + Save Changes + + + Cancel + + ${this.dataset.requireReason ? ` + + + + + ` : ''} + `; + + const container = editableFields[0].closest('.editable-container'); + container.appendChild(actionButtons); + + // Hide edit button while editing + this.style.display = 'none'; + }); + }); + + // Handle form submissions + document.querySelectorAll('form[data-submit-type]').forEach(form => { + form.addEventListener('submit', function(e) { + e.preventDefault(); + + const submitType = this.dataset.submitType; + const formData = new FormData(this); + const data = {}; + + formData.forEach((value, key) => { + data[key] = value; + }); + + // Get CSRF token from meta tag + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + + // Submit form + fetch(this.action, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + submission_type: submitType, + ...data + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + showNotification(data.message, 'success'); + if (data.redirect_url) { + window.location.href = data.redirect_url; + } + } else { + showNotification(data.message, 'error'); + } + }) + .catch(error => { + showNotification('An error occurred while submitting the form.', 'error'); + console.error('Error:', error); + }); + }); + }); + + // Handle save button clicks using event delegation + document.addEventListener('click', function(e) { + if (e.target.matches('[data-save-button]')) { + const container = e.target.closest('.editable-container'); + const contentId = container.querySelector('[data-editable]').dataset.contentId; + const contentType = container.querySelector('[data-edit-button]').dataset.contentType; + const editableFields = container.querySelectorAll('[data-editable]'); + + // Collect changes + const changes = {}; + editableFields.forEach(field => { + const input = field.querySelector('input, textarea, select'); + if (input && input.value !== input.dataset.originalValue) { + changes[input.dataset.fieldName] = input.value; + } + }); + + // If no changes, just cancel + if (Object.keys(changes).length === 0) { + cancelEdit(container); + return; + } + + // Get reason and source if required + const reasonInput = container.querySelector('[data-reason-input]'); + const sourceInput = container.querySelector('[data-source-input]'); + const reason = reasonInput ? reasonInput.value : ''; + const source = sourceInput ? sourceInput.value : ''; + + // Validate reason if required + if (reasonInput && !reason) { + alert('Please provide a reason for your changes.'); + return; + } + + // Get CSRF token from meta tag + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + + // Submit changes + fetch(window.location.pathname, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + content_type: contentType, + content_id: contentId, + changes, + reason, + source + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + if (data.auto_approved) { + // Update the display immediately + Object.entries(changes).forEach(([field, value]) => { + const element = container.querySelector(`[data-editable][data-field-name="${field}"]`); + if (element) { + element.textContent = value; + } + }); + } + showNotification(data.message, 'success'); + if (data.redirect_url) { + window.location.href = data.redirect_url; + } + } else { + showNotification(data.message, 'error'); + } + cancelEdit(container); + }) + .catch(error => { + showNotification('An error occurred while saving changes.', 'error'); + console.error('Error:', error); + cancelEdit(container); + }); + } + }); + + // Handle cancel button clicks using event delegation + document.addEventListener('click', function(e) { + if (e.target.matches('[data-cancel-button]')) { + const container = e.target.closest('.editable-container'); + cancelEdit(container); + } + }); +}); + +function cancelEdit(container) { + // Restore original content + container.querySelectorAll('[data-editable]').forEach(field => { + const input = field.querySelector('input, textarea, select'); + if (input) { + field.textContent = input.dataset.originalValue; + } + }); + + // Remove action buttons + const actionButtons = container.querySelector('.flex.gap-2'); + if (actionButtons) { + actionButtons.remove(); + } + + // Show edit button + const editButton = container.querySelector('[data-edit-button]'); + if (editButton) { + editButton.style.display = ''; + } +} + +function showNotification(message, type = 'info') { + const notification = document.createElement('div'); + notification.className = `fixed bottom-4 right-4 p-4 rounded-lg shadow-lg text-white ${ + type === 'success' ? 'bg-green-600 dark:bg-green-500' : + type === 'error' ? 'bg-red-600 dark:bg-red-500' : + 'bg-blue-600 dark:bg-blue-500' + }`; + notification.textContent = message; + + document.body.appendChild(notification); + + // Remove after 5 seconds + setTimeout(() => { + notification.remove(); + }, 5000); +} diff --git a/templates/accounts/turnstile_widget.html b/templates/accounts/turnstile_widget.html index 4723c9f6..28be5ce4 100644 --- a/templates/accounts/turnstile_widget.html +++ b/templates/accounts/turnstile_widget.html @@ -8,15 +8,17 @@ id="turnstile-widget" class="cf-turnstile" data-sitekey="{{ site_key }}" + data-theme="dark" > diff --git a/templates/base/base.html b/templates/base/base.html index 7643bbc9..681b94a8 100644 --- a/templates/base/base.html +++ b/templates/base/base.html @@ -1,13 +1,12 @@ {% load static %} - + {% block title %}ThrillWiki{% endblock %} - - + - + @@ -97,31 +100,45 @@ - {% if user.is_authenticated %} - - - {% if user.profile.avatar %} - - {% else %} - - {{ user.username.0|upper }} - - {% endif %} - + {% if has_moderation_access %} + + + Moderation + + {% endif %} + + + {% if user.profile.avatar %} + + {% else %} + + {{ user.username.0|upper }} + + {% endif %} + {{ user.username }} + - - - - + + + Profile @@ -129,7 +146,7 @@ Settings - {% if user.is_staff or user.is_superuser %} + {% if has_admin_access %} Admin @@ -144,19 +161,18 @@ - {% else %} - - - - - Login - - - - Register - - + + + + + Login + + + + Register + + {% endif %} @@ -186,11 +202,17 @@ {% if not user.is_authenticated %} - + Login - + Register @@ -201,12 +223,13 @@ - {% if messages %} {% for message in messages %} - + {{ message }} {% endfor %} @@ -219,7 +242,9 @@ -