good stuff

This commit is contained in:
pacnpal
2024-10-29 21:29:16 -04:00
parent 4e970400ef
commit 6880f36b99
42 changed files with 2835 additions and 262 deletions

View File

@@ -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

0
moderation/__init__.py Normal file
View File

78
moderation/admin.py Normal file
View File

@@ -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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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('<a href="{}">{}</a>', 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('<img src="{}" style="max-height: 100px; max-width: 200px;" />', 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)

6
moderation/apps.py Normal file
View File

@@ -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'

View File

@@ -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

View File

@@ -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"
),
],
},
),
]

View File

@@ -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)"
),
),
]

View File

205
moderation/mixins.py Normal file
View File

@@ -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

262
moderation/models.py Normal file
View File

@@ -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()

3
moderation/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
moderation/urls.py Normal file
View File

@@ -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),
]

100
moderation/views.py Normal file
View File

@@ -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)

View File

@@ -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,
),
),
]

View File

@@ -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)

View File

@@ -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('<slug:slug>/', views.ParkDetailView.as_view(), name='park_detail'),
path('<slug:park_slug>/<slug:ride_slug>/', RideDetailView.as_view(), name='ride_detail'),
]

View File

@@ -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 {

View File

@@ -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('<slug:park_slug>/<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
]

View File

@@ -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:

187
static/css/inline-edit.css Normal file
View File

@@ -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;
}

View File

@@ -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));

262
static/js/inline-edit.js Normal file
View File

@@ -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 = `
<button class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600" data-save-button>
<i class="mr-2 fas fa-save"></i>Save Changes
</button>
<button class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500" data-cancel-button>
<i class="mr-2 fas fa-times"></i>Cancel
</button>
${this.dataset.requireReason ? `
<div class="flex-grow">
<input type="text" class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Reason for changes (required)"
data-reason-input>
<input type="text" class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Source (optional)"
data-source-input>
</div>
` : ''}
`;
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);
}

View File

@@ -8,15 +8,17 @@
id="turnstile-widget"
class="cf-turnstile"
data-sitekey="{{ site_key }}"
data-theme="dark"
></div>
</div>
<script>
// Apply theme to the Turnstile widget based on the retrieved theme
// Apply theme to the Turnstile widget based on system preference
document.addEventListener("DOMContentLoaded", function () {
const turnstileWidget = document.getElementById("turnstile-widget");
if (turnstileWidget) {
turnstileWidget.setAttribute("data-theme", theme);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
turnstileWidget.setAttribute("data-theme", prefersDark ? "dark" : "light");
}
});
</script>

View File

@@ -1,13 +1,12 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<!-- [Previous head content remains unchanged...] -->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{% block title %}ThrillWiki{% endblock %}</title>
<!-- [Previous head content remains unchanged...] -->
<!-- Google Fonts -->
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
@@ -42,9 +41,13 @@
{% block extra_head %}{% endblock %}
</head>
<body class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white">
<body
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white"
>
<!-- Header -->
<header class="border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50">
<header
class="border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
>
<nav class="container mx-auto nav-container">
<div class="flex items-center justify-between">
<!-- Logo -->
@@ -97,12 +100,17 @@
</div>
</label>
<!-- [Rest of the content remains unchanged...] -->
<!-- User Menu -->
{% if user.is_authenticated %}
<div class="relative">
{% if has_moderation_access %}
<a href="{% url 'moderation:edit_submissions' %}" class="nav-link">
<i class="fas fa-shield-alt"></i>
<span>Moderation</span>
</a>
{% endif %}
<div class="relative" x-data="{ open: false }">
<button
id="userMenuBtn"
@click="open = !open"
class="flex items-center space-x-2 transition-transform hover:scale-105"
>
{% if user.profile.avatar %}
@@ -112,16 +120,25 @@
class="w-8 h-8 rounded-full ring-2 ring-primary/20"
/>
{% else %}
<div class="flex items-center justify-center w-8 h-8 text-white rounded-full bg-gradient-to-br from-primary to-secondary">
<div
class="flex items-center justify-center w-8 h-8 text-white rounded-full bg-gradient-to-br from-primary to-secondary"
>
{{ user.username.0|upper }}
</div>
{% endif %}
<span>{{ user.username }}</span>
</button>
<!-- Dropdown Menu -->
<div id="userDropdown" class="dropdown-menu">
<div class="py-1">
<a href="{% url 'user_profile' user.username %}" class="menu-item">
<div
x-show="open"
@click.away="open = false"
class="absolute right-0 w-48 py-1 mt-2 bg-white rounded-md shadow-lg dark:bg-gray-800"
>
<a
href="{% url 'profile' user.username %}"
class="menu-item"
>
<i class="w-5 fas fa-user"></i>
<span>Profile</span>
</a>
@@ -129,7 +146,7 @@
<i class="w-5 fas fa-cog"></i>
<span>Settings</span>
</a>
{% if user.is_staff or user.is_superuser %}
{% if has_admin_access %}
<a href="{% url 'admin:index' %}" class="menu-item">
<i class="w-5 fas fa-shield-alt"></i>
<span>Admin</span>
@@ -144,7 +161,6 @@
</form>
</div>
</div>
</div>
{% else %}
<!-- Login/Register (Desktop) -->
<div class="hidden space-x-3 lg:flex">
@@ -186,11 +202,17 @@
{% if not user.is_authenticated %}
<!-- Login/Register (Mobile) -->
<div class="flex items-center space-x-3">
<a href="{% url 'account_login' %}" class="flex-1 mobile-nav-link secondary">
<a
href="{% url 'account_login' %}"
class="flex-1 mobile-nav-link secondary"
>
<i class="fas fa-sign-in-alt"></i>
<span>Login</span>
</a>
<a href="{% url 'account_signup' %}" class="flex-1 mobile-nav-link primary">
<a
href="{% url 'account_signup' %}"
class="flex-1 mobile-nav-link primary"
>
<i class="fas fa-user-plus"></i>
<span>Register</span>
</a>
@@ -201,12 +223,13 @@
</nav>
</header>
<!-- [Rest of the content remains unchanged...] -->
<!-- Flash Messages -->
{% if messages %}
<div class="container px-6 mx-auto mt-4">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
<div
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
>
{{ message }}
</div>
{% endfor %}
@@ -219,7 +242,9 @@
</main>
<!-- Footer -->
<footer class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50">
<footer
class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
>
<div class="container px-6 py-6 mx-auto">
<div class="flex items-center justify-between">
<div class="text-gray-600 dark:text-gray-400">
@@ -229,11 +254,13 @@
<a
href="{% url 'terms' %}"
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
>Terms</a>
>Terms</a
>
<a
href="{% url 'privacy' %}"
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
>Privacy</a>
>Privacy</a
>
</div>
</div>
</div>
@@ -241,6 +268,6 @@
<!-- Custom JavaScript -->
<script src="{% static 'js/main.js' %}"></script>
{% block extra_scripts %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,159 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}ThrillWiki Moderation{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
<style>
.moderation-nav {
@apply bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm;
}
.moderation-nav-container {
@apply container px-4 mx-auto;
}
.moderation-nav ul {
@apply flex items-center h-16 space-x-6;
}
.moderation-nav li a {
@apply flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors;
}
.moderation-nav li a.active {
@apply bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300;
}
.moderation-nav li a i {
@apply mr-2;
}
.moderation-content {
@apply container px-4 py-6 mx-auto;
}
.submission-list {
@apply space-y-6;
}
.submission-card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200/50 dark:border-gray-700/50;
}
.submission-header {
@apply flex justify-between items-start;
}
.submission-meta {
@apply text-sm text-gray-600 dark:text-gray-400;
}
.submission-changes {
@apply mt-4 space-y-2;
}
.change-item {
@apply flex items-start space-x-2 p-2 rounded bg-gray-50 dark:bg-gray-700/50;
}
.change-field {
@apply font-medium text-gray-900 dark:text-gray-100;
}
.change-value {
@apply text-gray-700 dark:text-gray-300;
}
.action-buttons {
@apply flex justify-end space-x-4 mt-4;
}
.btn-approve {
@apply px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600;
}
.btn-reject {
@apply px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600;
}
.review-notes {
@apply mt-4;
}
.review-notes textarea {
@apply w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white;
}
.filters {
@apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200/50 dark:border-gray-700/50;
}
.form-select {
@apply border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white;
}
.status-badge {
@apply px-2 py-1 text-sm rounded-full;
}
.status-pending {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
}
.status-approved, .status-auto_approved {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.status-rejected {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
}
</style>
{% endblock %}
{% block content %}
<div class="moderation-nav">
<div class="moderation-nav-container">
<ul>
<li>
<a href="{% url 'moderation:admin:index' %}" class="{% if request.resolver_match.url_name == 'index' %}active{% endif %}">
<i class="fas fa-tachometer-alt"></i>
Dashboard
</a>
</li>
<li>
<a href="{% url 'moderation:edit_submissions' %}"
class="{% if request.resolver_match.url_name == 'edit_submissions' %}active{% endif %}">
<i class="fas fa-edit"></i>
Edit Submissions
</a>
</li>
<li>
<a href="{% url 'moderation:photo_submissions' %}"
class="{% if request.resolver_match.url_name == 'photo_submissions' %}active{% endif %}">
<i class="fas fa-image"></i>
Photo Submissions
</a>
</li>
{% if user.role == 'ADMIN' or user.role == 'SUPERUSER' %}
<li>
<a href="{% url 'admin:index' %}"
class="{% if request.resolver_match.url_name == 'admin' %}active{% endif %}">
<i class="fas fa-shield-alt"></i>
Admin Panel
</a>
</li>
{% endif %}
</ul>
</div>
</div>
<div class="moderation-content">
{% block moderation_content %}{% endblock %}
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/inline-edit.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,142 @@
{% extends "moderation/admin/base.html" %}
{% load static %}
{% block moderation_content %}
<div class="submission-list">
<h1 class="mb-6 text-2xl font-bold">Content Submissions</h1>
<div class="mb-6 filters">
<form method="get" class="flex flex-wrap gap-4">
<select name="status" class="form-select">
<option value="">All Statuses</option>
<option value="PENDING" {% if request.GET.status == 'PENDING' %}selected{% endif %}>Pending</option>
<option value="APPROVED" {% if request.GET.status == 'APPROVED' %}selected{% endif %}>Approved</option>
<option value="REJECTED" {% if request.GET.status == 'REJECTED' %}selected{% endif %}>Rejected</option>
<option value="AUTO_APPROVED" {% if request.GET.status == 'AUTO_APPROVED' %}selected{% endif %}>Auto Approved</option>
</select>
<select name="type" class="form-select">
<option value="">All Types</option>
<option value="EDIT" {% if request.GET.type == 'EDIT' %}selected{% endif %}>Edits</option>
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Additions</option>
</select>
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
<i class="mr-2 fas fa-filter"></i>Filter
</button>
</form>
</div>
{% for submission in submissions %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold">
{% if submission.submission_type == 'CREATE' %}
New {{ submission.content_type.name }}
{% else %}
Edit to {{ submission.content_type.name }}: {{ submission.content_object }}
{% endif %}
</h3>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Submitted by {{ submission.user.username }} on {{ submission.submitted_at|date:"M d, Y H:i" }}
</div>
</div>
<div class="flex items-center space-x-2">
<div class="px-2 py-1 text-sm rounded-full {% if submission.submission_type == 'CREATE' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200{% else %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200{% endif %}">
{{ submission.get_submission_type_display }}
</div>
<div class="px-2 py-1 text-sm rounded-full {% if submission.status == 'PENDING' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
{% elif submission.status == 'APPROVED' or submission.status == 'AUTO_APPROVED' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% else %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200{% endif %}">
{{ submission.get_status_display }}
</div>
</div>
</div>
<div class="p-4 mb-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="mb-2">
<strong>Reason:</strong> {{ submission.reason }}
</div>
{% if submission.source %}
<div>
<strong>Source:</strong> {{ submission.source }}
</div>
{% endif %}
</div>
<div class="p-4 mb-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<h4 class="mb-2 font-medium">
{% if submission.submission_type == 'CREATE' %}
Details:
{% else %}
Changes:
{% endif %}
</h4>
{% for field, value in submission.changes.items %}
<div class="mb-2">
<span class="font-medium">{{ field }}:</span>
<span>{{ value }}</span>
</div>
{% endfor %}
</div>
{% if submission.status == 'PENDING' %}
<form method="post" class="mt-4">
{% csrf_token %}
<input type="hidden" name="submission_id" value="{{ submission.id }}">
<div class="mb-4">
<label class="block mb-2 font-medium">Review Notes:</label>
<textarea name="review_notes" rows="3"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="{% if submission.submission_type == 'CREATE' %}Explain why you're approving/rejecting this new addition{% else %}Explain why you're approving/rejecting these changes{% endif %}"></textarea>
</div>
<div class="flex justify-end space-x-4">
<button type="submit" name="action" value="reject"
class="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600">
<i class="mr-2 fas fa-times"></i>Reject
</button>
<button type="submit" name="action" value="approve"
class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600">
<i class="mr-2 fas fa-check"></i>Approve
</button>
</div>
</form>
{% else %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="mb-2">
<strong>Reviewed by:</strong> {{ submission.reviewed_by.username }} on {{ submission.reviewed_at|date:"M d, Y H:i" }}
</div>
{% if submission.review_notes %}
<div>
<strong>Review Notes:</strong> {{ submission.review_notes }}
</div>
{% endif %}
</div>
{% endif %}
</div>
{% empty %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No submissions found.
</div>
{% endfor %}
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-sm">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "moderation/admin/base.html" %}
{% load static %}
{% block moderation_content %}
<div class="submission-list">
<h1 class="mb-6 text-2xl font-bold">Photo Submissions</h1>
<div class="mb-6 filters">
<form method="get" class="flex space-x-4">
<select name="status" class="form-select">
<option value="">All Statuses</option>
<option value="PENDING" {% if request.GET.status == 'PENDING' %}selected{% endif %}>Pending</option>
<option value="APPROVED" {% if request.GET.status == 'APPROVED' %}selected{% endif %}>Approved</option>
<option value="REJECTED" {% if request.GET.status == 'REJECTED' %}selected{% endif %}>Rejected</option>
<option value="AUTO_APPROVED" {% if request.GET.status == 'AUTO_APPROVED' %}selected{% endif %}>Auto Approved</option>
</select>
<button type="submit" class="btn btn-primary">Filter</button>
</form>
</div>
{% for submission in submissions %}
<div class="submission-card">
<div class="submission-header">
<div>
<h3 class="text-lg font-semibold">
Photo for {{ submission.content_type.name }}: {{ submission.content_object }}
</h3>
<div class="submission-meta">
Submitted by {{ submission.user.username }} on {{ submission.submitted_at|date:"M d, Y H:i" }}
</div>
</div>
<div class="status-badge status-{{ submission.status|lower }}">
{{ submission.get_status_display }}
</div>
</div>
<div class="submission-details">
<div class="mb-4 photo-preview">
<img src="{{ submission.photo.url }}" alt="Submitted photo" class="max-w-lg rounded">
</div>
{% if submission.caption %}
<div class="mb-2">
<strong>Caption:</strong> {{ submission.caption }}
</div>
{% endif %}
{% if submission.date_taken %}
<div class="mb-2">
<strong>Date Taken:</strong> {{ submission.date_taken|date:"M d, Y" }}
</div>
{% endif %}
</div>
{% if submission.status == 'PENDING' %}
<form method="post" class="mt-4">
{% csrf_token %}
<input type="hidden" name="submission_id" value="{{ submission.id }}">
<div class="review-notes">
<label class="block mb-2">Review Notes:</label>
<textarea name="review_notes" rows="3" class="w-full"></textarea>
</div>
<div class="action-buttons">
<button type="submit" name="action" value="approve" class="btn-approve">
Approve
</button>
<button type="submit" name="action" value="reject" class="btn-reject">
Reject
</button>
</div>
</form>
{% else %}
<div class="mt-4">
<strong>Reviewed by:</strong> {{ submission.reviewed_by.username }}
<br>
<strong>Review Notes:</strong> {{ submission.review_notes }}
</div>
{% endif %}
</div>
{% empty %}
<div class="py-8 text-center text-gray-500">
No submissions found.
</div>
{% endfor %}
{% if is_paginated %}
<div class="mt-6 pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -3,27 +3,38 @@
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<div class="container px-4 mx-auto">
<!-- Park Header -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center">
<div class="p-6 mb-6 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 editable-container">
<div class="flex flex-col items-start justify-between md:flex-row md:items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ park.name }}</h1>
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="name">{{ park.name }}</h1>
<p class="text-gray-600 dark:text-gray-300">
<i class="fas fa-map-marker-alt mr-2"></i>{{ park.location }}
<i class="mr-2 fas fa-map-marker-alt"></i>
<span data-editable data-content-id="{{ park.id }}"
data-field-name="location">{{ park.location }}</span>
</p>
</div>
<div class="mt-4 md:mt-0 flex gap-2">
<div class="flex gap-2 mt-4 md:mt-0">
{% if park.website %}
<a href="{{ park.website }}" target="_blank" rel="noopener noreferrer"
class="btn-secondary">
<i class="fas fa-external-link-alt mr-2"></i>Visit Website
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
</a>
{% endif %}
{% if user.is_authenticated %}
<button class="btn-secondary">
<i class="fas fa-edit mr-2"></i>Edit
<button class="btn-secondary" data-edit-button
data-content-id="{{ park.id }}"
data-content-type="park"
{% if not can_auto_approve %}data-require-reason="true"{% endif %}>
<i class="mr-2 fas fa-edit"></i>Edit
</button>
{% endif %}
</div>
@@ -32,12 +43,24 @@
<div class="flex flex-wrap gap-2 mt-4">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction{% endif %}">
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}"
data-editable data-content-id="{{ park.id }}"
data-field-name="status" data-field-type="select"
data-options='[
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"}
]'>
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="status-badge bg-yellow-100 text-yellow-800 dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="fas fa-star text-yellow-500 dark:text-yellow-300 mr-1"></i>
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
@@ -45,39 +68,41 @@
</div>
<!-- Park Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center transform transition-transform hover:-translate-y-1">
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{{ rides.count }}
</div>
<div class="text-gray-600 dark:text-gray-300 mt-1">Total Attractions</div>
<div class="mt-1 text-gray-600 dark:text-gray-300">Total Attractions</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center transform transition-transform hover:-translate-y-1">
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{% with roller_coasters=rides|dictsortreversed:"category"|slice:":RC" %}
{{ roller_coasters|length }}
{% endwith %}
</div>
<div class="text-gray-600 dark:text-gray-300 mt-1">Roller Coasters</div>
<div class="mt-1 text-gray-600 dark:text-gray-300">Roller Coasters</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center transform transition-transform hover:-translate-y-1">
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{{ areas.count }}
</div>
<div class="text-gray-600 dark:text-gray-300 mt-1">Areas</div>
<div class="mt-1 text-gray-600 dark:text-gray-300">Areas</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column - Description and Areas -->
<div class="lg:col-span-2">
{% if park.description %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">About</h2>
<div class="text-gray-700 dark:text-gray-300">
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800 editable-container">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">About</h2>
<div class="text-gray-700 dark:text-gray-300"
data-editable data-content-id="{{ park.id }}"
data-field-name="description" data-field-type="textarea">
{{ park.description|linebreaks }}
</div>
</div>
@@ -85,14 +110,16 @@
<!-- Park Areas -->
{% if areas %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Areas</h2>
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Areas</h2>
<div class="grid gap-4">
{% for area in areas %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ area.name }}</h3>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
<a href="{% url 'parks:area_detail' park.slug area.slug %}">{{ area.name }}</a>
</h3>
{% if area.description %}
<p class="text-gray-600 dark:text-gray-300 mb-2">
<p class="mb-2 text-gray-600 dark:text-gray-300">
{{ area.description }}
</p>
{% endif %}
@@ -106,19 +133,19 @@
{% endif %}
<!-- Rides List -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Attractions</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Attractions</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% for ride in rides %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<h3 class="text-lg font-semibold mb-2">
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<h3 class="mb-2 text-lg font-semibold">
<a href="{% url 'rides:ride_detail' park.slug ride.slug %}"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{{ ride.name }}
</a>
</h3>
<div class="flex flex-wrap gap-2 mb-2">
<span class="status-badge bg-blue-100 text-blue-800 dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
{{ ride.get_category_display }}
</span>
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
@@ -128,7 +155,7 @@
</span>
</div>
{% if ride.coaster_stats %}
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
{% if ride.coaster_stats.height_ft %}
<div>Height: {{ ride.coaster_stats.height_ft }}ft</div>
{% endif %}
@@ -139,7 +166,7 @@
{% endif %}
</div>
{% empty %}
<div class="col-span-2 text-center py-8">
<div class="col-span-2 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No attractions found.</p>
</div>
{% endfor %}
@@ -147,18 +174,18 @@
</div>
</div>
<!-- Right Column - Quick Facts -->
<!-- Right Column - Quick Facts and History -->
<div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Quick Facts</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-4">
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800 editable-container">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Quick Facts</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
{% if park.owner %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
<i class="fas fa-building text-blue-500 dark:text-blue-400 w-5"></i>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
<i class="w-5 text-blue-500 fas fa-building dark:text-blue-400"></i>
<span class="ml-2">Operator</span>
</dt>
<dd class="text-gray-900 dark:text-white font-medium">
<dd class="font-medium text-gray-900 dark:text-white">
<a href="{% url 'companies:company_detail' park.owner.slug %}"
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
{{ park.owner.name }}
@@ -166,55 +193,65 @@
</dd>
</div>
{% endif %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
<i class="fas fa-globe text-blue-500 dark:text-blue-400 w-5"></i>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
<i class="w-5 text-blue-500 fas fa-globe dark:text-blue-400"></i>
<span class="ml-2">Country</span>
</dt>
<dd class="text-gray-900 dark:text-white font-medium">
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="country">
{{ park.get_country_name }}
</dd>
</div>
{% if park.opening_date %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
<i class="fas fa-calendar-alt text-blue-500 dark:text-blue-400 w-5"></i>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
<i class="w-5 text-blue-500 fas fa-calendar-alt dark:text-blue-400"></i>
<span class="ml-2">Opening Date</span>
</dt>
<dd class="text-gray-900 dark:text-white font-medium">
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="opening_date" data-field-type="date">
{{ park.opening_date }}
</dd>
</div>
{% endif %}
{% if park.closing_date %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
<i class="fas fa-calendar-times text-blue-500 dark:text-blue-400 w-5"></i>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
<i class="w-5 text-blue-500 fas fa-calendar-times dark:text-blue-400"></i>
<span class="ml-2">Closing Date</span>
</dt>
<dd class="text-gray-900 dark:text-white font-medium">
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="closing_date" data-field-type="date">
{{ park.closing_date }}
</dd>
</div>
{% endif %}
{% if park.operating_season %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
<i class="fas fa-clock text-blue-500 dark:text-blue-400 w-5"></i>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
<i class="w-5 text-blue-500 fas fa-clock dark:text-blue-400"></i>
<span class="ml-2">Operating Season</span>
</dt>
<dd class="text-gray-900 dark:text-white font-medium">
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="operating_season">
{{ park.operating_season }}
</dd>
</div>
{% endif %}
{% if park.size_acres %}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
<i class="fas fa-ruler-combined text-blue-500 dark:text-blue-400 w-5"></i>
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
<i class="w-5 text-blue-500 fas fa-ruler-combined dark:text-blue-400"></i>
<span class="ml-2">Size</span>
</dt>
<dd class="text-gray-900 dark:text-white font-medium">
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="size_acres" data-field-type="number">
{{ park.size_acres }} acres
</dd>
</div>
@@ -222,9 +259,37 @@
</div>
</div>
<!-- History Panel -->
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">History</h2>
<div class="space-y-4">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ record.history_date|date:"M d, Y H:i" }}
{% if record.history_user %}
by {{ record.history_user.username }}
{% endif %}
</div>
<div class="mt-2">
{% for field, change in record.diff_against_previous %}
<div class="text-sm">
<span class="font-medium">{{ field }}:</span>
{{ change.old }} → {{ change.new }}
</div>
{% endfor %}
</div>
</div>
{% empty %}
<p class="text-gray-500 dark:text-gray-400">No history available.</p>
{% endfor %}
</div>
</div>
<!-- Photos -->
{% if park.photos.exists %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Photos</h2>
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
<div class="grid grid-cols-2 gap-2">
{% for photo in park.photos.all %}
<div class="aspect-w-16 aspect-h-9">
@@ -240,3 +305,7 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/inline-edit.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Add Park - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<div class="max-w-3xl mx-auto">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">Add Park</h1>
<form method="post" class="space-y-6">
{% csrf_token %}
{% for field in form %}
<div>
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ field.label }}
</label>
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'date' %}
<input type="{{ field.field.widget.input_type }}"
name="{{ field.name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default:'' }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
{% if field.field.required %}required{% endif %}>
{% elif field.field.widget.input_type == 'select' %}
<select name="{{ field.name }}"
id="{{ field.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
{% if field.field.required %}required{% endif %}>
{% for value, label in field.field.choices %}
<option value="{{ value }}" {% if field.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% elif field.field.widget.input_type == 'textarea' %}
<textarea name="{{ field.name }}"
id="{{ field.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="4"
{% if field.field.required %}required{% endif %}>{{ field.value|default:'' }}</textarea>
{% endif %}
{% if field.help_text %}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
{% endif %}
{% if field.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
<div class="space-y-4">
<div>
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for Addition
</label>
<textarea name="reason"
id="reason"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="3"
required
placeholder="Please explain why you're adding this park and provide any relevant details."></textarea>
</div>
<div>
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Source (Optional)
</label>
<input type="text"
name="source"
id="source"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Link to official website, news article, or other source">
</div>
</div>
{% endif %}
<div class="flex justify-end space-x-4">
<a href="{% url 'parks:park_list' %}" class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500">
Cancel
</a>
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
Submit
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,55 +4,135 @@
{% block title %}Parks - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Theme Parks & Amusement Parks</h1>
<div class="container px-4 mx-auto">
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Parks</h1>
{% if user.is_authenticated %}
<a href="{% url 'parks:park_create' %}" class="btn-primary">
<i class="mr-2 fas fa-plus"></i>Add Park
</a>
{% endif %}
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6">
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Filters</h2>
<form hx-get="{% url 'parks:park_list' %}"
hx-target="#parks-grid"
hx-push-url="true"
hx-trigger="change from:select, keyup[target.name=='search'] delay:500ms from:input"
class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form method="get" class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input type="text"
name="search"
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search"
value="{{ current_filters.search }}"
placeholder="Search parks..."
class="form-input w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search parks...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</label>
<select name="location"
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<label for="location" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
<select name="location" id="location"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Locations</option>
{% for location in locations %}
<option value="{{ location }}" {% if current_filters.location == location %}selected{% endif %}>{{ location }}</option>
<option value="{{ location }}" {% if current_filters.location == location %}selected{% endif %}>
{{ location }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status"
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Statuses</option>
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
<option value="CLOSED_PERM" {% if current_filters.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
<option value="UNDER_CONSTRUCTION" {% if current_filters.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
<option value="DEMOLISHED" {% if current_filters.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="w-full btn-primary">
<i class="mr-2 fas fa-filter"></i>Filter
</button>
</div>
</form>
</div>
<!-- Parks Grid -->
<div id="parks-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% include 'parks/partials/park_list.html' %}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for park in parks %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
{% if park.photos.exists %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="object-cover w-full">
</div>
{% endif %}
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ park.name }}
</a>
</h2>
<p class="mb-3 text-gray-600 dark:text-gray-400">
<i class="mr-1 fas fa-map-marker-alt"></i>
{{ park.location }}
</p>
<div class="flex flex-wrap gap-2">
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif park.status == 'DEMOLISHED' %}status-demolished
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ park.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ park.rides.count }} attractions
</div>
{% if park.owner %}
<a href="{% url 'companies:company_detail' park.owner.slug %}"
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ park.owner.name }}
</a>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-sm">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -3,33 +3,61 @@
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<div class="container px-4 mx-auto">
<!-- Header -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<div class="flex justify-between items-start">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<div class="flex items-start justify-between">
<div>
<h1 class="text-3xl font-bold mb-2">{{ ride.name }}</h1>
<p class="text-gray-600 dark:text-gray-400 mb-2">
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="name">{{ ride.name }}</h1>
<p class="mb-2 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
{% if ride.area %}
- {{ ride.area.name }}
{% if ride.park_area %}
- {{ ride.park_area.name }}
{% endif %}
</p>
<div class="flex flex-wrap gap-2 mt-3">
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED' %}status-closed
{% else %}status-construction{% endif %}">
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}"
data-editable data-content-id="{{ ride.id }}"
data-field-name="status" data-field-type="select"
data-options='[
{"value": "OPERATING", "label": "Operating"},
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
{"value": "DEMOLISHED", "label": "Demolished"},
{"value": "RELOCATED", "label": "Relocated"}
]'>
{{ ride.get_status_display }}
</span>
<span class="status-badge bg-blue-100 text-blue-800 dark:bg-blue-700 dark:text-blue-50">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50"
data-editable data-content-id="{{ ride.id }}"
data-field-name="category" data-field-type="select"
data-options='[
{"value": "RC", "label": "Roller Coaster"},
{"value": "DR", "label": "Dark Ride"},
{"value": "FR", "label": "Flat Ride"},
{"value": "WR", "label": "Water Ride"},
{"value": "TR", "label": "Transport"},
{"value": "OT", "label": "Other"}
]'>
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
<span class="status-badge bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50 flex items-center">
<span class="text-yellow-500 dark:text-yellow-200 mr-1"></span>
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
@@ -37,14 +65,13 @@
</div>
{% if user.is_authenticated %}
<div class="flex gap-2">
<button class="btn-secondary">
<i class="fas fa-edit mr-2"></i>
<button class="btn-secondary" data-edit-button
data-content-id="{{ ride.id }}"
data-content-type="ride"
{% if not can_auto_approve %}data-require-reason="true"{% endif %}>
<i class="mr-2 fas fa-edit"></i>
Edit
</button>
<button class="btn-secondary">
<i class="fas fa-history mr-2"></i>
History
</button>
</div>
{% endif %}
</div>
@@ -52,9 +79,9 @@
<!-- Photos Grid -->
{% if ride.photos.exists %}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Photos</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{% for photo in ride.photos.all %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ photo.image.url }}"
@@ -67,19 +94,21 @@
{% endif %}
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left Column - Description and Details -->
<div class="lg:col-span-2">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">About</h2>
<div class="prose dark:prose-invert max-w-none">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
<div class="prose dark:prose-invert max-w-none"
data-editable data-content-id="{{ ride.id }}"
data-field-name="description" data-field-type="textarea">
{{ ride.description|linebreaks }}
</div>
</div>
{% if ride.previous_names %}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Previous Names</h2>
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Previous Names</h2>
<div class="space-y-2">
{% for name_history in ride.previous_names %}
<div class="flex justify-between">
@@ -92,35 +121,55 @@
{% endif %}
{% if coaster_stats %}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Roller Coaster Statistics</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Roller Coaster Statistics</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
{% if coaster_stats.height_ft %}
<div>
<span class="text-gray-500 block">Height</span>
<span class="text-2xl font-bold">{{ coaster_stats.height_ft }} ft</span>
<span class="block text-gray-500">Height</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ coaster_stats.id }}"
data-field-name="height_ft" data-field-type="number">
{{ coaster_stats.height_ft }} ft
</span>
</div>
{% endif %}
{% if coaster_stats.length_ft %}
<div>
<span class="text-gray-500 block">Length</span>
<span class="text-2xl font-bold">{{ coaster_stats.length_ft }} ft</span>
<span class="block text-gray-500">Length</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ coaster_stats.id }}"
data-field-name="length_ft" data-field-type="number">
{{ coaster_stats.length_ft }} ft
</span>
</div>
{% endif %}
{% if coaster_stats.speed_mph %}
<div>
<span class="text-gray-500 block">Speed</span>
<span class="text-2xl font-bold">{{ coaster_stats.speed_mph }} mph</span>
<span class="block text-gray-500">Speed</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ coaster_stats.id }}"
data-field-name="speed_mph" data-field-type="number">
{{ coaster_stats.speed_mph }} mph
</span>
</div>
{% endif %}
<div>
<span class="text-gray-500 block">Inversions</span>
<span class="text-2xl font-bold">{{ coaster_stats.inversions }}</span>
<span class="block text-gray-500">Inversions</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ coaster_stats.id }}"
data-field-name="inversions" data-field-type="number">
{{ coaster_stats.inversions }}
</span>
</div>
{% if coaster_stats.ride_time_seconds %}
<div>
<span class="text-gray-500 block">Ride Duration</span>
<span class="text-2xl font-bold">{{ coaster_stats.ride_time_seconds }} sec</span>
<span class="block text-gray-500">Ride Duration</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white"
data-editable data-content-id="{{ coaster_stats.id }}"
data-field-name="ride_time_seconds" data-field-type="number">
{{ coaster_stats.ride_time_seconds }} sec
</span>
</div>
{% endif %}
</div>
@@ -130,75 +179,112 @@
<!-- Right Column - Quick Facts -->
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Quick Facts</h2>
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Quick Facts</h2>
<dl class="space-y-4">
<div>
<dt class="text-gray-500">Manufacturer</dt>
<dd>{{ ride.manufacturer }}</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="manufacturer">{{ ride.manufacturer }}</dd>
</div>
{% if ride.model_name %}
<div>
<dt class="text-gray-500">Model</dt>
<dd>{{ ride.model_name }}</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="model_name">{{ ride.model_name }}</dd>
</div>
{% endif %}
{% if ride.opening_date %}
<div>
<dt class="text-gray-500">Opening Date</dt>
<dd>{{ ride.opening_date }}</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="opening_date" data-field-type="date">
{{ ride.opening_date }}
</dd>
</div>
{% endif %}
{% if ride.status_since %}
<div>
<dt class="text-gray-500">Status Since</dt>
<dd>{{ ride.status_since }}</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="status_since" data-field-type="date">
{{ ride.status_since }}
</dd>
</div>
{% endif %}
{% if ride.closing_date %}
<div>
<dt class="text-gray-500">Closing Date</dt>
<dd>{{ ride.closing_date }}</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="closing_date" data-field-type="date">
{{ ride.closing_date }}
</dd>
</div>
{% endif %}
{% if ride.capacity_per_hour %}
<div>
<dt class="text-gray-500">Capacity</dt>
<dd>{{ ride.capacity_per_hour }} riders/hour</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="capacity_per_hour" data-field-type="number">
{{ ride.capacity_per_hour }} riders/hour
</dd>
</div>
{% endif %}
{% if ride.minimum_height %}
{% if ride.min_height_in %}
<div>
<dt class="text-gray-500">Minimum Height</dt>
<dd>{{ ride.minimum_height }} inches</dd>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="min_height_in" data-field-type="number">
{{ ride.min_height_in }} inches
</dd>
</div>
{% endif %}
</dl>
</div>
{% if ride.other_details %}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">Additional Details</h2>
<dl class="space-y-4">
{% for key, value in ride.other_details.items %}
<div>
<dt class="text-gray-500">{{ key }}</dt>
<dd>{{ value }}</dd>
<!-- History Panel -->
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
<div class="space-y-4">
{% for record in history %}
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ record.history_date|date:"M d, Y H:i" }}
{% if record.history_user %}
by {{ record.history_user.username }}
{% endif %}
</div>
<div class="mt-2">
{% for field, change in record.diff_against_previous %}
<div class="text-sm">
<span class="font-medium">{{ field }}:</span>
{{ change.old }} → {{ change.new }}
</div>
{% endfor %}
</dl>
</div>
{% endif %}
</div>
{% empty %}
<p class="text-gray-500">No history available.</p>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Reviews Section -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mt-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Reviews</h2>
<div class="p-6 mt-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Reviews</h2>
{% if user.is_authenticated %}
<button class="btn-primary">
<i class="fas fa-star mr-2"></i>
<i class="mr-2 fas fa-star"></i>
Write a Review
</button>
{% endif %}
@@ -207,20 +293,20 @@
{% if ride.reviews.exists %}
<div class="space-y-4">
{% for review in ride.reviews.all %}
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
<div class="flex justify-between items-start">
<div class="pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold">{{ review.title }}</h3>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ review.title }}</h3>
<p class="text-sm text-gray-500">
by {{ review.user.username }} on {{ review.created_at|date }}
</p>
</div>
<div class="flex items-center">
<span class="text-yellow-400 mr-1"></span>
<span class="mr-1 text-yellow-400"></span>
<span>{{ review.rating }}/10</span>
</div>
</div>
<p class="mt-2">{{ review.content }}</p>
<p class="mt-2 text-gray-700 dark:text-gray-300">{{ review.content }}</p>
</div>
{% endfor %}
</div>
@@ -230,3 +316,7 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/inline-edit.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Add Ride - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<div class="max-w-3xl mx-auto">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">Add Ride</h1>
<form method="post" class="space-y-6">
{% csrf_token %}
{% for field in form %}
<div>
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ field.label }}
</label>
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'date' or field.field.widget.input_type == 'number' %}
<input type="{{ field.field.widget.input_type }}"
name="{{ field.name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default:'' }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
{% if field.field.required %}required{% endif %}>
{% elif field.field.widget.input_type == 'select' %}
<select name="{{ field.name }}"
id="{{ field.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
{% if field.field.required %}required{% endif %}>
{% for value, label in field.field.choices %}
<option value="{{ value }}" {% if field.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% elif field.field.widget.input_type == 'textarea' %}
<textarea name="{{ field.name }}"
id="{{ field.id_for_label }}"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="4"
{% if field.field.required %}required{% endif %}>{{ field.value|default:'' }}</textarea>
{% endif %}
{% if field.help_text %}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
{% endif %}
{% if field.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
<div class="space-y-4">
<div>
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Reason for Addition
</label>
<textarea name="reason"
id="reason"
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
rows="3"
required
placeholder="Please explain why you're adding this ride and provide any relevant details."></textarea>
</div>
<div>
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Source (Optional)
</label>
<input type="text"
name="source"
id="source"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Link to official website, news article, or other source">
</div>
</div>
{% endif %}
<div class="flex justify-end space-x-4">
<a href="{% url 'rides:ride_list' %}" class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500">
Cancel
</a>
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
Submit
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -4,31 +4,30 @@
{% block title %}Rides - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Rides & Attractions</h1>
<div class="container px-4 mx-auto">
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Rides</h1>
{% if user.is_authenticated %}
<a href="{% url 'rides:ride_create' %}" class="btn-primary">
<i class="mr-2 fas fa-plus"></i>Add Ride
</a>
{% endif %}
</div>
<!-- Filters -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6">
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Filters</h2>
<form hx-get="{% url 'rides:ride_list' %}"
hx-target="#rides-grid"
hx-push-url="true"
hx-trigger="change from:select, keyup[target.name=='search'] delay:500ms from:input"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form method="get" class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input type="text"
name="search"
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search"
value="{{ current_filters.search }}"
placeholder="Search rides..."
class="form-input w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search rides...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select name="category"
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<label for="category" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
<select name="category" id="category"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Categories</option>
<option value="RC" {% if current_filters.category == 'RC' %}selected{% endif %}>Roller Coaster</option>
<option value="DR" {% if current_filters.category == 'DR' %}selected{% endif %}>Dark Ride</option>
@@ -39,9 +38,9 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status"
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status"
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Statuses</option>
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
@@ -51,22 +50,98 @@
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Manufacturer</label>
<select name="manufacturer"
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">All Manufacturers</option>
{% for manufacturer in manufacturers %}
<option value="{{ manufacturer }}" {% if current_filters.manufacturer == manufacturer %}selected{% endif %}>{{ manufacturer }}</option>
{% endfor %}
</select>
<div class="flex items-end">
<button type="submit" class="w-full btn-primary">
<i class="mr-2 fas fa-filter"></i>Filter
</button>
</div>
</form>
</div>
<!-- Rides Grid -->
<div id="rides-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% include 'rides/partials/ride_list.html' %}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for ride in rides %}
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
{% if ride.photos.exists %}
<div class="aspect-w-16 aspect-h-9">
<img src="{{ ride.photos.first.image.url }}"
alt="{{ ride.name }}"
class="object-cover w-full">
</div>
{% endif %}
<div class="p-4">
<h2 class="mb-2 text-xl font-bold">
<a href="{% url 'rides:ride_detail' ride.park.slug ride.slug %}"
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
{{ ride.name }}
</a>
</h2>
<p class="mb-3 text-gray-600 dark:text-gray-400">
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ ride.park.name }}
</a>
</p>
<div class="flex flex-wrap gap-2">
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
{% elif ride.status == 'DEMOLISHED' %}status-demolished
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
{% if ride.average_rating %}
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</div>
{% if ride.coaster_stats %}
<div class="grid grid-cols-2 gap-2 mt-4">
{% if ride.coaster_stats.height_ft %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Height: {{ ride.coaster_stats.height_ft }}ft
</div>
{% endif %}
{% if ride.coaster_stats.speed_mph %}
<div class="text-sm text-gray-600 dark:text-gray-400">
Speed: {{ ride.coaster_stats.speed_mph }}mph
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-span-3 py-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No rides found matching your criteria.</p>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<div class="flex justify-center mt-6">
<div class="inline-flex rounded-md shadow-sm">
{% if page_obj.has_previous %}
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">&laquo; First</a>
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
{% endif %}
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -46,6 +46,7 @@ INSTALLED_APPS = [
'reviews',
'email_service',
'media',
'moderation',
]
MIDDLEWARE = [
@@ -61,7 +62,7 @@ MIDDLEWARE = [
'allauth.account.middleware.AccountMiddleware',
'django.middleware.cache.FetchFromCacheMiddleware',
'simple_history.middleware.HistoryRequestMiddleware',
'django_htmx.middleware.HtmxMiddleware', # Added HTMX middleware
'django_htmx.middleware.HtmxMiddleware',
]
ROOT_URLCONF = 'thrillwiki.urls'
@@ -77,6 +78,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'moderation.context_processors.moderation_access', # Added moderation context processor
],
},
},
@@ -159,8 +161,8 @@ ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = True
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_EMAIL_VERIFICATION = 'optional'
LOGIN_REDIRECT_URL = '/' # Updated to use Django template URL
ACCOUNT_LOGOUT_REDIRECT_URL = '/' # Updated to use Django template URL
LOGIN_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
# Custom adapters
ACCOUNT_ADAPTER = 'accounts.adapters.CustomAccountAdapter'
@@ -193,8 +195,8 @@ SOCIALACCOUNT_PROVIDERS = {
# Additional social account settings
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_AUTO_SIGNUP = False # We want to handle the signup process
SOCIALACCOUNT_STORE_TOKENS = True # Store the OAuth tokens
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_STORE_TOKENS = True
# Email settings
EMAIL_BACKEND = 'email_service.backends.ForwardEmailBackend'

View File

@@ -35,6 +35,9 @@ urlpatterns = [
# Redirect /user/ to the user's profile if logged in
path('user/', accounts_views.user_redirect_view, name='user_redirect'),
# Moderation URLs - placed after other URLs but before static/media serving
path('moderation/', include('moderation.urls', namespace='moderation')),
]
# Serve static files in development