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 da0e11bd..d7d1fbce 100644 Binary files a/parks/__pycache__/models.cpython-312.pyc and b/parks/__pycache__/models.cpython-312.pyc differ diff --git a/parks/__pycache__/urls.cpython-312.pyc b/parks/__pycache__/urls.cpython-312.pyc index 83596390..47b164f7 100644 Binary files a/parks/__pycache__/urls.cpython-312.pyc and b/parks/__pycache__/urls.cpython-312.pyc differ diff --git a/parks/__pycache__/views.cpython-312.pyc b/parks/__pycache__/views.cpython-312.pyc index 87a8b9f9..a665879b 100644 Binary files a/parks/__pycache__/views.cpython-312.pyc and b/parks/__pycache__/views.cpython-312.pyc differ 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 d6b81071..e77dfb0c 100644 Binary files a/rides/__pycache__/urls.cpython-312.pyc and b/rides/__pycache__/urls.cpython-312.pyc differ diff --git a/rides/__pycache__/views.cpython-312.pyc b/rides/__pycache__/views.cpython-312.pyc index 1ee394de..8205004c 100644 Binary files a/rides/__pycache__/views.cpython-312.pyc and b/rides/__pycache__/views.cpython-312.pyc differ 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 = ` + + + ${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 %} - - + -
+
- {% if messages %}
{% for message in messages %} -
+
{{ message }}
{% endfor %} @@ -219,7 +242,9 @@ -