here we go

This commit is contained in:
pacnpal
2024-10-31 22:32:01 +00:00
parent 71272e36a6
commit 3cbda93094
68 changed files with 3114 additions and 1485 deletions

46
companies/forms.py Normal file
View File

@@ -0,0 +1,46 @@
from django import forms
from .models import Company, Manufacturer
class CompanyForm(forms.ModelForm):
class Meta:
model = Company
fields = ['name', 'headquarters', 'website', 'description']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'headquarters': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'e.g., Orlando, Florida, United States'
}),
'website': forms.URLInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'https://example.com'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
}
class ManufacturerForm(forms.ModelForm):
class Meta:
model = Manufacturer
fields = ['name', 'headquarters', 'website', 'description']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'headquarters': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'e.g., Altoona, Pennsylvania, United States'
}),
'website': forms.URLInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'https://example.com'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
}

View File

@@ -6,9 +6,13 @@ app_name = 'companies'
urlpatterns = [
# Company URLs
path('', views.CompanyListView.as_view(), name='company_list'),
path('create/', views.CompanyCreateView.as_view(), name='company_create'),
path('<slug:slug>/edit/', views.CompanyUpdateView.as_view(), name='company_edit'),
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
# Manufacturer URLs
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'),
path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'),
]

View File

@@ -1,11 +1,165 @@
from django.views.generic import DetailView, ListView
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import HttpResponseRedirect
from .models import Company, Manufacturer
from .forms import CompanyForm, ManufacturerForm
from rides.models import Ride
from parks.models import Park
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
class CompanyDetailView(SlugRedirectMixin, DetailView):
class CompanyCreateView(LoginRequiredMixin, CreateView):
model = Company
form_class = CompanyForm
template_name = 'companies/company_form.html'
def form_valid(self, form):
cleaned_data = form.cleaned_data.copy()
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Company),
submission_type='CREATE',
changes=cleaned_data,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.object_id = self.object.id
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully created {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, 'Your company submission has been sent for review')
return HttpResponseRedirect(reverse('companies:company_list'))
def get_success_url(self):
return reverse('companies:company_detail', kwargs={'slug': self.object.slug})
class CompanyUpdateView(LoginRequiredMixin, UpdateView):
model = Company
form_class = CompanyForm
template_name = 'companies/company_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_edit'] = True
return context
def form_valid(self, form):
cleaned_data = form.cleaned_data.copy()
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Company),
object_id=self.object.id,
submission_type='EDIT',
changes=cleaned_data,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully updated {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, f'Your changes to {self.object.name} have been sent for review')
return HttpResponseRedirect(reverse('companies:company_detail', kwargs={'slug': self.object.slug}))
def get_success_url(self):
return reverse('companies:company_detail', kwargs={'slug': self.object.slug})
class ManufacturerCreateView(LoginRequiredMixin, CreateView):
model = Manufacturer
form_class = ManufacturerForm
template_name = 'companies/manufacturer_form.html'
def form_valid(self, form):
cleaned_data = form.cleaned_data.copy()
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type='CREATE',
changes=cleaned_data,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.object_id = self.object.id
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully created {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, 'Your manufacturer submission has been sent for review')
return HttpResponseRedirect(reverse('companies:manufacturer_list'))
def get_success_url(self):
return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})
class ManufacturerUpdateView(LoginRequiredMixin, UpdateView):
model = Manufacturer
form_class = ManufacturerForm
template_name = 'companies/manufacturer_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_edit'] = True
return context
def form_valid(self, form):
cleaned_data = form.cleaned_data.copy()
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
object_id=self.object.id,
submission_type='EDIT',
changes=cleaned_data,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully updated {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, f'Your changes to {self.object.name} have been sent for review')
return HttpResponseRedirect(reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}))
def get_success_url(self):
return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})
class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Company
template_name = 'companies/company_detail.html'
context_object_name = 'company'
@@ -27,7 +181,7 @@ class CompanyDetailView(SlugRedirectMixin, DetailView):
def get_redirect_url_pattern(self):
return 'company_detail'
class ManufacturerDetailView(SlugRedirectMixin, DetailView):
class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Manufacturer
template_name = 'companies/manufacturer_detail.html'
context_object_name = 'manufacturer'

View File

@@ -16,10 +16,10 @@ class ModerationAdminSite(AdminSite):
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']
list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'created_at', 'handled_by']
list_filter = ['status', 'content_type', 'created_at']
search_fields = ['user__username', 'reason', 'source', 'notes']
readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'created_at']
def user_link(self, obj):
url = reverse('admin:accounts_user_change', args=[obj.user.id])
@@ -36,16 +36,18 @@ class EditSubmissionAdmin(admin.ModelAdmin):
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)
obj.approve(request.user)
elif obj.status == 'REJECTED':
obj.reject(request.user, obj.review_notes)
obj.reject(request.user)
elif obj.status == 'ESCALATED':
obj.escalate(request.user)
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']
list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'created_at', 'handled_by']
list_filter = ['status', 'content_type', 'created_at']
search_fields = ['user__username', 'caption', 'notes']
readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'created_at']
def user_link(self, obj):
url = reverse('admin:accounts_user_change', args=[obj.user.id])
@@ -68,9 +70,9 @@ class PhotoSubmissionAdmin(admin.ModelAdmin):
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)
obj.approve(request.user, obj.notes)
elif obj.status == 'REJECTED':
obj.reject(request.user, obj.review_notes)
obj.reject(request.user, obj.notes)
super().save_model(request, obj, form, change)
# Register with moderation site only

View File

@@ -0,0 +1,107 @@
# Generated manually
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('moderation', '0002_editsubmission_submission_type_and_more'),
]
operations = [
# EditSubmission changes
migrations.RenameField(
model_name='editsubmission',
old_name='submitted_at',
new_name='created_at',
),
migrations.RenameField(
model_name='editsubmission',
old_name='reviewed_by',
new_name='handled_by',
),
migrations.RenameField(
model_name='editsubmission',
old_name='reviewed_at',
new_name='handled_at',
),
migrations.RenameField(
model_name='editsubmission',
old_name='review_notes',
new_name='notes',
),
migrations.AlterField(
model_name='editsubmission',
name='status',
field=models.CharField(
choices=[
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('ESCALATED', 'Escalated'),
],
default='NEW',
max_length=20,
),
),
migrations.AlterField(
model_name='editsubmission',
name='handled_by',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='handled_submissions',
to='accounts.user',
),
),
# PhotoSubmission changes
migrations.RenameField(
model_name='photosubmission',
old_name='submitted_at',
new_name='created_at',
),
migrations.RenameField(
model_name='photosubmission',
old_name='reviewed_by',
new_name='handled_by',
),
migrations.RenameField(
model_name='photosubmission',
old_name='reviewed_at',
new_name='handled_at',
),
migrations.RenameField(
model_name='photosubmission',
old_name='review_notes',
new_name='notes',
),
migrations.AlterField(
model_name='photosubmission',
name='status',
field=models.CharField(
choices=[
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('AUTO_APPROVED', 'Auto Approved'),
],
default='NEW',
max_length=20,
),
),
migrations.AlterField(
model_name='photosubmission',
name='handled_by',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='handled_photos',
to='accounts.user',
),
),
]

View File

@@ -192,8 +192,8 @@ class InlineEditMixin:
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')
status='NEW'
).select_related('user').order_by('-created_at')
return context
class HistoryMixin:
@@ -211,7 +211,7 @@ class HistoryMixin:
content_type=content_type,
object_id=obj.id
).exclude(
status='PENDING'
).select_related('user', 'reviewed_by').order_by('-submitted_at')
status='NEW'
).select_related('user', 'handled_by').order_by('-created_at')
return context

View File

@@ -7,10 +7,10 @@ from django.apps import apps
class EditSubmission(models.Model):
STATUS_CHOICES = [
('PENDING', 'Pending'),
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('AUTO_APPROVED', 'Auto Approved'),
('ESCALATED', 'Escalated'),
]
SUBMISSION_TYPE_CHOICES = [
@@ -53,26 +53,26 @@ class EditSubmission(models.Model):
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='PENDING'
default='NEW'
)
submitted_at = models.DateTimeField(auto_now_add=True)
created_at = models.DateTimeField(auto_now_add=True)
# Review details
reviewed_by = models.ForeignKey(
handled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_submissions'
related_name='handled_submissions'
)
reviewed_at = models.DateTimeField(null=True, blank=True)
review_notes = models.TextField(
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True,
help_text='Notes from the moderator about this submission'
)
class Meta:
ordering = ['-submitted_at']
ordering = ['-created_at']
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['status']),
@@ -96,12 +96,11 @@ class EditSubmission(models.Model):
return resolved_data
def approve(self, moderator, notes=''):
def approve(self, user):
"""Approve the submission and apply the changes"""
self.status = 'APPROVED'
self.reviewed_by = moderator
self.reviewed_at = timezone.now()
self.review_notes = notes
self.handled_by = user
self.handled_at = timezone.now()
model_class = self.content_type.model_class()
resolved_data = self._resolve_foreign_keys(self.changes)
@@ -122,42 +121,23 @@ class EditSubmission(models.Model):
self.save()
return obj
def reject(self, moderator, notes):
def reject(self, user):
"""Reject the submission"""
self.status = 'REJECTED'
self.reviewed_by = moderator
self.reviewed_at = timezone.now()
self.review_notes = notes
self.handled_by = user
self.handled_at = timezone.now()
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()
def escalate(self, user):
"""Escalate the submission to admin"""
self.status = 'ESCALATED'
self.handled_by = user
self.handled_at = timezone.now()
self.save()
return obj
class PhotoSubmission(models.Model):
STATUS_CHOICES = [
('PENDING', 'Pending'),
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('AUTO_APPROVED', 'Auto Approved'),
@@ -184,26 +164,26 @@ class PhotoSubmission(models.Model):
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='PENDING'
default='NEW'
)
submitted_at = models.DateTimeField(auto_now_add=True)
created_at = models.DateTimeField(auto_now_add=True)
# Review details
reviewed_by = models.ForeignKey(
handled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_photos'
related_name='handled_photos'
)
reviewed_at = models.DateTimeField(null=True, blank=True)
review_notes = models.TextField(
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True,
help_text='Notes from the moderator about this photo submission'
)
class Meta:
ordering = ['-submitted_at']
ordering = ['-created_at']
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['status']),
@@ -217,9 +197,9 @@ class PhotoSubmission(models.Model):
from media.models import Photo
self.status = 'APPROVED'
self.reviewed_by = moderator
self.reviewed_at = timezone.now()
self.review_notes = notes
self.handled_by = moderator
self.handled_at = timezone.now()
self.notes = notes
# Create the approved photo
Photo.objects.create(
@@ -236,9 +216,9 @@ class PhotoSubmission(models.Model):
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.handled_by = moderator
self.handled_at = timezone.now()
self.notes = notes
self.save()
def auto_approve(self):
@@ -246,8 +226,8 @@ class PhotoSubmission(models.Model):
from media.models import Photo
self.status = 'AUTO_APPROVED'
self.reviewed_by = self.user
self.reviewed_at = timezone.now()
self.handled_by = self.user
self.handled_at = timezone.now()
# Create the approved photo
Photo.objects.create(

View File

@@ -1,16 +1,11 @@
from django.urls import path, include
from .admin import moderation_site
from .views import EditSubmissionListView, PhotoSubmissionListView
from django.urls import path
from . import views
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),
path('submissions/', views.EditSubmissionListView.as_view(), name='edit_submissions'),
path('submissions/<int:submission_id>/approve/', views.approve_submission, name='approve_submission'),
path('submissions/<int:submission_id>/reject/', views.reject_submission, name='reject_submission'),
path('submissions/<int:submission_id>/escalate/', views.escalate_submission, name='escalate_submission'),
]

View File

@@ -1,100 +1,90 @@
from django.views.generic import ListView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.shortcuts import get_object_or_404
from django.utils import timezone
from .models import EditSubmission, PhotoSubmission
from .mixins import ModeratorRequiredMixin
from django.http import HttpResponse
from django.contrib import messages
from django.db.models import Q
from .models import EditSubmission
class EditSubmissionListView(ModeratorRequiredMixin, ListView):
class ModeratorRequiredMixin(UserPassesTestMixin):
def test_func(self):
return self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
class EditSubmissionListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
model = EditSubmission
template_name = 'moderation/admin/edit_submission_list.html'
template_name = 'moderation/edit_submissions.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')
tab = self.request.GET.get('tab', 'new')
queryset = EditSubmission.objects.select_related('user', 'content_type')
# Filter by status
status = self.request.GET.get('status')
if status:
queryset = queryset.filter(status=status)
# Include edits by privileged users (mods, admins, superusers) in appropriate tabs
privileged_roles = ['MODERATOR', 'ADMIN', 'SUPERUSER']
# Filter by submission type
submission_type = self.request.GET.get('type')
if submission_type:
queryset = queryset.filter(submission_type=submission_type)
if tab == 'new':
# Show pending submissions, oldest first
queryset = queryset.filter(status='NEW').order_by('created_at')
elif tab == 'approved':
# Show approved submissions and auto-approved edits by privileged users
queryset = queryset.filter(
Q(status='APPROVED') |
Q(user__role__in=privileged_roles, status='NEW') # Include privileged users' edits
).order_by('-created_at')
elif tab == 'rejected':
# Show rejected submissions, newest first
queryset = queryset.filter(status='REJECTED').order_by('-created_at')
elif tab == 'escalated' and self.request.user.role in ['ADMIN', 'SUPERUSER']:
# Show escalated submissions, newest first
queryset = queryset.filter(status='ESCALATED').order_by('-created_at')
else:
# Default to new submissions if invalid tab
queryset = queryset.filter(status='NEW').order_by('created_at')
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', '')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['active_tab'] = self.request.GET.get('tab', 'new')
context['new_count'] = EditSubmission.objects.filter(status='NEW').count()
if self.request.user.role in ['ADMIN', 'SUPERUSER']:
context['escalated_count'] = EditSubmission.objects.filter(status='ESCALATED').count()
return context
def get_template_names(self):
if self.request.htmx:
return ['moderation/partials/submission_list.html']
return [self.template_name]
def approve_submission(request, submission_id):
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
})
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
submission.approve(request.user)
messages.success(request, 'Submission approved successfully')
return JsonResponse({
'status': 'error',
'message': 'Invalid action.'
}, status=400)
# Return updated submission list for current tab
view = EditSubmissionListView.as_view()
return view(request)
class PhotoSubmissionListView(ModeratorRequiredMixin, ListView):
model = PhotoSubmission
template_name = 'moderation/admin/photo_submission_list.html'
context_object_name = 'submissions'
paginate_by = 20
def reject_submission(request, submission_id):
submission = get_object_or_404(EditSubmission, id=submission_id)
def get_queryset(self):
queryset = super().get_queryset().select_related(
'user', 'reviewed_by', 'content_type'
).order_by('-submitted_at')
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
submission.reject(request.user)
messages.success(request, 'Submission rejected successfully')
status = self.request.GET.get('status')
if status:
queryset = queryset.filter(status=status)
# Return updated submission list for current tab
view = EditSubmissionListView.as_view()
return view(request)
return queryset
def escalate_submission(request, submission_id):
submission = get_object_or_404(EditSubmission, id=submission_id)
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', '')
if request.user.role == 'MODERATOR':
submission.escalate(request.user)
messages.success(request, 'Submission escalated to admin')
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)
# Return updated submission list for current tab
view = EditSubmissionListView.as_view()
return view(request)

View File

@@ -1,19 +1,49 @@
from django.contrib import admin
from django.utils.html import format_html
from simple_history.admin import SimpleHistoryAdmin
from .models import Park, ParkArea
@admin.register(Park)
class ParkAdmin(SimpleHistoryAdmin):
list_display = ('name', 'location', 'owner', 'status', 'opening_date')
list_filter = ('status', 'owner')
list_display = ('name', 'location', 'status', 'owner', 'created_at', 'updated_at')
list_filter = ('status', 'country', 'region', 'city')
search_fields = ('name', 'location', 'description')
readonly_fields = ('created_at', 'updated_at')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('id', 'created_at', 'updated_at')
@admin.register(ParkArea)
def get_history_list_display(self, request):
"""Customize the list display for history records"""
return ('name', 'location', 'status', 'history_date', 'history_user')
class ParkAreaAdmin(SimpleHistoryAdmin):
list_display = ('name', 'park', 'opening_date')
list_display = ('name', 'park', 'created_at', 'updated_at')
list_filter = ('park',)
search_fields = ('name', 'description')
search_fields = ('name', 'description', 'park__name')
readonly_fields = ('created_at', 'updated_at')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('id', 'created_at', 'updated_at')
def get_history_list_display(self, request):
"""Customize the list display for history records"""
return ('name', 'park', 'history_date', 'history_user')
# Register the models with their admin classes
admin.site.register(Park, ParkAdmin)
admin.site.register(ParkArea, ParkAreaAdmin)
# Register the historical records for direct editing
from simple_history.models import HistoricalRecords
from .models import Park
class HistoricalParkAdmin(admin.ModelAdmin):
list_display = ('name', 'location', 'status', 'history_date', 'history_user', 'history_type')
list_filter = ('status', 'country', 'region', 'city', 'history_type')
search_fields = ('name', 'location', 'description')
readonly_fields = ('history_date', 'history_type', 'history_id')
def has_add_permission(self, request):
return False # Prevent adding new historical records directly
def has_delete_permission(self, request, obj=None):
return False # Prevent deleting historical records
# Register the historical model
admin.site.register(Park.history.model, HistoricalParkAdmin)

View File

@@ -3,48 +3,89 @@ from django.urls import reverse_lazy
from .models import Park
from cities_light.models import Country, Region, City
class LocationFilterForm(forms.Form):
country = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Select or type a country',
'hx-get': reverse_lazy('parks:country_search'),
'hx-trigger': 'focus, input',
'hx-target': '#country-results',
'autocomplete': 'off'
})
)
region = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Select or type a region/state',
'hx-get': reverse_lazy('parks:region_search'),
'hx-trigger': 'focus, input',
'hx-target': '#region-results',
'autocomplete': 'off'
})
)
city = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Select or type a city',
'hx-get': reverse_lazy('parks:city_search'),
'hx-trigger': 'focus, input',
'hx-target': '#city-results',
'autocomplete': 'off'
})
)
class ParkForm(forms.ModelForm):
# Hidden fields for actual model relations
country = forms.ModelChoiceField(queryset=Country.objects.all(), required=True, widget=forms.HiddenInput())
region = forms.ModelChoiceField(queryset=Region.objects.all(), required=False, widget=forms.HiddenInput())
city = forms.ModelChoiceField(queryset=City.objects.all(), required=False, widget=forms.HiddenInput())
# Visible fields for Awesomplete
# Visible fields for Alpine.js
country_name = forms.CharField(
label="Country",
required=True,
widget=forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Start typing a country name...',
'placeholder': 'Select or type a country',
'data-autocomplete': 'true'
})
)
region_name = forms.CharField(
label="Region/State",
required=False,
widget=forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Start typing a region/state name...',
'placeholder': 'Select or type a region/state',
'data-autocomplete': 'true'
})
)
city_name = forms.CharField(
label="City",
required=False,
widget=forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Start typing a city name...',
'placeholder': 'Select or type a city',
'data-autocomplete': 'true'
})
)
class Meta:
model = Park
fields = ['name', 'country', 'region', 'city', 'description', 'owner', 'status',
'opening_date', 'closing_date', 'operating_season', 'size_acres', 'website']
fields = ['name', 'description', 'owner', 'status', 'opening_date', 'closing_date',
'operating_season', 'size_acres', 'website', 'country', 'region', 'city']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'rows': 4
}),
'owner': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
@@ -79,15 +120,26 @@ class ParkForm(forms.ModelForm):
super().__init__(*args, **kwargs)
instance = kwargs.get('instance')
if instance:
try:
if instance.country:
self.fields['country_name'].initial = instance.country.name
self.fields['country'].initial = instance.country
except Country.DoesNotExist:
pass
try:
if instance.region:
self.fields['region_name'].initial = instance.region.name
self.fields['region'].initial = instance.region
except Region.DoesNotExist:
pass
try:
if instance.city:
self.fields['city_name'].initial = instance.city.name
self.fields['city'].initial = instance.city
except City.DoesNotExist:
pass
def clean(self):
cleaned_data = super().clean()
@@ -95,12 +147,26 @@ class ParkForm(forms.ModelForm):
region_name = cleaned_data.get('region_name')
city_name = cleaned_data.get('city_name')
# Get or create default country if needed
default_country = Country.objects.first()
if not default_country:
default_country = Country.objects.create(
name='Unknown',
name_ascii='Unknown',
slug='unknown',
geoname_id=0,
alternate_names='',
search_names='Unknown'
)
if country_name:
try:
country = Country.objects.get(name__iexact=country_name)
cleaned_data['country'] = country
except Country.DoesNotExist:
self.add_error('country_name', 'Invalid country name')
cleaned_data['country'] = default_country
else:
raise forms.ValidationError("Country is required")
if region_name and cleaned_data.get('country'):
try:
@@ -110,7 +176,7 @@ class ParkForm(forms.ModelForm):
)
cleaned_data['region'] = region
except Region.DoesNotExist:
self.add_error('region_name', 'Invalid region name for selected country')
cleaned_data['region'] = None
if city_name and cleaned_data.get('region'):
try:
@@ -120,6 +186,6 @@ class ParkForm(forms.ModelForm):
)
cleaned_data['city'] = city
except City.DoesNotExist:
self.add_error('city_name', 'Invalid city name for selected region')
cleaned_data['city'] = None
return cleaned_data

View File

@@ -0,0 +1,99 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Fix historical park records with null location values'
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Make fields nullable temporarily
self.stdout.write('Making fields nullable...')
cursor.execute("""
ALTER TABLE parks_historicalpark
ALTER COLUMN city_id DROP NOT NULL,
ALTER COLUMN region_id DROP NOT NULL,
ALTER COLUMN country_id DROP NOT NULL,
ALTER COLUMN location DROP NOT NULL;
""")
# Get or create default locations
self.stdout.write('Creating default locations if needed...')
# Check if Unknown country exists
cursor.execute("SELECT id FROM cities_light_country WHERE name = 'Unknown' LIMIT 1;")
result = cursor.fetchone()
if not result:
cursor.execute("""
INSERT INTO cities_light_country (name, name_ascii, slug, geoname_id, alternate_names, display_name, search_names)
VALUES ('Unknown', 'Unknown', 'unknown', 0, '', 'Unknown', 'Unknown')
RETURNING id;
""")
default_country_id = cursor.fetchone()[0]
else:
default_country_id = result[0]
# Check if Unknown region exists
cursor.execute("""
SELECT id FROM cities_light_region
WHERE name = 'Unknown' AND country_id = %s LIMIT 1;
""", [default_country_id])
result = cursor.fetchone()
if not result:
cursor.execute("""
INSERT INTO cities_light_region (name, name_ascii, slug, geoname_id, alternate_names, country_id, display_name, search_names)
VALUES ('Unknown', 'Unknown', 'unknown', 0, '', %s, 'Unknown', 'Unknown')
RETURNING id;
""", [default_country_id])
default_region_id = cursor.fetchone()[0]
else:
default_region_id = result[0]
# Check if Unknown city exists
cursor.execute("""
SELECT id FROM cities_light_city
WHERE name = 'Unknown' AND region_id = %s LIMIT 1;
""", [default_region_id])
result = cursor.fetchone()
if not result:
cursor.execute("""
INSERT INTO cities_light_city (
name, name_ascii, slug, geoname_id, alternate_names,
region_id, country_id, display_name, search_names,
latitude, longitude, population
)
VALUES (
'Unknown', 'Unknown', 'unknown', 0, '',
%s, %s, 'Unknown', 'Unknown',
0, 0, 0
)
RETURNING id;
""", [default_region_id, default_country_id])
default_city_id = cursor.fetchone()[0]
else:
default_city_id = result[0]
# Update historical records with null values
self.stdout.write('Updating historical records...')
cursor.execute("""
UPDATE parks_historicalpark
SET country_id = %s,
region_id = %s,
city_id = %s,
location = 'Unknown, Unknown, Unknown'
WHERE country_id IS NULL
OR region_id IS NULL
OR city_id IS NULL
OR location IS NULL;
""", [default_country_id, default_region_id, default_city_id])
# Make fields non-nullable again
self.stdout.write('Making fields non-nullable...')
cursor.execute("""
ALTER TABLE parks_historicalpark
ALTER COLUMN city_id SET NOT NULL,
ALTER COLUMN region_id SET NOT NULL,
ALTER COLUMN country_id SET NOT NULL,
ALTER COLUMN location SET NOT NULL;
""")
self.stdout.write(self.style.SUCCESS('Successfully fixed historical records'))

View File

@@ -0,0 +1,90 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Fix location fields in parks and historical records'
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Check if Unknown country exists
cursor.execute("""
SELECT id FROM cities_light_country WHERE name = 'Unknown' LIMIT 1;
""")
result = cursor.fetchone()
if result:
default_country_id = result[0]
else:
cursor.execute("""
INSERT INTO cities_light_country (name, name_ascii, slug, geoname_id, alternate_names)
VALUES ('Unknown', 'Unknown', 'unknown', 0, '')
RETURNING id;
""")
default_country_id = cursor.fetchone()[0]
# Check if Unknown region exists
cursor.execute("""
SELECT id FROM cities_light_region
WHERE name = 'Unknown' AND country_id = %s LIMIT 1;
""", [default_country_id])
result = cursor.fetchone()
if result:
default_region_id = result[0]
else:
cursor.execute("""
INSERT INTO cities_light_region (name, name_ascii, slug, geoname_id, alternate_names, country_id, display_name)
VALUES ('Unknown', 'Unknown', 'unknown', 0, '', %s, 'Unknown')
RETURNING id;
""", [default_country_id])
default_region_id = cursor.fetchone()[0]
# Check if Unknown city exists
cursor.execute("""
SELECT id FROM cities_light_city
WHERE name = 'Unknown' AND region_id = %s LIMIT 1;
""", [default_region_id])
result = cursor.fetchone()
if result:
default_city_id = result[0]
else:
cursor.execute("""
INSERT INTO cities_light_city (
name, name_ascii, slug, geoname_id, alternate_names,
region_id, country_id, display_name,
latitude, longitude, population
)
VALUES (
'Unknown', 'Unknown', 'unknown', 0, '',
%s, %s, 'Unknown',
0, 0, 0
)
RETURNING id;
""", [default_region_id, default_country_id])
default_city_id = cursor.fetchone()[0]
# Update parks with null locations
cursor.execute("""
UPDATE parks_park
SET country_id = %s,
region_id = %s,
city_id = %s,
location = 'Unknown, Unknown, Unknown'
WHERE country_id IS NULL
OR region_id IS NULL
OR city_id IS NULL
OR location IS NULL;
""", [default_country_id, default_region_id, default_city_id])
# Update historical records with null locations
cursor.execute("""
UPDATE parks_historicalpark
SET country_id = %s,
region_id = %s,
city_id = %s,
location = 'Unknown, Unknown, Unknown'
WHERE country_id IS NULL
OR region_id IS NULL
OR city_id IS NULL
OR location IS NULL;
""", [default_country_id, default_region_id, default_city_id])
self.stdout.write(self.style.SUCCESS('Successfully fixed location fields'))

View File

@@ -10,6 +10,11 @@
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
"owner": "The Walt Disney Company",
"size_acres": 142,
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg/1280px-Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Magic_Kingdom_Main_Street_USA_Panorama.jpg/1280px-Magic_Kingdom_Main_Street_USA_Panorama.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg/1280px-Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg"
],
"rides": [
{
"name": "Space Mountain",
@@ -18,6 +23,10 @@
"status": "OPERATING",
"manufacturer": "Walt Disney Imagineering",
"description": "A high-speed roller coaster in the dark through space.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Magic_Kingdom_Space_Mountain.jpg/1280px-Magic_Kingdom_Space_Mountain.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Space_Mountain_%28Magic_Kingdom%29_entrance.jpg/1280px-Space_Mountain_%28Magic_Kingdom%29_entrance.jpg"
],
"stats": {
"height_ft": 183,
"length_ft": 3196,
@@ -33,6 +42,10 @@
"status": "OPERATING",
"manufacturer": "Walt Disney Imagineering",
"description": "A mine train roller coaster through the Old West.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg/1280px-Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg/1280px-Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg"
],
"stats": {
"height_ft": 104,
"length_ft": 2671,
@@ -48,6 +61,10 @@
"status": "OPERATING",
"manufacturer": "Vekoma",
"description": "A family roller coaster featuring unique swinging cars.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg/1280px-Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Seven_Dwarfs_Mine_Train_drop.jpg/1280px-Seven_Dwarfs_Mine_Train_drop.jpg"
],
"stats": {
"height_ft": 112,
"length_ft": 2000,
@@ -62,7 +79,11 @@
"opening_date": "1971-10-01",
"status": "OPERATING",
"manufacturer": "Walt Disney Imagineering",
"description": "A dark ride through a haunted estate."
"description": "A dark ride through a haunted estate.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_Mansion_at_Magic_Kingdom.jpg/1280px-Haunted_Mansion_at_Magic_Kingdom.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_Mansion_entrance.jpg/1280px-Haunted_Mansion_entrance.jpg"
]
},
{
"name": "Pirates of the Caribbean",
@@ -70,7 +91,11 @@
"opening_date": "1973-12-15",
"status": "OPERATING",
"manufacturer": "Walt Disney Imagineering",
"description": "A boat ride through pirate-filled Caribbean waters."
"description": "A boat ride through pirate-filled Caribbean waters.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_the_Caribbean_%28Magic_Kingdom%29.jpg/1280px-Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_the_Caribbean_entrance.jpg/1280px-Pirates_of_the_Caribbean_entrance.jpg"
]
}
]
},
@@ -84,6 +109,11 @@
"website": "https://www.cedarpoint.com",
"owner": "Cedar Fair",
"size_acres": 364,
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Cedar_Point_aerial_view.jpg/1280px-Cedar_Point_aerial_view.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Cedar_Point_Beach.jpg/1280px-Cedar_Point_Beach.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cedar_Point_at_dusk.jpg/1280px-Cedar_Point_at_dusk.jpg"
],
"rides": [
{
"name": "Steel Vengeance",
@@ -92,6 +122,10 @@
"status": "OPERATING",
"manufacturer": "Rocky Mountain Construction",
"description": "A hybrid roller coaster featuring multiple inversions.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Steel_Vengeance_at_Cedar_Point.jpg/1280px-Steel_Vengeance_at_Cedar_Point.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Steel_Vengeance_first_drop.jpg/1280px-Steel_Vengeance_first_drop.jpg"
],
"stats": {
"height_ft": 205,
"length_ft": 5740,
@@ -107,6 +141,10 @@
"status": "OPERATING",
"manufacturer": "Intamin",
"description": "A giga coaster with stunning views of Lake Erie.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_Force_at_Cedar_Point.jpg/1280px-Millennium_Force_at_Cedar_Point.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_Force_lift_hill.jpg/1280px-Millennium_Force_lift_hill.jpg"
],
"stats": {
"height_ft": 310,
"length_ft": 6595,
@@ -122,6 +160,10 @@
"status": "SBNO",
"manufacturer": "Intamin",
"description": "A strata coaster featuring a 420-foot top hat element.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Top_Thrill_Dragster.jpg/1280px-Top_Thrill_Dragster.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Top_Thrill_Dragster_launch.jpg/1280px-Top_Thrill_Dragster_launch.jpg"
],
"stats": {
"height_ft": 420,
"length_ft": 2800,
@@ -137,6 +179,10 @@
"status": "OPERATING",
"manufacturer": "Intamin",
"description": "A launched roller coaster with multiple inversions.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]k_at_Cedar_Point.jpg/1280px-Maverick_at_Cedar_Point.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]k_first_drop.jpg/1280px-Maverick_first_drop.jpg"
],
"stats": {
"height_ft": 105,
"length_ft": 4450,
@@ -157,6 +203,11 @@
"website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure",
"owner": "NBCUniversal",
"size_acres": 110,
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_Adventure_entrance.jpg/1280px-Islands_of_Adventure_entrance.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]s_Castle_at_Universal%27s_Islands_of_Adventure.jpg/1280px-Hogwarts_Castle_at_Universal%27s_Islands_of_Adventure.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Port_of_Entry_at_Islands_of_Adventure.jpg/1280px-Port_of_Entry_at_Islands_of_Adventure.jpg"
],
"rides": [
{
"name": "Jurassic World VelociCoaster",
@@ -165,6 +216,10 @@
"status": "OPERATING",
"manufacturer": "Intamin",
"description": "A high-speed launch coaster featuring velociraptors.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]c_World_VelociCoaster.jpg/1280px-Jurassic_World_VelociCoaster.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]oaster_top_hat.jpg/1280px-VelociCoaster_top_hat.jpg"
],
"stats": {
"height_ft": 155,
"length_ft": 4700,
@@ -180,6 +235,10 @@
"status": "OPERATING",
"manufacturer": "Intamin",
"description": "A story coaster through the Forbidden Forest.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg/1280px-Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Hagrid%27s_entrance.jpg/1280px-Hagrid%27s_entrance.jpg"
],
"stats": {
"height_ft": 65,
"length_ft": 5053,
@@ -194,7 +253,11 @@
"opening_date": "1999-05-28",
"status": "OPERATING",
"manufacturer": "Oceaneering International",
"description": "A 3D dark ride featuring Spider-Man."
"description": "A 3D dark ride featuring Spider-Man.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/The_Amazing_Adventures_of_Spider-Man.jpg/1280px-The_Amazing_Adventures_of_Spider-Man.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Spider-Man_ride_entrance.jpg/1280px-Spider-Man_ride_entrance.jpg"
]
}
]
},
@@ -208,6 +271,11 @@
"website": "https://www.altontowers.com",
"owner": "Merlin Entertainments",
"size_acres": 910,
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Alton_Towers_aerial_view.jpg/1280px-Alton_Towers_aerial_view.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Alton_Towers_mansion.jpg/1280px-Alton_Towers_mansion.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Alton_Towers_gardens.jpg/1280px-Alton_Towers_gardens.jpg"
],
"rides": [
{
"name": "Nemesis",
@@ -216,6 +284,10 @@
"status": "CLOSED",
"manufacturer": "Bolliger & Mabillard",
"description": "An inverted roller coaster through ravines.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_at_Alton_Towers.jpg/1280px-Nemesis_at_Alton_Towers.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]_loop.jpg/1280px-Nemesis_loop.jpg"
],
"stats": {
"height_ft": 43,
"length_ft": 2349,
@@ -231,6 +303,10 @@
"status": "OPERATING",
"manufacturer": "Bolliger & Mabillard",
"description": "The world's first vertical drop roller coaster.",
"photos": [
"https://upload.wikimedia.[AWS-SECRET-REMOVED]n_at_Alton_Towers.jpg/1280px-Oblivion_at_Alton_Towers.jpg",
"https://upload.wikimedia.[AWS-SECRET-REMOVED]n_vertical_drop.jpg/1280px-Oblivion_vertical_drop.jpg"
],
"stats": {
"height_ft": 65,
"length_ft": 1804,
@@ -251,6 +327,11 @@
"website": "https://www.europapark.de",
"owner": "Mack Rides",
"size_acres": 235,
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Europa-Park_entrance.jpg/1280px-Europa-Park_entrance.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Europa-Park_aerial_view.jpg/1280px-Europa-Park_aerial_view.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Europa-Park_at_night.jpg/1280px-Europa-Park_at_night.jpg"
],
"rides": [
{
"name": "Silver Star",
@@ -259,6 +340,10 @@
"status": "OPERATING",
"manufacturer": "Bolliger & Mabillard",
"description": "A hypercoaster with stunning views.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Silver_Star_at_Europa-Park.jpg/1280px-Silver_Star_at_Europa-Park.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Silver_Star_first_drop.jpg/1280px-Silver_Star_first_drop.jpg"
],
"stats": {
"height_ft": 239,
"length_ft": 4003,
@@ -274,6 +359,10 @@
"status": "OPERATING",
"manufacturer": "Mack Rides",
"description": "A launched roller coaster with multiple inversions.",
"photos": [
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Blue_Fire_at_Europa-Park.jpg/1280px-Blue_Fire_at_Europa-Park.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Blue_Fire_launch.jpg/1280px-Blue_Fire_launch.jpg"
],
"stats": {
"height_ft": 125,
"length_ft": 3465,

View File

@@ -13,6 +13,7 @@ from faker import Faker
import requests
from io import BytesIO
from PIL import Image
from cities_light.models import City, Country
from parks.models import Park
from rides.models import Ride, RollerCoasterStats
@@ -30,25 +31,30 @@ class Command(BaseCommand):
parser.add_argument('--users', type=int, default=50)
parser.add_argument('--reviews-per-item', type=int, default=10)
def download_and_save_image(self, url, prefix):
def download_and_save_image(self, url):
try:
response = requests.get(url)
img = Image.open(BytesIO(response.content))
img_io = BytesIO()
img.save(img_io, format='JPEG')
img_io.seek(0)
return f'{prefix}_{fake.uuid4()}.jpg', File(img_io)
except:
filename = url.split('/')[-1]
return filename, File(img_io)
except Exception as e:
self.stdout.write(self.style.WARNING(f'Failed to download image {url}: {str(e)}'))
return None, None
def create_users(self, count):
self.stdout.write('Creating users...')
users = []
try:
# Get existing admin user
admin_user = User.objects.get(username='admin')
users.append(admin_user)
self.stdout.write('Added existing admin user')
except User.DoesNotExist:
self.stdout.write(self.style.WARNING('Admin user not found, skipping...'))
# Create regular users using raw SQL
roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2
@@ -232,12 +238,25 @@ class Command(BaseCommand):
parks = []
for park_data in seed_data['parks']:
# Create park with company instance
try:
# Get country from cities_light
country = Country.objects.get(code2=park_data['country'])
# Try to find city, but don't require it
city = None
try:
city_name = park_data['location'].split(',')[0].strip()
city = City.objects.filter(name__iexact=city_name, country=country).first()
except:
self.stdout.write(self.style.WARNING(f'City not found for {park_data["name"]}, using location text'))
# Create park
park = Park.objects.create(
name=park_data['name'],
slug=slugify(park_data['name']),
location=park_data['location'],
country=park_data['country'],
country=country,
city=city,
opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(),
status=park_data['status'],
description=park_data['description'],
@@ -247,15 +266,14 @@ class Command(BaseCommand):
)
# Add park photos
for _ in range(random.randint(2, 5)):
img_url = f'https://picsum.photos/800/600?random={fake.random_number(5)}'
filename, file = self.download_and_save_image(img_url, 'park')
for photo_url in park_data.get('photos', []):
filename, file = self.download_and_save_image(photo_url)
if filename and file:
Photo.objects.create(
content_object=park,
image=file,
uploaded_by=random.choice(users),
caption=fake.sentence(),
caption=f"Photo of {park.name}",
is_approved=True
)
@@ -284,21 +302,24 @@ class Command(BaseCommand):
)
# Add ride photos
for _ in range(random.randint(2, 5)):
img_url = f'https://picsum.photos/800/600?random={fake.random_number(5)}'
filename, file = self.download_and_save_image(img_url, 'ride')
for photo_url in ride_data.get('photos', []):
filename, file = self.download_and_save_image(photo_url)
if filename and file:
Photo.objects.create(
content_object=ride,
image=file,
uploaded_by=random.choice(users),
caption=fake.sentence(),
caption=f"Photo of {ride.name}",
is_approved=True
)
parks.append(park)
self.stdout.write(f'Created park and rides: {park.name}')
except Exception as e:
self.stdout.write(self.style.ERROR(f'Failed to create park {park_data["name"]}: {str(e)}'))
continue
return parks
def create_reviews(self, users, reviews_per_item):

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cities_light', '0001_initial'),
('parks', '0006_update_location_fields_to_cities_light'),
]
operations = [
migrations.AlterField(
model_name='historicalpark',
name='city',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='cities_light.city'),
),
]

View File

@@ -0,0 +1,43 @@
from django.db import migrations
from django.db.models import Q
def fix_historical_park_data(apps, schema_editor):
HistoricalPark = apps.get_model('parks', 'HistoricalPark')
Park = apps.get_model('parks', 'Park')
Country = apps.get_model('cities_light', 'Country')
# Get a default country (create one if none exists)
default_country = Country.objects.first()
if not default_country:
default_country = Country.objects.create(name='Unknown')
# Fix all historical records with null country
historical_records = HistoricalPark.objects.filter(
Q(country__isnull=True) | Q(location__isnull=True)
)
for record in historical_records:
try:
# Try to get the current park's country
park = Park.objects.get(id=record.id)
record.country = park.country
record.location = park.location or f"{park.country.name}"
except Park.DoesNotExist:
# If park doesn't exist, use default country
record.country = default_country
record.location = default_country.name
record.save()
def reverse_func(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('parks', '0007_fix_historical_park_city_null'),
('cities_light', '0001_initial'),
]
operations = [
migrations.RunPython(fix_historical_park_data, reverse_func),
]

View File

@@ -0,0 +1,33 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cities_light', '0001_initial'),
('parks', '0008_fix_historical_park_data'),
]
operations = [
migrations.AlterField(
model_name='historicalpark',
name='city',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='cities_light.city'),
),
migrations.AlterField(
model_name='historicalpark',
name='region',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='cities_light.region'),
),
migrations.AlterField(
model_name='historicalpark',
name='location',
field=models.CharField(max_length=255, default=''),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalpark',
name='country',
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='cities_light.country'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.1.2 on 2024-10-31 20:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cities_light", "0011_alter_city_country_alter_city_region_and_more"),
("parks", "0009_fix_historical_park_fields"),
]
operations = [
migrations.AlterField(
model_name="historicalpark",
name="country",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="cities_light.country",
),
),
migrations.AlterField(
model_name="park",
name="country",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="cities_light.country"
),
),
]

View File

@@ -0,0 +1,72 @@
from django.db import migrations, models
from django.db.models import Q
import django.db.models.deletion
def fix_historical_records(apps, schema_editor):
HistoricalPark = apps.get_model('parks', 'HistoricalPark')
Park = apps.get_model('parks', 'Park')
Country = apps.get_model('cities_light', 'Country')
# Get or create a default country
default_country = Country.objects.first()
if not default_country:
default_country = Country.objects.create(name='Unknown')
# Update all historical records with null values
for record in HistoricalPark.objects.filter(Q(country__isnull=True) | Q(location__isnull=True)):
try:
park = Park.objects.get(id=record.id)
record.country = park.country
record.location = park.location
except Park.DoesNotExist:
record.country = default_country
record.location = default_country.name
record.save()
class Migration(migrations.Migration):
atomic = False # Allow long-running operations
dependencies = [
('parks', '0009_fix_historical_park_fields'),
]
operations = [
# First, make sure all fields allow null temporarily
migrations.AlterField(
model_name='historicalpark',
name='country',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.country'
),
),
migrations.AlterField(
model_name='historicalpark',
name='location',
field=models.CharField(max_length=255, null=True, blank=True),
),
# Fix the data
migrations.RunPython(fix_historical_records),
# Now make the fields non-nullable
migrations.AlterField(
model_name='historicalpark',
name='country',
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.country'
),
),
migrations.AlterField(
model_name='historicalpark',
name='location',
field=models.CharField(max_length=255),
),
]

View File

@@ -0,0 +1,52 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cities_light', '0001_initial'),
('parks', '0010_fix_historical_records'),
]
operations = [
migrations.AlterField(
model_name='historicalpark',
name='city',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.city'
),
),
migrations.AlterField(
model_name='historicalpark',
name='region',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.region'
),
),
migrations.AlterField(
model_name='historicalpark',
name='country',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.country'
),
),
migrations.AlterField(
model_name='historicalpark',
name='location',
field=models.CharField(max_length=255, blank=True, null=True),
),
]

View File

@@ -0,0 +1,13 @@
# Generated by Django 5.1.2 on 2024-10-31 20:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0010_alter_historicalpark_country_alter_park_country"),
("parks", "0010_fix_historical_records"),
]
operations = []

View File

@@ -0,0 +1,13 @@
# Generated by Django 5.1.2 on 2024-10-31 20:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0011_alter_historicalpark_fields"),
("parks", "0011_merge_20241031_1617"),
]
operations = []

View File

@@ -0,0 +1,67 @@
from django.db import migrations
def fix_null_locations(apps, schema_editor):
Park = apps.get_model('parks', 'Park')
Country = apps.get_model('cities_light', 'Country')
Region = apps.get_model('cities_light', 'Region')
City = apps.get_model('cities_light', 'City')
# Get or create default locations
default_country = Country.objects.first()
if not default_country:
default_country = Country.objects.create(
name='Unknown',
name_ascii='Unknown',
slug='unknown',
geoname_id=0,
alternate_names='',
search_names='Unknown'
)
default_region = Region.objects.filter(country=default_country).first()
if not default_region:
default_region = Region.objects.create(
name='Unknown',
name_ascii='Unknown',
slug='unknown',
geoname_id=0,
alternate_names='',
country=default_country,
display_name='Unknown',
search_names='Unknown'
)
default_city = City.objects.filter(region=default_region).first()
if not default_city:
default_city = City.objects.create(
name='Unknown',
name_ascii='Unknown',
slug='unknown',
geoname_id=0,
alternate_names='',
region=default_region,
country=default_country,
display_name='Unknown',
search_names='Unknown',
latitude=0,
longitude=0,
population=0
)
# Update parks with null locations
for park in Park.objects.filter(country__isnull=True):
park.country = default_country
park.region = default_region
park.city = default_city
park.location = 'Unknown, Unknown, Unknown'
park.save()
class Migration(migrations.Migration):
dependencies = [
('parks', '0012_merge_20241031_1635'),
('cities_light', '0001_initial'),
]
operations = [
migrations.RunPython(fix_null_locations, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,52 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cities_light', '0001_initial'),
('parks', '0013_fix_null_locations'),
]
operations = [
migrations.AlterField(
model_name='historicalpark',
name='city',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.city'
),
),
migrations.AlterField(
model_name='historicalpark',
name='region',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.region'
),
),
migrations.AlterField(
model_name='historicalpark',
name='country',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.country'
),
),
migrations.AlterField(
model_name='historicalpark',
name='location',
field=models.CharField(max_length=255, blank=True, null=True),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations
def fix_historical_records(apps, schema_editor):
HistoricalPark = apps.get_model('parks', 'HistoricalPark')
# Update any historical records that might have issues
HistoricalPark.objects.filter(city__isnull=True).update(city=None)
class Migration(migrations.Migration):
dependencies = [
('parks', '0014_alter_location_fields'),
]
operations = [
migrations.RunPython(fix_historical_records, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cities_light', '0011_alter_city_country_alter_city_region_and_more'),
('parks', '0015_fix_historical_park_city_constraint'),
]
operations = [
migrations.AlterField(
model_name='historicalpark',
name='city',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='cities_light.city'),
),
migrations.AlterField(
model_name='historicalpark',
name='region',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='cities_light.region'),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('parks', '0016_alter_historicalpark_city_nullable'),
]
operations = [
migrations.RunSQL(
# Make the city column nullable
sql='ALTER TABLE parks_historicalpark ALTER COLUMN city DROP NOT NULL;',
# Reverse operation if needed
reverse_sql='ALTER TABLE parks_historicalpark ALTER COLUMN city SET NOT NULL;'
),
migrations.RunSQL(
# Make the city_id column nullable
sql='ALTER TABLE parks_historicalpark ALTER COLUMN city_id DROP NOT NULL;',
# Reverse operation if needed
reverse_sql='ALTER TABLE parks_historicalpark ALTER COLUMN city_id SET NOT NULL;'
),
]

View File

@@ -0,0 +1,48 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cities_light', '0011_alter_city_country_alter_city_region_and_more'),
('parks', '0017_fix_historicalpark_city_column'),
]
operations = [
migrations.AlterField(
model_name='historicalpark',
name='country',
field=models.ForeignKey(
blank=False,
db_constraint=False,
null=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.country'
),
),
migrations.AlterField(
model_name='historicalpark',
name='region',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.region'
),
),
migrations.AlterField(
model_name='historicalpark',
name='city',
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='+',
to='cities_light.city'
),
),
]

View File

@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('parks', '0018_fix_historicalpark_location_fields'),
]
operations = [
migrations.RunSQL(
# Make region_id nullable
sql='ALTER TABLE parks_historicalpark ALTER COLUMN region_id DROP NOT NULL;',
reverse_sql='ALTER TABLE parks_historicalpark ALTER COLUMN region_id SET NOT NULL;'
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('parks', '0019_fix_historicalpark_region_constraint'),
]
operations = [
migrations.RunSQL(
# Remove the redundant city text column
sql='ALTER TABLE parks_historicalpark DROP COLUMN IF EXISTS city;',
# Recreate the column if needed (reverse migration)
reverse_sql='ALTER TABLE parks_historicalpark ADD COLUMN city character varying(255);'
),
]

View File

@@ -11,7 +11,7 @@ class Park(models.Model):
('CLOSED_PERM', 'Permanently Closed'),
('UNDER_CONSTRUCTION', 'Under Construction'),
('DEMOLISHED', 'Demolished'),
('RELOCATED', 'Relocated'), # Added to match Ride model
('RELOCATED', 'Relocated'),
]
name = models.CharField(max_length=255)
@@ -84,6 +84,9 @@ class Park(models.Model):
def get_formatted_location(self):
"""Get a formatted location string: $COUNTRY, $REGION, $CITY"""
if not self.country:
return ""
location = self.country.name
if self.region and self.city:

View File

@@ -11,6 +11,7 @@ urlpatterns = [
path('ajax/countries/', views.get_countries, name='get_countries'),
path('ajax/regions/', views.get_regions, name='get_regions'),
path('ajax/cities/', views.get_cities, name='get_cities'),
path('<slug:slug>/edit/', views.ParkUpdateView.as_view(), name='park_edit'),
path('<slug:slug>/', views.ParkDetailView.as_view(), name='park_detail'),
path('<slug:park_slug>/rides/', include('rides.urls', namespace='rides')),
]

View File

@@ -1,48 +1,76 @@
from django.views.generic import DetailView, ListView, CreateView
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404, render
from django.core.serializers.json import DjangoJSONEncoder
from django.urls import reverse
from django.db.models import Q
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse
from .models import Park, ParkArea
from .forms import ParkForm
from rides.models import Ride
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
from cities_light.models import Country, Region, City
def get_countries(request):
query = request.GET.get('q', '')
countries = Country.objects.filter(name__icontains=query).values_list('name', flat=True)[:10]
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
# Base query
countries = Country.objects.filter(name__icontains=query)
# Only filter by parks if explicitly requested
if filter_parks:
countries = countries.filter(park__isnull=False)
countries = countries.distinct().values('id', 'name')[:10]
return JsonResponse(list(countries), safe=False)
def get_regions(request):
query = request.GET.get('q', '')
country = request.GET.get('country', '')
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
if not country:
return JsonResponse([], safe=False)
# Base query
regions = Region.objects.filter(
Q(name__icontains=query) | Q(alternate_names__icontains=query),
country__name__iexact=country
).values_list('name', flat=True)[:10]
)
# Only filter by parks if explicitly requested
if filter_parks:
regions = regions.filter(park__isnull=False)
regions = regions.distinct().values('id', 'name')[:10]
return JsonResponse(list(regions), safe=False)
def get_cities(request):
query = request.GET.get('q', '')
region = request.GET.get('region', '')
country = request.GET.get('country', '')
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
if not region or not country:
return JsonResponse([], safe=False)
# Base query
cities = City.objects.filter(
Q(name__icontains=query) | Q(alternate_names__icontains=query),
region__name__iexact=region,
region__country__name__iexact=country
).values_list('name', flat=True)[:10]
)
# Only filter by parks if explicitly requested
if filter_parks:
cities = cities.filter(park__isnull=False)
cities = cities.distinct().values('id', 'name')[:10]
return JsonResponse(list(cities), safe=False)
class ParkCreateView(LoginRequiredMixin, CreateView):
@@ -50,38 +78,111 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
form_class = ParkForm
template_name = 'parks/park_form.html'
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()
def prepare_changes_data(self, cleaned_data):
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if cleaned_data.get('owner'):
cleaned_data['owner'] = cleaned_data['owner'].id
if cleaned_data.get('country'):
cleaned_data['country'] = cleaned_data['country'].id
if cleaned_data.get('region'):
cleaned_data['region'] = cleaned_data['region'].id
if cleaned_data.get('city'):
cleaned_data['city'] = cleaned_data['city'].id
if data.get('owner'):
data['owner'] = data['owner'].id
if data.get('country'):
data['country'] = data['country'].id
if data.get('region'):
data['region'] = data['region'].id
if data.get('city'):
data['city'] = data['city'].id
# Convert dates to ISO format strings
if data.get('opening_date'):
data['opening_date'] = data['opening_date'].isoformat()
if data.get('closing_date'):
data['closing_date'] = data['closing_date'].isoformat()
return data
def form_valid(self, form):
changes = self.prepare_changes_data(form.cleaned_data)
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
submission_type='CREATE',
changes=cleaned_data,
changes=changes,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.object_id = self.object.id
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully created {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, 'Your park submission has been sent for review')
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):
class ParkUpdateView(LoginRequiredMixin, UpdateView):
model = Park
form_class = ParkForm
template_name = 'parks/park_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_edit'] = True
return context
def prepare_changes_data(self, cleaned_data):
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get('owner'):
data['owner'] = data['owner'].id
if data.get('country'):
data['country'] = data['country'].id
if data.get('region'):
data['region'] = data['region'].id
if data.get('city'):
data['city'] = data['city'].id
# Convert dates to ISO format strings
if data.get('opening_date'):
data['opening_date'] = data['opening_date'].isoformat()
if data.get('closing_date'):
data['closing_date'] = data['closing_date'].isoformat()
return data
def form_valid(self, form):
changes = self.prepare_changes_data(form.cleaned_data)
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
submission_type='EDIT',
changes=changes,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully updated {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, f'Your changes to {self.object.name} have been sent for review')
return HttpResponseRedirect(reverse('parks:park_detail', kwargs={'slug': self.object.slug}))
def get_success_url(self):
return reverse('parks:park_detail', kwargs={'slug': self.object.slug})
class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Park
template_name = 'parks/park_detail.html'
context_object_name = 'park'
@@ -104,7 +205,7 @@ class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixi
def get_redirect_url_pattern(self):
return 'parks:park_detail'
class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = ParkArea
template_name = 'parks/area_detail.html'
context_object_name = 'area'
@@ -149,7 +250,7 @@ class ParkListView(ListView):
country = self.request.GET.get('country', '').strip()
region = self.request.GET.get('region', '').strip()
city = self.request.GET.get('city', '').strip()
status = self.request.GET.get('status', '').strip()
statuses = self.request.GET.getlist('status')
if search:
queryset = queryset.filter(
@@ -166,8 +267,8 @@ class ParkListView(ListView):
if city:
queryset = queryset.filter(city__name__icontains=city)
if status:
queryset = queryset.filter(status=status)
if statuses:
queryset = queryset.filter(status__in=statuses)
return queryset
@@ -178,7 +279,7 @@ class ParkListView(ListView):
'country': self.request.GET.get('country', ''),
'region': self.request.GET.get('region', ''),
'city': self.request.GET.get('city', ''),
'status': self.request.GET.get('status', '')
'statuses': self.request.GET.getlist('status')
}
return context

71
rides/forms.py Normal file
View File

@@ -0,0 +1,71 @@
from django import forms
from .models import Ride
class RideForm(forms.ModelForm):
class Meta:
model = Ride
fields = ['name', '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']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'park_area': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'category': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'manufacturer': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'model_name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'status': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'opening_date': forms.DateInput(attrs={
'type': 'date',
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'closing_date': forms.DateInput(attrs={
'type': 'date',
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'status_since': forms.DateInput(attrs={
'type': 'date',
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'min_height_in': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'max_height_in': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'accessibility_options': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'capacity_per_hour': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'ride_duration_seconds': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
}
def __init__(self, *args, **kwargs):
park = kwargs.pop('park', None)
super().__init__(*args, **kwargs)
if park:
# Filter park_area choices to only show areas from the current park
self.fields['park_area'].queryset = park.areas.all()

View File

@@ -6,5 +6,6 @@ app_name = 'rides'
urlpatterns = [
path('', views.RideListView.as_view(), name='ride_list'),
path('create/', views.RideCreateView.as_view(), name='ride_create'),
path('<slug:ride_slug>/edit/', views.RideUpdateView.as_view(), name='ride_edit'),
path('<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
]

View File

@@ -1,37 +1,36 @@
from django.views.generic import DetailView, ListView, CreateView
from django.views.generic import DetailView, ListView, CreateView, UpdateView
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.contrib import messages
from django.http import JsonResponse, HttpResponseRedirect
from .models import Ride, RollerCoasterStats
from .forms import RideForm
from parks.models import Park
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
class RideCreateView(LoginRequiredMixin, CreateView):
model = Ride
form_class = RideForm
template_name = 'rides/ride_form.html'
fields = ['name', '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 setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['park'] = self.park
return kwargs
def form_valid(self, form):
form.instance.park = self.park
# 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()
cleaned_data['park'] = self.park.id
# Convert model instances to IDs for JSON serialization
@@ -40,6 +39,7 @@ class RideCreateView(LoginRequiredMixin, CreateView):
if cleaned_data.get('manufacturer'):
cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Ride),
@@ -48,6 +48,18 @@ class RideCreateView(LoginRequiredMixin, CreateView):
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.object_id = self.object.id
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully created {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, 'Your ride submission has been sent for review')
return HttpResponseRedirect(reverse('parks:rides:ride_list', kwargs={'park_slug': self.park.slug}))
def get_success_url(self):
@@ -61,7 +73,69 @@ class RideCreateView(LoginRequiredMixin, CreateView):
context['park'] = self.park
return context
class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
class RideUpdateView(LoginRequiredMixin, UpdateView):
model = Ride
form_class = RideForm
template_name = 'rides/ride_form.html'
slug_url_kwarg = 'ride_slug'
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['park'] = self.park
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['park'] = self.park
context['is_edit'] = True
return context
def form_valid(self, form):
cleaned_data = form.cleaned_data.copy()
cleaned_data['park'] = self.park.id
# Convert model instances to IDs for JSON serialization
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
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Ride),
object_id=self.object.id,
submission_type='EDIT',
changes=cleaned_data,
reason=self.request.POST.get('reason', ''),
source=self.request.POST.get('source', '')
)
# If user is moderator or above, auto-approve
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
self.object = form.save()
submission.status = 'APPROVED'
submission.handled_by = self.request.user
submission.save()
messages.success(self.request, f'Successfully updated {self.object.name}')
return HttpResponseRedirect(self.get_success_url())
messages.success(self.request, f'Your changes to {self.object.name} have been sent for review')
return HttpResponseRedirect(reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
}))
def get_success_url(self):
return reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
})
class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Ride
template_name = 'rides/ride_detail.html'
context_object_name = 'ride'

44
static/css/alerts.css Normal file
View File

@@ -0,0 +1,44 @@
/* Alert Styles */
.alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
animation: slideIn 0.5s ease-out forwards;
}
.alert-success {
@apply text-white bg-green-500;
}
.alert-error {
@apply text-white bg-red-500;
}
.alert-info {
@apply text-white bg-blue-500;
}
.alert-warning {
@apply text-white bg-yellow-500;
}
/* Animation keyframes */
@keyframes slideIn {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}

View File

@@ -1,187 +0,0 @@
/* 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

@@ -1513,116 +1513,6 @@ select {
padding-bottom: 1.5rem;
}
.mobile-nav-link {
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
border-width: 1px;
border-color: transparent;
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.mobile-nav-link:hover {
border-color: rgb(79 70 229 / 0.2);
background-color: rgb(79 70 229 / 0.1);
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
}
.mobile-nav-link:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.mobile-nav-link:hover:is(.dark *) {
border-color: rgb(79 70 229 / 0.3);
background-color: rgb(79 70 229 / 0.2);
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
}
.mobile-nav-link i {
font-size: 1.25rem;
line-height: 1.75rem;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
@media (max-width: 540px) {
.mobile-nav-link i {
font-size: 1.125rem;
line-height: 1.75rem;
}
}
.mobile-nav-link.primary {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
--tw-gradient-from: #4f46e5 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: #e11d48 var(--tw-gradient-to-position);
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.mobile-nav-link.primary: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);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
--tw-gradient-to: rgb(225 29 72 / 0.9) var(--tw-gradient-to-position);
}
.mobile-nav-link.primary i {
margin-right: 0.75rem;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.mobile-nav-link.secondary {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.mobile-nav-link.secondary:hover {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.mobile-nav-link.secondary:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.mobile-nav-link.secondary:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
}
.mobile-nav-link.secondary i {
margin-right: 0.75rem;
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.mobile-nav-link.secondary i:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity));
}
/* Theme Toggle */
#theme-toggle+.theme-toggle-btn i::before {
@@ -2243,6 +2133,10 @@ select {
position: static;
}
.fixed {
position: fixed;
}
.absolute {
position: absolute;
}
@@ -2251,14 +2145,30 @@ select {
position: relative;
}
.sticky {
position: sticky;
}
.right-0 {
right: 0px;
}
.top-0 {
top: 0px;
}
.z-10 {
z-index: 10;
}
.z-40 {
z-index: 40;
}
.z-50 {
z-index: 50;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
@@ -2281,6 +2191,10 @@ select {
margin-right: auto;
}
.-mb-px {
margin-bottom: -1px;
}
.mb-1 {
margin-bottom: 0.25rem;
}
@@ -2317,6 +2231,10 @@ select {
margin-left: 0.5rem;
}
.ml-4 {
margin-left: 1rem;
}
.ml-6 {
margin-left: 1.5rem;
}
@@ -2409,6 +2327,10 @@ select {
height: 2rem;
}
.max-h-60 {
max-height: 15rem;
}
.min-h-\[calc\(100vh-16rem\)\] {
min-height: calc(100vh - 16rem);
}
@@ -2421,12 +2343,12 @@ select {
width: 6rem;
}
.w-4 {
width: 1rem;
.w-32 {
width: 8rem;
}
.w-48 {
width: 12rem;
.w-4 {
width: 1rem;
}
.w-5 {
@@ -2465,6 +2387,18 @@ select {
flex-grow: 1;
}
.scale-100 {
--tw-scale-x: 1;
--tw-scale-y: 1;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.scale-95 {
--tw-scale-x: .95;
--tw-scale-y: .95;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
@@ -2473,6 +2407,10 @@ select {
cursor: pointer;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@@ -2531,12 +2469,6 @@ select {
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
@@ -2573,6 +2505,10 @@ select {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
@@ -2603,6 +2539,11 @@ select {
border-bottom-right-radius: 0.5rem;
}
.rounded-t-lg {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.border {
border-width: 1px;
}
@@ -2611,10 +2552,24 @@ select {
border-bottom-width: 1px;
}
.border-b-2 {
border-bottom-width: 2px;
}
.border-t {
border-top-width: 1px;
}
.border-blue-400 {
--tw-border-opacity: 1;
border-color: rgb(96 165 250 / var(--tw-border-opacity));
}
.border-blue-600 {
--tw-border-opacity: 1;
border-color: rgb(37 99 235 / var(--tw-border-opacity));
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
@@ -2644,6 +2599,10 @@ select {
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-transparent {
border-color: transparent;
}
.bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
@@ -2654,16 +2613,16 @@ select {
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
}
.bg-blue-500 {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
@@ -2679,6 +2638,11 @@ select {
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.bg-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
@@ -2689,6 +2653,11 @@ select {
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.bg-red-600 {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
@@ -2708,6 +2677,11 @@ select {
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
}
.bg-yellow-500 {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-gradient-to-br {
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
}
@@ -2755,6 +2729,10 @@ select {
padding: 0.5rem;
}
.p-3 {
padding: 0.75rem;
}
.p-4 {
padding: 1rem;
}
@@ -2907,6 +2885,11 @@ select {
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.text-gray-400 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity));
@@ -2981,6 +2964,14 @@ select {
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.opacity-0 {
opacity: 0;
}
.opacity-100 {
opacity: 1;
}
.shadow {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
@@ -3015,6 +3006,11 @@ select {
--tw-ring-color: rgb(79 70 229 / 0.2);
}
.ring-blue-500 {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
@@ -3025,6 +3021,14 @@ select {
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -3043,6 +3047,22 @@ select {
transition-duration: 150ms;
}
.duration-100 {
transition-duration: 100ms;
}
.duration-75 {
transition-duration: 75ms;
}
.ease-in {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
.ease-out {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
.dark\:prose-invert:is(.dark *) {
--tw-prose-body: var(--tw-prose-invert-body);
--tw-prose-headings: var(--tw-prose-invert-headings);
@@ -3084,6 +3104,11 @@ select {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.hover\:border-gray-300:hover {
--tw-border-opacity: 1;
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
@@ -3129,6 +3154,11 @@ select {
color: rgb(29 78 216 / var(--tw-text-opacity));
}
.hover\:text-gray-600:hover {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.hover\:text-primary:hover {
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
@@ -3175,6 +3205,11 @@ select {
--tw-ring-offset-width: 2px;
}
.dark\:border-blue-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(29 78 216 / var(--tw-border-opacity));
}
.dark\:border-gray-600:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
@@ -3189,6 +3224,11 @@ select {
border-color: rgb(55 65 81 / 0.5);
}
.dark\:border-red-700:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(185 28 28 / var(--tw-border-opacity));
}
.dark\:bg-blue-400\/30:is(.dark *) {
background-color: rgb(96 165 250 / 0.3);
}
@@ -3284,6 +3324,11 @@ select {
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
}
.dark\:text-blue-100:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(219 234 254 / var(--tw-text-opacity));
}
.dark\:text-blue-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
@@ -3299,6 +3344,11 @@ select {
color: rgb(239 246 255 / var(--tw-text-opacity));
}
.dark\:text-blue-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.dark\:text-gray-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
@@ -3319,6 +3369,11 @@ select {
color: rgb(187 247 208 / var(--tw-text-opacity));
}
.dark\:text-red-100:is(.dark *) {
--tw-text-opacity: 1;
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));

18
static/js/alerts.js Normal file
View File

@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', function() {
// Get all alert elements
const alerts = document.querySelectorAll('.alert');
// For each alert
alerts.forEach(alert => {
// After 5 seconds
setTimeout(() => {
// Add slideOut animation
alert.style.animation = 'slideOut 0.5s ease-out forwards';
// Remove the alert after animation completes
setTimeout(() => {
alert.remove();
}, 500);
}, 5000);
});
});

5
static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,262 +0,0 @@
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

@@ -0,0 +1,81 @@
function locationAutocomplete(field, filterParks = false) {
return {
query: '',
suggestions: [],
fetchSuggestions() {
let url;
const params = new URLSearchParams({
q: this.query,
filter_parks: filterParks
});
switch (field) {
case 'country':
url = '/parks/ajax/countries/';
break;
case 'region':
url = '/parks/ajax/regions/';
// Add country parameter if we're fetching regions
const countryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (countryInput && countryInput.value) {
params.append('country', countryInput.value);
}
break;
case 'city':
url = '/parks/ajax/cities/';
// Add country and region parameters if we're fetching cities
const regionInput = document.getElementById(filterParks ? 'region' : 'id_region_name');
const cityCountryInput = document.getElementById(filterParks ? 'country' : 'id_country_name');
if (regionInput && regionInput.value && cityCountryInput && cityCountryInput.value) {
params.append('country', cityCountryInput.value);
params.append('region', regionInput.value);
}
break;
}
if (url) {
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
this.suggestions = data;
});
}
},
selectSuggestion(suggestion) {
this.query = suggestion.name;
this.suggestions = [];
// If this is a form field (not filter), update hidden fields
if (!filterParks) {
const hiddenField = document.getElementById(`id_${field}`);
if (hiddenField) {
hiddenField.value = suggestion.id;
}
// Clear dependent fields when parent field changes
if (field === 'country') {
const regionInput = document.getElementById('id_region_name');
const cityInput = document.getElementById('id_city_name');
const regionHidden = document.getElementById('id_region');
const cityHidden = document.getElementById('id_city');
if (regionInput) regionInput.value = '';
if (cityInput) cityInput.value = '';
if (regionHidden) regionHidden.value = '';
if (cityHidden) cityHidden.value = '';
} else if (field === 'region') {
const cityInput = document.getElementById('id_city_name');
const cityHidden = document.getElementById('id_city');
if (cityInput) cityInput.value = '';
if (cityHidden) cityHidden.value = '';
}
}
// Trigger form submission for filters
if (filterParks) {
htmx.trigger('#park-filters', 'change');
}
}
};
}

View File

@@ -30,8 +30,15 @@
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<!-- Alpine.js -->
<script defer src="{% static 'js/alpine.min.js' %}"></script>
<!-- Location Autocomplete -->
<script src="{% static 'js/location-autocomplete.js' %}"></script>
<!-- Tailwind CSS -->
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
<link href="{% static 'css/alerts.css' %}" rel="stylesheet" />
<!-- Font Awesome -->
<link
@@ -39,6 +46,23 @@
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
/>
<style>
[x-cloak] {
display: none !important;
}
.dropdown-menu {
position: absolute;
right: 0;
margin-top: 0.5rem;
width: 12rem;
border-radius: 0.375rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
z-index: 50;
overflow: hidden;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body
@@ -46,7 +70,7 @@
>
<!-- 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"
class="sticky top-0 z-40 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">
@@ -107,32 +131,39 @@
<span>Moderation</span>
</a>
{% endif %}
<div class="relative" x-data="{ open: false }">
<button
@click="open = !open"
class="flex items-center space-x-2 transition-transform hover:scale-105"
<div
class="relative"
x-data="{ open: false }"
@click.outside="open = false"
>
<!-- Profile Picture Button -->
{% if user.profile.avatar %}
<img
@click="open = !open"
src="{{ user.profile.avatar.url }}"
alt="{{ user.username }}"
class="w-8 h-8 rounded-full ring-2 ring-primary/20"
class="w-8 h-8 transition-transform rounded-full cursor-pointer ring-2 ring-primary/20 hover:scale-105"
/>
{% else %}
<div
class="flex items-center justify-center w-8 h-8 text-white rounded-full bg-gradient-to-br from-primary to-secondary"
@click="open = !open"
class="flex items-center justify-center w-8 h-8 text-white transition-transform rounded-full cursor-pointer bg-gradient-to-br from-primary to-secondary hover:scale-105"
>
{{ user.username.0|upper }}
</div>
{% endif %}
<span>{{ user.username }}</span>
</button>
<!-- Dropdown Menu -->
<div
x-cloak
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"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="bg-white dropdown-menu dark:bg-gray-800"
>
<a href="{% url 'profile' user.username %}" class="menu-item">
<i class="w-5 fas fa-user"></i>
@@ -158,17 +189,41 @@
</div>
</div>
{% else %}
<!-- Login/Register (Desktop) -->
<div class="hidden space-x-3 lg:flex">
<a href="{% url 'account_login' %}" class="btn-secondary">
<i class="mr-2 fas fa-sign-in-alt"></i>
Login
<!-- Generic Profile Icon for Unauthenticated Users -->
<div
class="relative"
x-data="{ open: false }"
@click.outside="open = false"
>
<div
@click="open = !open"
class="flex items-center justify-center w-8 h-8 text-gray-500 transition-transform rounded-full cursor-pointer hover:text-primary dark:text-gray-400 dark:hover:text-primary hover:scale-105"
>
<i class="text-xl fas fa-user"></i>
</div>
<!-- Auth Menu -->
<div
x-cloak
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="bg-white dropdown-menu dark:bg-gray-800"
>
<a href="{% url 'account_login' %}" class="menu-item">
<i class="w-5 fas fa-sign-in-alt"></i>
<span>Login</span>
</a>
<a href="{% url 'account_signup' %}" class="btn-primary">
<i class="mr-2 fas fa-user-plus"></i>
Register
<a href="{% url 'account_signup' %}" class="menu-item">
<i class="w-5 fas fa-user-plus"></i>
<span>Register</span>
</a>
</div>
</div>
{% endif %}
<!-- Mobile Menu Button -->
@@ -194,26 +249,6 @@
class="form-input"
/>
</form>
{% 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"
>
<i class="fas fa-sign-in-alt"></i>
<span>Login</span>
</a>
<a
href="{% url 'account_signup' %}"
class="flex-1 mobile-nav-link primary"
>
<i class="fas fa-user-plus"></i>
<span>Register</span>
</a>
</div>
{% endif %}
</div>
</div>
</nav>
@@ -221,7 +256,7 @@
<!-- Flash Messages -->
{% if messages %}
<div class="container px-6 mx-auto mt-4">
<div class="fixed top-0 right-0 z-50 p-4 space-y-4">
{% for message in messages %}
<div
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
@@ -264,6 +299,7 @@
<!-- Custom JavaScript -->
<script src="{% static 'js/main.js' %}"></script>
<script src="{% static 'js/alerts.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -4,24 +4,31 @@
{% block title %}{{ company.name }} - ThrillWiki{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<div class="container px-4 mx-auto">
<!-- Company Header -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg 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 rounded-lg shadow dark:bg-gray-800">
<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">{{ company.name }}</h1>
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ company.name }}</h1>
{% if company.headquarters %}
<p class="text-gray-600 dark:text-gray-400">
<i class="fas fa-map-marker-alt mr-2"></i>{{ company.headquarters }}
<i class="mr-2 fas fa-map-marker-alt"></i>{{ company.headquarters }}
</p>
{% endif %}
</div>
<div class="flex gap-2 mt-4 md:mt-0">
{% if company.website %}
<a href="{{ company.website }}" target="_blank" rel="noopener noreferrer"
class="btn-secondary mt-4 md:mt-0">
<i class="fas fa-external-link-alt mr-2"></i>Visit Website
class="btn-secondary">
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
</a>
{% endif %}
{% if user.is_authenticated %}
<a href="{% url 'companies:company_edit' slug=company.slug %}" class="btn-secondary">
<i class="mr-2 fas fa-edit"></i>Edit
</a>
{% endif %}
</div>
</div>
{% if company.description %}
@@ -32,22 +39,22 @@
</div>
<!-- Company Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 text-center">
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
<div class="p-6 text-center bg-white rounded-lg shadow dark:bg-gray-800">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{{ parks.count }}
</div>
<div class="text-gray-600 dark:text-gray-400 mt-1">Theme Parks</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">Theme Parks</div>
</div>
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 text-center">
<div class="p-6 text-center bg-white rounded-lg shadow dark:bg-gray-800">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{{ parks|length }}
</div>
<div class="text-gray-600 dark:text-gray-400 mt-1">Active Parks</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">Active Parks</div>
</div>
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 text-center">
<div class="p-6 text-center bg-white rounded-lg shadow dark:bg-gray-800">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{% with total_rides=0 %}
{% for park in parks %}
@@ -56,42 +63,42 @@
{{ total_rides }}
{% endwith %}
</div>
<div class="text-gray-600 dark:text-gray-400 mt-1">Total Attractions</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">Total Attractions</div>
</div>
</div>
<!-- Parks List -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Theme Parks</h2>
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Theme Parks</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for park in parks %}
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden">
<div class="overflow-hidden rounded-lg bg-gray-50 dark:bg-gray-700">
{% if park.photos.exists %}
<img src="{{ park.photos.first.image.url }}"
alt="{{ park.name }}"
class="w-full h-48 object-cover">
class="object-cover w-full h-48">
{% else %}
<div class="w-full h-48 bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<div class="flex items-center justify-center w-full h-48 bg-gray-200 dark:bg-gray-600">
<span class="text-gray-400">No image available</span>
</div>
{% endif %}
<div class="p-4">
<h3 class="text-lg font-semibold mb-2">
<h3 class="mb-2 text-lg font-semibold">
<a href="{% url 'parks:park_detail' park.slug %}"
class="text-blue-600 dark:text-blue-400 hover:underline">
{{ park.name }}
</a>
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-2">{{ park.location }}</p>
<div class="flex justify-between items-center">
<p class="mb-2 text-gray-600 dark:text-gray-400">{{ park.location }}</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ park.rides.count }} attractions
</span>
{% if park.average_rating %}
<div class="flex items-center">
<span class="text-yellow-400 mr-1"></span>
<span class="mr-1 text-yellow-400"></span>
<span class="text-gray-600 dark:text-gray-400">
{{ park.average_rating|floatformat:1 }}/10
</span>
@@ -101,7 +108,7 @@
</div>
</div>
{% empty %}
<div class="col-span-full text-center py-8">
<div class="py-8 text-center col-span-full">
<p class="text-gray-500 dark:text-gray-400">No parks found for this company.</p>
</div>
{% endfor %}

View File

@@ -0,0 +1,128 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Company - 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">{% if is_edit %}Edit{% else %}Add{% endif %} Company</h1>
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Name field -->
<div>
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<div>
{{ form.name }}
</div>
{% if form.name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<!-- Headquarters field -->
<div x-data="locationAutocomplete('country', false)" class="relative">
<label for="{{ form.headquarters.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Headquarters
</label>
<input type="text"
id="{{ form.headquarters.id_for_label }}"
name="headquarters"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., Orlando, Florida, United States"
value="{{ form.headquarters.value|default:'' }}"
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- Website field -->
<div>
<label for="{{ form.website.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Website
</label>
<div>
{{ form.website }}
</div>
{% if form.website.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.website.errors }}
</div>
{% endif %}
</div>
<!-- Description field -->
<div>
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<div>
{{ form.description }}
</div>
{% if form.description.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.description.errors }}
</div>
{% endif %}
</div>
{% 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 {% if is_edit %}Edit{% else %}Addition{% endif %}
</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 {% if is_edit %}editing{% else %}adding{% endif %} this company 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="{% if is_edit %}{% url 'companies:company_detail' slug=object.slug %}{% else %}{% url 'companies:company_list' %}{% endif %}"
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">
{% if is_edit %}Save Changes{% else %}Submit{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -16,12 +16,19 @@
</p>
{% endif %}
</div>
<div class="flex gap-2 mt-4 md:mt-0">
{% if manufacturer.website %}
<a href="{{ manufacturer.website }}" target="_blank" rel="noopener noreferrer"
class="mt-4 btn-secondary md:mt-0">
class="btn-secondary">
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
</a>
{% endif %}
{% if user.is_authenticated %}
<a href="{% url 'companies:manufacturer_edit' slug=manufacturer.slug %}" class="btn-secondary">
<i class="mr-2 fas fa-edit"></i>Edit
</a>
{% endif %}
</div>
</div>
{% if manufacturer.description %}

View File

@@ -0,0 +1,128 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Manufacturer - 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">{% if is_edit %}Edit{% else %}Add{% endif %} Manufacturer</h1>
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Name field -->
<div>
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<div>
{{ form.name }}
</div>
{% if form.name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<!-- Headquarters field -->
<div x-data="locationAutocomplete('country', false)" class="relative">
<label for="{{ form.headquarters.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Headquarters
</label>
<input type="text"
id="{{ form.headquarters.id_for_label }}"
name="headquarters"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., Altoona, Pennsylvania, United States"
value="{{ form.headquarters.value|default:'' }}"
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- Website field -->
<div>
<label for="{{ form.website.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Website
</label>
<div>
{{ form.website }}
</div>
{% if form.website.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.website.errors }}
</div>
{% endif %}
</div>
<!-- Description field -->
<div>
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<div>
{{ form.description }}
</div>
{% if form.description.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.description.errors }}
</div>
{% endif %}
</div>
{% 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 {% if is_edit %}Edit{% else %}Addition{% endif %}
</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 {% if is_edit %}editing{% else %}adding{% endif %} this manufacturer 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="{% if is_edit %}{% url 'companies:manufacturer_detail' slug=object.slug %}{% else %}{% url 'companies:manufacturer_list' %}{% endif %}"
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">
{% if is_edit %}Save Changes{% else %}Submit{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,127 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Moderation - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 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">Moderation Queue</h1>
<!-- Tabs -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<ul class="flex flex-wrap -mb-px" role="tablist">
<li class="mr-2">
<button class="tab-button {% if active_tab == 'new' %}active{% endif %}"
data-tab="new"
hx-get="{% url 'moderation:edit_submissions' %}?tab=new"
hx-target="#submissions-content"
hx-push-url="true">
New
{% if new_count %}<span class="ml-2 badge">{{ new_count }}</span>{% endif %}
</button>
</li>
<li class="mr-2">
<button class="tab-button {% if active_tab == 'approved' %}active{% endif %}"
data-tab="approved"
hx-get="{% url 'moderation:edit_submissions' %}?tab=approved"
hx-target="#submissions-content"
hx-push-url="true">
Approved
</button>
</li>
<li class="mr-2">
<button class="tab-button {% if active_tab == 'rejected' %}active{% endif %}"
data-tab="rejected"
hx-get="{% url 'moderation:edit_submissions' %}?tab=rejected"
hx-target="#submissions-content"
hx-push-url="true">
Rejected
</button>
</li>
{% if user.role == 'ADMIN' or user.role == 'SUPERUSER' %}
<li>
<button class="tab-button {% if active_tab == 'escalated' %}active{% endif %}"
data-tab="escalated"
hx-get="{% url 'moderation:edit_submissions' %}?tab=escalated"
hx-target="#submissions-content"
hx-push-url="true">
Escalated
{% if escalated_count %}<span class="ml-2 badge">{{ escalated_count }}</span>{% endif %}
</button>
</li>
{% endif %}
</ul>
</div>
<!-- Tab Content -->
<div id="submissions-content">
{% include 'moderation/partials/submission_list.html' %}
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.tab-button {
@apply inline-flex items-center px-4 py-2 text-sm font-medium border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300;
}
.tab-button.active {
@apply text-blue-600 border-blue-600 dark:text-blue-500 dark:border-blue-500;
}
.badge {
@apply px-2 py-1 text-xs font-semibold text-white bg-blue-500 rounded-full;
}
.submission-card {
@apply p-4 mb-4 bg-white border rounded-lg shadow dark:bg-gray-700 dark:border-gray-600;
}
.submission-header {
@apply flex items-center justify-between mb-2;
}
.submission-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.submission-meta {
@apply text-sm text-gray-500 dark:text-gray-400;
}
.submission-changes {
@apply mt-4 space-y-2;
}
.change-item {
@apply flex items-start;
}
.change-label {
@apply w-32 font-medium text-gray-700 dark:text-gray-300;
}
.change-value {
@apply flex-1 text-gray-900 dark:text-white;
}
.action-buttons {
@apply flex gap-2 mt-4;
}
.btn-approve {
@apply px-4 py-2 text-white bg-green-500 rounded-lg hover:bg-green-600;
}
.btn-reject {
@apply px-4 py-2 text-white bg-red-500 rounded-lg hover:bg-red-600;
}
.btn-escalate {
@apply px-4 py-2 text-white bg-yellow-500 rounded-lg hover:bg-yellow-600;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% for submission in submissions %}
<div class="submission-card">
<div class="submission-header">
<div>
<h3 class="submission-title">
{{ submission.get_content_type_display }} -
{% if submission.submission_type == 'CREATE' %}New{% else %}Edit{% endif %}
</h3>
<div class="submission-meta">
Submitted by {{ submission.user.username }} on {{ submission.created_at|date:"M d, Y H:i" }}
</div>
</div>
{% if submission.status == 'APPROVED' %}
<span class="px-2 py-1 text-sm text-white bg-green-500 rounded-full">Approved</span>
{% elif submission.status == 'REJECTED' %}
<span class="px-2 py-1 text-sm text-white bg-red-500 rounded-full">Rejected</span>
{% elif submission.status == 'ESCALATED' %}
<span class="px-2 py-1 text-sm text-white bg-yellow-500 rounded-full">Escalated</span>
{% endif %}
</div>
{% if submission.reason %}
<div class="p-3 mt-2 rounded bg-gray-50 dark:bg-gray-600">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">Reason:</div>
<div class="text-gray-600 dark:text-gray-400">{{ submission.reason }}</div>
</div>
{% endif %}
{% if submission.source %}
<div class="p-3 mt-2 rounded bg-gray-50 dark:bg-gray-600">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">Source:</div>
<div class="text-gray-600 dark:text-gray-400">
<a href="{{ submission.source }}" target="_blank" class="text-blue-500 hover:underline">
{{ submission.source }}
</a>
</div>
</div>
{% endif %}
<div class="submission-changes">
{% for field, value in submission.changes.items %}
<div class="change-item">
<div class="change-label">{{ field|title }}:</div>
<div class="change-value">{{ value }}</div>
</div>
{% endfor %}
</div>
{% if submission.status == 'NEW' %}
<div class="action-buttons">
<button class="btn-approve"
hx-post="{% url 'moderation:approve_submission' submission.id %}"
hx-target="#submissions-content">
<i class="mr-2 fas fa-check"></i>Approve
</button>
<button class="btn-reject"
hx-post="{% url 'moderation:reject_submission' submission.id %}"
hx-target="#submissions-content">
<i class="mr-2 fas fa-times"></i>Reject
</button>
{% if user.role == 'MODERATOR' %}
<button class="btn-escalate"
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
hx-target="#submissions-content">
<i class="mr-2 fas fa-arrow-up"></i>Escalate
</button>
{% endif %}
</div>
{% elif submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
<div class="action-buttons">
<button class="btn-approve"
hx-post="{% url 'moderation:approve_submission' submission.id %}"
hx-target="#submissions-content">
<i class="mr-2 fas fa-check"></i>Approve
</button>
<button class="btn-reject"
hx-post="{% url 'moderation:reject_submission' submission.id %}"
hx-target="#submissions-content">
<i class="mr-2 fas fa-times"></i>Reject
</button>
</div>
{% endif %}
</div>
{% empty %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No submissions found in this category.
</div>
{% endfor %}

View File

@@ -3,19 +3,13 @@
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
{% endblock %}
{% block content %}
<div class="container px-4 mx-auto">
<!-- Park Header -->
<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="p-6 mb-6 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex flex-col items-start justify-between md:flex-row md:items-center">
<div>
<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>
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ park.name }}</h1>
<p class="text-gray-600 dark:text-gray-300">
<i class="mr-2 fas fa-map-marker-alt"></i>
<span>{{ park.get_formatted_location }}</span>
@@ -29,12 +23,9 @@
</a>
{% endif %}
{% if user.is_authenticated %}
<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 %}>
<a href="{% url 'parks:park_edit' slug=park.slug %}" class="btn-secondary">
<i class="mr-2 fas fa-edit"></i>Edit
</button>
</a>
{% endif %}
</div>
</div>
@@ -44,17 +35,7 @@
{% 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 %}"
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"}
]'>
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ park.get_status_display }}
</span>
{% if park.average_rating %}
@@ -97,11 +78,9 @@
<!-- Left Column - Description and Areas -->
<div class="lg:col-span-2">
{% if park.description %}
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800 editable-container">
<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">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">
<div class="text-gray-700 dark:text-gray-300">
{{ park.description|linebreaks }}
</div>
</div>
@@ -180,7 +159,7 @@
<!-- Right Column - Quick Facts and History -->
<div>
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800 editable-container">
<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">Quick Facts</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
{% if park.owner %}
@@ -212,9 +191,7 @@
<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="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="opening_date" data-field-type="date">
<dd class="font-medium text-gray-900 dark:text-white">
{{ park.opening_date }}
</dd>
</div>
@@ -225,9 +202,7 @@
<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="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="closing_date" data-field-type="date">
<dd class="font-medium text-gray-900 dark:text-white">
{{ park.closing_date }}
</dd>
</div>
@@ -238,9 +213,7 @@
<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="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="operating_season">
<dd class="font-medium text-gray-900 dark:text-white">
{{ park.operating_season }}
</dd>
</div>
@@ -251,9 +224,7 @@
<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="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ park.id }}"
data-field-name="size_acres" data-field-type="number">
<dd class="font-medium text-gray-900 dark:text-white">
{{ park.size_acres }} acres
</dd>
</div>
@@ -307,7 +278,3 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/inline-edit.js' %}"></script>
{% endblock %}

View File

@@ -1,15 +1,31 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Add Park - ThrillWiki{% endblock %}
{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} 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>
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Park</h1>
<form method="post" class="space-y-6">
{% if form.errors %}
<div class="p-4 mb-6 text-red-700 bg-red-100 border border-red-400 rounded-lg dark:bg-red-900 dark:text-red-100 dark:border-red-700">
<p class="font-medium">Please correct the following errors:</p>
<ul class="ml-4 list-disc">
{% for field in form %}
{% for error in field.errors %}
<li>{{ field.label }}: {{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" class="space-y-6" id="park-form">
{% csrf_token %}
<!-- Hidden fields -->
@@ -20,7 +36,7 @@
<!-- Name field -->
<div>
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Name
Name *
</label>
<div>
{{ form.name }}
@@ -33,46 +49,88 @@
</div>
<!-- Location fields -->
<div>
<div x-data="locationAutocomplete('country', false)" class="relative">
<label for="{{ form.country_name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Country
Country *
</label>
<div>
{{ form.country_name }}
</div>
{% if form.country_name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.country_name.errors }}
</div>
{% endif %}
<input type="text"
id="id_country_name"
name="country_name"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select country..."
value="{{ form.country_name.value|default:'' }}"
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion.id">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion.name"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<div>
<div x-data="locationAutocomplete('region', false)" class="relative">
<label for="{{ form.region_name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
Region/State
</label>
<div>
{{ form.region_name }}
</div>
{% if form.region_name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.region_name.errors }}
</div>
{% endif %}
<input type="text"
id="id_region_name"
name="region_name"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select region/state..."
value="{{ form.region_name.value|default:'' }}"
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion.id">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion.name"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<div>
<div x-data="locationAutocomplete('city', false)" class="relative">
<label for="{{ form.city_name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<div>
{{ form.city_name }}
</div>
{% if form.city_name.errors %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form.city_name.errors }}
</div>
{% endif %}
<input type="text"
id="id_city_name"
name="city_name"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select city..."
value="{{ form.city_name.value|default:'' }}"
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion.id">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion.name"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- Other fields -->
@@ -80,7 +138,7 @@
{% if field.name not in 'name,country,region,city,country_name,region_name,city_name' %}
<div>
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ field.label }}
{{ field.label }}{% if field.field.required %} *{% endif %}
</label>
<div>
{{ field }}
@@ -98,17 +156,20 @@
{% endfor %}
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
<div class="p-4 mb-4 text-blue-700 bg-blue-100 border border-blue-400 rounded-lg dark:bg-blue-900 dark:text-blue-100 dark:border-blue-700">
<p>Your submission will be reviewed by a moderator before being published.</p>
</div>
<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
Reason for {% if is_edit %}Edit{% else %}Addition{% endif %} *
</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>
placeholder="Please explain why you're {% if is_edit %}editing{% else %}adding{% endif %} 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">
@@ -124,11 +185,12 @@
{% 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">
<a href="{% if is_edit %}{% url 'parks:park_detail' slug=object.slug %}{% else %}{% url 'parks:park_list' %}{% endif %}"
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
{% if is_edit %}Save Changes{% else %}Submit{% endif %}
</button>
</div>
</form>
@@ -136,100 +198,3 @@
</div>
</div>
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" />
<style>
.awesomplete {
width: 100%;
}
.awesomplete > ul {
@apply bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg;
}
.awesomplete > ul > li {
@apply px-4 py-2 cursor-pointer text-gray-700 dark:text-gray-300;
}
.awesomplete > ul > li:hover,
.awesomplete > ul > li[aria-selected="true"] {
@apply bg-gray-100 dark:bg-gray-600;
}
.awesomplete mark {
@apply bg-blue-100 dark:bg-blue-900;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Helper function to initialize Awesomplete
function initAwesomplete(input, url, params = {}) {
if (!input) return null;
var awesomplete = new Awesomplete(input, {
minChars: 1,
maxItems: 10,
autoFirst: true
});
input.addEventListener('input', function() {
// Build query parameters
const queryParams = new URLSearchParams({
q: this.value,
...Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, typeof value === 'function' ? value() : value])
)
});
fetch(`${url}?${queryParams}`)
.then(response => response.json())
.then(data => {
awesomplete.list = data;
});
});
return awesomplete;
}
// Initialize Awesomplete for each location field
var countryInput = document.getElementById('id_country_name');
var regionInput = document.getElementById('id_region_name');
var cityInput = document.getElementById('id_city_name');
var countryHidden = document.getElementById('id_country');
var regionHidden = document.getElementById('id_region');
var cityHidden = document.getElementById('id_city');
var countryAwesomplete = initAwesomplete(countryInput, '/parks/ajax/countries/');
if (regionInput) {
var regionAwesomplete = initAwesomplete(regionInput, '/parks/ajax/regions/', {
country: () => countryInput ? countryInput.value : ''
});
// Clear dependent fields when country changes
countryInput.addEventListener('awesomplete-select', function(event) {
regionInput.value = '';
regionHidden.value = '';
if (cityInput) {
cityInput.value = '';
cityHidden.value = '';
}
});
}
if (cityInput) {
var cityAwesomplete = initAwesomplete(cityInput, '/parks/ajax/cities/', {
country: () => countryInput ? countryInput.value : '',
region: () => regionInput ? regionInput.value : ''
});
// Clear city when region changes
regionInput.addEventListener('awesomplete-select', function(event) {
cityInput.value = '';
cityHidden.value = '';
});
}
});
</script>
{% endblock %}

View File

@@ -16,9 +16,9 @@
<!-- Filters -->
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form id="park-filters" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5"
<form id="park-filters" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
hx-get="{% url 'parks:park_list' %}"
hx-trigger="change from:select, input from:input[type='text'] delay:500ms"
hx-trigger="change from:select, input from:input[type='text'] delay:500ms, click from:.status-filter"
hx-target="#parks-grid"
hx-push-url="true">
<div>
@@ -28,41 +28,134 @@
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>
<!-- Country Field -->
<div x-data="locationAutocomplete('country', true)" class="relative">
<label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text" name="country" id="country"
<input type="text"
name="country"
id="country"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select country..."
value="{{ current_filters.country }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select country...">
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion.id">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion.name"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<div>
<!-- Region Field -->
<div x-data="locationAutocomplete('region', true)" class="relative">
<label for="region" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">State/Region</label>
<input type="text" name="region" id="region"
<input type="text"
name="region"
id="region"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select state/region..."
value="{{ current_filters.region }}"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select state/region...">
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion.id">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion.name"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<div>
<!-- City Field -->
<div x-data="locationAutocomplete('city', true)" class="relative">
<label for="city" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<input type="text" name="city" id="city"
value="{{ current_filters.city }}"
<input type="text"
name="city"
id="city"
x-model="query"
@input.debounce.300ms="fetchSuggestions()"
@focus="fetchSuggestions()"
@click.away="suggestions = []"
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Select city...">
</div>
<div>
<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>
placeholder="Select city..."
value="{{ current_filters.city }}"
autocomplete="off">
<!-- Suggestions Dropdown -->
<ul x-show="suggestions.length > 0"
x-cloak
class="absolute z-50 w-full py-1 mt-1 overflow-auto bg-white rounded-md shadow-lg dark:bg-gray-700 max-h-60">
<template x-for="suggestion in suggestions" :key="suggestion.id">
<li @click="selectSuggestion(suggestion)"
x-text="suggestion.name"
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
</li>
</template>
</ul>
</div>
<!-- Hidden inputs for selected statuses -->
{% for status in current_filters.statuses %}
<input type="hidden" name="status" value="{{ status }}">
{% endfor %}
</form>
<!-- Status Filter Icons -->
<div class="flex flex-wrap gap-2 mt-4">
<label class="block w-full mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status Filter</label>
<button type="button"
class="status-filter status-badge status-operating {% if 'OPERATING' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="OPERATING"
onclick="toggleStatus(this, 'OPERATING')">
<i class="mr-1 fas fa-check-circle"></i>Operating
</button>
<button type="button"
class="status-filter status-badge status-closed {% if 'CLOSED_TEMP' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="CLOSED_TEMP"
onclick="toggleStatus(this, 'CLOSED_TEMP')">
<i class="mr-1 fas fa-clock"></i>Temporarily Closed
</button>
<button type="button"
class="status-filter status-badge status-closed {% if 'CLOSED_PERM' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="CLOSED_PERM"
onclick="toggleStatus(this, 'CLOSED_PERM')">
<i class="mr-1 fas fa-times-circle"></i>Permanently Closed
</button>
<button type="button"
class="status-filter status-badge status-construction {% if 'UNDER_CONSTRUCTION' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="UNDER_CONSTRUCTION"
onclick="toggleStatus(this, 'UNDER_CONSTRUCTION')">
<i class="mr-1 fas fa-hard-hat"></i>Under Construction
</button>
<button type="button"
class="status-filter status-badge status-demolished {% if 'DEMOLISHED' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="DEMOLISHED"
onclick="toggleStatus(this, 'DEMOLISHED')">
<i class="mr-1 fas fa-ban"></i>Demolished
</button>
<button type="button"
class="status-filter status-badge status-relocated {% if 'RELOCATED' in current_filters.statuses %}ring-2 ring-blue-500{% endif %}"
data-status="RELOCATED"
onclick="toggleStatus(this, 'RELOCATED')">
<i class="mr-1 fas fa-truck-moving"></i>Relocated
</button>
</div>
</div>
<!-- Parks Grid -->
@@ -70,107 +163,28 @@
{% include "parks/partials/park_list.html" %}
</div>
</div>
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.css" />
<style>
.awesomplete {
width: 100%;
}
.awesomplete > ul {
@apply bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg;
}
.awesomplete > ul > li {
@apply px-4 py-2 cursor-pointer text-gray-700 dark:text-gray-300;
}
.awesomplete > ul > li:hover,
.awesomplete > ul > li[aria-selected="true"] {
@apply bg-gray-100 dark:bg-gray-600;
}
.awesomplete mark {
@apply bg-blue-100 dark:bg-blue-900;
}
</style>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const countryInput = document.getElementById('country');
const regionInput = document.getElementById('region');
const cityInput = document.getElementById('city');
function toggleStatus(button, status) {
const form = document.getElementById('park-filters');
const existingInputs = form.querySelectorAll(`input[name="status"][value="${status}"]`);
// Initialize Awesomplete for country
if (countryInput) {
const countryList = new Awesomplete(countryInput, {
minChars: 1,
maxItems: 10,
autoFirst: true
});
countryInput.addEventListener('input', function() {
fetch(`/parks/ajax/countries/?q=${encodeURIComponent(this.value)}`)
.then(response => response.json())
.then(data => {
countryList.list = data;
});
});
if (existingInputs.length > 0) {
// Status is already selected, remove it
existingInputs.forEach(input => input.remove());
button.classList.remove('ring-2', 'ring-blue-500');
} else {
// Status is not selected, add it
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'status';
input.value = status;
form.appendChild(input);
button.classList.add('ring-2', 'ring-blue-500');
}
// Initialize Awesomplete for region
if (regionInput) {
const regionList = new Awesomplete(regionInput, {
minChars: 1,
maxItems: 10,
autoFirst: true
});
regionInput.addEventListener('input', function() {
const country = countryInput.value;
fetch(`/parks/ajax/regions/?q=${encodeURIComponent(this.value)}&country=${encodeURIComponent(country)}`)
.then(response => response.json())
.then(data => {
regionList.list = data;
});
});
}
// Initialize Awesomplete for city
if (cityInput) {
const cityList = new Awesomplete(cityInput, {
minChars: 1,
maxItems: 10,
autoFirst: true
});
cityInput.addEventListener('input', function() {
const country = countryInput.value;
const region = regionInput.value;
fetch(`/parks/ajax/cities/?q=${encodeURIComponent(this.value)}&country=${encodeURIComponent(country)}&region=${encodeURIComponent(region)}`)
.then(response => response.json())
.then(data => {
cityList.list = data;
});
});
}
// Handle location link clicks
document.querySelectorAll('.location-link').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const params = new URLSearchParams(this.getAttribute('href').split('?')[1]);
// Update form inputs
countryInput.value = params.get('country') || '';
regionInput.value = params.get('region') || '';
cityInput.value = params.get('city') || '';
// Trigger form submission
htmx.trigger('#park-filters', 'change');
});
});
});
form.dispatchEvent(new Event('change'));
}
</script>
{% endblock %}

View File

@@ -3,19 +3,13 @@
{% 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 px-4 mx-auto">
<!-- Header -->
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-start justify-between">
<div>
<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>
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ 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 }}
@@ -29,30 +23,10 @@
{% 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"}
]'>
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
{{ ride.get_status_display }}
</span>
<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"}
]'>
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
@@ -65,13 +39,10 @@
</div>
{% if user.is_authenticated %}
<div class="flex gap-2">
<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 %}>
<a href="{% url 'parks:rides:ride_edit' park_slug=ride.park.slug ride_slug=ride.slug %}" class="btn-secondary">
<i class="mr-2 fas fa-edit"></i>
Edit
</button>
</a>
</div>
{% endif %}
</div>
@@ -97,11 +68,9 @@
<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="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<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">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">
<div class="prose dark:prose-invert max-w-none">
{{ ride.description|linebreaks }}
</div>
</div>
@@ -121,15 +90,13 @@
{% endif %}
{% if coaster_stats %}
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<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">Roller Coaster Statistics</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
{% if coaster_stats.height_ft %}
<div>
<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">
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.height_ft }} ft
</span>
</div>
@@ -137,9 +104,7 @@
{% if coaster_stats.length_ft %}
<div>
<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">
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.length_ft }} ft
</span>
</div>
@@ -147,27 +112,21 @@
{% if coaster_stats.speed_mph %}
<div>
<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">
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.speed_mph }} mph
</span>
</div>
{% endif %}
<div>
<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">
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.inversions }}
</span>
</div>
{% if coaster_stats.ride_time_seconds %}
<div>
<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">
<span class="text-2xl font-bold text-gray-900 dark:text-white">
{{ coaster_stats.ride_time_seconds }} sec
</span>
</div>
@@ -179,29 +138,23 @@
<!-- Right Column - Quick Facts -->
<div class="lg:col-span-1">
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
<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">Quick Facts</h2>
<dl class="space-y-4">
<div>
<dt class="text-gray-500">Manufacturer</dt>
<dd class="font-medium text-gray-900 dark:text-white"
data-editable data-content-id="{{ ride.id }}"
data-field-name="manufacturer">{{ ride.manufacturer }}</dd>
<dd class="font-medium text-gray-900 dark:text-white">{{ ride.manufacturer }}</dd>
</div>
{% if ride.model_name %}
<div>
<dt class="text-gray-500">Model</dt>
<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>
<dd class="font-medium text-gray-900 dark:text-white">{{ ride.model_name }}</dd>
</div>
{% endif %}
{% if ride.opening_date %}
<div>
<dt class="text-gray-500">Opening Date</dt>
<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">
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.opening_date }}
</dd>
</div>
@@ -209,9 +162,7 @@
{% if ride.status_since %}
<div>
<dt class="text-gray-500">Status Since</dt>
<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">
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.status_since }}
</dd>
</div>
@@ -219,9 +170,7 @@
{% if ride.closing_date %}
<div>
<dt class="text-gray-500">Closing Date</dt>
<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">
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.closing_date }}
</dd>
</div>
@@ -229,9 +178,7 @@
{% if ride.capacity_per_hour %}
<div>
<dt class="text-gray-500">Capacity</dt>
<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">
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.capacity_per_hour }} riders/hour
</dd>
</div>
@@ -239,9 +186,7 @@
{% if ride.min_height_in %}
<div>
<dt class="text-gray-500">Minimum Height</dt>
<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">
<dd class="font-medium text-gray-900 dark:text-white">
{{ ride.min_height_in }} inches
</dd>
</div>
@@ -332,7 +277,3 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/inline-edit.js' %}"></script>
{% endblock %}

View File

@@ -1,14 +1,14 @@
{% extends 'base/base.html' %}
{% load static %}
{% block title %}Add Ride at {{ park.name }} - ThrillWiki{% endblock %}
{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }} - 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">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Add Ride at {{ park.name }}</h1>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }}</h1>
<a href="{% url 'parks:rides:ride_list' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
Back to {{ park.name }} Rides
</a>
@@ -62,14 +62,14 @@
<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
Reason for {% if is_edit %}Edit{% else %}Addition{% endif %}
</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>
placeholder="Please explain why you're {% if is_edit %}editing{% else %}adding{% endif %} 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">
@@ -85,11 +85,12 @@
{% endif %}
<div class="flex justify-end space-x-4">
<a href="{% url 'parks:rides:ride_list' park.slug %}" 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">
<a href="{% if is_edit %}{% url 'parks:rides:ride_detail' park.slug object.slug %}{% else %}{% url 'parks:rides:ride_list' park.slug %}{% endif %}"
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
{% if is_edit %}Save Changes{% else %}Submit{% endif %}
</button>
</div>
</form>

View File

@@ -7,197 +7,204 @@ import os
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^'
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
CSRF_TRUSTED_ORIGINS = ['https://beta.thrillwiki.com']
ALLOWED_HOSTS = ['*']
#ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'thrillwiki.com', 'beta.thrillwiki.com', '192.168.86.6', 'syn.thewesker.com']
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
ALLOWED_HOSTS = ["*"]
# ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'thrillwiki.com', 'beta.thrillwiki.com', '192.168.86.6', 'syn.thewesker.com']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
# Third-party apps
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.google',
'allauth.socialaccount.providers.discord',
'simple_history',
'django_cleanup',
'django_filters',
'django_htmx',
'whitenoise',
'django_tailwind_cli',
'cities_light',
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.discord",
"simple_history",
"django_cleanup",
"django_filters",
"django_htmx",
"whitenoise",
"django_tailwind_cli",
"cities_light",
# Local apps
'core',
'accounts',
'companies',
'parks',
'rides',
'reviews',
'email_service',
'media',
'moderation',
"core",
"accounts",
"companies",
"parks",
"rides",
"reviews",
"email_service",
"media",
"moderation",
]
# Cities Light settings
CITIES_LIGHT_TRANSLATION_LANGUAGES = ['en']
CITIES_LIGHT_INCLUDE_COUNTRIES = ['US', 'CA', 'GB', 'FR', 'DE', 'ES', 'IT', 'JP', 'CN', 'AU']
CITIES_LIGHT_INCLUDE_CITY_TYPES = ['PPL', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4', 'PPLC', 'PPLG', 'PPLL', 'PPLR', 'PPLS']
MIDDLEWARE = [
'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
'django.middleware.cache.FetchFromCacheMiddleware',
'simple_history.middleware.HistoryRequestMiddleware',
'django_htmx.middleware.HtmxMiddleware',
CITIES_LIGHT_TRANSLATION_LANGUAGES = ["en"]
# pytCITIES_LIGHT_INCLUDE_COUNTRIES = ['US', 'CA', 'GB', 'FR', 'DE', 'ES', 'IT', 'JP', 'CN', 'AU']
CITIES_LIGHT_INCLUDE_CITY_TYPES = [
"PPL",
"PPLA",
"PPLA2",
"PPLA3",
"PPLA4",
"PPLC",
"PPLG",
"PPLL",
"PPLR",
"PPLS",
]
ROOT_URLCONF = 'thrillwiki.urls'
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"simple_history.middleware.HistoryRequestMiddleware",
"django_htmx.middleware.HtmxMiddleware",
]
ROOT_URLCONF = "thrillwiki.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'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
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"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
],
},
},
]
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
WSGI_APPLICATION = "thrillwiki.wsgi.application"
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
'USER': 'wiki',
'PASSWORD': 'thrillwiki',
'HOST': '192.168.86.3',
'PORT': '5432',
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "thrillwiki",
"USER": "wiki",
"PASSWORD": "thrillwiki",
"HOST": "192.168.86.3",
"PORT": "5432",
}
}
# Cache settings
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'unique-snowflake',
'TIMEOUT': 300, # 5 minutes
'OPTIONS': {
'MAX_ENTRIES': 1000
}
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
"TIMEOUT": 300, # 5 minutes
"OPTIONS": {"MAX_ENTRIES": 1000},
}
}
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
CACHE_MIDDLEWARE_KEY_PREFIX = 'thrillwiki'
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'America/New_York'
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Authentication settings
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# django-allauth settings
SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = True
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_EMAIL_VERIFICATION = 'optional'
LOGIN_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_VERIFICATION = "optional"
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
# Custom adapters
ACCOUNT_ADAPTER = 'accounts.adapters.CustomAccountAdapter'
SOCIALACCOUNT_ADAPTER = 'accounts.adapters.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = "accounts.adapters.CustomAccountAdapter"
SOCIALACCOUNT_ADAPTER = "accounts.adapters.CustomSocialAccountAdapter"
# Social account settings
SOCIALACCOUNT_PROVIDERS = {
'google': {
'APP': {
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
'secret': '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',
'key': ''
"google": {
"APP": {
"client_id": "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com",
"[SECRET-REMOVED]",
"key": "",
},
'SCOPE': [
'profile',
'email',
"SCOPE": [
"profile",
"email",
],
'AUTH_PARAMS': {'access_type': 'online'},
"AUTH_PARAMS": {"access_type": "online"},
},
'discord': {
'APP': {
'client_id': '1299112802274902047',
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
'key': ''
"discord": {
"APP": {
"client_id": "1299112802274902047",
"[SECRET-REMOVED]",
"key": "",
},
"SCOPE": ["identify", "email"],
"OAUTH_PKCE_ENABLED": True,
},
'SCOPE': ['identify', 'email'],
'OAUTH_PKCE_ENABLED': True,
}
}
# Additional social account settings
@@ -206,11 +213,11 @@ SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_STORE_TOKENS = True
# Email settings
EMAIL_BACKEND = 'email_service.backends.ForwardEmailBackend'
FORWARD_EMAIL_BASE_URL = 'https://api.forwardemail.net'
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
# Custom User Model
AUTH_USER_MODEL = 'accounts.User'
AUTH_USER_MODEL = "accounts.User"
# Tailwind configuration
TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js")
@@ -218,6 +225,6 @@ TAILWIND_CLI_SRC_CSS = os.path.join(BASE_DIR, "static/css/src/input.css")
TAILWIND_CLI_DIST_CSS = os.path.join(BASE_DIR, "static/css/tailwind.css")
# Cloudflare Turnstile settings
TURNSTILE_SITE_KEY = '0x4AAAAAAAyqVp3RjccrC9Kz'
TURNSTILE_SECRET_KEY = '0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY'
TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"