mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 13:51:09 -05:00
here we go
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
46
companies/forms.py
Normal file
46
companies/forms.py
Normal 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'
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@ app_name = 'companies'
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Company URLs
|
# Company URLs
|
||||||
path('', views.CompanyListView.as_view(), name='company_list'),
|
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'),
|
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
|
||||||
|
|
||||||
# Manufacturer URLs
|
# Manufacturer URLs
|
||||||
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
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'),
|
path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.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 .models import Company, Manufacturer
|
||||||
|
from .forms import CompanyForm, ManufacturerForm
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from core.views import SlugRedirectMixin
|
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
|
model = Company
|
||||||
template_name = 'companies/company_detail.html'
|
template_name = 'companies/company_detail.html'
|
||||||
context_object_name = 'company'
|
context_object_name = 'company'
|
||||||
@@ -27,7 +181,7 @@ class CompanyDetailView(SlugRedirectMixin, DetailView):
|
|||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self):
|
||||||
return 'company_detail'
|
return 'company_detail'
|
||||||
|
|
||||||
class ManufacturerDetailView(SlugRedirectMixin, DetailView):
|
class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
|
||||||
model = Manufacturer
|
model = Manufacturer
|
||||||
template_name = 'companies/manufacturer_detail.html'
|
template_name = 'companies/manufacturer_detail.html'
|
||||||
context_object_name = 'manufacturer'
|
context_object_name = 'manufacturer'
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ class ModerationAdminSite(AdminSite):
|
|||||||
moderation_site = ModerationAdminSite(name='moderation')
|
moderation_site = ModerationAdminSite(name='moderation')
|
||||||
|
|
||||||
class EditSubmissionAdmin(admin.ModelAdmin):
|
class EditSubmissionAdmin(admin.ModelAdmin):
|
||||||
list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'submitted_at', 'reviewed_by']
|
list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'created_at', 'handled_by']
|
||||||
list_filter = ['status', 'content_type', 'submitted_at']
|
list_filter = ['status', 'content_type', 'created_at']
|
||||||
search_fields = ['user__username', 'reason', 'source', 'review_notes']
|
search_fields = ['user__username', 'reason', 'source', 'notes']
|
||||||
readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'submitted_at']
|
readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'created_at']
|
||||||
|
|
||||||
def user_link(self, obj):
|
def user_link(self, obj):
|
||||||
url = reverse('admin:accounts_user_change', args=[obj.user.id])
|
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):
|
def save_model(self, request, obj, form, change):
|
||||||
if 'status' in form.changed_data:
|
if 'status' in form.changed_data:
|
||||||
if obj.status == 'APPROVED':
|
if obj.status == 'APPROVED':
|
||||||
obj.approve(request.user, obj.review_notes)
|
obj.approve(request.user)
|
||||||
elif obj.status == 'REJECTED':
|
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)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
||||||
list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'submitted_at', 'reviewed_by']
|
list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'created_at', 'handled_by']
|
||||||
list_filter = ['status', 'content_type', 'submitted_at']
|
list_filter = ['status', 'content_type', 'created_at']
|
||||||
search_fields = ['user__username', 'caption', 'review_notes']
|
search_fields = ['user__username', 'caption', 'notes']
|
||||||
readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'submitted_at']
|
readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'created_at']
|
||||||
|
|
||||||
def user_link(self, obj):
|
def user_link(self, obj):
|
||||||
url = reverse('admin:accounts_user_change', args=[obj.user.id])
|
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):
|
def save_model(self, request, obj, form, change):
|
||||||
if 'status' in form.changed_data:
|
if 'status' in form.changed_data:
|
||||||
if obj.status == 'APPROVED':
|
if obj.status == 'APPROVED':
|
||||||
obj.approve(request.user, obj.review_notes)
|
obj.approve(request.user, obj.notes)
|
||||||
elif obj.status == 'REJECTED':
|
elif obj.status == 'REJECTED':
|
||||||
obj.reject(request.user, obj.review_notes)
|
obj.reject(request.user, obj.notes)
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
# Register with moderation site only
|
# Register with moderation site only
|
||||||
|
|||||||
107
moderation/migrations/0003_rename_fields_and_update_status.py
Normal file
107
moderation/migrations/0003_rename_fields_and_update_status.py
Normal 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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -192,8 +192,8 @@ class InlineEditMixin:
|
|||||||
context['pending_edits'] = EditSubmission.objects.filter(
|
context['pending_edits'] = EditSubmission.objects.filter(
|
||||||
content_type=ContentType.objects.get_for_model(obj),
|
content_type=ContentType.objects.get_for_model(obj),
|
||||||
object_id=obj.id,
|
object_id=obj.id,
|
||||||
status='PENDING'
|
status='NEW'
|
||||||
).select_related('user').order_by('-submitted_at')
|
).select_related('user').order_by('-created_at')
|
||||||
return context
|
return context
|
||||||
|
|
||||||
class HistoryMixin:
|
class HistoryMixin:
|
||||||
@@ -211,7 +211,7 @@ class HistoryMixin:
|
|||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
object_id=obj.id
|
object_id=obj.id
|
||||||
).exclude(
|
).exclude(
|
||||||
status='PENDING'
|
status='NEW'
|
||||||
).select_related('user', 'reviewed_by').order_by('-submitted_at')
|
).select_related('user', 'handled_by').order_by('-created_at')
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from django.apps import apps
|
|||||||
|
|
||||||
class EditSubmission(models.Model):
|
class EditSubmission(models.Model):
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('PENDING', 'Pending'),
|
('NEW', 'New'),
|
||||||
('APPROVED', 'Approved'),
|
('APPROVED', 'Approved'),
|
||||||
('REJECTED', 'Rejected'),
|
('REJECTED', 'Rejected'),
|
||||||
('AUTO_APPROVED', 'Auto Approved'),
|
('ESCALATED', 'Escalated'),
|
||||||
]
|
]
|
||||||
|
|
||||||
SUBMISSION_TYPE_CHOICES = [
|
SUBMISSION_TYPE_CHOICES = [
|
||||||
@@ -53,26 +53,26 @@ class EditSubmission(models.Model):
|
|||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=STATUS_CHOICES,
|
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
|
# Review details
|
||||||
reviewed_by = models.ForeignKey(
|
handled_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='reviewed_submissions'
|
related_name='handled_submissions'
|
||||||
)
|
)
|
||||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
handled_at = models.DateTimeField(null=True, blank=True)
|
||||||
review_notes = models.TextField(
|
notes = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text='Notes from the moderator about this submission'
|
help_text='Notes from the moderator about this submission'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-submitted_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
@@ -96,12 +96,11 @@ class EditSubmission(models.Model):
|
|||||||
|
|
||||||
return resolved_data
|
return resolved_data
|
||||||
|
|
||||||
def approve(self, moderator, notes=''):
|
def approve(self, user):
|
||||||
"""Approve the submission and apply the changes"""
|
"""Approve the submission and apply the changes"""
|
||||||
self.status = 'APPROVED'
|
self.status = 'APPROVED'
|
||||||
self.reviewed_by = moderator
|
self.handled_by = user
|
||||||
self.reviewed_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.review_notes = notes
|
|
||||||
|
|
||||||
model_class = self.content_type.model_class()
|
model_class = self.content_type.model_class()
|
||||||
resolved_data = self._resolve_foreign_keys(self.changes)
|
resolved_data = self._resolve_foreign_keys(self.changes)
|
||||||
@@ -122,42 +121,23 @@ class EditSubmission(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def reject(self, moderator, notes):
|
def reject(self, user):
|
||||||
"""Reject the submission"""
|
"""Reject the submission"""
|
||||||
self.status = 'REJECTED'
|
self.status = 'REJECTED'
|
||||||
self.reviewed_by = moderator
|
self.handled_by = user
|
||||||
self.reviewed_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.review_notes = notes
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def auto_approve(self):
|
def escalate(self, user):
|
||||||
"""Auto-approve the submission (for moderators/admins)"""
|
"""Escalate the submission to admin"""
|
||||||
self.status = 'AUTO_APPROVED'
|
self.status = 'ESCALATED'
|
||||||
self.reviewed_by = self.user
|
self.handled_by = user
|
||||||
self.reviewed_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
|
|
||||||
model_class = self.content_type.model_class()
|
|
||||||
resolved_data = self._resolve_foreign_keys(self.changes)
|
|
||||||
|
|
||||||
if self.submission_type == 'CREATE':
|
|
||||||
# Create new object
|
|
||||||
obj = model_class(**resolved_data)
|
|
||||||
obj.save()
|
|
||||||
# Update object_id after creation
|
|
||||||
self.object_id = obj.id
|
|
||||||
else:
|
|
||||||
# Apply changes to existing object
|
|
||||||
obj = self.content_object
|
|
||||||
for field, value in resolved_data.items():
|
|
||||||
setattr(obj, field, value)
|
|
||||||
obj.save()
|
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
return obj
|
|
||||||
|
|
||||||
class PhotoSubmission(models.Model):
|
class PhotoSubmission(models.Model):
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('PENDING', 'Pending'),
|
('NEW', 'New'),
|
||||||
('APPROVED', 'Approved'),
|
('APPROVED', 'Approved'),
|
||||||
('REJECTED', 'Rejected'),
|
('REJECTED', 'Rejected'),
|
||||||
('AUTO_APPROVED', 'Auto Approved'),
|
('AUTO_APPROVED', 'Auto Approved'),
|
||||||
@@ -184,26 +164,26 @@ class PhotoSubmission(models.Model):
|
|||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=STATUS_CHOICES,
|
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
|
# Review details
|
||||||
reviewed_by = models.ForeignKey(
|
handled_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='reviewed_photos'
|
related_name='handled_photos'
|
||||||
)
|
)
|
||||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
handled_at = models.DateTimeField(null=True, blank=True)
|
||||||
review_notes = models.TextField(
|
notes = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text='Notes from the moderator about this photo submission'
|
help_text='Notes from the moderator about this photo submission'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-submitted_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
@@ -217,9 +197,9 @@ class PhotoSubmission(models.Model):
|
|||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
|
|
||||||
self.status = 'APPROVED'
|
self.status = 'APPROVED'
|
||||||
self.reviewed_by = moderator
|
self.handled_by = moderator
|
||||||
self.reviewed_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.review_notes = notes
|
self.notes = notes
|
||||||
|
|
||||||
# Create the approved photo
|
# Create the approved photo
|
||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
@@ -236,9 +216,9 @@ class PhotoSubmission(models.Model):
|
|||||||
def reject(self, moderator, notes):
|
def reject(self, moderator, notes):
|
||||||
"""Reject the photo submission"""
|
"""Reject the photo submission"""
|
||||||
self.status = 'REJECTED'
|
self.status = 'REJECTED'
|
||||||
self.reviewed_by = moderator
|
self.handled_by = moderator
|
||||||
self.reviewed_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.review_notes = notes
|
self.notes = notes
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def auto_approve(self):
|
def auto_approve(self):
|
||||||
@@ -246,8 +226,8 @@ class PhotoSubmission(models.Model):
|
|||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
|
|
||||||
self.status = 'AUTO_APPROVED'
|
self.status = 'AUTO_APPROVED'
|
||||||
self.reviewed_by = self.user
|
self.handled_by = self.user
|
||||||
self.reviewed_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
|
|
||||||
# Create the approved photo
|
# Create the approved photo
|
||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path
|
||||||
from .admin import moderation_site
|
from . import views
|
||||||
from .views import EditSubmissionListView, PhotoSubmissionListView
|
|
||||||
|
|
||||||
app_name = 'moderation'
|
app_name = 'moderation'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Custom moderation views
|
path('submissions/', views.EditSubmissionListView.as_view(), name='edit_submissions'),
|
||||||
path('submissions/', include([
|
path('submissions/<int:submission_id>/approve/', views.approve_submission, name='approve_submission'),
|
||||||
path('edits/', EditSubmissionListView.as_view(), name='edit_submissions'),
|
path('submissions/<int:submission_id>/reject/', views.reject_submission, name='reject_submission'),
|
||||||
path('photos/', PhotoSubmissionListView.as_view(), name='photo_submissions'),
|
path('submissions/<int:submission_id>/escalate/', views.escalate_submission, name='escalate_submission'),
|
||||||
])),
|
|
||||||
|
|
||||||
# Admin site URLs
|
|
||||||
path('admin/', moderation_site.urls),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,100 +1,90 @@
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.http import JsonResponse
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.http import HttpResponse
|
||||||
from .models import EditSubmission, PhotoSubmission
|
from django.contrib import messages
|
||||||
from .mixins import ModeratorRequiredMixin
|
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
|
model = EditSubmission
|
||||||
template_name = 'moderation/admin/edit_submission_list.html'
|
template_name = 'moderation/edit_submissions.html'
|
||||||
context_object_name = 'submissions'
|
context_object_name = 'submissions'
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset().select_related(
|
tab = self.request.GET.get('tab', 'new')
|
||||||
'user', 'reviewed_by', 'content_type'
|
queryset = EditSubmission.objects.select_related('user', 'content_type')
|
||||||
).order_by('-submitted_at')
|
|
||||||
|
|
||||||
# Filter by status
|
# Include edits by privileged users (mods, admins, superusers) in appropriate tabs
|
||||||
status = self.request.GET.get('status')
|
privileged_roles = ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||||
if status:
|
|
||||||
queryset = queryset.filter(status=status)
|
|
||||||
|
|
||||||
# Filter by submission type
|
if tab == 'new':
|
||||||
submission_type = self.request.GET.get('type')
|
# Show pending submissions, oldest first
|
||||||
if submission_type:
|
queryset = queryset.filter(status='NEW').order_by('created_at')
|
||||||
queryset = queryset.filter(submission_type=submission_type)
|
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
|
return queryset
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
submission_id = request.POST.get('submission_id')
|
context = super().get_context_data(**kwargs)
|
||||||
action = request.POST.get('action')
|
context['active_tab'] = self.request.GET.get('tab', 'new')
|
||||||
review_notes = request.POST.get('review_notes', '')
|
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)
|
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||||
|
|
||||||
if action == 'approve':
|
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||||
obj = submission.approve(request.user, review_notes)
|
submission.approve(request.user)
|
||||||
message = 'New addition approved successfully.' if submission.submission_type == 'CREATE' else 'Changes approved successfully.'
|
messages.success(request, 'Submission approved successfully')
|
||||||
return JsonResponse({
|
|
||||||
'status': 'success',
|
|
||||||
'message': message,
|
|
||||||
'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None
|
|
||||||
})
|
|
||||||
elif action == 'reject':
|
|
||||||
submission.reject(request.user, review_notes)
|
|
||||||
message = 'New addition rejected.' if submission.submission_type == 'CREATE' else 'Changes rejected.'
|
|
||||||
return JsonResponse({
|
|
||||||
'status': 'success',
|
|
||||||
'message': message
|
|
||||||
})
|
|
||||||
|
|
||||||
return JsonResponse({
|
# Return updated submission list for current tab
|
||||||
'status': 'error',
|
view = EditSubmissionListView.as_view()
|
||||||
'message': 'Invalid action.'
|
return view(request)
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
class PhotoSubmissionListView(ModeratorRequiredMixin, ListView):
|
def reject_submission(request, submission_id):
|
||||||
model = PhotoSubmission
|
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||||
template_name = 'moderation/admin/photo_submission_list.html'
|
|
||||||
context_object_name = 'submissions'
|
|
||||||
paginate_by = 20
|
|
||||||
|
|
||||||
def get_queryset(self):
|
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||||
queryset = super().get_queryset().select_related(
|
submission.reject(request.user)
|
||||||
'user', 'reviewed_by', 'content_type'
|
messages.success(request, 'Submission rejected successfully')
|
||||||
).order_by('-submitted_at')
|
|
||||||
|
|
||||||
status = self.request.GET.get('status')
|
# Return updated submission list for current tab
|
||||||
if status:
|
view = EditSubmissionListView.as_view()
|
||||||
queryset = queryset.filter(status=status)
|
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):
|
if request.user.role == 'MODERATOR':
|
||||||
submission_id = request.POST.get('submission_id')
|
submission.escalate(request.user)
|
||||||
action = request.POST.get('action')
|
messages.success(request, 'Submission escalated to admin')
|
||||||
review_notes = request.POST.get('review_notes', '')
|
|
||||||
|
|
||||||
submission = get_object_or_404(PhotoSubmission, id=submission_id)
|
# Return updated submission list for current tab
|
||||||
|
view = EditSubmissionListView.as_view()
|
||||||
if action == 'approve':
|
return view(request)
|
||||||
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)
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,19 +1,49 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
from simple_history.admin import SimpleHistoryAdmin
|
from simple_history.admin import SimpleHistoryAdmin
|
||||||
from .models import Park, ParkArea
|
from .models import Park, ParkArea
|
||||||
|
|
||||||
@admin.register(Park)
|
|
||||||
class ParkAdmin(SimpleHistoryAdmin):
|
class ParkAdmin(SimpleHistoryAdmin):
|
||||||
list_display = ('name', 'location', 'owner', 'status', 'opening_date')
|
list_display = ('name', 'location', 'status', 'owner', 'created_at', 'updated_at')
|
||||||
list_filter = ('status', 'owner')
|
list_filter = ('status', 'country', 'region', 'city')
|
||||||
search_fields = ('name', 'location', 'description')
|
search_fields = ('name', 'location', 'description')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
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):
|
class ParkAreaAdmin(SimpleHistoryAdmin):
|
||||||
list_display = ('name', 'park', 'opening_date')
|
list_display = ('name', 'park', 'created_at', 'updated_at')
|
||||||
list_filter = ('park',)
|
list_filter = ('park',)
|
||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description', 'park__name')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
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)
|
||||||
|
|||||||
@@ -3,48 +3,89 @@ from django.urls import reverse_lazy
|
|||||||
from .models import Park
|
from .models import Park
|
||||||
from cities_light.models import Country, Region, City
|
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):
|
class ParkForm(forms.ModelForm):
|
||||||
# Hidden fields for actual model relations
|
# Hidden fields for actual model relations
|
||||||
country = forms.ModelChoiceField(queryset=Country.objects.all(), required=True, widget=forms.HiddenInput())
|
country = forms.ModelChoiceField(queryset=Country.objects.all(), required=True, widget=forms.HiddenInput())
|
||||||
region = forms.ModelChoiceField(queryset=Region.objects.all(), required=False, 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())
|
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(
|
country_name = forms.CharField(
|
||||||
label="Country",
|
label="Country",
|
||||||
|
required=True,
|
||||||
widget=forms.TextInput(attrs={
|
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',
|
'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(
|
region_name = forms.CharField(
|
||||||
label="Region/State",
|
label="Region/State",
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.TextInput(attrs={
|
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',
|
'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(
|
city_name = forms.CharField(
|
||||||
label="City",
|
label="City",
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.TextInput(attrs={
|
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',
|
'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:
|
class Meta:
|
||||||
model = Park
|
model = Park
|
||||||
fields = ['name', 'country', 'region', 'city', 'description', 'owner', 'status',
|
fields = ['name', 'description', 'owner', 'status', 'opening_date', 'closing_date',
|
||||||
'opening_date', 'closing_date', 'operating_season', 'size_acres', 'website']
|
'operating_season', 'size_acres', 'website', 'country', 'region', 'city']
|
||||||
widgets = {
|
widgets = {
|
||||||
'name': forms.TextInput(attrs={
|
'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'
|
'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={
|
'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={
|
'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'
|
'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)
|
super().__init__(*args, **kwargs)
|
||||||
instance = kwargs.get('instance')
|
instance = kwargs.get('instance')
|
||||||
if instance:
|
if instance:
|
||||||
|
try:
|
||||||
if instance.country:
|
if instance.country:
|
||||||
self.fields['country_name'].initial = instance.country.name
|
self.fields['country_name'].initial = instance.country.name
|
||||||
self.fields['country'].initial = instance.country
|
self.fields['country'].initial = instance.country
|
||||||
|
except Country.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
if instance.region:
|
if instance.region:
|
||||||
self.fields['region_name'].initial = instance.region.name
|
self.fields['region_name'].initial = instance.region.name
|
||||||
self.fields['region'].initial = instance.region
|
self.fields['region'].initial = instance.region
|
||||||
|
except Region.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
if instance.city:
|
if instance.city:
|
||||||
self.fields['city_name'].initial = instance.city.name
|
self.fields['city_name'].initial = instance.city.name
|
||||||
self.fields['city'].initial = instance.city
|
self.fields['city'].initial = instance.city
|
||||||
|
except City.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
@@ -95,12 +147,26 @@ class ParkForm(forms.ModelForm):
|
|||||||
region_name = cleaned_data.get('region_name')
|
region_name = cleaned_data.get('region_name')
|
||||||
city_name = cleaned_data.get('city_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:
|
if country_name:
|
||||||
try:
|
try:
|
||||||
country = Country.objects.get(name__iexact=country_name)
|
country = Country.objects.get(name__iexact=country_name)
|
||||||
cleaned_data['country'] = country
|
cleaned_data['country'] = country
|
||||||
except Country.DoesNotExist:
|
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'):
|
if region_name and cleaned_data.get('country'):
|
||||||
try:
|
try:
|
||||||
@@ -110,7 +176,7 @@ class ParkForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
cleaned_data['region'] = region
|
cleaned_data['region'] = region
|
||||||
except Region.DoesNotExist:
|
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'):
|
if city_name and cleaned_data.get('region'):
|
||||||
try:
|
try:
|
||||||
@@ -120,6 +186,6 @@ class ParkForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
cleaned_data['city'] = city
|
cleaned_data['city'] = city
|
||||||
except City.DoesNotExist:
|
except City.DoesNotExist:
|
||||||
self.add_error('city_name', 'Invalid city name for selected region')
|
cleaned_data['city'] = None
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|||||||
99
parks/management/commands/fix_historical_parks.py
Normal file
99
parks/management/commands/fix_historical_parks.py
Normal 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'))
|
||||||
90
parks/management/commands/fix_locations.py
Normal file
90
parks/management/commands/fix_locations.py
Normal 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'))
|
||||||
@@ -10,6 +10,11 @@
|
|||||||
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
|
"website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/",
|
||||||
"owner": "The Walt Disney Company",
|
"owner": "The Walt Disney Company",
|
||||||
"size_acres": 142,
|
"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": [
|
"rides": [
|
||||||
{
|
{
|
||||||
"name": "Space Mountain",
|
"name": "Space Mountain",
|
||||||
@@ -18,6 +23,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Walt Disney Imagineering",
|
"manufacturer": "Walt Disney Imagineering",
|
||||||
"description": "A high-speed roller coaster in the dark through space.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 183,
|
"height_ft": 183,
|
||||||
"length_ft": 3196,
|
"length_ft": 3196,
|
||||||
@@ -33,6 +42,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Walt Disney Imagineering",
|
"manufacturer": "Walt Disney Imagineering",
|
||||||
"description": "A mine train roller coaster through the Old West.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 104,
|
"height_ft": 104,
|
||||||
"length_ft": 2671,
|
"length_ft": 2671,
|
||||||
@@ -48,6 +61,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Vekoma",
|
"manufacturer": "Vekoma",
|
||||||
"description": "A family roller coaster featuring unique swinging cars.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 112,
|
"height_ft": 112,
|
||||||
"length_ft": 2000,
|
"length_ft": 2000,
|
||||||
@@ -62,7 +79,11 @@
|
|||||||
"opening_date": "1971-10-01",
|
"opening_date": "1971-10-01",
|
||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Walt Disney Imagineering",
|
"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",
|
"name": "Pirates of the Caribbean",
|
||||||
@@ -70,7 +91,11 @@
|
|||||||
"opening_date": "1973-12-15",
|
"opening_date": "1973-12-15",
|
||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Walt Disney Imagineering",
|
"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",
|
"website": "https://www.cedarpoint.com",
|
||||||
"owner": "Cedar Fair",
|
"owner": "Cedar Fair",
|
||||||
"size_acres": 364,
|
"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": [
|
"rides": [
|
||||||
{
|
{
|
||||||
"name": "Steel Vengeance",
|
"name": "Steel Vengeance",
|
||||||
@@ -92,6 +122,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Rocky Mountain Construction",
|
"manufacturer": "Rocky Mountain Construction",
|
||||||
"description": "A hybrid roller coaster featuring multiple inversions.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 205,
|
"height_ft": 205,
|
||||||
"length_ft": 5740,
|
"length_ft": 5740,
|
||||||
@@ -107,6 +141,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Intamin",
|
"manufacturer": "Intamin",
|
||||||
"description": "A giga coaster with stunning views of Lake Erie.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 310,
|
"height_ft": 310,
|
||||||
"length_ft": 6595,
|
"length_ft": 6595,
|
||||||
@@ -122,6 +160,10 @@
|
|||||||
"status": "SBNO",
|
"status": "SBNO",
|
||||||
"manufacturer": "Intamin",
|
"manufacturer": "Intamin",
|
||||||
"description": "A strata coaster featuring a 420-foot top hat element.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 420,
|
"height_ft": 420,
|
||||||
"length_ft": 2800,
|
"length_ft": 2800,
|
||||||
@@ -137,6 +179,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Intamin",
|
"manufacturer": "Intamin",
|
||||||
"description": "A launched roller coaster with multiple inversions.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 105,
|
"height_ft": 105,
|
||||||
"length_ft": 4450,
|
"length_ft": 4450,
|
||||||
@@ -157,6 +203,11 @@
|
|||||||
"website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure",
|
"website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure",
|
||||||
"owner": "NBCUniversal",
|
"owner": "NBCUniversal",
|
||||||
"size_acres": 110,
|
"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": [
|
"rides": [
|
||||||
{
|
{
|
||||||
"name": "Jurassic World VelociCoaster",
|
"name": "Jurassic World VelociCoaster",
|
||||||
@@ -165,6 +216,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Intamin",
|
"manufacturer": "Intamin",
|
||||||
"description": "A high-speed launch coaster featuring velociraptors.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 155,
|
"height_ft": 155,
|
||||||
"length_ft": 4700,
|
"length_ft": 4700,
|
||||||
@@ -180,6 +235,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Intamin",
|
"manufacturer": "Intamin",
|
||||||
"description": "A story coaster through the Forbidden Forest.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 65,
|
"height_ft": 65,
|
||||||
"length_ft": 5053,
|
"length_ft": 5053,
|
||||||
@@ -194,7 +253,11 @@
|
|||||||
"opening_date": "1999-05-28",
|
"opening_date": "1999-05-28",
|
||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Oceaneering International",
|
"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",
|
"website": "https://www.altontowers.com",
|
||||||
"owner": "Merlin Entertainments",
|
"owner": "Merlin Entertainments",
|
||||||
"size_acres": 910,
|
"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": [
|
"rides": [
|
||||||
{
|
{
|
||||||
"name": "Nemesis",
|
"name": "Nemesis",
|
||||||
@@ -216,6 +284,10 @@
|
|||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"manufacturer": "Bolliger & Mabillard",
|
"manufacturer": "Bolliger & Mabillard",
|
||||||
"description": "An inverted roller coaster through ravines.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 43,
|
"height_ft": 43,
|
||||||
"length_ft": 2349,
|
"length_ft": 2349,
|
||||||
@@ -231,6 +303,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Bolliger & Mabillard",
|
"manufacturer": "Bolliger & Mabillard",
|
||||||
"description": "The world's first vertical drop roller coaster.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 65,
|
"height_ft": 65,
|
||||||
"length_ft": 1804,
|
"length_ft": 1804,
|
||||||
@@ -251,6 +327,11 @@
|
|||||||
"website": "https://www.europapark.de",
|
"website": "https://www.europapark.de",
|
||||||
"owner": "Mack Rides",
|
"owner": "Mack Rides",
|
||||||
"size_acres": 235,
|
"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": [
|
"rides": [
|
||||||
{
|
{
|
||||||
"name": "Silver Star",
|
"name": "Silver Star",
|
||||||
@@ -259,6 +340,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Bolliger & Mabillard",
|
"manufacturer": "Bolliger & Mabillard",
|
||||||
"description": "A hypercoaster with stunning views.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 239,
|
"height_ft": 239,
|
||||||
"length_ft": 4003,
|
"length_ft": 4003,
|
||||||
@@ -274,6 +359,10 @@
|
|||||||
"status": "OPERATING",
|
"status": "OPERATING",
|
||||||
"manufacturer": "Mack Rides",
|
"manufacturer": "Mack Rides",
|
||||||
"description": "A launched roller coaster with multiple inversions.",
|
"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": {
|
"stats": {
|
||||||
"height_ft": 125,
|
"height_ft": 125,
|
||||||
"length_ft": 3465,
|
"length_ft": 3465,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from faker import Faker
|
|||||||
import requests
|
import requests
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from cities_light.models import City, Country
|
||||||
|
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from rides.models import Ride, RollerCoasterStats
|
from rides.models import Ride, RollerCoasterStats
|
||||||
@@ -30,25 +31,30 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument('--users', type=int, default=50)
|
parser.add_argument('--users', type=int, default=50)
|
||||||
parser.add_argument('--reviews-per-item', type=int, default=10)
|
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:
|
try:
|
||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
img = Image.open(BytesIO(response.content))
|
img = Image.open(BytesIO(response.content))
|
||||||
img_io = BytesIO()
|
img_io = BytesIO()
|
||||||
img.save(img_io, format='JPEG')
|
img.save(img_io, format='JPEG')
|
||||||
img_io.seek(0)
|
img_io.seek(0)
|
||||||
return f'{prefix}_{fake.uuid4()}.jpg', File(img_io)
|
filename = url.split('/')[-1]
|
||||||
except:
|
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
|
return None, None
|
||||||
|
|
||||||
def create_users(self, count):
|
def create_users(self, count):
|
||||||
self.stdout.write('Creating users...')
|
self.stdout.write('Creating users...')
|
||||||
users = []
|
users = []
|
||||||
|
|
||||||
|
try:
|
||||||
# Get existing admin user
|
# Get existing admin user
|
||||||
admin_user = User.objects.get(username='admin')
|
admin_user = User.objects.get(username='admin')
|
||||||
users.append(admin_user)
|
users.append(admin_user)
|
||||||
self.stdout.write('Added existing 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
|
# Create regular users using raw SQL
|
||||||
roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2
|
roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2
|
||||||
@@ -232,12 +238,25 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
parks = []
|
parks = []
|
||||||
for park_data in seed_data['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(
|
park = Park.objects.create(
|
||||||
name=park_data['name'],
|
name=park_data['name'],
|
||||||
slug=slugify(park_data['name']),
|
slug=slugify(park_data['name']),
|
||||||
location=park_data['location'],
|
location=park_data['location'],
|
||||||
country=park_data['country'],
|
country=country,
|
||||||
|
city=city,
|
||||||
opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(),
|
opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(),
|
||||||
status=park_data['status'],
|
status=park_data['status'],
|
||||||
description=park_data['description'],
|
description=park_data['description'],
|
||||||
@@ -247,15 +266,14 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add park photos
|
# Add park photos
|
||||||
for _ in range(random.randint(2, 5)):
|
for photo_url in park_data.get('photos', []):
|
||||||
img_url = f'https://picsum.photos/800/600?random={fake.random_number(5)}'
|
filename, file = self.download_and_save_image(photo_url)
|
||||||
filename, file = self.download_and_save_image(img_url, 'park')
|
|
||||||
if filename and file:
|
if filename and file:
|
||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
content_object=park,
|
content_object=park,
|
||||||
image=file,
|
image=file,
|
||||||
uploaded_by=random.choice(users),
|
uploaded_by=random.choice(users),
|
||||||
caption=fake.sentence(),
|
caption=f"Photo of {park.name}",
|
||||||
is_approved=True
|
is_approved=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,21 +302,24 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add ride photos
|
# Add ride photos
|
||||||
for _ in range(random.randint(2, 5)):
|
for photo_url in ride_data.get('photos', []):
|
||||||
img_url = f'https://picsum.photos/800/600?random={fake.random_number(5)}'
|
filename, file = self.download_and_save_image(photo_url)
|
||||||
filename, file = self.download_and_save_image(img_url, 'ride')
|
|
||||||
if filename and file:
|
if filename and file:
|
||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
content_object=ride,
|
content_object=ride,
|
||||||
image=file,
|
image=file,
|
||||||
uploaded_by=random.choice(users),
|
uploaded_by=random.choice(users),
|
||||||
caption=fake.sentence(),
|
caption=f"Photo of {ride.name}",
|
||||||
is_approved=True
|
is_approved=True
|
||||||
)
|
)
|
||||||
|
|
||||||
parks.append(park)
|
parks.append(park)
|
||||||
self.stdout.write(f'Created park and rides: {park.name}')
|
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
|
return parks
|
||||||
|
|
||||||
def create_reviews(self, users, reviews_per_item):
|
def create_reviews(self, users, reviews_per_item):
|
||||||
|
|||||||
17
parks/migrations/0007_fix_historical_park_city_null.py
Normal file
17
parks/migrations/0007_fix_historical_park_city_null.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
43
parks/migrations/0008_fix_historical_park_data.py
Normal file
43
parks/migrations/0008_fix_historical_park_data.py
Normal 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),
|
||||||
|
]
|
||||||
33
parks/migrations/0009_fix_historical_park_fields.py
Normal file
33
parks/migrations/0009_fix_historical_park_fields.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
72
parks/migrations/0010_fix_historical_records.py
Normal file
72
parks/migrations/0010_fix_historical_records.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
52
parks/migrations/0011_alter_historicalpark_fields.py
Normal file
52
parks/migrations/0011_alter_historicalpark_fields.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
13
parks/migrations/0011_merge_20241031_1617.py
Normal file
13
parks/migrations/0011_merge_20241031_1617.py
Normal 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 = []
|
||||||
13
parks/migrations/0012_merge_20241031_1635.py
Normal file
13
parks/migrations/0012_merge_20241031_1635.py
Normal 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 = []
|
||||||
67
parks/migrations/0013_fix_null_locations.py
Normal file
67
parks/migrations/0013_fix_null_locations.py
Normal 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),
|
||||||
|
]
|
||||||
52
parks/migrations/0014_alter_location_fields.py
Normal file
52
parks/migrations/0014_alter_location_fields.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
16
parks/migrations/0015_fix_historical_park_city_constraint.py
Normal file
16
parks/migrations/0015_fix_historical_park_city_constraint.py
Normal 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),
|
||||||
|
]
|
||||||
22
parks/migrations/0016_alter_historicalpark_city_nullable.py
Normal file
22
parks/migrations/0016_alter_historicalpark_city_nullable.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
parks/migrations/0017_fix_historicalpark_city_column.py
Normal file
22
parks/migrations/0017_fix_historicalpark_city_column.py
Normal 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;'
|
||||||
|
),
|
||||||
|
]
|
||||||
48
parks/migrations/0018_fix_historicalpark_location_fields.py
Normal file
48
parks/migrations/0018_fix_historicalpark_location_fields.py
Normal 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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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;'
|
||||||
|
),
|
||||||
|
]
|
||||||
16
parks/migrations/0020_remove_historicalpark_city_text.py
Normal file
16
parks/migrations/0020_remove_historicalpark_city_text.py
Normal 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);'
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,7 +11,7 @@ class Park(models.Model):
|
|||||||
('CLOSED_PERM', 'Permanently Closed'),
|
('CLOSED_PERM', 'Permanently Closed'),
|
||||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||||
('DEMOLISHED', 'Demolished'),
|
('DEMOLISHED', 'Demolished'),
|
||||||
('RELOCATED', 'Relocated'), # Added to match Ride model
|
('RELOCATED', 'Relocated'),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@@ -84,6 +84,9 @@ class Park(models.Model):
|
|||||||
|
|
||||||
def get_formatted_location(self):
|
def get_formatted_location(self):
|
||||||
"""Get a formatted location string: $COUNTRY, $REGION, $CITY"""
|
"""Get a formatted location string: $COUNTRY, $REGION, $CITY"""
|
||||||
|
if not self.country:
|
||||||
|
return ""
|
||||||
|
|
||||||
location = self.country.name
|
location = self.country.name
|
||||||
|
|
||||||
if self.region and self.city:
|
if self.region and self.city:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
|||||||
path('ajax/countries/', views.get_countries, name='get_countries'),
|
path('ajax/countries/', views.get_countries, name='get_countries'),
|
||||||
path('ajax/regions/', views.get_regions, name='get_regions'),
|
path('ajax/regions/', views.get_regions, name='get_regions'),
|
||||||
path('ajax/cities/', views.get_cities, name='get_cities'),
|
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:slug>/', views.ParkDetailView.as_view(), name='park_detail'),
|
||||||
path('<slug:park_slug>/rides/', include('rides.urls', namespace='rides')),
|
path('<slug:park_slug>/rides/', include('rides.urls', namespace='rides')),
|
||||||
]
|
]
|
||||||
|
|||||||
157
parks/views.py
157
parks/views.py
@@ -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.shortcuts import get_object_or_404, render
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib import messages
|
||||||
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse
|
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse
|
||||||
from .models import Park, ParkArea
|
from .models import Park, ParkArea
|
||||||
from .forms import ParkForm
|
from .forms import ParkForm
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
from core.views import SlugRedirectMixin
|
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 moderation.models import EditSubmission
|
||||||
from cities_light.models import Country, Region, City
|
from cities_light.models import Country, Region, City
|
||||||
|
|
||||||
def get_countries(request):
|
def get_countries(request):
|
||||||
query = request.GET.get('q', '')
|
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)
|
return JsonResponse(list(countries), safe=False)
|
||||||
|
|
||||||
def get_regions(request):
|
def get_regions(request):
|
||||||
query = request.GET.get('q', '')
|
query = request.GET.get('q', '')
|
||||||
country = request.GET.get('country', '')
|
country = request.GET.get('country', '')
|
||||||
|
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
|
||||||
|
|
||||||
if not country:
|
if not country:
|
||||||
return JsonResponse([], safe=False)
|
return JsonResponse([], safe=False)
|
||||||
|
|
||||||
|
# Base query
|
||||||
regions = Region.objects.filter(
|
regions = Region.objects.filter(
|
||||||
Q(name__icontains=query) | Q(alternate_names__icontains=query),
|
Q(name__icontains=query) | Q(alternate_names__icontains=query),
|
||||||
country__name__iexact=country
|
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)
|
return JsonResponse(list(regions), safe=False)
|
||||||
|
|
||||||
def get_cities(request):
|
def get_cities(request):
|
||||||
query = request.GET.get('q', '')
|
query = request.GET.get('q', '')
|
||||||
region = request.GET.get('region', '')
|
region = request.GET.get('region', '')
|
||||||
country = request.GET.get('country', '')
|
country = request.GET.get('country', '')
|
||||||
|
filter_parks = request.GET.get('filter_parks', 'false') == 'true'
|
||||||
|
|
||||||
if not region or not country:
|
if not region or not country:
|
||||||
return JsonResponse([], safe=False)
|
return JsonResponse([], safe=False)
|
||||||
|
|
||||||
|
# Base query
|
||||||
cities = City.objects.filter(
|
cities = City.objects.filter(
|
||||||
Q(name__icontains=query) | Q(alternate_names__icontains=query),
|
Q(name__icontains=query) | Q(alternate_names__icontains=query),
|
||||||
region__name__iexact=region,
|
region__name__iexact=region,
|
||||||
region__country__name__iexact=country
|
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)
|
return JsonResponse(list(cities), safe=False)
|
||||||
|
|
||||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||||
@@ -50,38 +78,111 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
form_class = ParkForm
|
form_class = ParkForm
|
||||||
template_name = 'parks/park_form.html'
|
template_name = 'parks/park_form.html'
|
||||||
|
|
||||||
def form_valid(self, form):
|
def prepare_changes_data(self, cleaned_data):
|
||||||
# If user is moderator or above, save directly
|
data = cleaned_data.copy()
|
||||||
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
|
||||||
self.object = form.save()
|
|
||||||
return HttpResponseRedirect(self.get_success_url())
|
|
||||||
|
|
||||||
# Otherwise, create a submission
|
|
||||||
cleaned_data = form.cleaned_data.copy()
|
|
||||||
# Convert model instances to IDs for JSON serialization
|
# Convert model instances to IDs for JSON serialization
|
||||||
if cleaned_data.get('owner'):
|
if data.get('owner'):
|
||||||
cleaned_data['owner'] = cleaned_data['owner'].id
|
data['owner'] = data['owner'].id
|
||||||
if cleaned_data.get('country'):
|
if data.get('country'):
|
||||||
cleaned_data['country'] = cleaned_data['country'].id
|
data['country'] = data['country'].id
|
||||||
if cleaned_data.get('region'):
|
if data.get('region'):
|
||||||
cleaned_data['region'] = cleaned_data['region'].id
|
data['region'] = data['region'].id
|
||||||
if cleaned_data.get('city'):
|
if data.get('city'):
|
||||||
cleaned_data['city'] = cleaned_data['city'].id
|
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(
|
submission = EditSubmission.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
submission_type='CREATE',
|
submission_type='CREATE',
|
||||||
changes=cleaned_data,
|
changes=changes,
|
||||||
reason=self.request.POST.get('reason', ''),
|
reason=self.request.POST.get('reason', ''),
|
||||||
source=self.request.POST.get('source', '')
|
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'))
|
return HttpResponseRedirect(reverse('parks:park_list'))
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse('parks:park_detail', kwargs={'slug': self.object.slug})
|
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
|
model = Park
|
||||||
template_name = 'parks/park_detail.html'
|
template_name = 'parks/park_detail.html'
|
||||||
context_object_name = 'park'
|
context_object_name = 'park'
|
||||||
@@ -104,7 +205,7 @@ class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixi
|
|||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self):
|
||||||
return 'parks:park_detail'
|
return 'parks:park_detail'
|
||||||
|
|
||||||
class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
|
class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
|
||||||
model = ParkArea
|
model = ParkArea
|
||||||
template_name = 'parks/area_detail.html'
|
template_name = 'parks/area_detail.html'
|
||||||
context_object_name = 'area'
|
context_object_name = 'area'
|
||||||
@@ -149,7 +250,7 @@ class ParkListView(ListView):
|
|||||||
country = self.request.GET.get('country', '').strip()
|
country = self.request.GET.get('country', '').strip()
|
||||||
region = self.request.GET.get('region', '').strip()
|
region = self.request.GET.get('region', '').strip()
|
||||||
city = self.request.GET.get('city', '').strip()
|
city = self.request.GET.get('city', '').strip()
|
||||||
status = self.request.GET.get('status', '').strip()
|
statuses = self.request.GET.getlist('status')
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
@@ -166,8 +267,8 @@ class ParkListView(ListView):
|
|||||||
if city:
|
if city:
|
||||||
queryset = queryset.filter(city__name__icontains=city)
|
queryset = queryset.filter(city__name__icontains=city)
|
||||||
|
|
||||||
if status:
|
if statuses:
|
||||||
queryset = queryset.filter(status=status)
|
queryset = queryset.filter(status__in=statuses)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@@ -178,7 +279,7 @@ class ParkListView(ListView):
|
|||||||
'country': self.request.GET.get('country', ''),
|
'country': self.request.GET.get('country', ''),
|
||||||
'region': self.request.GET.get('region', ''),
|
'region': self.request.GET.get('region', ''),
|
||||||
'city': self.request.GET.get('city', ''),
|
'city': self.request.GET.get('city', ''),
|
||||||
'status': self.request.GET.get('status', '')
|
'statuses': self.request.GET.getlist('status')
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
71
rides/forms.py
Normal file
71
rides/forms.py
Normal 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()
|
||||||
@@ -6,5 +6,6 @@ app_name = 'rides'
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.RideListView.as_view(), name='ride_list'),
|
path('', views.RideListView.as_view(), name='ride_list'),
|
||||||
path('create/', views.RideCreateView.as_view(), name='ride_create'),
|
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'),
|
path('<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.shortcuts import get_object_or_404
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib import messages
|
||||||
from django.http import JsonResponse, HttpResponseRedirect
|
from django.http import JsonResponse, HttpResponseRedirect
|
||||||
from .models import Ride, RollerCoasterStats
|
from .models import Ride, RollerCoasterStats
|
||||||
|
from .forms import RideForm
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from core.views import SlugRedirectMixin
|
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 moderation.models import EditSubmission
|
||||||
|
|
||||||
class RideCreateView(LoginRequiredMixin, CreateView):
|
class RideCreateView(LoginRequiredMixin, CreateView):
|
||||||
model = Ride
|
model = Ride
|
||||||
|
form_class = RideForm
|
||||||
template_name = 'rides/ride_form.html'
|
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):
|
def setup(self, request, *args, **kwargs):
|
||||||
super().setup(request, *args, **kwargs)
|
super().setup(request, *args, **kwargs)
|
||||||
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
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):
|
def form_valid(self, form):
|
||||||
form.instance.park = self.park
|
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 = form.cleaned_data.copy()
|
||||||
cleaned_data['park'] = self.park.id
|
cleaned_data['park'] = self.park.id
|
||||||
# Convert model instances to IDs for JSON serialization
|
# Convert model instances to IDs for JSON serialization
|
||||||
@@ -40,6 +39,7 @@ class RideCreateView(LoginRequiredMixin, CreateView):
|
|||||||
if cleaned_data.get('manufacturer'):
|
if cleaned_data.get('manufacturer'):
|
||||||
cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id
|
cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id
|
||||||
|
|
||||||
|
# Create submission record
|
||||||
submission = EditSubmission.objects.create(
|
submission = EditSubmission.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Ride),
|
content_type=ContentType.objects.get_for_model(Ride),
|
||||||
@@ -48,6 +48,18 @@ class RideCreateView(LoginRequiredMixin, CreateView):
|
|||||||
reason=self.request.POST.get('reason', ''),
|
reason=self.request.POST.get('reason', ''),
|
||||||
source=self.request.POST.get('source', '')
|
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}))
|
return HttpResponseRedirect(reverse('parks:rides:ride_list', kwargs={'park_slug': self.park.slug}))
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@@ -61,7 +73,69 @@ class RideCreateView(LoginRequiredMixin, CreateView):
|
|||||||
context['park'] = self.park
|
context['park'] = self.park
|
||||||
return context
|
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
|
model = Ride
|
||||||
template_name = 'rides/ride_detail.html'
|
template_name = 'rides/ride_detail.html'
|
||||||
context_object_name = 'ride'
|
context_object_name = 'ride'
|
||||||
|
|||||||
44
static/css/alerts.css
Normal file
44
static/css/alerts.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1513,116 +1513,6 @@ select {
|
|||||||
padding-bottom: 1.5rem;
|
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+.theme-toggle-btn i::before {
|
#theme-toggle+.theme-toggle-btn i::before {
|
||||||
@@ -2243,6 +2133,10 @@ select {
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fixed {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
.absolute {
|
.absolute {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
@@ -2251,14 +2145,30 @@ select {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
|
||||||
.right-0 {
|
.right-0 {
|
||||||
right: 0px;
|
right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-0 {
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
.z-10 {
|
.z-10 {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.z-40 {
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-50 {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
.col-span-2 {
|
.col-span-2 {
|
||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
@@ -2281,6 +2191,10 @@ select {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.-mb-px {
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-1 {
|
.mb-1 {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -2317,6 +2231,10 @@ select {
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-4 {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-6 {
|
.ml-6 {
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -2409,6 +2327,10 @@ select {
|
|||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-h-60 {
|
||||||
|
max-height: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.min-h-\[calc\(100vh-16rem\)\] {
|
.min-h-\[calc\(100vh-16rem\)\] {
|
||||||
min-height: calc(100vh - 16rem);
|
min-height: calc(100vh - 16rem);
|
||||||
}
|
}
|
||||||
@@ -2421,12 +2343,12 @@ select {
|
|||||||
width: 6rem;
|
width: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-4 {
|
.w-32 {
|
||||||
width: 1rem;
|
width: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-48 {
|
.w-4 {
|
||||||
width: 12rem;
|
width: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-5 {
|
.w-5 {
|
||||||
@@ -2465,6 +2387,18 @@ select {
|
|||||||
flex-grow: 1;
|
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 {
|
||||||
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: 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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-disc {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
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)));
|
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]) {
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
--tw-space-x-reverse: 0;
|
--tw-space-x-reverse: 0;
|
||||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||||
@@ -2573,6 +2505,10 @@ select {
|
|||||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-auto {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.overflow-hidden {
|
.overflow-hidden {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -2603,6 +2539,11 @@ select {
|
|||||||
border-bottom-right-radius: 0.5rem;
|
border-bottom-right-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-t-lg {
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
@@ -2611,10 +2552,24 @@ select {
|
|||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-b-2 {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.border-t {
|
.border-t {
|
||||||
border-top-width: 1px;
|
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 {
|
.border-gray-200 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(229 231 235 / var(--tw-border-opacity));
|
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-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-transparent {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.bg-blue-100 {
|
.bg-blue-100 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
|
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));
|
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 {
|
.bg-blue-600 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
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 {
|
.bg-gray-200 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
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));
|
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 {
|
.bg-green-600 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
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));
|
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 {
|
.bg-red-600 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
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));
|
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 {
|
.bg-gradient-to-br {
|
||||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
|
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops));
|
||||||
}
|
}
|
||||||
@@ -2755,6 +2729,10 @@ select {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-3 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@@ -2907,6 +2885,11 @@ select {
|
|||||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
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 {
|
.text-gray-400 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||||||
@@ -2981,6 +2964,14 @@ select {
|
|||||||
color: rgb(133 77 14 / var(--tw-text-opacity));
|
color: rgb(133 77 14 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-100 {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.shadow {
|
.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: 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);
|
--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);
|
--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 {
|
||||||
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);
|
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);
|
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-all {
|
||||||
transition-property: all;
|
transition-property: all;
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -3043,6 +3047,22 @@ select {
|
|||||||
transition-duration: 150ms;
|
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 *) {
|
.dark\:prose-invert:is(.dark *) {
|
||||||
--tw-prose-body: var(--tw-prose-invert-body);
|
--tw-prose-body: var(--tw-prose-invert-body);
|
||||||
--tw-prose-headings: var(--tw-prose-invert-headings);
|
--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));
|
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 {
|
.hover\:bg-blue-700:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
||||||
@@ -3129,6 +3154,11 @@ select {
|
|||||||
color: rgb(29 78 216 / var(--tw-text-opacity));
|
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 {
|
.hover\:text-primary:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||||
@@ -3175,6 +3205,11 @@ select {
|
|||||||
--tw-ring-offset-width: 2px;
|
--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 *) {
|
.dark\:border-gray-600:is(.dark *) {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(75 85 99 / var(--tw-border-opacity));
|
border-color: rgb(75 85 99 / var(--tw-border-opacity));
|
||||||
@@ -3189,6 +3224,11 @@ select {
|
|||||||
border-color: rgb(55 65 81 / 0.5);
|
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 *) {
|
.dark\:bg-blue-400\/30:is(.dark *) {
|
||||||
background-color: rgb(96 165 250 / 0.3);
|
background-color: rgb(96 165 250 / 0.3);
|
||||||
}
|
}
|
||||||
@@ -3284,6 +3324,11 @@ select {
|
|||||||
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
|
--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 *) {
|
.dark\:text-blue-200:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(191 219 254 / var(--tw-text-opacity));
|
color: rgb(191 219 254 / var(--tw-text-opacity));
|
||||||
@@ -3299,6 +3344,11 @@ select {
|
|||||||
color: rgb(239 246 255 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:text-gray-200:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||||
@@ -3319,6 +3369,11 @@ select {
|
|||||||
color: rgb(187 247 208 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:text-red-200:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(254 202 202 / var(--tw-text-opacity));
|
color: rgb(254 202 202 / var(--tw-text-opacity));
|
||||||
|
|||||||
18
static/js/alerts.js
Normal file
18
static/js/alerts.js
Normal 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
5
static/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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);
|
|
||||||
}
|
|
||||||
81
static/js/location-autocomplete.js
Normal file
81
static/js/location-autocomplete.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,8 +30,15 @@
|
|||||||
<!-- HTMX -->
|
<!-- HTMX -->
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
<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 -->
|
<!-- Tailwind CSS -->
|
||||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
||||||
|
<link href="{% static 'css/alerts.css' %}" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link
|
<link
|
||||||
@@ -39,6 +46,23 @@
|
|||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
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 %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -46,7 +70,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<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">
|
<nav class="container mx-auto nav-container">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -107,32 +131,39 @@
|
|||||||
<span>Moderation</span>
|
<span>Moderation</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="relative" x-data="{ open: false }">
|
<div
|
||||||
<button
|
class="relative"
|
||||||
@click="open = !open"
|
x-data="{ open: false }"
|
||||||
class="flex items-center space-x-2 transition-transform hover:scale-105"
|
@click.outside="open = false"
|
||||||
>
|
>
|
||||||
|
<!-- Profile Picture Button -->
|
||||||
{% if user.profile.avatar %}
|
{% if user.profile.avatar %}
|
||||||
<img
|
<img
|
||||||
|
@click="open = !open"
|
||||||
src="{{ user.profile.avatar.url }}"
|
src="{{ user.profile.avatar.url }}"
|
||||||
alt="{{ user.username }}"
|
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 %}
|
{% else %}
|
||||||
<div
|
<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 }}
|
{{ user.username.0|upper }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>{{ user.username }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Dropdown Menu -->
|
<!-- Dropdown Menu -->
|
||||||
<div
|
<div
|
||||||
|
x-cloak
|
||||||
x-show="open"
|
x-show="open"
|
||||||
@click.away="open = false"
|
x-transition:enter="transition ease-out duration-100"
|
||||||
class="absolute right-0 w-48 py-1 mt-2 bg-white rounded-md shadow-lg dark:bg-gray-800"
|
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">
|
<a href="{% url 'profile' user.username %}" class="menu-item">
|
||||||
<i class="w-5 fas fa-user"></i>
|
<i class="w-5 fas fa-user"></i>
|
||||||
@@ -158,17 +189,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Login/Register (Desktop) -->
|
<!-- Generic Profile Icon for Unauthenticated Users -->
|
||||||
<div class="hidden space-x-3 lg:flex">
|
<div
|
||||||
<a href="{% url 'account_login' %}" class="btn-secondary">
|
class="relative"
|
||||||
<i class="mr-2 fas fa-sign-in-alt"></i>
|
x-data="{ open: false }"
|
||||||
Login
|
@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>
|
||||||
<a href="{% url 'account_signup' %}" class="btn-primary">
|
<a href="{% url 'account_signup' %}" class="menu-item">
|
||||||
<i class="mr-2 fas fa-user-plus"></i>
|
<i class="w-5 fas fa-user-plus"></i>
|
||||||
Register
|
<span>Register</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Mobile Menu Button -->
|
<!-- Mobile Menu Button -->
|
||||||
@@ -194,26 +249,6 @@
|
|||||||
class="form-input"
|
class="form-input"
|
||||||
/>
|
/>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -221,7 +256,7 @@
|
|||||||
|
|
||||||
<!-- Flash Messages -->
|
<!-- Flash Messages -->
|
||||||
{% if 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 %}
|
{% for message in messages %}
|
||||||
<div
|
<div
|
||||||
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
||||||
@@ -264,6 +299,7 @@
|
|||||||
|
|
||||||
<!-- Custom JavaScript -->
|
<!-- Custom JavaScript -->
|
||||||
<script src="{% static 'js/main.js' %}"></script>
|
<script src="{% static 'js/main.js' %}"></script>
|
||||||
|
<script src="{% static 'js/alerts.js' %}"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,24 +4,31 @@
|
|||||||
{% block title %}{{ company.name }} - ThrillWiki{% endblock %}
|
{% block title %}{{ company.name }} - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container px-4 mx-auto">
|
||||||
<!-- Company Header -->
|
<!-- Company Header -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center">
|
<div class="flex flex-col items-start justify-between md:flex-row md:items-center">
|
||||||
<div>
|
<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 %}
|
{% if company.headquarters %}
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<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>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2 mt-4 md:mt-0">
|
||||||
{% if company.website %}
|
{% if company.website %}
|
||||||
<a href="{{ company.website }}" target="_blank" rel="noopener noreferrer"
|
<a href="{{ company.website }}" target="_blank" rel="noopener noreferrer"
|
||||||
class="btn-secondary mt-4 md:mt-0">
|
class="btn-secondary">
|
||||||
<i class="fas fa-external-link-alt mr-2"></i>Visit Website
|
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
{% if company.description %}
|
{% if company.description %}
|
||||||
@@ -32,22 +39,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Company Stats -->
|
<!-- Company Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
|
||||||
<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">
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{{ parks.count }}
|
{{ parks.count }}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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">
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{{ parks|length }}
|
{{ parks|length }}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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">
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{% with total_rides=0 %}
|
{% with total_rides=0 %}
|
||||||
{% for park in parks %}
|
{% for park in parks %}
|
||||||
@@ -56,42 +63,42 @@
|
|||||||
{{ total_rides }}
|
{{ total_rides }}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Parks List -->
|
<!-- Parks List -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Theme Parks</h2>
|
<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 %}
|
{% 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 %}
|
{% if park.photos.exists %}
|
||||||
<img src="{{ park.photos.first.image.url }}"
|
<img src="{{ park.photos.first.image.url }}"
|
||||||
alt="{{ park.name }}"
|
alt="{{ park.name }}"
|
||||||
class="w-full h-48 object-cover">
|
class="object-cover w-full h-48">
|
||||||
{% else %}
|
{% 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>
|
<span class="text-gray-400">No image available</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="p-4">
|
<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 %}"
|
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||||
class="text-blue-600 dark:text-blue-400 hover:underline">
|
class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
{{ park.name }}
|
{{ park.name }}
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-2">{{ park.location }}</p>
|
<p class="mb-2 text-gray-600 dark:text-gray-400">{{ park.location }}</p>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{{ park.rides.count }} attractions
|
{{ park.rides.count }} attractions
|
||||||
</span>
|
</span>
|
||||||
{% if park.average_rating %}
|
{% if park.average_rating %}
|
||||||
<div class="flex items-center">
|
<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">
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
{{ park.average_rating|floatformat:1 }}/10
|
{{ park.average_rating|floatformat:1 }}/10
|
||||||
</span>
|
</span>
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% 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>
|
<p class="text-gray-500 dark:text-gray-400">No parks found for this company.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
128
templates/companies/company_form.html
Normal file
128
templates/companies/company_form.html
Normal 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 %}
|
||||||
@@ -16,12 +16,19 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2 mt-4 md:mt-0">
|
||||||
{% if manufacturer.website %}
|
{% if manufacturer.website %}
|
||||||
<a href="{{ manufacturer.website }}" target="_blank" rel="noopener noreferrer"
|
<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
|
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
{% if manufacturer.description %}
|
{% if manufacturer.description %}
|
||||||
|
|||||||
128
templates/companies/manufacturer_form.html
Normal file
128
templates/companies/manufacturer_form.html
Normal 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 %}
|
||||||
127
templates/moderation/edit_submissions.html
Normal file
127
templates/moderation/edit_submissions.html
Normal 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 %}
|
||||||
88
templates/moderation/partials/submission_list.html
Normal file
88
templates/moderation/partials/submission_list.html
Normal 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 %}
|
||||||
@@ -3,19 +3,13 @@
|
|||||||
|
|
||||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container px-4 mx-auto">
|
<div class="container px-4 mx-auto">
|
||||||
<!-- Park Header -->
|
<!-- 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 class="flex flex-col items-start justify-between md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white"
|
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ park.name }}</h1>
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="name">{{ park.name }}</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300">
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
<i class="mr-2 fas fa-map-marker-alt"></i>
|
<i class="mr-2 fas fa-map-marker-alt"></i>
|
||||||
<span>{{ park.get_formatted_location }}</span>
|
<span>{{ park.get_formatted_location }}</span>
|
||||||
@@ -29,12 +23,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<button class="btn-secondary" data-edit-button
|
<a href="{% url 'parks:park_edit' slug=park.slug %}" class="btn-secondary">
|
||||||
data-content-id="{{ park.id }}"
|
|
||||||
data-content-type="park"
|
|
||||||
{% if not can_auto_approve %}data-require-reason="true"{% endif %}>
|
|
||||||
<i class="mr-2 fas fa-edit"></i>Edit
|
<i class="mr-2 fas fa-edit"></i>Edit
|
||||||
</button>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,17 +35,7 @@
|
|||||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}"
|
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="status" data-field-type="select"
|
|
||||||
data-options='[
|
|
||||||
{"value": "OPERATING", "label": "Operating"},
|
|
||||||
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
|
||||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
|
||||||
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
|
||||||
{"value": "DEMOLISHED", "label": "Demolished"},
|
|
||||||
{"value": "RELOCATED", "label": "Relocated"}
|
|
||||||
]'>
|
|
||||||
{{ park.get_status_display }}
|
{{ park.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if park.average_rating %}
|
{% if park.average_rating %}
|
||||||
@@ -97,11 +78,9 @@
|
|||||||
<!-- Left Column - Description and Areas -->
|
<!-- Left Column - Description and Areas -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
{% if park.description %}
|
{% 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>
|
<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"
|
<div class="text-gray-700 dark:text-gray-300">
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="description" data-field-type="textarea">
|
|
||||||
{{ park.description|linebreaks }}
|
{{ park.description|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +159,7 @@
|
|||||||
|
|
||||||
<!-- Right Column - Quick Facts and History -->
|
<!-- Right Column - Quick Facts and History -->
|
||||||
<div>
|
<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>
|
<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">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
{% if park.owner %}
|
{% if park.owner %}
|
||||||
@@ -212,9 +191,7 @@
|
|||||||
<i class="w-5 text-blue-500 fas fa-calendar-alt dark:text-blue-400"></i>
|
<i class="w-5 text-blue-500 fas fa-calendar-alt dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Opening Date</span>
|
<span class="ml-2">Opening Date</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="opening_date" data-field-type="date">
|
|
||||||
{{ park.opening_date }}
|
{{ park.opening_date }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,9 +202,7 @@
|
|||||||
<i class="w-5 text-blue-500 fas fa-calendar-times dark:text-blue-400"></i>
|
<i class="w-5 text-blue-500 fas fa-calendar-times dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Closing Date</span>
|
<span class="ml-2">Closing Date</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="closing_date" data-field-type="date">
|
|
||||||
{{ park.closing_date }}
|
{{ park.closing_date }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,9 +213,7 @@
|
|||||||
<i class="w-5 text-blue-500 fas fa-clock dark:text-blue-400"></i>
|
<i class="w-5 text-blue-500 fas fa-clock dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Operating Season</span>
|
<span class="ml-2">Operating Season</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="operating_season">
|
|
||||||
{{ park.operating_season }}
|
{{ park.operating_season }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,9 +224,7 @@
|
|||||||
<i class="w-5 text-blue-500 fas fa-ruler-combined dark:text-blue-400"></i>
|
<i class="w-5 text-blue-500 fas fa-ruler-combined dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Size</span>
|
<span class="ml-2">Size</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ park.id }}"
|
|
||||||
data-field-name="size_acres" data-field-type="number">
|
|
||||||
{{ park.size_acres }} acres
|
{{ park.size_acres }} acres
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +278,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="{% static 'js/inline-edit.js' %}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
{% extends 'base/base.html' %}
|
{% extends 'base/base.html' %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Add Park - ThrillWiki{% endblock %}
|
{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Park - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container px-4 mx-auto">
|
<div class="container px-4 mx-auto">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
<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 %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<!-- Hidden fields -->
|
<!-- Hidden fields -->
|
||||||
@@ -20,7 +36,7 @@
|
|||||||
<!-- Name field -->
|
<!-- Name field -->
|
||||||
<div>
|
<div>
|
||||||
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<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>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
{{ form.name }}
|
{{ form.name }}
|
||||||
@@ -33,46 +49,88 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location fields -->
|
<!-- 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">
|
<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>
|
</label>
|
||||||
<div>
|
<input type="text"
|
||||||
{{ form.country_name }}
|
id="id_country_name"
|
||||||
</div>
|
name="country_name"
|
||||||
{% if form.country_name.errors %}
|
x-model="query"
|
||||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
@input.debounce.300ms="fetchSuggestions()"
|
||||||
{{ form.country_name.errors }}
|
@focus="fetchSuggestions()"
|
||||||
</div>
|
@click.away="suggestions = []"
|
||||||
{% endif %}
|
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>
|
<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">
|
<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
|
Region/State
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<input type="text"
|
||||||
{{ form.region_name }}
|
id="id_region_name"
|
||||||
</div>
|
name="region_name"
|
||||||
{% if form.region_name.errors %}
|
x-model="query"
|
||||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
@input.debounce.300ms="fetchSuggestions()"
|
||||||
{{ form.region_name.errors }}
|
@focus="fetchSuggestions()"
|
||||||
</div>
|
@click.away="suggestions = []"
|
||||||
{% endif %}
|
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>
|
<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">
|
<label for="{{ form.city_name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
City
|
City
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<input type="text"
|
||||||
{{ form.city_name }}
|
id="id_city_name"
|
||||||
</div>
|
name="city_name"
|
||||||
{% if form.city_name.errors %}
|
x-model="query"
|
||||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
@input.debounce.300ms="fetchSuggestions()"
|
||||||
{{ form.city_name.errors }}
|
@focus="fetchSuggestions()"
|
||||||
</div>
|
@click.away="suggestions = []"
|
||||||
{% endif %}
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Other fields -->
|
<!-- Other fields -->
|
||||||
@@ -80,7 +138,7 @@
|
|||||||
{% if field.name not in 'name,country,region,city,country_name,region_name,city_name' %}
|
{% if field.name not in 'name,country,region,city,country_name,region_name,city_name' %}
|
||||||
<div>
|
<div>
|
||||||
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<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>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
{{ field }}
|
{{ field }}
|
||||||
@@ -98,17 +156,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
|
{% 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 class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<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>
|
</label>
|
||||||
<textarea name="reason"
|
<textarea name="reason"
|
||||||
id="reason"
|
id="reason"
|
||||||
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="3"
|
rows="3"
|
||||||
required
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@@ -124,11 +185,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex justify-end space-x-4">
|
<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
|
Cancel
|
||||||
</a>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -136,100 +198,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
<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-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-target="#parks-grid"
|
||||||
hx-push-url="true">
|
hx-push-url="true">
|
||||||
<div>
|
<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"
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="Search parks...">
|
placeholder="Search parks...">
|
||||||
</div>
|
</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>
|
<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 }}"
|
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"
|
autocomplete="off">
|
||||||
placeholder="Select country...">
|
<!-- 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>
|
|
||||||
|
<!-- 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>
|
<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 }}"
|
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"
|
autocomplete="off">
|
||||||
placeholder="Select state/region...">
|
<!-- 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>
|
|
||||||
|
<!-- 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>
|
<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"
|
<input type="text"
|
||||||
value="{{ current_filters.city }}"
|
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"
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
placeholder="Select city...">
|
placeholder="Select city..."
|
||||||
</div>
|
value="{{ current_filters.city }}"
|
||||||
<div>
|
autocomplete="off">
|
||||||
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
<!-- Suggestions Dropdown -->
|
||||||
<select name="status" id="status"
|
<ul x-show="suggestions.length > 0"
|
||||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
x-cloak
|
||||||
<option value="">All Statuses</option>
|
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">
|
||||||
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
<template x-for="suggestion in suggestions" :key="suggestion.id">
|
||||||
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
<li @click="selectSuggestion(suggestion)"
|
||||||
<option value="CLOSED_PERM" {% if current_filters.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
x-text="suggestion.name"
|
||||||
<option value="UNDER_CONSTRUCTION" {% if current_filters.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
class="px-4 py-2 text-gray-700 cursor-pointer dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||||
<option value="DEMOLISHED" {% if current_filters.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
</li>
|
||||||
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
</template>
|
||||||
</select>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden inputs for selected statuses -->
|
||||||
|
{% for status in current_filters.statuses %}
|
||||||
|
<input type="hidden" name="status" value="{{ status }}">
|
||||||
|
{% endfor %}
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Parks Grid -->
|
<!-- Parks Grid -->
|
||||||
@@ -70,107 +163,28 @@
|
|||||||
{% include "parks/partials/park_list.html" %}
|
{% include "parks/partials/park_list.html" %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
function toggleStatus(button, status) {
|
||||||
const countryInput = document.getElementById('country');
|
const form = document.getElementById('park-filters');
|
||||||
const regionInput = document.getElementById('region');
|
const existingInputs = form.querySelectorAll(`input[name="status"][value="${status}"]`);
|
||||||
const cityInput = document.getElementById('city');
|
|
||||||
|
|
||||||
// Initialize Awesomplete for country
|
if (existingInputs.length > 0) {
|
||||||
if (countryInput) {
|
// Status is already selected, remove it
|
||||||
const countryList = new Awesomplete(countryInput, {
|
existingInputs.forEach(input => input.remove());
|
||||||
minChars: 1,
|
button.classList.remove('ring-2', 'ring-blue-500');
|
||||||
maxItems: 10,
|
} else {
|
||||||
autoFirst: true
|
// Status is not selected, add it
|
||||||
});
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
countryInput.addEventListener('input', function() {
|
input.name = 'status';
|
||||||
fetch(`/parks/ajax/countries/?q=${encodeURIComponent(this.value)}`)
|
input.value = status;
|
||||||
.then(response => response.json())
|
form.appendChild(input);
|
||||||
.then(data => {
|
button.classList.add('ring-2', 'ring-blue-500');
|
||||||
countryList.list = data;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)}®ion=${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
|
// Trigger form submission
|
||||||
htmx.trigger('#park-filters', 'change');
|
form.dispatchEvent(new Event('change'));
|
||||||
});
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,19 +3,13 @@
|
|||||||
|
|
||||||
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container px-4 mx-auto">
|
<div class="container px-4 mx-auto">
|
||||||
<!-- Header -->
|
<!-- 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 class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white"
|
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">{{ ride.name }}</h1>
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="name">{{ ride.name }}</h1>
|
|
||||||
<p class="mb-2 text-gray-600 dark:text-gray-400">
|
<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">
|
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 }}
|
{{ ride.park.name }}
|
||||||
@@ -29,30 +23,10 @@
|
|||||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}"
|
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="status" data-field-type="select"
|
|
||||||
data-options='[
|
|
||||||
{"value": "OPERATING", "label": "Operating"},
|
|
||||||
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
|
||||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
|
||||||
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
|
||||||
{"value": "DEMOLISHED", "label": "Demolished"},
|
|
||||||
{"value": "RELOCATED", "label": "Relocated"}
|
|
||||||
]'>
|
|
||||||
{{ ride.get_status_display }}
|
{{ ride.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50"
|
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="category" data-field-type="select"
|
|
||||||
data-options='[
|
|
||||||
{"value": "RC", "label": "Roller Coaster"},
|
|
||||||
{"value": "DR", "label": "Dark Ride"},
|
|
||||||
{"value": "FR", "label": "Flat Ride"},
|
|
||||||
{"value": "WR", "label": "Water Ride"},
|
|
||||||
{"value": "TR", "label": "Transport"},
|
|
||||||
{"value": "OT", "label": "Other"}
|
|
||||||
]'>
|
|
||||||
{{ ride.get_category_display }}
|
{{ ride.get_category_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if ride.average_rating %}
|
{% if ride.average_rating %}
|
||||||
@@ -65,13 +39,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn-secondary" data-edit-button
|
<a href="{% url 'parks:rides:ride_edit' park_slug=ride.park.slug ride_slug=ride.slug %}" class="btn-secondary">
|
||||||
data-content-id="{{ ride.id }}"
|
|
||||||
data-content-type="ride"
|
|
||||||
{% if not can_auto_approve %}data-require-reason="true"{% endif %}>
|
|
||||||
<i class="mr-2 fas fa-edit"></i>
|
<i class="mr-2 fas fa-edit"></i>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,11 +68,9 @@
|
|||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<!-- Left Column - Description and Details -->
|
<!-- Left Column - Description and Details -->
|
||||||
<div class="lg:col-span-2">
|
<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>
|
<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"
|
<div class="prose dark:prose-invert max-w-none">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="description" data-field-type="textarea">
|
|
||||||
{{ ride.description|linebreaks }}
|
{{ ride.description|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,15 +90,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if coaster_stats %}
|
{% 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>
|
<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">
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
{% if coaster_stats.height_ft %}
|
{% if coaster_stats.height_ft %}
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-gray-500">Height</span>
|
<span class="block text-gray-500">Height</span>
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ coaster_stats.id }}"
|
|
||||||
data-field-name="height_ft" data-field-type="number">
|
|
||||||
{{ coaster_stats.height_ft }} ft
|
{{ coaster_stats.height_ft }} ft
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,9 +104,7 @@
|
|||||||
{% if coaster_stats.length_ft %}
|
{% if coaster_stats.length_ft %}
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-gray-500">Length</span>
|
<span class="block text-gray-500">Length</span>
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ coaster_stats.id }}"
|
|
||||||
data-field-name="length_ft" data-field-type="number">
|
|
||||||
{{ coaster_stats.length_ft }} ft
|
{{ coaster_stats.length_ft }} ft
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,27 +112,21 @@
|
|||||||
{% if coaster_stats.speed_mph %}
|
{% if coaster_stats.speed_mph %}
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-gray-500">Speed</span>
|
<span class="block text-gray-500">Speed</span>
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ coaster_stats.id }}"
|
|
||||||
data-field-name="speed_mph" data-field-type="number">
|
|
||||||
{{ coaster_stats.speed_mph }} mph
|
{{ coaster_stats.speed_mph }} mph
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-gray-500">Inversions</span>
|
<span class="block text-gray-500">Inversions</span>
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ coaster_stats.id }}"
|
|
||||||
data-field-name="inversions" data-field-type="number">
|
|
||||||
{{ coaster_stats.inversions }}
|
{{ coaster_stats.inversions }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% if coaster_stats.ride_time_seconds %}
|
{% if coaster_stats.ride_time_seconds %}
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-gray-500">Ride Duration</span>
|
<span class="block text-gray-500">Ride Duration</span>
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
<span class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ coaster_stats.id }}"
|
|
||||||
data-field-name="ride_time_seconds" data-field-type="number">
|
|
||||||
{{ coaster_stats.ride_time_seconds }} sec
|
{{ coaster_stats.ride_time_seconds }} sec
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,29 +138,23 @@
|
|||||||
|
|
||||||
<!-- Right Column - Quick Facts -->
|
<!-- Right Column - Quick Facts -->
|
||||||
<div class="lg:col-span-1">
|
<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>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Quick Facts</h2>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Manufacturer</dt>
|
<dt class="text-gray-500">Manufacturer</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">{{ ride.manufacturer }}</dd>
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="manufacturer">{{ ride.manufacturer }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{% if ride.model_name %}
|
{% if ride.model_name %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Model</dt>
|
<dt class="text-gray-500">Model</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">{{ ride.model_name }}</dd>
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="model_name">{{ ride.model_name }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ride.opening_date %}
|
{% if ride.opening_date %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Opening Date</dt>
|
<dt class="text-gray-500">Opening Date</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="opening_date" data-field-type="date">
|
|
||||||
{{ ride.opening_date }}
|
{{ ride.opening_date }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,9 +162,7 @@
|
|||||||
{% if ride.status_since %}
|
{% if ride.status_since %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Status Since</dt>
|
<dt class="text-gray-500">Status Since</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="status_since" data-field-type="date">
|
|
||||||
{{ ride.status_since }}
|
{{ ride.status_since }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,9 +170,7 @@
|
|||||||
{% if ride.closing_date %}
|
{% if ride.closing_date %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Closing Date</dt>
|
<dt class="text-gray-500">Closing Date</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="closing_date" data-field-type="date">
|
|
||||||
{{ ride.closing_date }}
|
{{ ride.closing_date }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,9 +178,7 @@
|
|||||||
{% if ride.capacity_per_hour %}
|
{% if ride.capacity_per_hour %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Capacity</dt>
|
<dt class="text-gray-500">Capacity</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="capacity_per_hour" data-field-type="number">
|
|
||||||
{{ ride.capacity_per_hour }} riders/hour
|
{{ ride.capacity_per_hour }} riders/hour
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,9 +186,7 @@
|
|||||||
{% if ride.min_height_in %}
|
{% if ride.min_height_in %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Minimum Height</dt>
|
<dt class="text-gray-500">Minimum Height</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-white"
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
data-editable data-content-id="{{ ride.id }}"
|
|
||||||
data-field-name="min_height_in" data-field-type="number">
|
|
||||||
{{ ride.min_height_in }} inches
|
{{ ride.min_height_in }} inches
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,7 +277,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="{% static 'js/inline-edit.js' %}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{% extends 'base/base.html' %}
|
{% extends 'base/base.html' %}
|
||||||
{% load static %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container px-4 mx-auto">
|
<div class="container px-4 mx-auto">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<div class="mb-6">
|
<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">
|
<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
|
Back to {{ park.name }} Rides
|
||||||
</a>
|
</a>
|
||||||
@@ -62,14 +62,14 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<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>
|
</label>
|
||||||
<textarea name="reason"
|
<textarea name="reason"
|
||||||
id="reason"
|
id="reason"
|
||||||
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="3"
|
rows="3"
|
||||||
required
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@@ -85,11 +85,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex justify-end space-x-4">
|
<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
|
Cancel
|
||||||
</a>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Binary file not shown.
@@ -7,197 +7,204 @@ import os
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# 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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = ['https://beta.thrillwiki.com']
|
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
|
||||||
ALLOWED_HOSTS = ['*']
|
ALLOWED_HOSTS = ["*"]
|
||||||
#ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'thrillwiki.com', 'beta.thrillwiki.com', '192.168.86.6', 'syn.thewesker.com']
|
# ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'thrillwiki.com', 'beta.thrillwiki.com', '192.168.86.6', 'syn.thewesker.com']
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'django.contrib.sites',
|
"django.contrib.sites",
|
||||||
|
|
||||||
# Third-party apps
|
# Third-party apps
|
||||||
'allauth',
|
"allauth",
|
||||||
'allauth.account',
|
"allauth.account",
|
||||||
'allauth.socialaccount',
|
"allauth.socialaccount",
|
||||||
'allauth.socialaccount.providers.google',
|
"allauth.socialaccount.providers.google",
|
||||||
'allauth.socialaccount.providers.discord',
|
"allauth.socialaccount.providers.discord",
|
||||||
'simple_history',
|
"simple_history",
|
||||||
'django_cleanup',
|
"django_cleanup",
|
||||||
'django_filters',
|
"django_filters",
|
||||||
'django_htmx',
|
"django_htmx",
|
||||||
'whitenoise',
|
"whitenoise",
|
||||||
'django_tailwind_cli',
|
"django_tailwind_cli",
|
||||||
'cities_light',
|
"cities_light",
|
||||||
|
|
||||||
# Local apps
|
# Local apps
|
||||||
'core',
|
"core",
|
||||||
'accounts',
|
"accounts",
|
||||||
'companies',
|
"companies",
|
||||||
'parks',
|
"parks",
|
||||||
'rides',
|
"rides",
|
||||||
'reviews',
|
"reviews",
|
||||||
'email_service',
|
"email_service",
|
||||||
'media',
|
"media",
|
||||||
'moderation',
|
"moderation",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Cities Light settings
|
# Cities Light settings
|
||||||
CITIES_LIGHT_TRANSLATION_LANGUAGES = ['en']
|
CITIES_LIGHT_TRANSLATION_LANGUAGES = ["en"]
|
||||||
CITIES_LIGHT_INCLUDE_COUNTRIES = ['US', 'CA', 'GB', 'FR', 'DE', 'ES', 'IT', 'JP', 'CN', 'AU']
|
# 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']
|
CITIES_LIGHT_INCLUDE_CITY_TYPES = [
|
||||||
|
"PPL",
|
||||||
MIDDLEWARE = [
|
"PPLA",
|
||||||
'django.middleware.cache.UpdateCacheMiddleware',
|
"PPLA2",
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"PPLA3",
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
"PPLA4",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"PPLC",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"PPLG",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"PPLL",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"PPLR",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"PPLS",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
'allauth.account.middleware.AccountMiddleware',
|
|
||||||
'django.middleware.cache.FetchFromCacheMiddleware',
|
|
||||||
'simple_history.middleware.HistoryRequestMiddleware',
|
|
||||||
'django_htmx.middleware.HtmxMiddleware',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
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 = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [os.path.join(BASE_DIR, 'templates')],
|
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
'moderation.context_processors.moderation_access', # Added moderation context processor
|
"moderation.context_processors.moderation_access", # Added moderation context processor
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
|
WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
'NAME': 'thrillwiki',
|
"NAME": "thrillwiki",
|
||||||
'USER': 'wiki',
|
"USER": "wiki",
|
||||||
'PASSWORD': 'thrillwiki',
|
"PASSWORD": "thrillwiki",
|
||||||
'HOST': '192.168.86.3',
|
"HOST": "192.168.86.3",
|
||||||
'PORT': '5432',
|
"PORT": "5432",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache settings
|
# Cache settings
|
||||||
CACHES = {
|
CACHES = {
|
||||||
'default': {
|
"default": {
|
||||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
'LOCATION': 'unique-snowflake',
|
"LOCATION": "unique-snowflake",
|
||||||
'TIMEOUT': 300, # 5 minutes
|
"TIMEOUT": 300, # 5 minutes
|
||||||
'OPTIONS': {
|
"OPTIONS": {"MAX_ENTRIES": 1000},
|
||||||
'MAX_ENTRIES': 1000
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
|
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
|
||||||
CACHE_MIDDLEWARE_KEY_PREFIX = 'thrillwiki'
|
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
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
|
# Internationalization
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = 'America/New_York'
|
TIME_ZONE = "America/New_York"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = "static/"
|
||||||
STATICFILES_DIRS = [BASE_DIR / "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 files
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
'allauth.account.auth_backends.AuthenticationBackend',
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
]
|
]
|
||||||
|
|
||||||
# django-allauth settings
|
# django-allauth settings
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
ACCOUNT_EMAIL_REQUIRED = True
|
ACCOUNT_EMAIL_REQUIRED = True
|
||||||
ACCOUNT_USERNAME_REQUIRED = True
|
ACCOUNT_USERNAME_REQUIRED = True
|
||||||
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
|
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
||||||
ACCOUNT_EMAIL_VERIFICATION = 'optional'
|
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||||
LOGIN_REDIRECT_URL = '/'
|
LOGIN_REDIRECT_URL = "/"
|
||||||
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
|
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
# Custom adapters
|
# Custom adapters
|
||||||
ACCOUNT_ADAPTER = 'accounts.adapters.CustomAccountAdapter'
|
ACCOUNT_ADAPTER = "accounts.adapters.CustomAccountAdapter"
|
||||||
SOCIALACCOUNT_ADAPTER = 'accounts.adapters.CustomSocialAccountAdapter'
|
SOCIALACCOUNT_ADAPTER = "accounts.adapters.CustomSocialAccountAdapter"
|
||||||
|
|
||||||
# Social account settings
|
# Social account settings
|
||||||
SOCIALACCOUNT_PROVIDERS = {
|
SOCIALACCOUNT_PROVIDERS = {
|
||||||
'google': {
|
"google": {
|
||||||
'APP': {
|
"APP": {
|
||||||
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
"client_id": "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com",
|
||||||
'secret': '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',
|
"[SECRET-REMOVED]",
|
||||||
'key': ''
|
"key": "",
|
||||||
},
|
},
|
||||||
'SCOPE': [
|
"SCOPE": [
|
||||||
'profile',
|
"profile",
|
||||||
'email',
|
"email",
|
||||||
],
|
],
|
||||||
'AUTH_PARAMS': {'access_type': 'online'},
|
"AUTH_PARAMS": {"access_type": "online"},
|
||||||
},
|
},
|
||||||
'discord': {
|
"discord": {
|
||||||
'APP': {
|
"APP": {
|
||||||
'client_id': '1299112802274902047',
|
"client_id": "1299112802274902047",
|
||||||
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
"[SECRET-REMOVED]",
|
||||||
'key': ''
|
"key": "",
|
||||||
|
},
|
||||||
|
"SCOPE": ["identify", "email"],
|
||||||
|
"OAUTH_PKCE_ENABLED": True,
|
||||||
},
|
},
|
||||||
'SCOPE': ['identify', 'email'],
|
|
||||||
'OAUTH_PKCE_ENABLED': True,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Additional social account settings
|
# Additional social account settings
|
||||||
@@ -206,11 +213,11 @@ SOCIALACCOUNT_AUTO_SIGNUP = False
|
|||||||
SOCIALACCOUNT_STORE_TOKENS = True
|
SOCIALACCOUNT_STORE_TOKENS = True
|
||||||
|
|
||||||
# Email settings
|
# Email settings
|
||||||
EMAIL_BACKEND = 'email_service.backends.ForwardEmailBackend'
|
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
|
||||||
FORWARD_EMAIL_BASE_URL = 'https://api.forwardemail.net'
|
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
|
||||||
|
|
||||||
# Custom User Model
|
# Custom User Model
|
||||||
AUTH_USER_MODEL = 'accounts.User'
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
|
|
||||||
# Tailwind configuration
|
# Tailwind configuration
|
||||||
TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js")
|
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")
|
TAILWIND_CLI_DIST_CSS = os.path.join(BASE_DIR, "static/css/tailwind.css")
|
||||||
|
|
||||||
# Cloudflare Turnstile settings
|
# Cloudflare Turnstile settings
|
||||||
TURNSTILE_SITE_KEY = '0x4AAAAAAAyqVp3RjccrC9Kz'
|
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
|
||||||
TURNSTILE_SECRET_KEY = '0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY'
|
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
|
||||||
TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
|||||||
Reference in New Issue
Block a user