diff --git a/accounts/__pycache__/urls.cpython-312.pyc b/accounts/__pycache__/urls.cpython-312.pyc index 04cdef27..64c3eb6f 100644 Binary files a/accounts/__pycache__/urls.cpython-312.pyc and b/accounts/__pycache__/urls.cpython-312.pyc differ diff --git a/accounts/__pycache__/views.cpython-312.pyc b/accounts/__pycache__/views.cpython-312.pyc index 566b8e1d..3f1bcf4e 100644 Binary files a/accounts/__pycache__/views.cpython-312.pyc and b/accounts/__pycache__/views.cpython-312.pyc differ diff --git a/companies/__pycache__/urls.cpython-312.pyc b/companies/__pycache__/urls.cpython-312.pyc index 325c5226..224eb278 100644 Binary files a/companies/__pycache__/urls.cpython-312.pyc and b/companies/__pycache__/urls.cpython-312.pyc differ diff --git a/companies/__pycache__/views.cpython-312.pyc b/companies/__pycache__/views.cpython-312.pyc index 404056a7..0bce09c2 100644 Binary files a/companies/__pycache__/views.cpython-312.pyc and b/companies/__pycache__/views.cpython-312.pyc differ diff --git a/companies/forms.py b/companies/forms.py new file mode 100644 index 00000000..6a9a0937 --- /dev/null +++ b/companies/forms.py @@ -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' + }), + } diff --git a/companies/urls.py b/companies/urls.py index ee57a601..03e79a8f 100644 --- a/companies/urls.py +++ b/companies/urls.py @@ -6,9 +6,13 @@ app_name = 'companies' urlpatterns = [ # Company URLs path('', views.CompanyListView.as_view(), name='company_list'), + path('create/', views.CompanyCreateView.as_view(), name='company_create'), + path('/edit/', views.CompanyUpdateView.as_view(), name='company_edit'), path('/', views.CompanyDetailView.as_view(), name='company_detail'), # Manufacturer URLs path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), + path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'), + path('manufacturers//edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'), path('manufacturers//', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'), ] diff --git a/companies/views.py b/companies/views.py index 2a229689..db27238a 100644 --- a/companies/views.py +++ b/companies/views.py @@ -1,11 +1,165 @@ -from django.views.generic import DetailView, ListView +from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.contenttypes.models import ContentType +from django.contrib import messages +from django.http import HttpResponseRedirect from .models import Company, Manufacturer +from .forms import CompanyForm, ManufacturerForm from rides.models import Ride from parks.models import Park from core.views import SlugRedirectMixin +from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin +from moderation.models import EditSubmission -class CompanyDetailView(SlugRedirectMixin, DetailView): +class CompanyCreateView(LoginRequiredMixin, CreateView): + model = Company + form_class = CompanyForm + template_name = 'companies/company_form.html' + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Company), + submission_type='CREATE', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.object_id = self.object.id + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully created {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, 'Your company submission has been sent for review') + return HttpResponseRedirect(reverse('companies:company_list')) + + def get_success_url(self): + return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) + +class CompanyUpdateView(LoginRequiredMixin, UpdateView): + model = Company + form_class = CompanyForm + template_name = 'companies/company_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['is_edit'] = True + return context + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Company), + object_id=self.object.id, + submission_type='EDIT', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully updated {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') + return HttpResponseRedirect(reverse('companies:company_detail', kwargs={'slug': self.object.slug})) + + def get_success_url(self): + return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) + +class ManufacturerCreateView(LoginRequiredMixin, CreateView): + model = Manufacturer + form_class = ManufacturerForm + template_name = 'companies/manufacturer_form.html' + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Manufacturer), + submission_type='CREATE', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.object_id = self.object.id + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully created {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, 'Your manufacturer submission has been sent for review') + return HttpResponseRedirect(reverse('companies:manufacturer_list')) + + def get_success_url(self): + return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) + +class ManufacturerUpdateView(LoginRequiredMixin, UpdateView): + model = Manufacturer + form_class = ManufacturerForm + template_name = 'companies/manufacturer_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['is_edit'] = True + return context + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Manufacturer), + object_id=self.object.id, + submission_type='EDIT', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully updated {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') + return HttpResponseRedirect(reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})) + + def get_success_url(self): + return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) + +class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = Company template_name = 'companies/company_detail.html' context_object_name = 'company' @@ -27,7 +181,7 @@ class CompanyDetailView(SlugRedirectMixin, DetailView): def get_redirect_url_pattern(self): return 'company_detail' -class ManufacturerDetailView(SlugRedirectMixin, DetailView): +class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = Manufacturer template_name = 'companies/manufacturer_detail.html' context_object_name = 'manufacturer' diff --git a/moderation/admin.py b/moderation/admin.py index 745dd646..43c377bb 100644 --- a/moderation/admin.py +++ b/moderation/admin.py @@ -16,10 +16,10 @@ class ModerationAdminSite(AdminSite): moderation_site = ModerationAdminSite(name='moderation') class EditSubmissionAdmin(admin.ModelAdmin): - list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'submitted_at', 'reviewed_by'] - list_filter = ['status', 'content_type', 'submitted_at'] - search_fields = ['user__username', 'reason', 'source', 'review_notes'] - readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'submitted_at'] + list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'created_at', 'handled_by'] + list_filter = ['status', 'content_type', 'created_at'] + search_fields = ['user__username', 'reason', 'source', 'notes'] + readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'created_at'] def user_link(self, obj): url = reverse('admin:accounts_user_change', args=[obj.user.id]) @@ -36,16 +36,18 @@ class EditSubmissionAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): if 'status' in form.changed_data: if obj.status == 'APPROVED': - obj.approve(request.user, obj.review_notes) + obj.approve(request.user) elif obj.status == 'REJECTED': - obj.reject(request.user, obj.review_notes) + obj.reject(request.user) + elif obj.status == 'ESCALATED': + obj.escalate(request.user) super().save_model(request, obj, form, change) class PhotoSubmissionAdmin(admin.ModelAdmin): - list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'submitted_at', 'reviewed_by'] - list_filter = ['status', 'content_type', 'submitted_at'] - search_fields = ['user__username', 'caption', 'review_notes'] - readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'submitted_at'] + list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'created_at', 'handled_by'] + list_filter = ['status', 'content_type', 'created_at'] + search_fields = ['user__username', 'caption', 'notes'] + readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'created_at'] def user_link(self, obj): url = reverse('admin:accounts_user_change', args=[obj.user.id]) @@ -68,9 +70,9 @@ class PhotoSubmissionAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): if 'status' in form.changed_data: if obj.status == 'APPROVED': - obj.approve(request.user, obj.review_notes) + obj.approve(request.user, obj.notes) elif obj.status == 'REJECTED': - obj.reject(request.user, obj.review_notes) + obj.reject(request.user, obj.notes) super().save_model(request, obj, form, change) # Register with moderation site only diff --git a/moderation/migrations/0003_rename_fields_and_update_status.py b/moderation/migrations/0003_rename_fields_and_update_status.py new file mode 100644 index 00000000..9f86a541 --- /dev/null +++ b/moderation/migrations/0003_rename_fields_and_update_status.py @@ -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', + ), + ), + ] diff --git a/moderation/mixins.py b/moderation/mixins.py index 9dc8ecf1..80d53de9 100644 --- a/moderation/mixins.py +++ b/moderation/mixins.py @@ -192,8 +192,8 @@ class InlineEditMixin: context['pending_edits'] = EditSubmission.objects.filter( content_type=ContentType.objects.get_for_model(obj), object_id=obj.id, - status='PENDING' - ).select_related('user').order_by('-submitted_at') + status='NEW' + ).select_related('user').order_by('-created_at') return context class HistoryMixin: @@ -211,7 +211,7 @@ class HistoryMixin: content_type=content_type, object_id=obj.id ).exclude( - status='PENDING' - ).select_related('user', 'reviewed_by').order_by('-submitted_at') + status='NEW' + ).select_related('user', 'handled_by').order_by('-created_at') return context diff --git a/moderation/models.py b/moderation/models.py index 67440d0b..8f4c503d 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -7,10 +7,10 @@ from django.apps import apps class EditSubmission(models.Model): STATUS_CHOICES = [ - ('PENDING', 'Pending'), + ('NEW', 'New'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), - ('AUTO_APPROVED', 'Auto Approved'), + ('ESCALATED', 'Escalated'), ] SUBMISSION_TYPE_CHOICES = [ @@ -53,26 +53,26 @@ class EditSubmission(models.Model): status = models.CharField( max_length=20, choices=STATUS_CHOICES, - default='PENDING' + default='NEW' ) - submitted_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) # Review details - reviewed_by = models.ForeignKey( + handled_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, - related_name='reviewed_submissions' + related_name='handled_submissions' ) - reviewed_at = models.DateTimeField(null=True, blank=True) - review_notes = models.TextField( + handled_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField( blank=True, help_text='Notes from the moderator about this submission' ) class Meta: - ordering = ['-submitted_at'] + ordering = ['-created_at'] indexes = [ models.Index(fields=['content_type', 'object_id']), models.Index(fields=['status']), @@ -96,12 +96,11 @@ class EditSubmission(models.Model): return resolved_data - def approve(self, moderator, notes=''): + def approve(self, user): """Approve the submission and apply the changes""" self.status = 'APPROVED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = user + self.handled_at = timezone.now() model_class = self.content_type.model_class() resolved_data = self._resolve_foreign_keys(self.changes) @@ -122,42 +121,23 @@ class EditSubmission(models.Model): self.save() return obj - def reject(self, moderator, notes): + def reject(self, user): """Reject the submission""" self.status = 'REJECTED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = user + self.handled_at = timezone.now() self.save() - def auto_approve(self): - """Auto-approve the submission (for moderators/admins)""" - self.status = 'AUTO_APPROVED' - self.reviewed_by = self.user - self.reviewed_at = timezone.now() - - model_class = self.content_type.model_class() - resolved_data = self._resolve_foreign_keys(self.changes) - - if self.submission_type == 'CREATE': - # Create new object - obj = model_class(**resolved_data) - obj.save() - # Update object_id after creation - self.object_id = obj.id - else: - # Apply changes to existing object - obj = self.content_object - for field, value in resolved_data.items(): - setattr(obj, field, value) - obj.save() - + def escalate(self, user): + """Escalate the submission to admin""" + self.status = 'ESCALATED' + self.handled_by = user + self.handled_at = timezone.now() self.save() - return obj class PhotoSubmission(models.Model): STATUS_CHOICES = [ - ('PENDING', 'Pending'), + ('NEW', 'New'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), ('AUTO_APPROVED', 'Auto Approved'), @@ -184,26 +164,26 @@ class PhotoSubmission(models.Model): status = models.CharField( max_length=20, choices=STATUS_CHOICES, - default='PENDING' + default='NEW' ) - submitted_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) # Review details - reviewed_by = models.ForeignKey( + handled_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, - related_name='reviewed_photos' + related_name='handled_photos' ) - reviewed_at = models.DateTimeField(null=True, blank=True) - review_notes = models.TextField( + handled_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField( blank=True, help_text='Notes from the moderator about this photo submission' ) class Meta: - ordering = ['-submitted_at'] + ordering = ['-created_at'] indexes = [ models.Index(fields=['content_type', 'object_id']), models.Index(fields=['status']), @@ -217,9 +197,9 @@ class PhotoSubmission(models.Model): from media.models import Photo self.status = 'APPROVED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = moderator + self.handled_at = timezone.now() + self.notes = notes # Create the approved photo Photo.objects.create( @@ -236,9 +216,9 @@ class PhotoSubmission(models.Model): def reject(self, moderator, notes): """Reject the photo submission""" self.status = 'REJECTED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = moderator + self.handled_at = timezone.now() + self.notes = notes self.save() def auto_approve(self): @@ -246,8 +226,8 @@ class PhotoSubmission(models.Model): from media.models import Photo self.status = 'AUTO_APPROVED' - self.reviewed_by = self.user - self.reviewed_at = timezone.now() + self.handled_by = self.user + self.handled_at = timezone.now() # Create the approved photo Photo.objects.create( diff --git a/moderation/urls.py b/moderation/urls.py index b4d320b0..55706ba9 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -1,16 +1,11 @@ -from django.urls import path, include -from .admin import moderation_site -from .views import EditSubmissionListView, PhotoSubmissionListView +from django.urls import path +from . import views app_name = 'moderation' urlpatterns = [ - # Custom moderation views - path('submissions/', include([ - path('edits/', EditSubmissionListView.as_view(), name='edit_submissions'), - path('photos/', PhotoSubmissionListView.as_view(), name='photo_submissions'), - ])), - - # Admin site URLs - path('admin/', moderation_site.urls), + path('submissions/', views.EditSubmissionListView.as_view(), name='edit_submissions'), + path('submissions//approve/', views.approve_submission, name='approve_submission'), + path('submissions//reject/', views.reject_submission, name='reject_submission'), + path('submissions//escalate/', views.escalate_submission, name='escalate_submission'), ] diff --git a/moderation/views.py b/moderation/views.py index 9c2d6a6b..ff809879 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -1,100 +1,90 @@ from django.views.generic import ListView -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import JsonResponse +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.shortcuts import get_object_or_404 -from django.utils import timezone -from .models import EditSubmission, PhotoSubmission -from .mixins import ModeratorRequiredMixin +from django.http import HttpResponse +from django.contrib import messages +from django.db.models import Q +from .models import EditSubmission -class EditSubmissionListView(ModeratorRequiredMixin, ListView): +class ModeratorRequiredMixin(UserPassesTestMixin): + def test_func(self): + return self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + +class EditSubmissionListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): model = EditSubmission - template_name = 'moderation/admin/edit_submission_list.html' + template_name = 'moderation/edit_submissions.html' context_object_name = 'submissions' - paginate_by = 20 def get_queryset(self): - queryset = super().get_queryset().select_related( - 'user', 'reviewed_by', 'content_type' - ).order_by('-submitted_at') - - # Filter by status - status = self.request.GET.get('status') - if status: - queryset = queryset.filter(status=status) - - # Filter by submission type - submission_type = self.request.GET.get('type') - if submission_type: - queryset = queryset.filter(submission_type=submission_type) - + tab = self.request.GET.get('tab', 'new') + queryset = EditSubmission.objects.select_related('user', 'content_type') + + # Include edits by privileged users (mods, admins, superusers) in appropriate tabs + privileged_roles = ['MODERATOR', 'ADMIN', 'SUPERUSER'] + + if tab == 'new': + # Show pending submissions, oldest first + queryset = queryset.filter(status='NEW').order_by('created_at') + elif tab == 'approved': + # Show approved submissions and auto-approved edits by privileged users + queryset = queryset.filter( + Q(status='APPROVED') | + Q(user__role__in=privileged_roles, status='NEW') # Include privileged users' edits + ).order_by('-created_at') + elif tab == 'rejected': + # Show rejected submissions, newest first + queryset = queryset.filter(status='REJECTED').order_by('-created_at') + elif tab == 'escalated' and self.request.user.role in ['ADMIN', 'SUPERUSER']: + # Show escalated submissions, newest first + queryset = queryset.filter(status='ESCALATED').order_by('-created_at') + else: + # Default to new submissions if invalid tab + queryset = queryset.filter(status='NEW').order_by('created_at') + return queryset - def post(self, request, *args, **kwargs): - submission_id = request.POST.get('submission_id') - action = request.POST.get('action') - review_notes = request.POST.get('review_notes', '') - - submission = get_object_or_404(EditSubmission, id=submission_id) - - if action == 'approve': - obj = submission.approve(request.user, review_notes) - message = 'New addition approved successfully.' if submission.submission_type == 'CREATE' else 'Changes approved successfully.' - return JsonResponse({ - 'status': 'success', - 'message': message, - 'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None - }) - elif action == 'reject': - submission.reject(request.user, review_notes) - message = 'New addition rejected.' if submission.submission_type == 'CREATE' else 'Changes rejected.' - return JsonResponse({ - 'status': 'success', - 'message': message - }) - - return JsonResponse({ - 'status': 'error', - 'message': 'Invalid action.' - }, status=400) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['active_tab'] = self.request.GET.get('tab', 'new') + context['new_count'] = EditSubmission.objects.filter(status='NEW').count() + if self.request.user.role in ['ADMIN', 'SUPERUSER']: + context['escalated_count'] = EditSubmission.objects.filter(status='ESCALATED').count() + return context -class PhotoSubmissionListView(ModeratorRequiredMixin, ListView): - model = PhotoSubmission - template_name = 'moderation/admin/photo_submission_list.html' - context_object_name = 'submissions' - paginate_by = 20 + def get_template_names(self): + if self.request.htmx: + return ['moderation/partials/submission_list.html'] + return [self.template_name] - def get_queryset(self): - queryset = super().get_queryset().select_related( - 'user', 'reviewed_by', 'content_type' - ).order_by('-submitted_at') - - status = self.request.GET.get('status') - if status: - queryset = queryset.filter(status=status) - - return queryset +def approve_submission(request, submission_id): + submission = get_object_or_404(EditSubmission, id=submission_id) + + if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + submission.approve(request.user) + messages.success(request, 'Submission approved successfully') + + # Return updated submission list for current tab + view = EditSubmissionListView.as_view() + return view(request) - def post(self, request, *args, **kwargs): - submission_id = request.POST.get('submission_id') - action = request.POST.get('action') - review_notes = request.POST.get('review_notes', '') - - submission = get_object_or_404(PhotoSubmission, id=submission_id) - - if action == 'approve': - submission.approve(request.user, review_notes) - return JsonResponse({ - 'status': 'success', - 'message': 'Photo approved successfully.' - }) - elif action == 'reject': - submission.reject(request.user, review_notes) - return JsonResponse({ - 'status': 'success', - 'message': 'Photo rejected successfully.' - }) - - return JsonResponse({ - 'status': 'error', - 'message': 'Invalid action.' - }, status=400) +def reject_submission(request, submission_id): + submission = get_object_or_404(EditSubmission, id=submission_id) + + if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + submission.reject(request.user) + messages.success(request, 'Submission rejected successfully') + + # Return updated submission list for current tab + view = EditSubmissionListView.as_view() + return view(request) + +def escalate_submission(request, submission_id): + submission = get_object_or_404(EditSubmission, id=submission_id) + + if request.user.role == 'MODERATOR': + submission.escalate(request.user) + messages.success(request, 'Submission escalated to admin') + + # Return updated submission list for current tab + view = EditSubmissionListView.as_view() + return view(request) diff --git a/parks/__pycache__/admin.cpython-312.pyc b/parks/__pycache__/admin.cpython-312.pyc index bdfc28ce..7abc574b 100644 Binary files a/parks/__pycache__/admin.cpython-312.pyc and b/parks/__pycache__/admin.cpython-312.pyc differ diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index a2e154aa..a4b34985 100644 Binary files a/parks/__pycache__/models.cpython-312.pyc and b/parks/__pycache__/models.cpython-312.pyc differ diff --git a/parks/__pycache__/urls.cpython-312.pyc b/parks/__pycache__/urls.cpython-312.pyc index 016ae9e8..883650c5 100644 Binary files a/parks/__pycache__/urls.cpython-312.pyc and b/parks/__pycache__/urls.cpython-312.pyc differ diff --git a/parks/__pycache__/views.cpython-312.pyc b/parks/__pycache__/views.cpython-312.pyc index af4d4b64..d802516b 100644 Binary files a/parks/__pycache__/views.cpython-312.pyc and b/parks/__pycache__/views.cpython-312.pyc differ diff --git a/parks/admin.py b/parks/admin.py index faebaac1..a16f8207 100644 --- a/parks/admin.py +++ b/parks/admin.py @@ -1,19 +1,49 @@ from django.contrib import admin +from django.utils.html import format_html from simple_history.admin import SimpleHistoryAdmin from .models import Park, ParkArea -@admin.register(Park) class ParkAdmin(SimpleHistoryAdmin): - list_display = ('name', 'location', 'owner', 'status', 'opening_date') - list_filter = ('status', 'owner') + list_display = ('name', 'location', 'status', 'owner', 'created_at', 'updated_at') + list_filter = ('status', 'country', 'region', 'city') search_fields = ('name', 'location', 'description') + readonly_fields = ('created_at', 'updated_at') prepopulated_fields = {'slug': ('name',)} - readonly_fields = ('id', 'created_at', 'updated_at') -@admin.register(ParkArea) + def get_history_list_display(self, request): + """Customize the list display for history records""" + return ('name', 'location', 'status', 'history_date', 'history_user') + class ParkAreaAdmin(SimpleHistoryAdmin): - list_display = ('name', 'park', 'opening_date') + list_display = ('name', 'park', 'created_at', 'updated_at') list_filter = ('park',) - search_fields = ('name', 'description') + search_fields = ('name', 'description', 'park__name') + readonly_fields = ('created_at', 'updated_at') prepopulated_fields = {'slug': ('name',)} - readonly_fields = ('id', 'created_at', 'updated_at') + + def get_history_list_display(self, request): + """Customize the list display for history records""" + return ('name', 'park', 'history_date', 'history_user') + +# Register the models with their admin classes +admin.site.register(Park, ParkAdmin) +admin.site.register(ParkArea, ParkAreaAdmin) + +# Register the historical records for direct editing +from simple_history.models import HistoricalRecords +from .models import Park + +class HistoricalParkAdmin(admin.ModelAdmin): + list_display = ('name', 'location', 'status', 'history_date', 'history_user', 'history_type') + list_filter = ('status', 'country', 'region', 'city', 'history_type') + search_fields = ('name', 'location', 'description') + readonly_fields = ('history_date', 'history_type', 'history_id') + + def has_add_permission(self, request): + return False # Prevent adding new historical records directly + + def has_delete_permission(self, request, obj=None): + return False # Prevent deleting historical records + +# Register the historical model +admin.site.register(Park.history.model, HistoricalParkAdmin) diff --git a/parks/forms.py b/parks/forms.py index 020c9c93..23b81d9b 100644 --- a/parks/forms.py +++ b/parks/forms.py @@ -3,48 +3,89 @@ from django.urls import reverse_lazy from .models import Park from cities_light.models import Country, Region, City +class LocationFilterForm(forms.Form): + country = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white', + 'placeholder': 'Select or type a country', + 'hx-get': reverse_lazy('parks:country_search'), + 'hx-trigger': 'focus, input', + 'hx-target': '#country-results', + 'autocomplete': 'off' + }) + ) + region = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white', + 'placeholder': 'Select or type a region/state', + 'hx-get': reverse_lazy('parks:region_search'), + 'hx-trigger': 'focus, input', + 'hx-target': '#region-results', + 'autocomplete': 'off' + }) + ) + city = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white', + 'placeholder': 'Select or type a city', + 'hx-get': reverse_lazy('parks:city_search'), + 'hx-trigger': 'focus, input', + 'hx-target': '#city-results', + 'autocomplete': 'off' + }) + ) + class ParkForm(forms.ModelForm): # Hidden fields for actual model relations country = forms.ModelChoiceField(queryset=Country.objects.all(), required=True, widget=forms.HiddenInput()) region = forms.ModelChoiceField(queryset=Region.objects.all(), required=False, widget=forms.HiddenInput()) city = forms.ModelChoiceField(queryset=City.objects.all(), required=False, widget=forms.HiddenInput()) - # Visible fields for Awesomplete + # Visible fields for Alpine.js country_name = forms.CharField( label="Country", + required=True, widget=forms.TextInput(attrs={ 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white', - 'placeholder': 'Start typing a country name...', + 'placeholder': 'Select or type a country', + 'data-autocomplete': 'true' }) ) + region_name = forms.CharField( label="Region/State", required=False, widget=forms.TextInput(attrs={ 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white', - 'placeholder': 'Start typing a region/state name...', + 'placeholder': 'Select or type a region/state', + 'data-autocomplete': 'true' }) ) + city_name = forms.CharField( label="City", required=False, widget=forms.TextInput(attrs={ 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white', - 'placeholder': 'Start typing a city name...', + 'placeholder': 'Select or type a city', + 'data-autocomplete': 'true' }) ) class Meta: model = Park - fields = ['name', 'country', 'region', 'city', 'description', 'owner', 'status', - 'opening_date', 'closing_date', 'operating_season', 'size_acres', 'website'] + fields = ['name', 'description', 'owner', 'status', 'opening_date', 'closing_date', + 'operating_season', 'size_acres', 'website', 'country', 'region', 'city'] widgets = { 'name': forms.TextInput(attrs={ 'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white' }), 'description': forms.Textarea(attrs={ - 'rows': 4, - 'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white' + 'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white', + 'rows': 4 }), 'owner': forms.Select(attrs={ 'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white' @@ -79,15 +120,26 @@ class ParkForm(forms.ModelForm): super().__init__(*args, **kwargs) instance = kwargs.get('instance') if instance: - if instance.country: - self.fields['country_name'].initial = instance.country.name - self.fields['country'].initial = instance.country - if instance.region: - self.fields['region_name'].initial = instance.region.name - self.fields['region'].initial = instance.region - if instance.city: - self.fields['city_name'].initial = instance.city.name - self.fields['city'].initial = instance.city + try: + if instance.country: + self.fields['country_name'].initial = instance.country.name + self.fields['country'].initial = instance.country + except Country.DoesNotExist: + pass + + try: + if instance.region: + self.fields['region_name'].initial = instance.region.name + self.fields['region'].initial = instance.region + except Region.DoesNotExist: + pass + + try: + if instance.city: + self.fields['city_name'].initial = instance.city.name + self.fields['city'].initial = instance.city + except City.DoesNotExist: + pass def clean(self): cleaned_data = super().clean() @@ -95,12 +147,26 @@ class ParkForm(forms.ModelForm): region_name = cleaned_data.get('region_name') city_name = cleaned_data.get('city_name') + # Get or create default country if needed + default_country = Country.objects.first() + if not default_country: + default_country = Country.objects.create( + name='Unknown', + name_ascii='Unknown', + slug='unknown', + geoname_id=0, + alternate_names='', + search_names='Unknown' + ) + if country_name: try: country = Country.objects.get(name__iexact=country_name) cleaned_data['country'] = country except Country.DoesNotExist: - self.add_error('country_name', 'Invalid country name') + cleaned_data['country'] = default_country + else: + raise forms.ValidationError("Country is required") if region_name and cleaned_data.get('country'): try: @@ -110,7 +176,7 @@ class ParkForm(forms.ModelForm): ) cleaned_data['region'] = region except Region.DoesNotExist: - self.add_error('region_name', 'Invalid region name for selected country') + cleaned_data['region'] = None if city_name and cleaned_data.get('region'): try: @@ -120,6 +186,6 @@ class ParkForm(forms.ModelForm): ) cleaned_data['city'] = city except City.DoesNotExist: - self.add_error('city_name', 'Invalid city name for selected region') + cleaned_data['city'] = None return cleaned_data diff --git a/parks/management/commands/fix_historical_parks.py b/parks/management/commands/fix_historical_parks.py new file mode 100644 index 00000000..b972f45d --- /dev/null +++ b/parks/management/commands/fix_historical_parks.py @@ -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')) diff --git a/parks/management/commands/fix_locations.py b/parks/management/commands/fix_locations.py new file mode 100644 index 00000000..7ab0c724 --- /dev/null +++ b/parks/management/commands/fix_locations.py @@ -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')) diff --git a/parks/management/commands/seed_data.json b/parks/management/commands/seed_data.json index e851bd33..a127f457 100644 --- a/parks/management/commands/seed_data.json +++ b/parks/management/commands/seed_data.json @@ -10,6 +10,11 @@ "website": "https://disneyworld.disney.go.com/destinations/magic-kingdom/", "owner": "The Walt Disney Company", "size_acres": 142, + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg/1280px-Walt_Disney_World_Magic_Kingdom_Cinderella_Castle.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Magic_Kingdom_Main_Street_USA_Panorama.jpg/1280px-Magic_Kingdom_Main_Street_USA_Panorama.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg/1280px-Magic_Kingdom_-_Cinderella_Castle_at_Night.jpg" + ], "rides": [ { "name": "Space Mountain", @@ -18,6 +23,10 @@ "status": "OPERATING", "manufacturer": "Walt Disney Imagineering", "description": "A high-speed roller coaster in the dark through space.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Magic_Kingdom_Space_Mountain.jpg/1280px-Magic_Kingdom_Space_Mountain.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9a/Space_Mountain_%28Magic_Kingdom%29_entrance.jpg/1280px-Space_Mountain_%28Magic_Kingdom%29_entrance.jpg" + ], "stats": { "height_ft": 183, "length_ft": 3196, @@ -33,6 +42,10 @@ "status": "OPERATING", "manufacturer": "Walt Disney Imagineering", "description": "A mine train roller coaster through the Old West.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9c/Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg/1280px-Big_Thunder_Mountain_Railroad_at_Magic_Kingdom.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg/1280px-Big_Thunder_Mountain_Railroad_%28Magic_Kingdom%29.jpg" + ], "stats": { "height_ft": 104, "length_ft": 2671, @@ -48,6 +61,10 @@ "status": "OPERATING", "manufacturer": "Vekoma", "description": "A family roller coaster featuring unique swinging cars.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg/1280px-Seven_Dwarfs_Mine_Train_at_Magic_Kingdom.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Seven_Dwarfs_Mine_Train_drop.jpg/1280px-Seven_Dwarfs_Mine_Train_drop.jpg" + ], "stats": { "height_ft": 112, "length_ft": 2000, @@ -62,7 +79,11 @@ "opening_date": "1971-10-01", "status": "OPERATING", "manufacturer": "Walt Disney Imagineering", - "description": "A dark ride through a haunted estate." + "description": "A dark ride through a haunted estate.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_Mansion_at_Magic_Kingdom.jpg/1280px-Haunted_Mansion_at_Magic_Kingdom.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_Mansion_entrance.jpg/1280px-Haunted_Mansion_entrance.jpg" + ] }, { "name": "Pirates of the Caribbean", @@ -70,7 +91,11 @@ "opening_date": "1973-12-15", "status": "OPERATING", "manufacturer": "Walt Disney Imagineering", - "description": "A boat ride through pirate-filled Caribbean waters." + "description": "A boat ride through pirate-filled Caribbean waters.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_the_Caribbean_%28Magic_Kingdom%29.jpg/1280px-Pirates_of_the_Caribbean_%28Magic_Kingdom%29.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_the_Caribbean_entrance.jpg/1280px-Pirates_of_the_Caribbean_entrance.jpg" + ] } ] }, @@ -84,6 +109,11 @@ "website": "https://www.cedarpoint.com", "owner": "Cedar Fair", "size_acres": 364, + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Cedar_Point_aerial_view.jpg/1280px-Cedar_Point_aerial_view.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Cedar_Point_Beach.jpg/1280px-Cedar_Point_Beach.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cedar_Point_at_dusk.jpg/1280px-Cedar_Point_at_dusk.jpg" + ], "rides": [ { "name": "Steel Vengeance", @@ -92,6 +122,10 @@ "status": "OPERATING", "manufacturer": "Rocky Mountain Construction", "description": "A hybrid roller coaster featuring multiple inversions.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Steel_Vengeance_at_Cedar_Point.jpg/1280px-Steel_Vengeance_at_Cedar_Point.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Steel_Vengeance_first_drop.jpg/1280px-Steel_Vengeance_first_drop.jpg" + ], "stats": { "height_ft": 205, "length_ft": 5740, @@ -107,6 +141,10 @@ "status": "OPERATING", "manufacturer": "Intamin", "description": "A giga coaster with stunning views of Lake Erie.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_Force_at_Cedar_Point.jpg/1280px-Millennium_Force_at_Cedar_Point.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]ium_Force_lift_hill.jpg/1280px-Millennium_Force_lift_hill.jpg" + ], "stats": { "height_ft": 310, "length_ft": 6595, @@ -122,6 +160,10 @@ "status": "SBNO", "manufacturer": "Intamin", "description": "A strata coaster featuring a 420-foot top hat element.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Top_Thrill_Dragster.jpg/1280px-Top_Thrill_Dragster.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8f/Top_Thrill_Dragster_launch.jpg/1280px-Top_Thrill_Dragster_launch.jpg" + ], "stats": { "height_ft": 420, "length_ft": 2800, @@ -137,6 +179,10 @@ "status": "OPERATING", "manufacturer": "Intamin", "description": "A launched roller coaster with multiple inversions.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]k_at_Cedar_Point.jpg/1280px-Maverick_at_Cedar_Point.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]k_first_drop.jpg/1280px-Maverick_first_drop.jpg" + ], "stats": { "height_ft": 105, "length_ft": 4450, @@ -157,6 +203,11 @@ "website": "https://www.universalorlando.com/web/en/us/theme-parks/islands-of-adventure", "owner": "NBCUniversal", "size_acres": 110, + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_of_Adventure_entrance.jpg/1280px-Islands_of_Adventure_entrance.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]s_Castle_at_Universal%27s_Islands_of_Adventure.jpg/1280px-Hogwarts_Castle_at_Universal%27s_Islands_of_Adventure.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Port_of_Entry_at_Islands_of_Adventure.jpg/1280px-Port_of_Entry_at_Islands_of_Adventure.jpg" + ], "rides": [ { "name": "Jurassic World VelociCoaster", @@ -165,6 +216,10 @@ "status": "OPERATING", "manufacturer": "Intamin", "description": "A high-speed launch coaster featuring velociraptors.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]c_World_VelociCoaster.jpg/1280px-Jurassic_World_VelociCoaster.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]oaster_top_hat.jpg/1280px-VelociCoaster_top_hat.jpg" + ], "stats": { "height_ft": 155, "length_ft": 4700, @@ -180,6 +235,10 @@ "status": "OPERATING", "manufacturer": "Intamin", "description": "A story coaster through the Forbidden Forest.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg/1280px-Hagrid%27s_Magical_Creatures_Motorbike_Adventure.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Hagrid%27s_entrance.jpg/1280px-Hagrid%27s_entrance.jpg" + ], "stats": { "height_ft": 65, "length_ft": 5053, @@ -194,7 +253,11 @@ "opening_date": "1999-05-28", "status": "OPERATING", "manufacturer": "Oceaneering International", - "description": "A 3D dark ride featuring Spider-Man." + "description": "A 3D dark ride featuring Spider-Man.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/The_Amazing_Adventures_of_Spider-Man.jpg/1280px-The_Amazing_Adventures_of_Spider-Man.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Spider-Man_ride_entrance.jpg/1280px-Spider-Man_ride_entrance.jpg" + ] } ] }, @@ -208,6 +271,11 @@ "website": "https://www.altontowers.com", "owner": "Merlin Entertainments", "size_acres": 910, + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Alton_Towers_aerial_view.jpg/1280px-Alton_Towers_aerial_view.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Alton_Towers_mansion.jpg/1280px-Alton_Towers_mansion.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Alton_Towers_gardens.jpg/1280px-Alton_Towers_gardens.jpg" + ], "rides": [ { "name": "Nemesis", @@ -216,6 +284,10 @@ "status": "CLOSED", "manufacturer": "Bolliger & Mabillard", "description": "An inverted roller coaster through ravines.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_at_Alton_Towers.jpg/1280px-Nemesis_at_Alton_Towers.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]_loop.jpg/1280px-Nemesis_loop.jpg" + ], "stats": { "height_ft": 43, "length_ft": 2349, @@ -231,6 +303,10 @@ "status": "OPERATING", "manufacturer": "Bolliger & Mabillard", "description": "The world's first vertical drop roller coaster.", + "photos": [ + "https://upload.wikimedia.[AWS-SECRET-REMOVED]n_at_Alton_Towers.jpg/1280px-Oblivion_at_Alton_Towers.jpg", + "https://upload.wikimedia.[AWS-SECRET-REMOVED]n_vertical_drop.jpg/1280px-Oblivion_vertical_drop.jpg" + ], "stats": { "height_ft": 65, "length_ft": 1804, @@ -251,6 +327,11 @@ "website": "https://www.europapark.de", "owner": "Mack Rides", "size_acres": 235, + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Europa-Park_entrance.jpg/1280px-Europa-Park_entrance.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Europa-Park_aerial_view.jpg/1280px-Europa-Park_aerial_view.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Europa-Park_at_night.jpg/1280px-Europa-Park_at_night.jpg" + ], "rides": [ { "name": "Silver Star", @@ -259,6 +340,10 @@ "status": "OPERATING", "manufacturer": "Bolliger & Mabillard", "description": "A hypercoaster with stunning views.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Silver_Star_at_Europa-Park.jpg/1280px-Silver_Star_at_Europa-Park.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Silver_Star_first_drop.jpg/1280px-Silver_Star_first_drop.jpg" + ], "stats": { "height_ft": 239, "length_ft": 4003, @@ -274,6 +359,10 @@ "status": "OPERATING", "manufacturer": "Mack Rides", "description": "A launched roller coaster with multiple inversions.", + "photos": [ + "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/Blue_Fire_at_Europa-Park.jpg/1280px-Blue_Fire_at_Europa-Park.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Blue_Fire_launch.jpg/1280px-Blue_Fire_launch.jpg" + ], "stats": { "height_ft": 125, "length_ft": 3465, @@ -285,4 +374,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/parks/management/commands/seed_data.py b/parks/management/commands/seed_data.py index 9c2c64da..d5f6f4d2 100644 --- a/parks/management/commands/seed_data.py +++ b/parks/management/commands/seed_data.py @@ -13,6 +13,7 @@ from faker import Faker import requests from io import BytesIO from PIL import Image +from cities_light.models import City, Country from parks.models import Park from rides.models import Ride, RollerCoasterStats @@ -30,25 +31,30 @@ class Command(BaseCommand): parser.add_argument('--users', type=int, default=50) parser.add_argument('--reviews-per-item', type=int, default=10) - def download_and_save_image(self, url, prefix): + def download_and_save_image(self, url): try: response = requests.get(url) img = Image.open(BytesIO(response.content)) img_io = BytesIO() img.save(img_io, format='JPEG') img_io.seek(0) - return f'{prefix}_{fake.uuid4()}.jpg', File(img_io) - except: + filename = url.split('/')[-1] + return filename, File(img_io) + except Exception as e: + self.stdout.write(self.style.WARNING(f'Failed to download image {url}: {str(e)}')) return None, None def create_users(self, count): self.stdout.write('Creating users...') users = [] - # Get existing admin user - admin_user = User.objects.get(username='admin') - users.append(admin_user) - self.stdout.write('Added existing admin user') + try: + # Get existing admin user + admin_user = User.objects.get(username='admin') + users.append(admin_user) + self.stdout.write('Added existing admin user') + except User.DoesNotExist: + self.stdout.write(self.style.WARNING('Admin user not found, skipping...')) # Create regular users using raw SQL roles = ['USER'] * 20 + ['MODERATOR'] * 3 + ['ADMIN'] * 2 @@ -232,72 +238,87 @@ class Command(BaseCommand): parks = [] for park_data in seed_data['parks']: - # Create park with company instance - park = Park.objects.create( - name=park_data['name'], - slug=slugify(park_data['name']), - location=park_data['location'], - country=park_data['country'], - opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(), - status=park_data['status'], - description=park_data['description'], - website=park_data['website'], - owner=companies[park_data['owner']], - size_acres=park_data['size_acres'] - ) - - # Add park photos - for _ in range(random.randint(2, 5)): - img_url = f'https://picsum.photos/800/600?random={fake.random_number(5)}' - filename, file = self.download_and_save_image(img_url, 'park') - if filename and file: - Photo.objects.create( - content_object=park, - image=file, - uploaded_by=random.choice(users), - caption=fake.sentence(), - is_approved=True - ) - - # Create rides for this park - for ride_data in park_data['rides']: - ride = Ride.objects.create( - name=ride_data['name'], - slug=slugify(ride_data['name']), - category=ride_data['category'], - park=park, - status=ride_data['status'], - opening_date=datetime.strptime(ride_data['opening_date'], '%Y-%m-%d').date(), - manufacturer=manufacturers[ride_data['manufacturer']], - description=ride_data['description'] + try: + # Get country from cities_light + country = Country.objects.get(code2=park_data['country']) + + # Try to find city, but don't require it + city = None + try: + city_name = park_data['location'].split(',')[0].strip() + city = City.objects.filter(name__iexact=city_name, country=country).first() + except: + self.stdout.write(self.style.WARNING(f'City not found for {park_data["name"]}, using location text')) + + # Create park + park = Park.objects.create( + name=park_data['name'], + slug=slugify(park_data['name']), + location=park_data['location'], + country=country, + city=city, + opening_date=datetime.strptime(park_data['opening_date'], '%Y-%m-%d').date(), + status=park_data['status'], + description=park_data['description'], + website=park_data['website'], + owner=companies[park_data['owner']], + size_acres=park_data['size_acres'] ) - # Add roller coaster stats if applicable - if ride_data['category'] == 'RC' and 'stats' in ride_data: - RollerCoasterStats.objects.create( - ride=ride, - height_ft=ride_data['stats']['height_ft'], - length_ft=ride_data['stats']['length_ft'], - speed_mph=ride_data['stats']['speed_mph'], - inversions=ride_data['stats']['inversions'], - ride_time_seconds=ride_data['stats']['ride_time_seconds'] - ) - - # Add ride photos - for _ in range(random.randint(2, 5)): - img_url = f'https://picsum.photos/800/600?random={fake.random_number(5)}' - filename, file = self.download_and_save_image(img_url, 'ride') + # Add park photos + for photo_url in park_data.get('photos', []): + filename, file = self.download_and_save_image(photo_url) if filename and file: Photo.objects.create( - content_object=ride, + content_object=park, image=file, uploaded_by=random.choice(users), - caption=fake.sentence(), + caption=f"Photo of {park.name}", is_approved=True ) + + # Create rides for this park + for ride_data in park_data['rides']: + ride = Ride.objects.create( + name=ride_data['name'], + slug=slugify(ride_data['name']), + category=ride_data['category'], + park=park, + status=ride_data['status'], + opening_date=datetime.strptime(ride_data['opening_date'], '%Y-%m-%d').date(), + manufacturer=manufacturers[ride_data['manufacturer']], + description=ride_data['description'] + ) + + # Add roller coaster stats if applicable + if ride_data['category'] == 'RC' and 'stats' in ride_data: + RollerCoasterStats.objects.create( + ride=ride, + height_ft=ride_data['stats']['height_ft'], + length_ft=ride_data['stats']['length_ft'], + speed_mph=ride_data['stats']['speed_mph'], + inversions=ride_data['stats']['inversions'], + ride_time_seconds=ride_data['stats']['ride_time_seconds'] + ) + + # Add ride photos + for photo_url in ride_data.get('photos', []): + filename, file = self.download_and_save_image(photo_url) + if filename and file: + Photo.objects.create( + content_object=ride, + image=file, + uploaded_by=random.choice(users), + caption=f"Photo of {ride.name}", + is_approved=True + ) + + parks.append(park) + self.stdout.write(f'Created park and rides: {park.name}') - parks.append(park) - self.stdout.write(f'Created park and rides: {park.name}') + except Exception as e: + self.stdout.write(self.style.ERROR(f'Failed to create park {park_data["name"]}: {str(e)}')) + continue return parks diff --git a/parks/migrations/0007_fix_historical_park_city_null.py b/parks/migrations/0007_fix_historical_park_city_null.py new file mode 100644 index 00000000..440cd603 --- /dev/null +++ b/parks/migrations/0007_fix_historical_park_city_null.py @@ -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'), + ), + ] diff --git a/parks/migrations/0008_fix_historical_park_data.py b/parks/migrations/0008_fix_historical_park_data.py new file mode 100644 index 00000000..acda8332 --- /dev/null +++ b/parks/migrations/0008_fix_historical_park_data.py @@ -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), + ] diff --git a/parks/migrations/0009_fix_historical_park_fields.py b/parks/migrations/0009_fix_historical_park_fields.py new file mode 100644 index 00000000..b4496738 --- /dev/null +++ b/parks/migrations/0009_fix_historical_park_fields.py @@ -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'), + ), + ] diff --git a/parks/migrations/0010_alter_historicalpark_country_alter_park_country.py b/parks/migrations/0010_alter_historicalpark_country_alter_park_country.py new file mode 100644 index 00000000..370e503d --- /dev/null +++ b/parks/migrations/0010_alter_historicalpark_country_alter_park_country.py @@ -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" + ), + ), + ] diff --git a/parks/migrations/0010_fix_historical_records.py b/parks/migrations/0010_fix_historical_records.py new file mode 100644 index 00000000..01a30a7f --- /dev/null +++ b/parks/migrations/0010_fix_historical_records.py @@ -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), + ), + ] diff --git a/parks/migrations/0011_alter_historicalpark_fields.py b/parks/migrations/0011_alter_historicalpark_fields.py new file mode 100644 index 00000000..eaa4701a --- /dev/null +++ b/parks/migrations/0011_alter_historicalpark_fields.py @@ -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), + ), + ] diff --git a/parks/migrations/0011_merge_20241031_1617.py b/parks/migrations/0011_merge_20241031_1617.py new file mode 100644 index 00000000..82e8e862 --- /dev/null +++ b/parks/migrations/0011_merge_20241031_1617.py @@ -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 = [] diff --git a/parks/migrations/0012_merge_20241031_1635.py b/parks/migrations/0012_merge_20241031_1635.py new file mode 100644 index 00000000..a66f3c37 --- /dev/null +++ b/parks/migrations/0012_merge_20241031_1635.py @@ -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 = [] diff --git a/parks/migrations/0013_fix_null_locations.py b/parks/migrations/0013_fix_null_locations.py new file mode 100644 index 00000000..970bed10 --- /dev/null +++ b/parks/migrations/0013_fix_null_locations.py @@ -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), + ] diff --git a/parks/migrations/0014_alter_location_fields.py b/parks/migrations/0014_alter_location_fields.py new file mode 100644 index 00000000..103d8697 --- /dev/null +++ b/parks/migrations/0014_alter_location_fields.py @@ -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), + ), + ] diff --git a/parks/migrations/0015_fix_historical_park_city_constraint.py b/parks/migrations/0015_fix_historical_park_city_constraint.py new file mode 100644 index 00000000..ff6b7dcb --- /dev/null +++ b/parks/migrations/0015_fix_historical_park_city_constraint.py @@ -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), + ] diff --git a/parks/migrations/0016_alter_historicalpark_city_nullable.py b/parks/migrations/0016_alter_historicalpark_city_nullable.py new file mode 100644 index 00000000..56b58f89 --- /dev/null +++ b/parks/migrations/0016_alter_historicalpark_city_nullable.py @@ -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'), + ), + ] diff --git a/parks/migrations/0017_fix_historicalpark_city_column.py b/parks/migrations/0017_fix_historicalpark_city_column.py new file mode 100644 index 00000000..52a6a151 --- /dev/null +++ b/parks/migrations/0017_fix_historicalpark_city_column.py @@ -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;' + ), + ] diff --git a/parks/migrations/0018_fix_historicalpark_location_fields.py b/parks/migrations/0018_fix_historicalpark_location_fields.py new file mode 100644 index 00000000..869db625 --- /dev/null +++ b/parks/migrations/0018_fix_historicalpark_location_fields.py @@ -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' + ), + ), + ] diff --git a/parks/migrations/0019_fix_historicalpark_region_constraint.py b/parks/migrations/0019_fix_historicalpark_region_constraint.py new file mode 100644 index 00000000..a31c6a48 --- /dev/null +++ b/parks/migrations/0019_fix_historicalpark_region_constraint.py @@ -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;' + ), + ] diff --git a/parks/migrations/0020_remove_historicalpark_city_text.py b/parks/migrations/0020_remove_historicalpark_city_text.py new file mode 100644 index 00000000..82f56736 --- /dev/null +++ b/parks/migrations/0020_remove_historicalpark_city_text.py @@ -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);' + ), + ] diff --git a/parks/models.py b/parks/models.py index 7f767ddd..74ac23ba 100644 --- a/parks/models.py +++ b/parks/models.py @@ -11,7 +11,7 @@ class Park(models.Model): ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), - ('RELOCATED', 'Relocated'), # Added to match Ride model + ('RELOCATED', 'Relocated'), ] name = models.CharField(max_length=255) @@ -84,6 +84,9 @@ class Park(models.Model): def get_formatted_location(self): """Get a formatted location string: $COUNTRY, $REGION, $CITY""" + if not self.country: + return "" + location = self.country.name if self.region and self.city: diff --git a/parks/urls.py b/parks/urls.py index d2db9d9f..ebb28127 100644 --- a/parks/urls.py +++ b/parks/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('ajax/countries/', views.get_countries, name='get_countries'), path('ajax/regions/', views.get_regions, name='get_regions'), path('ajax/cities/', views.get_cities, name='get_cities'), + path('/edit/', views.ParkUpdateView.as_view(), name='park_edit'), path('/', views.ParkDetailView.as_view(), name='park_detail'), path('/rides/', include('rides.urls', namespace='rides')), ] diff --git a/parks/views.py b/parks/views.py index c6d19611..dca008bf 100644 --- a/parks/views.py +++ b/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.core.serializers.json import DjangoJSONEncoder from django.urls import reverse from django.db.models import Q from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType +from django.contrib import messages from django.http import JsonResponse, HttpResponseRedirect, HttpResponse from .models import Park, ParkArea from .forms import ParkForm from rides.models import Ride from core.views import SlugRedirectMixin -from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin +from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.models import EditSubmission from cities_light.models import Country, Region, City def get_countries(request): query = request.GET.get('q', '') - countries = Country.objects.filter(name__icontains=query).values_list('name', flat=True)[:10] + filter_parks = request.GET.get('filter_parks', 'false') == 'true' + + # Base query + countries = Country.objects.filter(name__icontains=query) + + # Only filter by parks if explicitly requested + if filter_parks: + countries = countries.filter(park__isnull=False) + + countries = countries.distinct().values('id', 'name')[:10] return JsonResponse(list(countries), safe=False) def get_regions(request): query = request.GET.get('q', '') country = request.GET.get('country', '') + filter_parks = request.GET.get('filter_parks', 'false') == 'true' + if not country: return JsonResponse([], safe=False) + # Base query regions = Region.objects.filter( Q(name__icontains=query) | Q(alternate_names__icontains=query), country__name__iexact=country - ).values_list('name', flat=True)[:10] + ) + + # Only filter by parks if explicitly requested + if filter_parks: + regions = regions.filter(park__isnull=False) + + regions = regions.distinct().values('id', 'name')[:10] return JsonResponse(list(regions), safe=False) def get_cities(request): query = request.GET.get('q', '') region = request.GET.get('region', '') country = request.GET.get('country', '') + filter_parks = request.GET.get('filter_parks', 'false') == 'true' + if not region or not country: return JsonResponse([], safe=False) + # Base query cities = City.objects.filter( Q(name__icontains=query) | Q(alternate_names__icontains=query), region__name__iexact=region, region__country__name__iexact=country - ).values_list('name', flat=True)[:10] + ) + + # Only filter by parks if explicitly requested + if filter_parks: + cities = cities.filter(park__isnull=False) + + cities = cities.distinct().values('id', 'name')[:10] return JsonResponse(list(cities), safe=False) class ParkCreateView(LoginRequiredMixin, CreateView): @@ -50,38 +78,111 @@ class ParkCreateView(LoginRequiredMixin, CreateView): form_class = ParkForm template_name = 'parks/park_form.html' - def form_valid(self, form): - # If user is moderator or above, save directly - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - self.object = form.save() - return HttpResponseRedirect(self.get_success_url()) - - # Otherwise, create a submission - cleaned_data = form.cleaned_data.copy() + def prepare_changes_data(self, cleaned_data): + data = cleaned_data.copy() # Convert model instances to IDs for JSON serialization - if cleaned_data.get('owner'): - cleaned_data['owner'] = cleaned_data['owner'].id - if cleaned_data.get('country'): - cleaned_data['country'] = cleaned_data['country'].id - if cleaned_data.get('region'): - cleaned_data['region'] = cleaned_data['region'].id - if cleaned_data.get('city'): - cleaned_data['city'] = cleaned_data['city'].id + if data.get('owner'): + data['owner'] = data['owner'].id + if data.get('country'): + data['country'] = data['country'].id + if data.get('region'): + data['region'] = data['region'].id + if data.get('city'): + data['city'] = data['city'].id + # Convert dates to ISO format strings + if data.get('opening_date'): + data['opening_date'] = data['opening_date'].isoformat() + if data.get('closing_date'): + data['closing_date'] = data['closing_date'].isoformat() + return data + def form_valid(self, form): + changes = self.prepare_changes_data(form.cleaned_data) + + # Create submission record submission = EditSubmission.objects.create( user=self.request.user, content_type=ContentType.objects.get_for_model(Park), submission_type='CREATE', - changes=cleaned_data, + changes=changes, reason=self.request.POST.get('reason', ''), source=self.request.POST.get('source', '') ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.object_id = self.object.id + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully created {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, 'Your park submission has been sent for review') return HttpResponseRedirect(reverse('parks:park_list')) def get_success_url(self): return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) -class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView): +class ParkUpdateView(LoginRequiredMixin, UpdateView): + model = Park + form_class = ParkForm + template_name = 'parks/park_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['is_edit'] = True + return context + + def prepare_changes_data(self, cleaned_data): + data = cleaned_data.copy() + # Convert model instances to IDs for JSON serialization + if data.get('owner'): + data['owner'] = data['owner'].id + if data.get('country'): + data['country'] = data['country'].id + if data.get('region'): + data['region'] = data['region'].id + if data.get('city'): + data['city'] = data['city'].id + # Convert dates to ISO format strings + if data.get('opening_date'): + data['opening_date'] = data['opening_date'].isoformat() + if data.get('closing_date'): + data['closing_date'] = data['closing_date'].isoformat() + return data + + def form_valid(self, form): + changes = self.prepare_changes_data(form.cleaned_data) + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Park), + object_id=self.object.id, + submission_type='EDIT', + changes=changes, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully updated {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') + return HttpResponseRedirect(reverse('parks:park_detail', kwargs={'slug': self.object.slug})) + + def get_success_url(self): + return reverse('parks:park_detail', kwargs={'slug': self.object.slug}) + +class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = Park template_name = 'parks/park_detail.html' context_object_name = 'park' @@ -104,7 +205,7 @@ class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixi def get_redirect_url_pattern(self): return 'parks:park_detail' -class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView): +class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = ParkArea template_name = 'parks/area_detail.html' context_object_name = 'area' @@ -149,7 +250,7 @@ class ParkListView(ListView): country = self.request.GET.get('country', '').strip() region = self.request.GET.get('region', '').strip() city = self.request.GET.get('city', '').strip() - status = self.request.GET.get('status', '').strip() + statuses = self.request.GET.getlist('status') if search: queryset = queryset.filter( @@ -166,8 +267,8 @@ class ParkListView(ListView): if city: queryset = queryset.filter(city__name__icontains=city) - if status: - queryset = queryset.filter(status=status) + if statuses: + queryset = queryset.filter(status__in=statuses) return queryset @@ -178,7 +279,7 @@ class ParkListView(ListView): 'country': self.request.GET.get('country', ''), 'region': self.request.GET.get('region', ''), 'city': self.request.GET.get('city', ''), - 'status': self.request.GET.get('status', '') + 'statuses': self.request.GET.getlist('status') } return context diff --git a/rides/__pycache__/urls.cpython-312.pyc b/rides/__pycache__/urls.cpython-312.pyc index 7affe81b..638973ad 100644 Binary files a/rides/__pycache__/urls.cpython-312.pyc and b/rides/__pycache__/urls.cpython-312.pyc differ diff --git a/rides/__pycache__/views.cpython-312.pyc b/rides/__pycache__/views.cpython-312.pyc index c5d55b31..d2a42c39 100644 Binary files a/rides/__pycache__/views.cpython-312.pyc and b/rides/__pycache__/views.cpython-312.pyc differ diff --git a/rides/forms.py b/rides/forms.py new file mode 100644 index 00000000..94a27918 --- /dev/null +++ b/rides/forms.py @@ -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() diff --git a/rides/urls.py b/rides/urls.py index ad9b31ed..2599df63 100644 --- a/rides/urls.py +++ b/rides/urls.py @@ -6,5 +6,6 @@ app_name = 'rides' urlpatterns = [ path('', views.RideListView.as_view(), name='ride_list'), path('create/', views.RideCreateView.as_view(), name='ride_create'), + path('/edit/', views.RideUpdateView.as_view(), name='ride_edit'), path('/', views.RideDetailView.as_view(), name='ride_detail'), ] diff --git a/rides/views.py b/rides/views.py index cd628b51..6f7f108a 100644 --- a/rides/views.py +++ b/rides/views.py @@ -1,37 +1,36 @@ -from django.views.generic import DetailView, ListView, CreateView +from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.shortcuts import get_object_or_404 from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse from django.db.models import Q from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType +from django.contrib import messages from django.http import JsonResponse, HttpResponseRedirect from .models import Ride, RollerCoasterStats +from .forms import RideForm from parks.models import Park from core.views import SlugRedirectMixin -from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin +from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.models import EditSubmission class RideCreateView(LoginRequiredMixin, CreateView): model = Ride + form_class = RideForm template_name = 'rides/ride_form.html' - fields = ['name', 'park_area', 'category', 'manufacturer', 'model_name', 'status', - 'opening_date', 'closing_date', 'status_since', 'min_height_in', 'max_height_in', - 'accessibility_options', 'capacity_per_hour', 'ride_duration_seconds', 'description'] def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['park'] = self.park + return kwargs + def form_valid(self, form): form.instance.park = self.park - # If user is moderator or above, save directly - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - self.object = form.save() - return HttpResponseRedirect(self.get_success_url()) - - # Otherwise, create a submission cleaned_data = form.cleaned_data.copy() cleaned_data['park'] = self.park.id # Convert model instances to IDs for JSON serialization @@ -40,6 +39,7 @@ class RideCreateView(LoginRequiredMixin, CreateView): if cleaned_data.get('manufacturer'): cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id + # Create submission record submission = EditSubmission.objects.create( user=self.request.user, content_type=ContentType.objects.get_for_model(Ride), @@ -48,6 +48,18 @@ class RideCreateView(LoginRequiredMixin, CreateView): reason=self.request.POST.get('reason', ''), source=self.request.POST.get('source', '') ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.object_id = self.object.id + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully created {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, 'Your ride submission has been sent for review') return HttpResponseRedirect(reverse('parks:rides:ride_list', kwargs={'park_slug': self.park.slug})) def get_success_url(self): @@ -61,7 +73,69 @@ class RideCreateView(LoginRequiredMixin, CreateView): context['park'] = self.park return context -class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView): +class RideUpdateView(LoginRequiredMixin, UpdateView): + model = Ride + form_class = RideForm + template_name = 'rides/ride_form.html' + slug_url_kwarg = 'ride_slug' + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['park'] = self.park + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['park'] = self.park + context['is_edit'] = True + return context + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + cleaned_data['park'] = self.park.id + # Convert model instances to IDs for JSON serialization + if cleaned_data.get('park_area'): + cleaned_data['park_area'] = cleaned_data['park_area'].id + if cleaned_data.get('manufacturer'): + cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Ride), + object_id=self.object.id, + submission_type='EDIT', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully updated {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') + return HttpResponseRedirect(reverse('parks:rides:ride_detail', kwargs={ + 'park_slug': self.park.slug, + 'ride_slug': self.object.slug + })) + + def get_success_url(self): + return reverse('parks:rides:ride_detail', kwargs={ + 'park_slug': self.park.slug, + 'ride_slug': self.object.slug + }) + +class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = Ride template_name = 'rides/ride_detail.html' context_object_name = 'ride' diff --git a/static/css/alerts.css b/static/css/alerts.css new file mode 100644 index 00000000..3b7d4260 --- /dev/null +++ b/static/css/alerts.css @@ -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; + } +} diff --git a/static/css/inline-edit.css b/static/css/inline-edit.css deleted file mode 100644 index a0b77294..00000000 --- a/static/css/inline-edit.css +++ /dev/null @@ -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; -} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 9b56c102..042e37a9 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1513,116 +1513,6 @@ select { padding-bottom: 1.5rem; } -.mobile-nav-link { - display: flex; - align-items: center; - justify-content: center; - border-radius: 0.5rem; - border-width: 1px; - border-color: transparent; - padding-left: 1.5rem; - padding-right: 1.5rem; - padding-top: 0.75rem; - padding-bottom: 0.75rem; - --tw-text-opacity: 1; - color: rgb(55 65 81 / var(--tw-text-opacity)); - transition-property: all; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -.mobile-nav-link:hover { - border-color: rgb(79 70 229 / 0.2); - background-color: rgb(79 70 229 / 0.1); - --tw-text-opacity: 1; - color: rgb(79 70 229 / var(--tw-text-opacity)); -} - -.mobile-nav-link:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(229 231 235 / var(--tw-text-opacity)); -} - -.mobile-nav-link:hover:is(.dark *) { - border-color: rgb(79 70 229 / 0.3); - background-color: rgb(79 70 229 / 0.2); - --tw-text-opacity: 1; - color: rgb(79 70 229 / var(--tw-text-opacity)); -} - -.mobile-nav-link i { - font-size: 1.25rem; - line-height: 1.75rem; - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -@media (max-width: 540px) { - .mobile-nav-link i { - font-size: 1.125rem; - line-height: 1.75rem; - } -} - -.mobile-nav-link.primary { - background-image: linear-gradient(to right, var(--tw-gradient-stops)); - --tw-gradient-from: #4f46e5 var(--tw-gradient-from-position); - --tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); - --tw-gradient-to: #e11d48 var(--tw-gradient-to-position); - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); -} - -.mobile-nav-link.primary:hover { - --tw-gradient-from: rgb(79 70 229 / 0.9) var(--tw-gradient-from-position); - --tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); - --tw-gradient-to: rgb(225 29 72 / 0.9) var(--tw-gradient-to-position); -} - -.mobile-nav-link.primary i { - margin-right: 0.75rem; - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); -} - -.mobile-nav-link.secondary { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); - --tw-text-opacity: 1; - color: rgb(55 65 81 / var(--tw-text-opacity)); -} - -.mobile-nav-link.secondary:hover { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity)); -} - -.mobile-nav-link.secondary:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(55 65 81 / var(--tw-bg-opacity)); - --tw-text-opacity: 1; - color: rgb(209 213 219 / var(--tw-text-opacity)); -} - -.mobile-nav-link.secondary:hover:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(75 85 99 / var(--tw-bg-opacity)); -} - -.mobile-nav-link.secondary i { - margin-right: 0.75rem; - --tw-text-opacity: 1; - color: rgb(107 114 128 / var(--tw-text-opacity)); -} - -.mobile-nav-link.secondary i:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(156 163 175 / var(--tw-text-opacity)); -} - /* Theme Toggle */ #theme-toggle+.theme-toggle-btn i::before { @@ -2243,6 +2133,10 @@ select { position: static; } +.fixed { + position: fixed; +} + .absolute { position: absolute; } @@ -2251,14 +2145,30 @@ select { position: relative; } +.sticky { + position: sticky; +} + .right-0 { right: 0px; } +.top-0 { + top: 0px; +} + .z-10 { z-index: 10; } +.z-40 { + z-index: 40; +} + +.z-50 { + z-index: 50; +} + .col-span-2 { grid-column: span 2 / span 2; } @@ -2281,6 +2191,10 @@ select { margin-right: auto; } +.-mb-px { + margin-bottom: -1px; +} + .mb-1 { margin-bottom: 0.25rem; } @@ -2317,6 +2231,10 @@ select { margin-left: 0.5rem; } +.ml-4 { + margin-left: 1rem; +} + .ml-6 { margin-left: 1.5rem; } @@ -2409,6 +2327,10 @@ select { height: 2rem; } +.max-h-60 { + max-height: 15rem; +} + .min-h-\[calc\(100vh-16rem\)\] { min-height: calc(100vh - 16rem); } @@ -2421,12 +2343,12 @@ select { width: 6rem; } -.w-4 { - width: 1rem; +.w-32 { + width: 8rem; } -.w-48 { - width: 12rem; +.w-4 { + width: 1rem; } .w-5 { @@ -2465,6 +2387,18 @@ select { flex-grow: 1; } +.scale-100 { + --tw-scale-x: 1; + --tw-scale-y: 1; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.scale-95 { + --tw-scale-x: .95; + --tw-scale-y: .95; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } @@ -2473,6 +2407,10 @@ select { cursor: pointer; } +.list-disc { + list-style-type: disc; +} + .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -2531,12 +2469,6 @@ select { margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } -.space-x-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.75rem * var(--tw-space-x-reverse)); - margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); -} - .space-x-4 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(1rem * var(--tw-space-x-reverse)); @@ -2573,6 +2505,10 @@ select { margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); } +.overflow-auto { + overflow: auto; +} + .overflow-hidden { overflow: hidden; } @@ -2603,6 +2539,11 @@ select { border-bottom-right-radius: 0.5rem; } +.rounded-t-lg { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + .border { border-width: 1px; } @@ -2611,10 +2552,24 @@ select { border-bottom-width: 1px; } +.border-b-2 { + border-bottom-width: 2px; +} + .border-t { border-top-width: 1px; } +.border-blue-400 { + --tw-border-opacity: 1; + border-color: rgb(96 165 250 / var(--tw-border-opacity)); +} + +.border-blue-600 { + --tw-border-opacity: 1; + border-color: rgb(37 99 235 / var(--tw-border-opacity)); +} + .border-gray-200 { --tw-border-opacity: 1; border-color: rgb(229 231 235 / var(--tw-border-opacity)); @@ -2644,6 +2599,10 @@ select { border-color: rgb(239 68 68 / var(--tw-border-opacity)); } +.border-transparent { + border-color: transparent; +} + .bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity)); @@ -2654,16 +2613,16 @@ select { background-color: rgb(239 246 255 / var(--tw-bg-opacity)); } +.bg-blue-500 { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + .bg-blue-600 { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity)); } -.bg-gray-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); -} - .bg-gray-200 { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity)); @@ -2679,6 +2638,11 @@ select { background-color: rgb(220 252 231 / var(--tw-bg-opacity)); } +.bg-green-500 { + --tw-bg-opacity: 1; + background-color: rgb(34 197 94 / var(--tw-bg-opacity)); +} + .bg-green-600 { --tw-bg-opacity: 1; background-color: rgb(22 163 74 / var(--tw-bg-opacity)); @@ -2689,6 +2653,11 @@ select { background-color: rgb(254 226 226 / var(--tw-bg-opacity)); } +.bg-red-500 { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + .bg-red-600 { --tw-bg-opacity: 1; background-color: rgb(220 38 38 / var(--tw-bg-opacity)); @@ -2708,6 +2677,11 @@ select { background-color: rgb(254 249 195 / var(--tw-bg-opacity)); } +.bg-yellow-500 { + --tw-bg-opacity: 1; + background-color: rgb(234 179 8 / var(--tw-bg-opacity)); +} + .bg-gradient-to-br { background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); } @@ -2755,6 +2729,10 @@ select { padding: 0.5rem; } +.p-3 { + padding: 0.75rem; +} + .p-4 { padding: 1rem; } @@ -2907,6 +2885,11 @@ select { color: rgb(30 64 175 / var(--tw-text-opacity)); } +.text-gray-300 { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); +} + .text-gray-400 { --tw-text-opacity: 1; color: rgb(156 163 175 / var(--tw-text-opacity)); @@ -2981,6 +2964,14 @@ select { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.opacity-0 { + opacity: 0; +} + +.opacity-100 { + opacity: 1; +} + .shadow { --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); @@ -3015,6 +3006,11 @@ select { --tw-ring-color: rgb(79 70 229 / 0.2); } +.ring-blue-500 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } @@ -3025,6 +3021,14 @@ select { backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); } +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + .transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); @@ -3043,6 +3047,22 @@ select { transition-duration: 150ms; } +.duration-100 { + transition-duration: 100ms; +} + +.duration-75 { + transition-duration: 75ms; +} + +.ease-in { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + +.ease-out { + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); +} + .dark\:prose-invert:is(.dark *) { --tw-prose-body: var(--tw-prose-invert-body); --tw-prose-headings: var(--tw-prose-invert-headings); @@ -3084,6 +3104,11 @@ select { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +.hover\:border-gray-300:hover { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + .hover\:bg-blue-700:hover { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity)); @@ -3129,6 +3154,11 @@ select { color: rgb(29 78 216 / var(--tw-text-opacity)); } +.hover\:text-gray-600:hover { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity)); +} + .hover\:text-primary:hover { --tw-text-opacity: 1; color: rgb(79 70 229 / var(--tw-text-opacity)); @@ -3175,6 +3205,11 @@ select { --tw-ring-offset-width: 2px; } +.dark\:border-blue-700:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(29 78 216 / var(--tw-border-opacity)); +} + .dark\:border-gray-600:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(75 85 99 / var(--tw-border-opacity)); @@ -3189,6 +3224,11 @@ select { border-color: rgb(55 65 81 / 0.5); } +.dark\:border-red-700:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(185 28 28 / var(--tw-border-opacity)); +} + .dark\:bg-blue-400\/30:is(.dark *) { background-color: rgb(96 165 250 / 0.3); } @@ -3284,6 +3324,11 @@ select { --tw-gradient-to: #3b0764 var(--tw-gradient-to-position); } +.dark\:text-blue-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(219 234 254 / var(--tw-text-opacity)); +} + .dark\:text-blue-200:is(.dark *) { --tw-text-opacity: 1; color: rgb(191 219 254 / var(--tw-text-opacity)); @@ -3299,6 +3344,11 @@ select { color: rgb(239 246 255 / var(--tw-text-opacity)); } +.dark\:text-blue-500:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); +} + .dark\:text-gray-200:is(.dark *) { --tw-text-opacity: 1; color: rgb(229 231 235 / var(--tw-text-opacity)); @@ -3319,6 +3369,11 @@ select { color: rgb(187 247 208 / var(--tw-text-opacity)); } +.dark\:text-red-100:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); +} + .dark\:text-red-200:is(.dark *) { --tw-text-opacity: 1; color: rgb(254 202 202 / var(--tw-text-opacity)); diff --git a/static/js/alerts.js b/static/js/alerts.js new file mode 100644 index 00000000..fc054c16 --- /dev/null +++ b/static/js/alerts.js @@ -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); + }); +}); diff --git a/static/js/alpine.min.js b/static/js/alpine.min.js new file mode 100644 index 00000000..a7120970 --- /dev/null +++ b/static/js/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var tt=!1,rt=!1,V=[],nt=-1;function Vt(e){Sn(e)}function Sn(e){V.includes(e)||V.push(e),An()}function Ee(e){let t=V.indexOf(e);t!==-1&&t>nt&&V.splice(t,1)}function An(){!rt&&!tt&&(tt=!0,queueMicrotask(On))}function On(){tt=!1,rt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{it?Vt(r):r()}}),ot=e.raw}function st(e){k=e}function Wt(e){let t=()=>{};return[n=>{let i=k(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function q(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function O(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>O(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)O(n,t,!1),n=n.nextElementSibling}function v(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Gt=!1;function Jt(){Gt&&v("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Gt=!0,document.body||v("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + + + + + {% block extra_head %}{% endblock %}
@@ -221,7 +256,7 @@ {% if messages %} -
+
{% for message in messages %}
+ {% block extra_js %}{% endblock %} diff --git a/templates/companies/company_detail.html b/templates/companies/company_detail.html index 761fd69e..58a68233 100644 --- a/templates/companies/company_detail.html +++ b/templates/companies/company_detail.html @@ -4,24 +4,31 @@ {% block title %}{{ company.name }} - ThrillWiki{% endblock %} {% block content %} -
+
-
-
+
+
-

{{ company.name }}

+

{{ company.name }}

{% if company.headquarters %}

- {{ company.headquarters }} + {{ company.headquarters }}

{% endif %}
- {% if company.website %} - - Visit Website - - {% endif %} +
+ {% if company.website %} + + Visit Website + + {% endif %} + {% if user.is_authenticated %} + + Edit + + {% endif %} +
{% if company.description %} @@ -32,22 +39,22 @@
-
-
+
+
{{ parks.count }}
-
Theme Parks
+
Theme Parks
-
+
{{ parks|length }}
-
Active Parks
+
Active Parks
-
+
{% with total_rides=0 %} {% for park in parks %} @@ -56,42 +63,42 @@ {{ total_rides }} {% endwith %}
-
Total Attractions
+
Total Attractions
-
-

Theme Parks

+
+

Theme Parks

-
+
{% for park in parks %} -
+
{% if park.photos.exists %} {{ park.name }} + class="object-cover w-full h-48"> {% else %} -
+
No image available
{% endif %}
-

+

{{ park.name }}

-

{{ park.location }}

-
+

{{ park.location }}

+
{{ park.rides.count }} attractions {% if park.average_rating %}
- ★ + ★ {{ park.average_rating|floatformat:1 }}/10 @@ -101,7 +108,7 @@
{% empty %} -
+

No parks found for this company.

{% endfor %} diff --git a/templates/companies/company_form.html b/templates/companies/company_form.html new file mode 100644 index 00000000..0b597468 --- /dev/null +++ b/templates/companies/company_form.html @@ -0,0 +1,128 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Company - ThrillWiki{% endblock %} + +{% block content %} +
+
+
+

{% if is_edit %}Edit{% else %}Add{% endif %} Company

+ +
+ {% csrf_token %} + + +
+ +
+ {{ form.name }} +
+ {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} +
+ + +
+ + + +
    + +
+
+ + +
+ +
+ {{ form.website }} +
+ {% if form.website.errors %} +
+ {{ form.website.errors }} +
+ {% endif %} +
+ + +
+ +
+ {{ form.description }} +
+ {% if form.description.errors %} +
+ {{ form.description.errors }} +
+ {% endif %} +
+ + {% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %} +
+
+ + +
+
+ + +
+
+ {% endif %} + +
+ + Cancel + + +
+
+
+
+
+{% endblock %} diff --git a/templates/companies/manufacturer_detail.html b/templates/companies/manufacturer_detail.html index 769d7750..dac5026a 100644 --- a/templates/companies/manufacturer_detail.html +++ b/templates/companies/manufacturer_detail.html @@ -16,12 +16,19 @@

{% endif %}
- {% if manufacturer.website %} - - Visit Website - - {% endif %} +
+ {% if manufacturer.website %} + + Visit Website + + {% endif %} + {% if user.is_authenticated %} + + Edit + + {% endif %} +
{% if manufacturer.description %} diff --git a/templates/companies/manufacturer_form.html b/templates/companies/manufacturer_form.html new file mode 100644 index 00000000..1d06de0d --- /dev/null +++ b/templates/companies/manufacturer_form.html @@ -0,0 +1,128 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Manufacturer - ThrillWiki{% endblock %} + +{% block content %} +
+
+
+

{% if is_edit %}Edit{% else %}Add{% endif %} Manufacturer

+ +
+ {% csrf_token %} + + +
+ +
+ {{ form.name }} +
+ {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} +
+ + +
+ + + +
    + +
+
+ + +
+ +
+ {{ form.website }} +
+ {% if form.website.errors %} +
+ {{ form.website.errors }} +
+ {% endif %} +
+ + +
+ +
+ {{ form.description }} +
+ {% if form.description.errors %} +
+ {{ form.description.errors }} +
+ {% endif %} +
+ + {% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %} +
+
+ + +
+
+ + +
+
+ {% endif %} + +
+ + Cancel + + +
+
+
+
+
+{% endblock %} diff --git a/templates/moderation/edit_submissions.html b/templates/moderation/edit_submissions.html new file mode 100644 index 00000000..3ec615f1 --- /dev/null +++ b/templates/moderation/edit_submissions.html @@ -0,0 +1,127 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}Moderation - ThrillWiki{% endblock %} + +{% block content %} +
+
+

Moderation Queue

+ + +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • + {% if user.role == 'ADMIN' or user.role == 'SUPERUSER' %} +
  • + +
  • + {% endif %} +
+
+ + +
+ {% include 'moderation/partials/submission_list.html' %} +
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/templates/moderation/partials/submission_list.html b/templates/moderation/partials/submission_list.html new file mode 100644 index 00000000..13958529 --- /dev/null +++ b/templates/moderation/partials/submission_list.html @@ -0,0 +1,88 @@ +{% for submission in submissions %} +
+
+
+

+ {{ submission.get_content_type_display }} - + {% if submission.submission_type == 'CREATE' %}New{% else %}Edit{% endif %} +

+
+ Submitted by {{ submission.user.username }} on {{ submission.created_at|date:"M d, Y H:i" }} +
+
+ {% if submission.status == 'APPROVED' %} + Approved + {% elif submission.status == 'REJECTED' %} + Rejected + {% elif submission.status == 'ESCALATED' %} + Escalated + {% endif %} +
+ + {% if submission.reason %} +
+
Reason:
+
{{ submission.reason }}
+
+ {% endif %} + + {% if submission.source %} + + {% endif %} + +
+ {% for field, value in submission.changes.items %} +
+
{{ field|title }}:
+
{{ value }}
+
+ {% endfor %} +
+ + {% if submission.status == 'NEW' %} +
+ + + {% if user.role == 'MODERATOR' %} + + {% endif %} +
+ {% elif submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %} +
+ + +
+ {% endif %} +
+{% empty %} +
+ No submissions found in this category. +
+{% endfor %} diff --git a/templates/parks/park_detail.html b/templates/parks/park_detail.html index 45be2302..497660c8 100644 --- a/templates/parks/park_detail.html +++ b/templates/parks/park_detail.html @@ -3,19 +3,13 @@ {% block title %}{{ park.name }} - ThrillWiki{% endblock %} -{% block extra_css %} - -{% endblock %} - {% block content %}
-
+
-

{{ park.name }}

+

{{ park.name }}

{{ park.get_formatted_location }} @@ -29,12 +23,9 @@ {% endif %} {% if user.is_authenticated %} - + {% endif %}

@@ -44,17 +35,7 @@ {% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed {% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction {% elif park.status == 'DEMOLISHED' %}status-demolished - {% elif park.status == 'RELOCATED' %}status-relocated{% endif %}" - data-editable data-content-id="{{ park.id }}" - data-field-name="status" data-field-type="select" - data-options='[ - {"value": "OPERATING", "label": "Operating"}, - {"value": "CLOSED_TEMP", "label": "Temporarily Closed"}, - {"value": "CLOSED_PERM", "label": "Permanently Closed"}, - {"value": "UNDER_CONSTRUCTION", "label": "Under Construction"}, - {"value": "DEMOLISHED", "label": "Demolished"}, - {"value": "RELOCATED", "label": "Relocated"} - ]'> + {% elif park.status == 'RELOCATED' %}status-relocated{% endif %}"> {{ park.get_status_display }} {% if park.average_rating %} @@ -97,11 +78,9 @@
{% if park.description %} -
+

About

-
+
{{ park.description|linebreaks }}
@@ -180,7 +159,7 @@
-
+

Quick Facts

{% if park.owner %} @@ -212,9 +191,7 @@ Opening Date -
+
{{ park.opening_date }}
@@ -225,9 +202,7 @@ Closing Date -
+
{{ park.closing_date }}
@@ -238,9 +213,7 @@ Operating Season -
+
{{ park.operating_season }}
@@ -251,9 +224,7 @@ Size -
+
{{ park.size_acres }} acres
@@ -307,7 +278,3 @@
{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/parks/park_form.html b/templates/parks/park_form.html index bff795b8..db1256d8 100644 --- a/templates/parks/park_form.html +++ b/templates/parks/park_form.html @@ -1,15 +1,31 @@ {% extends 'base/base.html' %} {% load static %} -{% block title %}Add Park - ThrillWiki{% endblock %} +{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Park - ThrillWiki{% endblock %} {% block content %}
-

Add Park

+

{% if is_edit %}Edit{% else %}Add{% endif %} Park

-
+ {% if form.errors %} +
+

Please correct the following errors:

+
    + {% for field in form %} + {% for error in field.errors %} +
  • {{ field.label }}: {{ error }}
  • + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + + {% csrf_token %} @@ -20,7 +36,7 @@
{{ form.name }} @@ -33,46 +49,88 @@
-
+
-
- {{ form.country_name }} -
- {% if form.country_name.errors %} -
- {{ form.country_name.errors }} -
- {% endif %} + + +
    + +
-
+
-
- {{ form.region_name }} -
- {% if form.region_name.errors %} -
- {{ form.region_name.errors }} -
- {% endif %} + + +
    + +
-
+
-
- {{ form.city_name }} -
- {% if form.city_name.errors %} -
- {{ form.city_name.errors }} -
- {% endif %} + + +
    + +
@@ -80,7 +138,7 @@ {% if field.name not in 'name,country,region,city,country_name,region_name,city_name' %}
{{ field }} @@ -98,17 +156,20 @@ {% endfor %} {% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %} +
+

Your submission will be reviewed by a moderator before being published.

+
+ placeholder="Please explain why you're {% if is_edit %}editing{% else %}adding{% endif %} this park and provide any relevant details.">
{% endblock %} - -{% block extra_css %} - - -{% endblock %} - -{% block extra_js %} - - -{% endblock %} diff --git a/templates/parks/park_list.html b/templates/parks/park_list.html index 7d6ca276..42126ec3 100644 --- a/templates/parks/park_list.html +++ b/templates/parks/park_list.html @@ -16,9 +16,9 @@
-
@@ -28,41 +28,134 @@ class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" placeholder="Search parks...">
-
+ + +
- + autocomplete="off"> + +
    + +
-
+ + +
- + autocomplete="off"> + +
    + +
-
+ + +
- -
-
- - + placeholder="Select city..." + value="{{ current_filters.city }}" + autocomplete="off"> + +
    + +
+ + + {% for status in current_filters.statuses %} + + {% endfor %} + + +
+ + + + + + + +
@@ -70,107 +163,28 @@ {% include "parks/partials/park_list.html" %}
-{% endblock %} -{% block extra_css %} - - -{% endblock %} - -{% block extra_js %} - {% endblock %} diff --git a/templates/rides/ride_detail.html b/templates/rides/ride_detail.html index c48d808c..a89a7b23 100644 --- a/templates/rides/ride_detail.html +++ b/templates/rides/ride_detail.html @@ -3,19 +3,13 @@ {% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %} -{% block extra_css %} - -{% endblock %} - {% block content %}
-
+
@@ -97,11 +68,9 @@
-
+

About

-
+
{{ ride.description|linebreaks }}
@@ -121,15 +90,13 @@ {% endif %} {% if coaster_stats %} -
+

Roller Coaster Statistics

{% if coaster_stats.height_ft %}
Height - + {{ coaster_stats.height_ft }} ft
@@ -137,9 +104,7 @@ {% if coaster_stats.length_ft %}
Length - + {{ coaster_stats.length_ft }} ft
@@ -147,27 +112,21 @@ {% if coaster_stats.speed_mph %}
Speed - + {{ coaster_stats.speed_mph }} mph
{% endif %}
Inversions - + {{ coaster_stats.inversions }}
{% if coaster_stats.ride_time_seconds %}
Ride Duration - + {{ coaster_stats.ride_time_seconds }} sec
@@ -179,29 +138,23 @@
-
+

Quick Facts

Manufacturer
-
{{ ride.manufacturer }}
+
{{ ride.manufacturer }}
{% if ride.model_name %}
Model
-
{{ ride.model_name }}
+
{{ ride.model_name }}
{% endif %} {% if ride.opening_date %}
Opening Date
-
+
{{ ride.opening_date }}
@@ -209,9 +162,7 @@ {% if ride.status_since %}
Status Since
-
+
{{ ride.status_since }}
@@ -219,9 +170,7 @@ {% if ride.closing_date %}
Closing Date
-
+
{{ ride.closing_date }}
@@ -229,9 +178,7 @@ {% if ride.capacity_per_hour %}
Capacity
-
+
{{ ride.capacity_per_hour }} riders/hour
@@ -239,9 +186,7 @@ {% if ride.min_height_in %}
Minimum Height
-
+
{{ ride.min_height_in }} inches
@@ -332,7 +277,3 @@
{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/rides/ride_form.html b/templates/rides/ride_form.html index 98b44a89..20f8d26e 100644 --- a/templates/rides/ride_form.html +++ b/templates/rides/ride_form.html @@ -1,14 +1,14 @@ {% extends 'base/base.html' %} {% load static %} -{% block title %}Add Ride at {{ park.name }} - ThrillWiki{% endblock %} +{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }} - ThrillWiki{% endblock %} {% block content %}
-

Add Ride at {{ park.name }}

+

{% if is_edit %}Edit{% else %}Add{% endif %} Ride at {{ park.name }}

Back to {{ park.name }} Rides @@ -62,14 +62,14 @@
+ placeholder="Please explain why you're {% if is_edit %}editing{% else %}adding{% endif %} this ride and provide any relevant details.">