From 2279e19cfdc9beb4a87584b64f3cfb2927351f71 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:38:38 +0000 Subject: [PATCH] Enhance moderation dashboard UI and UX: - Add HTMX-powered filtering with instant updates - Add smooth transitions and loading states - Improve visual hierarchy and styling - Add review notes functionality - Add confirmation dialogs for actions - Make navigation sticky - Add hover effects and visual feedback - Improve dark mode support --- accounts/migrations/0001_initial.py | 26 +- accounts/views.py | 27 +- analytics/migrations/0001_initial.py | 2 +- companies/migrations/0001_initial.py | 125 +-- .../migrations/0002_add_designer_model.py | 28 + companies/migrations/0002_stats_fields.py | 53 -- .../migrations/0003_remove_total_parks.py | 13 - companies/migrations/0004_add_total_parks.py | 14 - companies/models.py | 40 + core/migrations/0001_initial.py | 44 +- designers/migrations/0001_initial.py | 2 +- email_service/migrations/0001_initial.py | 45 +- history_tracking/migrations/0001_initial.py | 60 +- ...lpark_history_user_delete_park_and_more.py | 23 - history_tracking/migrations/0003_initial.py | 54 -- history_tracking/mixins.py | 41 +- history_tracking/models.py | 12 +- location/migrations/0001_initial.py | 73 +- ..._alter_historicallocation_city_and_more.py | 84 -- ..._alter_historicallocation_city_and_more.py | 67 -- location/migrations/0004_add_point_field.py | 27 - .../0005_convert_coordinates_to_points.py | 52 -- .../0006_readd_historical_records.py | 174 ----- media/migrations/0001_initial.py | 68 +- media/migrations/0002_photo_uploaded_by.py | 26 - .../0003_update_photo_field_and_normalize.py | 69 -- media/migrations/0004_update_photo_paths.py | 10 - media/migrations/0005_alter_photo_image.py | 24 - media/migrations/0006_photo_is_approved.py | 18 - media/migrations/0007_photo_date_taken.py | 18 - moderation/migrations/0001_initial.py | 56 +- ...editsubmission_submission_type_and_more.py | 46 -- .../0003_rename_fields_and_update_status.py | 107 --- ...4_alter_editsubmission_options_and_more.py | 21 - .../management/commands/seed_initial_data.py | 245 ++++++ parks/management/commands/seed_ride_data.py | 321 ++++++++ parks/migrations/0001_initial.py | 253 +++++- .../0002_historicalpark_historicalparkarea.py | 84 -- .../0003_update_coordinate_fields.py | 55 -- .../0004_add_coordinate_validators.py | 101 --- .../migrations/0005_normalize_coordinates.py | 58 -- ..._alter_historicalpark_latitude_and_more.py | 75 -- ...istoricalparkarea_history_user_and_more.py | 27 - .../0008_historicalpark_historicalparkarea.py | 209 ----- .../0009_migrate_to_location_model.py | 83 -- .../0010_remove_legacy_location_fields.py | 69 -- parks/models.py | 8 +- parks/urls.py | 16 +- parks/views.py | 274 ++++--- reviews/migrations/0001_initial.py | 199 +++-- reviews/templatetags/__init__.py | 0 reviews/templatetags/review_tags.py | 11 + rides/apps.py | 3 +- rides/forms.py | 335 ++++++-- rides/migrations/0001_initial.py | 163 +++- ...ricalride_designer_alter_ride_designer.py} | 14 +- .../0002_alter_ride_manufacturer.py | 25 - ...oasterstats_max_drop_height_ft_and_more.py | 65 -- ...icalride_accessibility_options_and_more.py | 160 ++++ ...oricalride_category_alter_ride_category.py | 49 ++ ...asterstats_roller_coaster_type_and_more.py | 69 -- ...05_alter_rollercoasterstats_id_and_more.py | 259 ++++++ ...odel_typical_capacity_per_hour_and_more.py | 37 + .../0007_alter_ridemodel_manufacturer.py | 26 + ...oricalride_post_closing_status_and_more.py | 75 ++ ...move_historicalride_model_name_and_more.py | 21 + rides/models.py | 155 ++-- rides/signals.py | 17 + rides/urls.py | 69 +- rides/views.py | 736 +++++++----------- static/css/tailwind.css | 106 +++ templates/account/partials/login_modal.html | 70 ++ templates/account/partials/signup_modal.html | 261 +++++++ templates/base/base.html | 13 +- templates/home.html | 6 +- templates/parks/park_detail.html | 36 +- templates/parks/park_list.html | 6 +- templates/parks/partials/add_park_button.html | 5 + templates/parks/partials/park_actions.html | 37 + .../parks/partials/park_search_results.html | 27 + templates/rides/park_category_list.html | 139 ++++ templates/rides/partials/add_ride_modal.html | 47 ++ templates/rides/partials/coaster_fields.html | 110 +++ .../partials/create_ride_model_form.html | 93 +++ .../rides/partials/designer_created.html | 26 + templates/rides/partials/designer_form.html | 84 ++ .../partials/designer_search_results.html | 27 + .../rides/partials/manufacturer_created.html | 26 + .../rides/partials/manufacturer_form.html | 84 ++ .../partials/manufacturer_search_results.html | 33 + templates/rides/partials/ride_form.html | 291 +++++++ .../rides/partials/ride_model_created.html | 44 ++ templates/rides/partials/ride_model_form.html | 215 +++++ .../partials/ride_model_search_results.html | 38 + templates/rides/ride_detail.html | 34 +- templates/rides/ride_form.html | 319 +++++--- templates/rides/ride_list.html | 20 +- thrillwiki/settings.py | 1 + 98 files changed, 5073 insertions(+), 3040 deletions(-) create mode 100644 companies/migrations/0002_add_designer_model.py delete mode 100644 companies/migrations/0002_stats_fields.py delete mode 100644 companies/migrations/0003_remove_total_parks.py delete mode 100644 companies/migrations/0004_add_total_parks.py delete mode 100644 history_tracking/migrations/0002_remove_historicalpark_history_user_delete_park_and_more.py delete mode 100644 history_tracking/migrations/0003_initial.py delete mode 100644 location/migrations/0002_alter_historicallocation_city_and_more.py delete mode 100644 location/migrations/0003_alter_historicallocation_city_and_more.py delete mode 100644 location/migrations/0004_add_point_field.py delete mode 100644 location/migrations/0005_convert_coordinates_to_points.py delete mode 100644 location/migrations/0006_readd_historical_records.py delete mode 100644 media/migrations/0002_photo_uploaded_by.py delete mode 100644 media/migrations/0003_update_photo_field_and_normalize.py delete mode 100644 media/migrations/0004_update_photo_paths.py delete mode 100644 media/migrations/0005_alter_photo_image.py delete mode 100644 media/migrations/0006_photo_is_approved.py delete mode 100644 media/migrations/0007_photo_date_taken.py delete mode 100644 moderation/migrations/0002_editsubmission_submission_type_and_more.py delete mode 100644 moderation/migrations/0003_rename_fields_and_update_status.py delete mode 100644 moderation/migrations/0004_alter_editsubmission_options_and_more.py create mode 100644 parks/management/commands/seed_initial_data.py create mode 100644 parks/management/commands/seed_ride_data.py delete mode 100644 parks/migrations/0002_historicalpark_historicalparkarea.py delete mode 100644 parks/migrations/0003_update_coordinate_fields.py delete mode 100644 parks/migrations/0004_add_coordinate_validators.py delete mode 100644 parks/migrations/0005_normalize_coordinates.py delete mode 100644 parks/migrations/0006_alter_historicalpark_latitude_and_more.py delete mode 100644 parks/migrations/0007_remove_historicalparkarea_history_user_and_more.py delete mode 100644 parks/migrations/0008_historicalpark_historicalparkarea.py delete mode 100644 parks/migrations/0009_migrate_to_location_model.py delete mode 100644 parks/migrations/0010_remove_legacy_location_fields.py create mode 100644 reviews/templatetags/__init__.py create mode 100644 reviews/templatetags/review_tags.py rename rides/migrations/{0005_historicalride_designer_ride_designer.py => 0002_alter_historicalride_designer_alter_ride_designer.py} (75%) delete mode 100644 rides/migrations/0002_alter_ride_manufacturer.py delete mode 100644 rides/migrations/0003_historicalrollercoasterstats_max_drop_height_ft_and_more.py create mode 100644 rides/migrations/0003_remove_historicalride_accessibility_options_and_more.py create mode 100644 rides/migrations/0004_alter_historicalride_category_alter_ride_category.py delete mode 100644 rides/migrations/0004_historicalrollercoasterstats_roller_coaster_type_and_more.py create mode 100644 rides/migrations/0005_alter_rollercoasterstats_id_and_more.py create mode 100644 rides/migrations/0006_remove_historicalridemodel_typical_capacity_per_hour_and_more.py create mode 100644 rides/migrations/0007_alter_ridemodel_manufacturer.py create mode 100644 rides/migrations/0008_historicalride_post_closing_status_and_more.py create mode 100644 rides/migrations/0009_remove_historicalride_model_name_and_more.py create mode 100644 templates/account/partials/login_modal.html create mode 100644 templates/account/partials/signup_modal.html create mode 100644 templates/parks/partials/add_park_button.html create mode 100644 templates/parks/partials/park_actions.html create mode 100644 templates/parks/partials/park_search_results.html create mode 100644 templates/rides/park_category_list.html create mode 100644 templates/rides/partials/add_ride_modal.html create mode 100644 templates/rides/partials/coaster_fields.html create mode 100644 templates/rides/partials/create_ride_model_form.html create mode 100644 templates/rides/partials/designer_created.html create mode 100644 templates/rides/partials/designer_form.html create mode 100644 templates/rides/partials/designer_search_results.html create mode 100644 templates/rides/partials/manufacturer_created.html create mode 100644 templates/rides/partials/manufacturer_form.html create mode 100644 templates/rides/partials/manufacturer_search_results.html create mode 100644 templates/rides/partials/ride_form.html create mode 100644 templates/rides/partials/ride_model_created.html create mode 100644 templates/rides/partials/ride_model_form.html create mode 100644 templates/rides/partials/ride_model_search_results.html diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 7a48d0eb..b8aaa751 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-10-28 21:50 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.contrib.auth.models import django.contrib.auth.validators @@ -60,6 +60,18 @@ class Migration(migrations.Migration): verbose_name="username", ), ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), ( "email", models.EmailField( @@ -97,18 +109,6 @@ class Migration(migrations.Migration): unique=True, ), ), - ( - "first_name", - models.CharField( - default="", max_length=150, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - default="", max_length=150, verbose_name="last name" - ), - ), ( "role", models.CharField( diff --git a/accounts/views.py b/accounts/views.py index f2661fb2..92bba1e1 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -64,7 +64,7 @@ class CustomLoginView(TurnstileMixin, LoginView): if getattr(request, 'htmx', False): return render( request, - 'account/partials/login_form.html', + 'account/partials/login_modal.html', self.get_context_data() ) return super().get(request, *args, **kwargs) @@ -76,7 +76,30 @@ class CustomSignupView(TurnstileMixin, SignupView): except ValidationError as e: form.add_error(None, str(e)) return self.form_invalid(form) - return super().form_valid(form) + + response = super().form_valid(form) + + if getattr(self.request, 'htmx', False): + return HttpResponseClientRefresh() + return response + + def form_invalid(self, form): + if getattr(self.request, 'htmx', False): + return render( + self.request, + 'account/partials/signup_modal.html', + self.get_context_data(form=form) + ) + return super().form_invalid(form) + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if getattr(request, 'htmx', False): + return render( + request, + 'account/partials/signup_modal.html', + self.get_context_data() + ) + return super().get(request, *args, **kwargs) @login_required def user_redirect_view(request: HttpRequest) -> HttpResponse: diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py index 89a5f5b6..d3ef4548 100644 --- a/analytics/migrations/0001_initial.py +++ b/analytics/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-04 00:46 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion from django.db import migrations, models diff --git a/companies/migrations/0001_initial.py b/companies/migrations/0001_initial.py index b6e36b19..1edf10a1 100644 --- a/companies/migrations/0001_initial.py +++ b/companies/migrations/0001_initial.py @@ -1,8 +1,5 @@ -# Generated by Django 5.1.2 on 2024-10-28 20:17 +# Generated by Django 5.1.3 on 2024-11-12 18:07 -import django.db.models.deletion -import simple_history.models -from django.conf import settings from django.db import migrations, models @@ -10,96 +7,60 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Company', + name="Company", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255, unique=True)), - ('headquarters', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('website', models.URLField(blank=True)), - ('founded_date', models.DateField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255, unique=True)), + ("website", models.URLField(blank=True)), + ("headquarters", models.CharField(blank=True, max_length=255)), + ("description", models.TextField(blank=True)), + ("total_parks", models.IntegerField(default=0)), + ("total_rides", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], options={ - 'verbose_name_plural': 'companies', - 'ordering': ['name'], + "verbose_name_plural": "companies", + "ordering": ["name"], }, ), migrations.CreateModel( - name='Manufacturer', + name="Manufacturer", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255, unique=True)), - ('headquarters', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('website', models.URLField(blank=True)), - ('founded_date', models.DateField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255, unique=True)), + ("website", models.URLField(blank=True)), + ("headquarters", models.CharField(blank=True, max_length=255)), + ("description", models.TextField(blank=True)), + ("total_rides", models.IntegerField(default=0)), + ("total_roller_coasters", models.IntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), - migrations.CreateModel( - name='HistoricalCompany', - fields=[ - ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255)), - ('headquarters', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('website', models.URLField(blank=True)), - ('founded_date', models.DateField(blank=True, null=True)), - ('created_at', models.DateTimeField(blank=True, editable=False)), - ('updated_at', models.DateTimeField(blank=True, editable=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField(db_index=True)), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'historical company', - 'verbose_name_plural': 'historical companies', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': ('history_date', 'history_id'), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - migrations.CreateModel( - name='HistoricalManufacturer', - fields=[ - ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255)), - ('headquarters', models.CharField(blank=True, max_length=255)), - ('description', models.TextField(blank=True)), - ('website', models.URLField(blank=True)), - ('founded_date', models.DateField(blank=True, null=True)), - ('created_at', models.DateTimeField(blank=True, editable=False)), - ('updated_at', models.DateTimeField(blank=True, editable=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField(db_index=True)), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'historical manufacturer', - 'verbose_name_plural': 'historical manufacturers', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': ('history_date', 'history_id'), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), ] diff --git a/companies/migrations/0002_add_designer_model.py b/companies/migrations/0002_add_designer_model.py new file mode 100644 index 00000000..4663df28 --- /dev/null +++ b/companies/migrations/0002_add_designer_model.py @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('companies', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Designer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('slug', models.SlugField(max_length=255, unique=True)), + ('website', models.URLField(blank=True)), + ('description', models.TextField(blank=True)), + ('total_rides', models.IntegerField(default=0)), + ('total_roller_coasters', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/companies/migrations/0002_stats_fields.py b/companies/migrations/0002_stats_fields.py deleted file mode 100644 index 86585c78..00000000 --- a/companies/migrations/0002_stats_fields.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated manually - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('companies', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='company', - name='total_parks', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='company', - name='total_rides', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='historicalcompany', - name='total_parks', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='historicalcompany', - name='total_rides', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='manufacturer', - name='total_rides', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='manufacturer', - name='total_roller_coasters', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='historicalmanufacturer', - name='total_rides', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='historicalmanufacturer', - name='total_roller_coasters', - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/companies/migrations/0003_remove_total_parks.py b/companies/migrations/0003_remove_total_parks.py deleted file mode 100644 index bbfb1ed2..00000000 --- a/companies/migrations/0003_remove_total_parks.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.db import migrations - -class Migration(migrations.Migration): - dependencies = [ - ('companies', '0002_stats_fields'), - ] - - operations = [ - migrations.RemoveField( - model_name='company', - name='total_parks', - ), - ] diff --git a/companies/migrations/0004_add_total_parks.py b/companies/migrations/0004_add_total_parks.py deleted file mode 100644 index 57fce622..00000000 --- a/companies/migrations/0004_add_total_parks.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.db import migrations, models - -class Migration(migrations.Migration): - dependencies = [ - ('companies', '0003_remove_total_parks'), - ] - - operations = [ - migrations.AddField( - model_name='company', - name='total_parks', - field=models.PositiveIntegerField(default=0), - ), - ] diff --git a/companies/models.py b/companies/models.py index 834d9fc4..240c7bf1 100644 --- a/companies/models.py +++ b/companies/models.py @@ -88,3 +88,43 @@ class Manufacturer(models.Model): return cls.objects.get(pk=historical.object_id), True except (HistoricalSlug.DoesNotExist, cls.DoesNotExist): raise cls.DoesNotExist() + +class Designer(models.Model): + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + website = models.URLField(blank=True) + description = models.TextField(blank=True) + total_rides = models.IntegerField(default=0) + total_roller_coasters = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects: ClassVar[models.Manager['Designer']] + + class Meta: + ordering = ['name'] + + def __str__(self) -> str: + return self.name + + def save(self, *args, **kwargs) -> None: + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + @classmethod + def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]: + """Get designer by slug, checking historical slugs if needed""" + try: + return cls.objects.get(slug=slug), False + except cls.DoesNotExist: + # Check historical slugs + from history_tracking.models import HistoricalSlug + try: + historical = HistoricalSlug.objects.get( + content_type__model='designer', + slug=slug + ) + return cls.objects.get(pk=historical.object_id), True + except (HistoricalSlug.DoesNotExist, cls.DoesNotExist): + raise cls.DoesNotExist() diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 8d15d0fe..7316b71e 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-10-28 20:17 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion from django.db import migrations, models @@ -9,23 +9,45 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name='SlugHistory', + name="SlugHistory", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.CharField(max_length=50)), - ('old_slug', models.SlugField(max_length=200)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.CharField(max_length=50)), + ("old_slug", models.SlugField(max_length=200)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), ], options={ - 'verbose_name_plural': 'Slug histories', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['content_type', 'object_id'], name='core_slughi_content_8bbf56_idx'), models.Index(fields=['old_slug'], name='core_slughi_old_slu_aaef7f_idx')], + "verbose_name_plural": "Slug histories", + "ordering": ["-created_at"], + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="core_slughi_content_8bbf56_idx", + ), + models.Index( + fields=["old_slug"], name="core_slughi_old_slu_aaef7f_idx" + ), + ], }, ), ] diff --git a/designers/migrations/0001_initial.py b/designers/migrations/0001_initial.py index afb8916f..8a48a2c7 100644 --- a/designers/migrations/0001_initial.py +++ b/designers/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-04 00:28 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion import simple_history.models diff --git a/email_service/migrations/0001_initial.py b/email_service/migrations/0001_initial.py index ab345305..30ff9383 100644 --- a/email_service/migrations/0001_initial.py +++ b/email_service/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-10-28 20:17 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion from django.db import migrations, models @@ -9,25 +9,44 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('sites', '0002_alter_domain_unique'), + ("sites", "0002_alter_domain_unique"), ] operations = [ migrations.CreateModel( - name='EmailConfiguration', + name="EmailConfiguration", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('api_key', models.CharField(max_length=255)), - ('from_email', models.EmailField(max_length=254)), - ('from_name', models.CharField(help_text='The name that will appear in the From field of emails', max_length=255)), - ('reply_to', models.EmailField(max_length=254)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("api_key", models.CharField(max_length=255)), + ("from_email", models.EmailField(max_length=254)), + ( + "from_name", + models.CharField( + help_text="The name that will appear in the From field of emails", + max_length=255, + ), + ), + ("reply_to", models.EmailField(max_length=254)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "site", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sites.site" + ), + ), ], options={ - 'verbose_name': 'Email Configuration', - 'verbose_name_plural': 'Email Configurations', + "verbose_name": "Email Configuration", + "verbose_name_plural": "Email Configurations", }, ), ] diff --git a/history_tracking/migrations/0001_initial.py b/history_tracking/migrations/0001_initial.py index e8d71312..afb92d14 100644 --- a/history_tracking/migrations/0001_initial.py +++ b/history_tracking/migrations/0001_initial.py @@ -1,9 +1,6 @@ -# Generated by Django 5.1.2 on 2024-11-03 19:59 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion -import history_tracking.mixins -import simple_history.models -from django.conf import settings from django.db import migrations, models @@ -12,12 +9,12 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name="Park", + name="HistoricalSlug", fields=[ ( "id", @@ -28,49 +25,26 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("name", models.CharField(max_length=200)), - ], - ), - migrations.CreateModel( - name="HistoricalPark", - fields=[ + ("object_id", models.PositiveIntegerField()), + ("slug", models.SlugField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), ( - "id", - models.BigIntegerField( - auto_created=True, blank=True, db_index=True, verbose_name="ID" - ), - ), - ("name", models.CharField(max_length=200)), - ("history_id", models.AutoField(primary_key=True, serialize=False)), - ("history_date", models.DateTimeField(db_index=True)), - ("history_change_reason", models.CharField(max_length=100, null=True)), - ( - "history_type", - models.CharField( - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], - max_length=1, - ), - ), - ( - "history_user", + "content_type", models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", ), ), ], options={ - "verbose_name": "historical park", - "verbose_name_plural": "historical parks", - "ordering": ("-history_date", "-history_id"), - "get_latest_by": ("history_date", "history_id"), + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="history_tra_content_63013c_idx", + ), + models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"), + ], + "unique_together": {("content_type", "slug")}, }, - bases=( - history_tracking.mixins.HistoricalChangeMixin, - simple_history.models.HistoricalChanges, - models.Model, - ), ), ] diff --git a/history_tracking/migrations/0002_remove_historicalpark_history_user_delete_park_and_more.py b/history_tracking/migrations/0002_remove_historicalpark_history_user_delete_park_and_more.py deleted file mode 100644 index a5941190..00000000 --- a/history_tracking/migrations/0002_remove_historicalpark_history_user_delete_park_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 00:17 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("history_tracking", "0001_initial"), - ] - - operations = [ - migrations.RemoveField( - model_name="historicalpark", - name="history_user", - ), - migrations.DeleteModel( - name="Park", - ), - migrations.DeleteModel( - name="HistoricalPark", - ), - ] diff --git a/history_tracking/migrations/0003_initial.py b/history_tracking/migrations/0003_initial.py deleted file mode 100644 index 50ca1d12..00000000 --- a/history_tracking/migrations/0003_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-05 20:44 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("contenttypes", "0002_remove_content_type_name"), - ( - "history_tracking", - "0002_remove_historicalpark_history_user_delete_park_and_more", - ), - ] - - operations = [ - migrations.CreateModel( - name="HistoricalSlug", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("object_id", models.PositiveIntegerField()), - ("slug", models.SlugField(max_length=255)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "content_type", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="contenttypes.contenttype", - ), - ), - ], - options={ - "indexes": [ - models.Index( - fields=["content_type", "object_id"], - name="history_tra_content_63013c_idx", - ), - models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"), - ], - "unique_together": {("content_type", "slug")}, - }, - ), - ] diff --git a/history_tracking/mixins.py b/history_tracking/mixins.py index 8bee4203..1ddf4347 100644 --- a/history_tracking/mixins.py +++ b/history_tracking/mixins.py @@ -1,12 +1,30 @@ # history_tracking/mixins.py +from django.db import models +from django.conf import settings +class HistoricalChangeMixin(models.Model): + """Mixin for historical models to track changes""" + id = models.BigIntegerField(db_index=True, auto_created=True, blank=True) + history_date = models.DateTimeField() + history_id = models.AutoField(primary_key=True) + history_type = models.CharField(max_length=1) + history_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + on_delete=models.SET_NULL, + related_name='+' + ) + history_change_reason = models.CharField(max_length=100, null=True) + + class Meta: + abstract = True + ordering = ['-history_date', '-history_id'] -class HistoricalChangeMixin: @property def prev_record(self): """Get the previous record for this instance""" try: - return type(self).objects.filter( + return self.__class__.objects.filter( history_date__lt=self.history_date, id=self.id ).order_by('-history_date').first() @@ -28,12 +46,29 @@ class HistoricalChangeMixin: "history_user_id", "history_change_reason", "history_type", + "id", + "_state", + "_history_user_cache" ] and not field.startswith("_"): try: old_value = getattr(prev_record, field) new_value = getattr(self, field) if old_value != new_value: - changes[field] = {"old": old_value, "new": new_value} + changes[field] = {"old": str(old_value), "new": str(new_value)} except AttributeError: continue return changes + + @property + def history_user_display(self): + """Get a display name for the history user""" + if hasattr(self, 'history_user') and self.history_user: + return str(self.history_user) + return None + + def get_instance(self): + """Get the model instance this history record represents""" + try: + return self.__class__.objects.get(id=self.id) + except self.__class__.DoesNotExist: + return None diff --git a/history_tracking/models.py b/history_tracking/models.py index 9d89474e..234fd852 100644 --- a/history_tracking/models.py +++ b/history_tracking/models.py @@ -5,12 +5,17 @@ from django.contrib.contenttypes.fields import GenericForeignKey from simple_history.models import HistoricalRecords from .mixins import HistoricalChangeMixin from typing import Any, Type, TypeVar, cast +from django.db.models import QuerySet T = TypeVar('T', bound=models.Model) class HistoricalModel(models.Model): """Abstract base class for models with history tracking""" - history: HistoricalRecords = HistoricalRecords(inherit=True) + id = models.BigAutoField(primary_key=True) + history: HistoricalRecords = HistoricalRecords( + inherit=True, + bases=(HistoricalChangeMixin,) + ) class Meta: abstract = True @@ -20,6 +25,11 @@ class HistoricalModel(models.Model): """Get the history model class""" return cast(Type[T], self.history.model) # type: ignore + def get_history(self) -> QuerySet: + """Get all history records for this instance""" + model = self._history_model + return model.objects.filter(id=self.pk).order_by('-history_date') + class HistoricalSlug(models.Model): """Track historical slugs for models""" content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) diff --git a/location/migrations/0001_initial.py b/location/migrations/0001_initial.py index b04953b5..de40b309 100644 --- a/location/migrations/0001_initial.py +++ b/location/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.1.2 on 2024-11-02 23:28 +# Generated by Django 5.1.3 on 2024-11-12 18:07 +import django.contrib.gis.db.models.fields import django.core.validators import django.db.models.deletion import simple_history.models @@ -44,9 +45,11 @@ class Migration(migrations.Migration): ( "latitude", models.DecimalField( + blank=True, decimal_places=6, - help_text="Latitude coordinate", + help_text="Latitude coordinate (legacy field)", max_digits=9, + null=True, validators=[ django.core.validators.MinValueValidator(-90), django.core.validators.MaxValueValidator(90), @@ -56,25 +59,42 @@ class Migration(migrations.Migration): ( "longitude", models.DecimalField( + blank=True, decimal_places=6, - help_text="Longitude coordinate", + help_text="Longitude coordinate (legacy field)", max_digits=9, + null=True, validators=[ django.core.validators.MinValueValidator(-180), django.core.validators.MaxValueValidator(180), ], ), ), - ("street_address", models.CharField(blank=True, max_length=255)), - ("city", models.CharField(max_length=100)), + ( + "point", + django.contrib.gis.db.models.fields.PointField( + blank=True, + help_text="Geographic coordinates as a Point", + null=True, + srid=4326, + ), + ), + ( + "street_address", + models.CharField(blank=True, max_length=255, null=True), + ), + ("city", models.CharField(blank=True, max_length=100, null=True)), ( "state", models.CharField( - blank=True, help_text="State/Region/Province", max_length=100 + blank=True, + help_text="State/Region/Province", + max_length=100, + null=True, ), ), - ("country", models.CharField(max_length=100)), - ("postal_code", models.CharField(blank=True, max_length=20)), + ("country", models.CharField(blank=True, max_length=100, null=True)), + ("postal_code", models.CharField(blank=True, max_length=20, null=True)), ("created_at", models.DateTimeField(blank=True, editable=False)), ("updated_at", models.DateTimeField(blank=True, editable=False)), ("history_id", models.AutoField(primary_key=True, serialize=False)), @@ -146,9 +166,11 @@ class Migration(migrations.Migration): ( "latitude", models.DecimalField( + blank=True, decimal_places=6, - help_text="Latitude coordinate", + help_text="Latitude coordinate (legacy field)", max_digits=9, + null=True, validators=[ django.core.validators.MinValueValidator(-90), django.core.validators.MaxValueValidator(90), @@ -158,25 +180,42 @@ class Migration(migrations.Migration): ( "longitude", models.DecimalField( + blank=True, decimal_places=6, - help_text="Longitude coordinate", + help_text="Longitude coordinate (legacy field)", max_digits=9, + null=True, validators=[ django.core.validators.MinValueValidator(-180), django.core.validators.MaxValueValidator(180), ], ), ), - ("street_address", models.CharField(blank=True, max_length=255)), - ("city", models.CharField(max_length=100)), + ( + "point", + django.contrib.gis.db.models.fields.PointField( + blank=True, + help_text="Geographic coordinates as a Point", + null=True, + srid=4326, + ), + ), + ( + "street_address", + models.CharField(blank=True, max_length=255, null=True), + ), + ("city", models.CharField(blank=True, max_length=100, null=True)), ( "state", models.CharField( - blank=True, help_text="State/Region/Province", max_length=100 + blank=True, + help_text="State/Region/Province", + max_length=100, + null=True, ), ), - ("country", models.CharField(max_length=100)), - ("postal_code", models.CharField(blank=True, max_length=20)), + ("country", models.CharField(blank=True, max_length=100, null=True)), + ("postal_code", models.CharField(blank=True, max_length=20, null=True)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ( @@ -194,10 +233,6 @@ class Migration(migrations.Migration): fields=["content_type", "object_id"], name="location_lo_content_9ee1bd_idx", ), - models.Index( - fields=["latitude", "longitude"], - name="location_lo_latitud_7045c4_idx", - ), models.Index(fields=["city"], name="location_lo_city_99f908_idx"), models.Index( fields=["country"], name="location_lo_country_b75eba_idx" diff --git a/location/migrations/0002_alter_historicallocation_city_and_more.py b/location/migrations/0002_alter_historicallocation_city_and_more.py deleted file mode 100644 index 0b69a10f..00000000 --- a/location/migrations/0002_alter_historicallocation_city_and_more.py +++ /dev/null @@ -1,84 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-02 23:34 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("location", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="historicallocation", - name="city", - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name="historicallocation", - name="latitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-90), - django.core.validators.MaxValueValidator(90), - ], - ), - ), - migrations.AlterField( - model_name="historicallocation", - name="longitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-180), - django.core.validators.MaxValueValidator(180), - ], - ), - ), - migrations.AlterField( - model_name="location", - name="city", - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name="location", - name="latitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-90), - django.core.validators.MaxValueValidator(90), - ], - ), - ), - migrations.AlterField( - model_name="location", - name="longitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-180), - django.core.validators.MaxValueValidator(180), - ], - ), - ), - ] diff --git a/location/migrations/0003_alter_historicallocation_city_and_more.py b/location/migrations/0003_alter_historicallocation_city_and_more.py deleted file mode 100644 index e20cc521..00000000 --- a/location/migrations/0003_alter_historicallocation_city_and_more.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-02 23:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("location", "0002_alter_historicallocation_city_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="historicallocation", - name="city", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AlterField( - model_name="historicallocation", - name="country", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AlterField( - model_name="historicallocation", - name="postal_code", - field=models.CharField(blank=True, max_length=20, null=True), - ), - migrations.AlterField( - model_name="historicallocation", - name="state", - field=models.CharField( - blank=True, help_text="State/Region/Province", max_length=100, null=True - ), - ), - migrations.AlterField( - model_name="historicallocation", - name="street_address", - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name="location", - name="city", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AlterField( - model_name="location", - name="country", - field=models.CharField(blank=True, max_length=100, null=True), - ), - migrations.AlterField( - model_name="location", - name="postal_code", - field=models.CharField(blank=True, max_length=20, null=True), - ), - migrations.AlterField( - model_name="location", - name="state", - field=models.CharField( - blank=True, help_text="State/Region/Province", max_length=100, null=True - ), - ), - migrations.AlterField( - model_name="location", - name="street_address", - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/location/migrations/0004_add_point_field.py b/location/migrations/0004_add_point_field.py deleted file mode 100644 index 1d98f195..00000000 --- a/location/migrations/0004_add_point_field.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 22:30 - -import django.contrib.gis.db.models.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("location", "0003_alter_historicallocation_city_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="location", - name="point", - field=django.contrib.gis.db.models.fields.PointField( - blank=True, - help_text="Geographic coordinates as a Point", - null=True, - srid=4326, - ), - ), - migrations.DeleteModel( - name="HistoricalLocation", - ), - ] diff --git a/location/migrations/0005_convert_coordinates_to_points.py b/location/migrations/0005_convert_coordinates_to_points.py deleted file mode 100644 index 0648cb35..00000000 --- a/location/migrations/0005_convert_coordinates_to_points.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 22:21 - -from django.db import migrations, transaction -from django.contrib.gis.geos import Point - -def forwards_func(apps, schema_editor): - """Convert existing lat/lon coordinates to points""" - Location = apps.get_model("location", "Location") - db_alias = schema_editor.connection.alias - - # Update all locations with points based on existing lat/lon - with transaction.atomic(): - for location in Location.objects.using(db_alias).all(): - if location.latitude is not None and location.longitude is not None: - try: - location.point = Point( - float(location.longitude), # x coordinate (longitude) - float(location.latitude), # y coordinate (latitude) - srid=4326 # WGS84 coordinate system - ) - location.save(update_fields=['point']) - except (ValueError, TypeError): - print(f"Warning: Could not convert coordinates for location {location.id}") - continue - -def reverse_func(apps, schema_editor): - """Convert points back to lat/lon coordinates""" - Location = apps.get_model("location", "Location") - db_alias = schema_editor.connection.alias - - # Update all locations with lat/lon based on points - with transaction.atomic(): - for location in Location.objects.using(db_alias).all(): - if location.point: - try: - location.latitude = location.point.y - location.longitude = location.point.x - location.point = None - location.save(update_fields=['latitude', 'longitude', 'point']) - except (ValueError, TypeError, AttributeError): - print(f"Warning: Could not convert point back to coordinates for location {location.id}") - continue - -class Migration(migrations.Migration): - - dependencies = [ - ('location', '0004_add_point_field'), - ] - - operations = [ - migrations.RunPython(forwards_func, reverse_func, atomic=True), - ] diff --git a/location/migrations/0006_readd_historical_records.py b/location/migrations/0006_readd_historical_records.py deleted file mode 100644 index 3bc51178..00000000 --- a/location/migrations/0006_readd_historical_records.py +++ /dev/null @@ -1,174 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 22:32 - -import django.contrib.gis.db.models.fields -import django.core.validators -import django.db.models.deletion -import simple_history.models -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("contenttypes", "0002_remove_content_type_name"), - ("location", "0005_convert_coordinates_to_points"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="HistoricalLocation", - fields=[ - ( - "id", - models.BigIntegerField( - auto_created=True, blank=True, db_index=True, verbose_name="ID" - ), - ), - ("object_id", models.PositiveIntegerField()), - ( - "name", - models.CharField( - help_text="Name of the location (e.g. business name, landmark)", - max_length=255, - ), - ), - ( - "location_type", - models.CharField( - help_text="Type of location (e.g. business, landmark, address)", - max_length=50, - ), - ), - ( - "latitude", - models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate (legacy field)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-90), - django.core.validators.MaxValueValidator(90), - ], - ), - ), - ( - "longitude", - models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate (legacy field)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-180), - django.core.validators.MaxValueValidator(180), - ], - ), - ), - ( - "point", - django.contrib.gis.db.models.fields.PointField( - blank=True, - help_text="Geographic coordinates as a Point", - null=True, - srid=4326, - ), - ), - ( - "street_address", - models.CharField(blank=True, max_length=255, null=True), - ), - ("city", models.CharField(blank=True, max_length=100, null=True)), - ( - "state", - models.CharField( - blank=True, - help_text="State/Region/Province", - max_length=100, - null=True, - ), - ), - ("country", models.CharField(blank=True, max_length=100, null=True)), - ("postal_code", models.CharField(blank=True, max_length=20, null=True)), - ("created_at", models.DateTimeField(blank=True, editable=False)), - ("updated_at", models.DateTimeField(blank=True, editable=False)), - ("history_id", models.AutoField(primary_key=True, serialize=False)), - ("history_date", models.DateTimeField(db_index=True)), - ("history_change_reason", models.CharField(max_length=100, null=True)), - ( - "history_type", - models.CharField( - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], - max_length=1, - ), - ), - ], - options={ - "verbose_name": "historical location", - "verbose_name_plural": "historical locations", - "ordering": ("-history_date", "-history_id"), - "get_latest_by": ("history_date", "history_id"), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - migrations.RemoveIndex( - model_name="location", - name="location_lo_latitud_7045c4_idx", - ), - migrations.AlterField( - model_name="location", - name="latitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate (legacy field)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-90), - django.core.validators.MaxValueValidator(90), - ], - ), - ), - migrations.AlterField( - model_name="location", - name="longitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate (legacy field)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(-180), - django.core.validators.MaxValueValidator(180), - ], - ), - ), - migrations.AddField( - model_name="historicallocation", - name="content_type", - field=models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="contenttypes.contenttype", - ), - ), - migrations.AddField( - model_name="historicallocation", - name="history_user", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/media/migrations/0001_initial.py b/media/migrations/0001_initial.py index fb01e64c..418e1b30 100644 --- a/media/migrations/0001_initial.py +++ b/media/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 5.1.2 on 2024-10-28 20:17 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion import media.models +import media.storage +from django.conf import settings from django.db import migrations, models @@ -10,26 +12,64 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Photo', + name="Photo", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('image', models.ImageField(upload_to=media.models.photo_upload_path)), - ('caption', models.CharField(blank=True, max_length=255)), - ('alt_text', models.CharField(blank=True, max_length=255)), - ('is_primary', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('object_id', models.PositiveIntegerField()), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image", + models.ImageField( + max_length=255, + storage=media.storage.MediaStorage(), + upload_to=media.models.photo_upload_path, + ), + ), + ("caption", models.CharField(blank=True, max_length=255)), + ("alt_text", models.CharField(blank=True, max_length=255)), + ("is_primary", models.BooleanField(default=False)), + ("is_approved", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("date_taken", models.DateTimeField(blank=True, null=True)), + ("object_id", models.PositiveIntegerField()), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "uploaded_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="uploaded_photos", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'ordering': ['-is_primary', '-created_at'], - 'indexes': [models.Index(fields=['content_type', 'object_id'], name='media_photo_content_0187f5_idx')], + "ordering": ["-is_primary", "-created_at"], + "indexes": [ + models.Index( + fields=["content_type", "object_id"], + name="media_photo_content_0187f5_idx", + ) + ], }, ), ] diff --git a/media/migrations/0002_photo_uploaded_by.py b/media/migrations/0002_photo_uploaded_by.py deleted file mode 100644 index b5dfbd19..00000000 --- a/media/migrations/0002_photo_uploaded_by.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-01 00:24 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("media", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="photo", - name="uploaded_by", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="uploaded_photos", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/media/migrations/0003_update_photo_field_and_normalize.py b/media/migrations/0003_update_photo_field_and_normalize.py deleted file mode 100644 index 6e4d20eb..00000000 --- a/media/migrations/0003_update_photo_field_and_normalize.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.db import migrations, models -import os -from django.db import transaction - -def normalize_filenames(apps, schema_editor): - Photo = apps.get_model('media', 'Photo') - db_alias = schema_editor.connection.alias - - # Get all photos - photos = Photo.objects.using(db_alias).all() - - for photo in photos: - try: - with transaction.atomic(): - # Get content type model name - content_type_model = photo.content_type.model - - # Get current filename and extension - old_path = photo.image.name - _, ext = os.path.splitext(old_path) - if not ext: - ext = '.jpg' # Default to .jpg if no extension - ext = ext.lower() - - # Get the photo number (based on creation order) - photo_number = Photo.objects.using(db_alias).filter( - content_type=photo.content_type, - object_id=photo.object_id, - created_at__lte=photo.created_at - ).count() - - # Extract identifier from current path - parts = old_path.split('/') - if len(parts) >= 2: - identifier = parts[1] # e.g., "alton-towers" from "park/alton-towers/..." - - # Create new normalized filename - new_filename = f"{identifier}_{photo_number}{ext}" - new_path = f"{content_type_model}/{identifier}/{new_filename}" - - # Update the image field if path would change - if old_path != new_path: - photo.image.name = new_path - photo.save(using=db_alias) - - except Exception as e: - print(f"Error normalizing photo {photo.id}: {str(e)}") - # Continue with next photo even if this one fails - continue - -def reverse_normalize(apps, schema_editor): - # No reverse operation needed since we're just renaming files - pass - -class Migration(migrations.Migration): - dependencies = [ - ('media', '0002_photo_uploaded_by'), - ] - - operations = [ - # First increase the field length - migrations.AlterField( - model_name='photo', - name='image', - field=models.ImageField(max_length=255, upload_to='photos'), - ), - # Then normalize the filenames - migrations.RunPython(normalize_filenames, reverse_normalize), - ] diff --git a/media/migrations/0004_update_photo_paths.py b/media/migrations/0004_update_photo_paths.py deleted file mode 100644 index 449b796b..00000000 --- a/media/migrations/0004_update_photo_paths.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.db import migrations - -class Migration(migrations.Migration): - dependencies = [ - ('media', '0003_update_photo_field_and_normalize'), - ] - - operations = [ - # No schema changes needed, just need to trigger the new upload_to path - ] diff --git a/media/migrations/0005_alter_photo_image.py b/media/migrations/0005_alter_photo_image.py deleted file mode 100644 index c746778f..00000000 --- a/media/migrations/0005_alter_photo_image.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-02 23:30 - -import media.models -import media.storage -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("media", "0004_update_photo_paths"), - ] - - operations = [ - migrations.AlterField( - model_name="photo", - name="image", - field=models.ImageField( - max_length=255, - storage=media.storage.MediaStorage(), - upload_to=media.models.photo_upload_path, - ), - ), - ] diff --git a/media/migrations/0006_photo_is_approved.py b/media/migrations/0006_photo_is_approved.py deleted file mode 100644 index b04a82fd..00000000 --- a/media/migrations/0006_photo_is_approved.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-05 03:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("media", "0005_alter_photo_image"), - ] - - operations = [ - migrations.AddField( - model_name="photo", - name="is_approved", - field=models.BooleanField(default=False), - ), - ] diff --git a/media/migrations/0007_photo_date_taken.py b/media/migrations/0007_photo_date_taken.py deleted file mode 100644 index 74781a08..00000000 --- a/media/migrations/0007_photo_date_taken.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-05 18:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("media", "0006_photo_is_approved"), - ] - - operations = [ - migrations.AddField( - model_name="photo", - name="date_taken", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/moderation/migrations/0001_initial.py b/moderation/migrations/0001_initial.py index 7d6a96ba..ea23b8f9 100644 --- a/moderation/migrations/0001_initial.py +++ b/moderation/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-10-30 00:41 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion from django.conf import settings @@ -27,38 +27,48 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("object_id", models.PositiveIntegerField()), + ("object_id", models.PositiveIntegerField(blank=True, null=True)), + ( + "submission_type", + models.CharField( + choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")], + default="EDIT", + max_length=10, + ), + ), ( "changes", models.JSONField( - help_text="JSON representation of the changes made" + help_text="JSON representation of the changes or new object data" ), ), - ("reason", models.TextField(help_text="Why this edit is needed")), + ( + "reason", + models.TextField(help_text="Why this edit/addition is needed"), + ), ( "source", models.TextField( - blank=True, - help_text="Source of information for this edit (if applicable)", + blank=True, help_text="Source of information (if applicable)" ), ), ( "status", models.CharField( choices=[ - ("PENDING", "Pending"), + ("NEW", "New"), ("APPROVED", "Approved"), ("REJECTED", "Rejected"), - ("AUTO_APPROVED", "Auto Approved"), + ("ESCALATED", "Escalated"), ], - default="PENDING", + default="NEW", max_length=20, ), ), - ("submitted_at", models.DateTimeField(auto_now_add=True)), - ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("handled_at", models.DateTimeField(blank=True, null=True)), ( - "review_notes", + "notes", models.TextField( blank=True, help_text="Notes from the moderator about this submission", @@ -72,12 +82,12 @@ class Migration(migrations.Migration): ), ), ( - "reviewed_by", + "handled_by", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="reviewed_submissions", + related_name="handled_submissions", to=settings.AUTH_USER_MODEL, ), ), @@ -91,7 +101,7 @@ class Migration(migrations.Migration): ), ], options={ - "ordering": ["-submitted_at"], + "ordering": ["-created_at"], "indexes": [ models.Index( fields=["content_type", "object_id"], @@ -123,19 +133,19 @@ class Migration(migrations.Migration): "status", models.CharField( choices=[ - ("PENDING", "Pending"), + ("NEW", "New"), ("APPROVED", "Approved"), ("REJECTED", "Rejected"), ("AUTO_APPROVED", "Auto Approved"), ], - default="PENDING", + default="NEW", max_length=20, ), ), - ("submitted_at", models.DateTimeField(auto_now_add=True)), - ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("handled_at", models.DateTimeField(blank=True, null=True)), ( - "review_notes", + "notes", models.TextField( blank=True, help_text="Notes from the moderator about this photo submission", @@ -149,12 +159,12 @@ class Migration(migrations.Migration): ), ), ( - "reviewed_by", + "handled_by", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, - related_name="reviewed_photos", + related_name="handled_photos", to=settings.AUTH_USER_MODEL, ), ), @@ -168,7 +178,7 @@ class Migration(migrations.Migration): ), ], options={ - "ordering": ["-submitted_at"], + "ordering": ["-created_at"], "indexes": [ models.Index( fields=["content_type", "object_id"], diff --git a/moderation/migrations/0002_editsubmission_submission_type_and_more.py b/moderation/migrations/0002_editsubmission_submission_type_and_more.py deleted file mode 100644 index c2a2acd5..00000000 --- a/moderation/migrations/0002_editsubmission_submission_type_and_more.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-30 01:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("moderation", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="editsubmission", - name="submission_type", - field=models.CharField( - choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")], - default="EDIT", - max_length=10, - ), - ), - migrations.AlterField( - model_name="editsubmission", - name="changes", - field=models.JSONField( - help_text="JSON representation of the changes or new object data" - ), - ), - migrations.AlterField( - model_name="editsubmission", - name="object_id", - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name="editsubmission", - name="reason", - field=models.TextField(help_text="Why this edit/addition is needed"), - ), - migrations.AlterField( - model_name="editsubmission", - name="source", - field=models.TextField( - blank=True, help_text="Source of information (if applicable)" - ), - ), - ] diff --git a/moderation/migrations/0003_rename_fields_and_update_status.py b/moderation/migrations/0003_rename_fields_and_update_status.py deleted file mode 100644 index 9f86a541..00000000 --- a/moderation/migrations/0003_rename_fields_and_update_status.py +++ /dev/null @@ -1,107 +0,0 @@ -# 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/migrations/0004_alter_editsubmission_options_and_more.py b/moderation/migrations/0004_alter_editsubmission_options_and_more.py deleted file mode 100644 index 3a7c32aa..00000000 --- a/moderation/migrations/0004_alter_editsubmission_options_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-02 23:30 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("moderation", "0003_rename_fields_and_update_status"), - ] - - operations = [ - migrations.AlterModelOptions( - name="editsubmission", - options={"ordering": ["-created_at"]}, - ), - migrations.AlterModelOptions( - name="photosubmission", - options={"ordering": ["-created_at"]}, - ), - ] diff --git a/parks/management/commands/seed_initial_data.py b/parks/management/commands/seed_initial_data.py new file mode 100644 index 00000000..b49679ef --- /dev/null +++ b/parks/management/commands/seed_initial_data.py @@ -0,0 +1,245 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from companies.models import Company +from parks.models import Park, ParkArea +from location.models import Location +from django.contrib.contenttypes.models import ContentType + +class Command(BaseCommand): + help = 'Seeds initial park data with major theme parks worldwide' + + def handle(self, *args, **options): + # Create major theme park companies + companies_data = [ + { + 'name': 'The Walt Disney Company', + 'website': 'https://www.disney.com/', + 'headquarters': 'Burbank, California', + 'description': 'The world\'s largest entertainment company and theme park operator.' + }, + { + 'name': 'Universal Parks & Resorts', + 'website': 'https://www.universalparks.com/', + 'headquarters': 'Orlando, Florida', + 'description': 'A division of Comcast NBCUniversal, operating major theme parks worldwide.' + }, + { + 'name': 'Six Flags Entertainment Corporation', + 'website': 'https://www.sixflags.com/', + 'headquarters': 'Arlington, Texas', + 'description': 'The world\'s largest regional theme park company.' + }, + { + 'name': 'Cedar Fair Entertainment Company', + 'website': 'https://www.cedarfair.com/', + 'headquarters': 'Sandusky, Ohio', + 'description': 'One of North America\'s largest operators of regional amusement parks.' + }, + { + 'name': 'Herschend Family Entertainment', + 'website': 'https://www.hfecorp.com/', + 'headquarters': 'Atlanta, Georgia', + 'description': 'The largest family-owned themed attractions corporation in the United States.' + }, + { + 'name': 'SeaWorld Parks & Entertainment', + 'website': 'https://www.seaworldentertainment.com/', + 'headquarters': 'Orlando, Florida', + 'description': 'Theme park and entertainment company focusing on nature-based themes.' + } + ] + + companies = {} + for company_data in companies_data: + company, created = Company.objects.get_or_create( + name=company_data['name'], + defaults=company_data + ) + companies[company.name] = company + self.stdout.write(f'{"Created" if created else "Found"} company: {company.name}') + + # Create parks with their locations + parks_data = [ + { + 'name': 'Magic Kingdom', + 'company': 'The Walt Disney Company', + 'description': 'The first theme park at Walt Disney World Resort in Florida, opened in 1971.', + 'opening_date': '1971-10-01', + 'size_acres': 142, + 'location': { + 'street_address': '1180 Seven Seas Dr', + 'city': 'Lake Buena Vista', + 'state': 'Florida', + 'country': 'United States', + 'postal_code': '32830', + 'latitude': 28.4177, + 'longitude': -81.5812 + }, + 'areas': [ + {'name': 'Main Street, U.S.A.', 'description': 'Victorian-era themed entrance corridor'}, + {'name': 'Adventureland', 'description': 'Exotic tropical places themed area'}, + {'name': 'Frontierland', 'description': 'American Old West themed area'}, + {'name': 'Liberty Square', 'description': 'Colonial America themed area'}, + {'name': 'Fantasyland', 'description': 'Fairy tale themed area'}, + {'name': 'Tomorrowland', 'description': 'Future themed area'} + ] + }, + { + 'name': 'Universal Studios Florida', + 'company': 'Universal Parks & Resorts', + 'description': 'Movie and television-based theme park in Orlando, Florida.', + 'opening_date': '1990-06-07', + 'size_acres': 108, + 'location': { + 'street_address': '6000 Universal Blvd', + 'city': 'Orlando', + 'state': 'Florida', + 'country': 'United States', + 'postal_code': '32819', + 'latitude': 28.4749, + 'longitude': -81.4687 + }, + 'areas': [ + {'name': 'Production Central', 'description': 'Main entrance area with movie-themed attractions'}, + {'name': 'New York', 'description': 'Themed after New York City streets'}, + {'name': 'San Francisco', 'description': 'Themed after San Francisco\'s waterfront'}, + {'name': 'The Wizarding World of Harry Potter - Diagon Alley', 'description': 'Themed after the Harry Potter series'}, + {'name': 'Springfield', 'description': 'Themed after The Simpsons hometown'} + ] + }, + { + 'name': 'Cedar Point', + 'company': 'Cedar Fair Entertainment Company', + 'description': 'Known as the "Roller Coaster Capital of the World".', + 'opening_date': '1870-06-01', + 'size_acres': 364, + 'location': { + 'street_address': '1 Cedar Point Dr', + 'city': 'Sandusky', + 'state': 'Ohio', + 'country': 'United States', + 'postal_code': '44870', + 'latitude': 41.4822, + 'longitude': -82.6835 + }, + 'areas': [ + {'name': 'Frontiertown', 'description': 'Western-themed area with multiple roller coasters'}, + {'name': 'Millennium Island', 'description': 'Home to the Millennium Force roller coaster'}, + {'name': 'Cedar Point Shores', 'description': 'Waterpark area'}, + {'name': 'Top Thrill Dragster', 'description': 'Area surrounding the iconic launched coaster'} + ] + }, + { + 'name': 'Silver Dollar City', + 'company': 'Herschend Family Entertainment', + 'description': 'An 1880s-themed park featuring over 40 rides and attractions.', + 'opening_date': '1960-05-01', + 'size_acres': 61, + 'location': { + 'street_address': '399 Silver Dollar City Parkway', + 'city': 'Branson', + 'state': 'Missouri', + 'country': 'United States', + 'postal_code': '65616', + 'latitude': 36.668497, + 'longitude': -93.339074 + }, + 'areas': [ + {'name': 'Grand Exposition', 'description': 'Home to many family rides and attractions'}, + {'name': 'Wildfire', 'description': 'Named after the famous B&M coaster'}, + {'name': 'Wilson\'s Farm', 'description': 'Farm-themed attractions and dining'}, + {'name': 'Riverfront', 'description': 'Water-themed attractions area'}, + {'name': 'The Valley', 'description': 'Home to Time Traveler and other major attractions'} + ] + }, + { + 'name': 'Six Flags Magic Mountain', + 'company': 'Six Flags Entertainment Corporation', + 'description': 'Known for its world-record 19 roller coasters.', + 'opening_date': '1971-05-29', + 'size_acres': 262, + 'location': { + 'street_address': '26101 Magic Mountain Pkwy', + 'city': 'Valencia', + 'state': 'California', + 'country': 'United States', + 'postal_code': '91355', + 'latitude': 34.4253, + 'longitude': -118.5971 + }, + 'areas': [ + {'name': 'Six Flags Plaza', 'description': 'Main entrance area'}, + {'name': 'DC Universe', 'description': 'DC Comics themed area'}, + {'name': 'Screampunk District', 'description': 'Steampunk themed area'}, + {'name': 'The Underground', 'description': 'Urban themed area'}, + {'name': 'Goliath Territory', 'description': 'Area surrounding the Goliath hypercoaster'} + ] + }, + { + 'name': 'SeaWorld Orlando', + 'company': 'SeaWorld Parks & Entertainment', + 'description': 'Marine zoological park combined with thrill rides and shows.', + 'opening_date': '1973-12-15', + 'size_acres': 200, + 'location': { + 'street_address': '7007 Sea World Dr', + 'city': 'Orlando', + 'state': 'Florida', + 'country': 'United States', + 'postal_code': '32821', + 'latitude': 28.4115, + 'longitude': -81.4617 + }, + 'areas': [ + {'name': 'Sea Harbor', 'description': 'Main entrance and shopping area'}, + {'name': 'Shark Encounter', 'description': 'Shark exhibit and themed area'}, + {'name': 'Antarctica: Empire of the Penguin', 'description': 'Penguin-themed area'}, + {'name': 'Manta', 'description': 'Area themed around the Manta flying roller coaster'}, + {'name': 'Sesame Street Land', 'description': 'Kid-friendly area based on Sesame Street'} + ] + } + ] + + # Create parks and their areas + for park_data in parks_data: + company = companies[park_data['company']] + park, created = Park.objects.get_or_create( + name=park_data['name'], + defaults={ + 'description': park_data['description'], + 'status': 'OPERATING', + 'opening_date': park_data['opening_date'], + 'size_acres': park_data['size_acres'], + 'owner': company + } + ) + self.stdout.write(f'{"Created" if created else "Found"} park: {park.name}') + + # Create location for park + if created: + loc_data = park_data['location'] + park_content_type = ContentType.objects.get_for_model(Park) + Location.objects.create( + content_type=park_content_type, + object_id=park.id, + street_address=loc_data['street_address'], + city=loc_data['city'], + state=loc_data['state'], + country=loc_data['country'], + postal_code=loc_data['postal_code'], + latitude=loc_data['latitude'], + longitude=loc_data['longitude'] + ) + + # Create areas for park + for area_data in park_data['areas']: + area, created = ParkArea.objects.get_or_create( + name=area_data['name'], + park=park, + defaults={ + 'description': area_data['description'] + } + ) + self.stdout.write(f'{"Created" if created else "Found"} area: {area.name} in {park.name}') + + self.stdout.write(self.style.SUCCESS('Successfully seeded initial park data')) diff --git a/parks/management/commands/seed_ride_data.py b/parks/management/commands/seed_ride_data.py new file mode 100644 index 00000000..8157f34c --- /dev/null +++ b/parks/management/commands/seed_ride_data.py @@ -0,0 +1,321 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from companies.models import Manufacturer +from parks.models import Park +from rides.models import Ride, RollerCoasterStats +from decimal import Decimal + +class Command(BaseCommand): + help = 'Seeds ride data for parks' + + def handle(self, *args, **options): + # Create major ride manufacturers + manufacturers_data = [ + { + 'name': 'Bolliger & Mabillard', + 'website': 'https://www.bolligermabillard.com/', + 'headquarters': 'Monthey, Switzerland', + 'description': 'Known for their smooth steel roller coasters.' + }, + { + 'name': 'Rocky Mountain Construction', + 'website': 'https://www.rockymtnconstruction.com/', + 'headquarters': 'Hayden, Idaho, USA', + 'description': 'Specialists in hybrid and steel roller coasters.' + }, + { + 'name': 'Intamin', + 'website': 'https://www.intamin.com/', + 'headquarters': 'Schaan, Liechtenstein', + 'description': 'Creators of record-breaking roller coasters and rides.' + }, + { + 'name': 'Vekoma', + 'website': 'https://www.vekoma.com/', + 'headquarters': 'Vlodrop, Netherlands', + 'description': 'Manufacturers of various roller coaster types.' + }, + { + 'name': 'Mack Rides', + 'website': 'https://mack-rides.com/', + 'headquarters': 'Waldkirch, Germany', + 'description': 'Family-owned manufacturer of roller coasters and attractions.' + }, + { + 'name': 'Sally Dark Rides', + 'website': 'https://sallydarkrides.com/', + 'headquarters': 'Jacksonville, Florida, USA', + 'description': 'Specialists in dark rides and interactive attractions.' + }, + { + 'name': 'Zamperla', + 'website': 'https://www.zamperla.com/', + 'headquarters': 'Vicenza, Italy', + 'description': 'Manufacturer of family rides and thrill attractions.' + } + ] + + manufacturers = {} + for mfg_data in manufacturers_data: + manufacturer, created = Manufacturer.objects.get_or_create( + name=mfg_data['name'], + defaults=mfg_data + ) + manufacturers[manufacturer.name] = manufacturer + self.stdout.write(f'{"Created" if created else "Found"} manufacturer: {manufacturer.name}') + + # Create rides for each park + rides_data = [ + # Silver Dollar City Rides + { + 'park_name': 'Silver Dollar City', + 'rides': [ + { + 'name': 'Time Traveler', + 'manufacturer': 'Mack Rides', + 'description': 'The world\'s fastest, steepest, and tallest spinning roller coaster.', + 'category': 'RC', + 'opening_date': '2018-03-14', + 'stats': { + 'height_ft': 100, + 'length_ft': 3020, + 'speed_mph': 50.3, + 'inversions': 3, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SPINNING', + 'launch_type': 'LSM' + } + }, + { + 'name': 'Wildfire', + 'manufacturer': 'Bolliger & Mabillard', + 'description': 'A multi-looping roller coaster with a 155-foot drop.', + 'category': 'RC', + 'opening_date': '2001-04-01', + 'stats': { + 'height_ft': 155, + 'length_ft': 3073, + 'speed_mph': 66, + 'inversions': 5, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SITDOWN', + 'launch_type': 'CHAIN' + } + }, + { + 'name': 'Fire In The Hole', + 'manufacturer': 'Sally Dark Rides', + 'description': 'Indoor coaster featuring special effects and storytelling.', + 'category': 'DR', + 'opening_date': '1972-01-01' + }, + { + 'name': 'American Plunge', + 'manufacturer': 'Intamin', + 'description': 'Log flume ride with a 50-foot splashdown.', + 'category': 'WR', + 'opening_date': '1981-01-01' + } + ] + }, + # Magic Kingdom Rides + { + 'park_name': 'Magic Kingdom', + 'rides': [ + { + 'name': 'Space Mountain', + 'manufacturer': 'Vekoma', + 'description': 'An indoor roller coaster through space.', + 'category': 'RC', + 'opening_date': '1975-01-15', + 'stats': { + 'height_ft': 180, + 'length_ft': 3196, + 'speed_mph': 27, + 'inversions': 0, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SITDOWN', + 'launch_type': 'CHAIN' + } + }, + { + 'name': 'Haunted Mansion', + 'manufacturer': 'Sally Dark Rides', + 'description': 'Classic dark ride through a haunted estate.', + 'category': 'DR', + 'opening_date': '1971-10-01' + }, + { + 'name': 'Mad Tea Party', + 'manufacturer': 'Zamperla', + 'description': 'Spinning teacup ride based on Alice in Wonderland.', + 'category': 'FR', + 'opening_date': '1971-10-01' + }, + { + 'name': 'Splash Mountain', + 'manufacturer': 'Intamin', + 'description': 'Log flume ride with multiple drops and animatronics.', + 'category': 'WR', + 'opening_date': '1992-10-02' + } + ] + }, + # Cedar Point Rides + { + 'park_name': 'Cedar Point', + 'rides': [ + { + 'name': 'Millennium Force', + 'manufacturer': 'Intamin', + 'description': 'Former world\'s tallest and fastest complete-circuit roller coaster.', + 'category': 'RC', + 'opening_date': '2000-05-13', + 'stats': { + 'height_ft': 310, + 'length_ft': 6595, + 'speed_mph': 93, + 'inversions': 0, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SITDOWN', + 'launch_type': 'CABLE' + } + }, + { + 'name': 'Cedar Downs Racing Derby', + 'manufacturer': 'Zamperla', + 'description': 'High-speed carousel with racing horses.', + 'category': 'FR', + 'opening_date': '1967-01-01' + }, + { + 'name': 'Snake River Falls', + 'manufacturer': 'Intamin', + 'description': 'Shoot-the-Chutes water ride with an 82-foot drop.', + 'category': 'WR', + 'opening_date': '1993-05-01' + } + ] + }, + # Universal Studios Florida Rides + { + 'park_name': 'Universal Studios Florida', + 'rides': [ + { + 'name': 'Harry Potter and the Escape from Gringotts', + 'manufacturer': 'Intamin', + 'description': 'Indoor steel roller coaster with 3D effects.', + 'category': 'RC', + 'opening_date': '2014-07-08', + 'stats': { + 'height_ft': 65, + 'length_ft': 2000, + 'speed_mph': 50, + 'inversions': 0, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SITDOWN', + 'launch_type': 'LSM' + } + }, + { + 'name': 'The Amazing Adventures of Spider-Man', + 'manufacturer': 'Sally Dark Rides', + 'description': 'groundbreaking 3D dark ride.', + 'category': 'DR', + 'opening_date': '1999-05-28' + }, + { + 'name': 'Jurassic World VelociCoaster', + 'manufacturer': 'Intamin', + 'description': 'Florida\'s fastest and tallest launch coaster.', + 'category': 'RC', + 'opening_date': '2021-06-10', + 'stats': { + 'height_ft': 155, + 'length_ft': 4700, + 'speed_mph': 70, + 'inversions': 4, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SITDOWN', + 'launch_type': 'LSM' + } + } + ] + }, + # SeaWorld Orlando Rides + { + 'park_name': 'SeaWorld Orlando', + 'rides': [ + { + 'name': 'Mako', + 'manufacturer': 'Bolliger & Mabillard', + 'description': 'Orlando\'s tallest, fastest and longest roller coaster.', + 'category': 'RC', + 'opening_date': '2016-06-10', + 'stats': { + 'height_ft': 200, + 'length_ft': 4760, + 'speed_mph': 73, + 'inversions': 0, + 'track_material': 'STEEL', + 'roller_coaster_type': 'SITDOWN', + 'launch_type': 'CHAIN' + } + }, + { + 'name': 'Journey to Atlantis', + 'manufacturer': 'Mack Rides', + 'description': 'Water coaster combining dark ride elements with splashes.', + 'category': 'WR', + 'opening_date': '1998-03-01' + }, + { + 'name': 'Sky Tower', + 'manufacturer': 'Intamin', + 'description': 'Rotating observation tower providing views of Orlando.', + 'category': 'TR', + 'opening_date': '1973-12-15' + } + ] + } + ] + + # Create rides and their stats + for park_data in rides_data: + try: + park = Park.objects.get(name=park_data['park_name']) + + for ride_data in park_data['rides']: + manufacturer = manufacturers[ride_data['manufacturer']] + + ride, created = Ride.objects.get_or_create( + name=ride_data['name'], + park=park, + defaults={ + 'description': ride_data['description'], + 'category': ride_data['category'], + 'manufacturer': manufacturer, + 'opening_date': ride_data['opening_date'], + 'status': 'OPERATING' + } + ) + self.stdout.write(f'{"Created" if created else "Found"} ride: {ride.name}') + + if created and ride_data.get('stats'): + stats = ride_data['stats'] + RollerCoasterStats.objects.create( + ride=ride, + height_ft=stats['height_ft'], + length_ft=stats['length_ft'], + speed_mph=stats['speed_mph'], + inversions=stats['inversions'], + track_material=stats['track_material'], + roller_coaster_type=stats['roller_coaster_type'], + launch_type=stats['launch_type'] + ) + self.stdout.write(f'Created stats for: {ride.name}') + + except Park.DoesNotExist: + self.stdout.write(self.style.WARNING(f'Park not found: {park_data["park_name"]}')) + + self.stdout.write(self.style.SUCCESS('Successfully seeded ride data')) diff --git a/parks/migrations/0001_initial.py b/parks/migrations/0001_initial.py index e6853326..abee3e4f 100644 --- a/parks/migrations/0001_initial.py +++ b/parks/migrations/0001_initial.py @@ -1,5 +1,9 @@ -from django.db import migrations, models +# Generated by Django 5.1.3 on 2024-11-12 18:07 + import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): @@ -7,57 +11,228 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('companies', '0001_initial'), + ("companies", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Park', + name="HistoricalPark", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255, unique=True)), - ('description', models.TextField(blank=True)), - ('status', models.CharField(choices=[('OPERATING', 'Operating'), ('CLOSED_TEMP', 'Temporarily Closed'), ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), ('RELOCATED', 'Relocated')], default='OPERATING', max_length=20)), - ('opening_date', models.DateField(blank=True, null=True)), - ('closing_date', models.DateField(blank=True, null=True)), - ('operating_season', models.CharField(blank=True, max_length=255)), - ('size_acres', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('website', models.URLField(blank=True)), - ('average_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)), - ('total_rides', models.IntegerField(blank=True, null=True)), - ('total_roller_coasters', models.IntegerField(blank=True, null=True)), - ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), - ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), - ('street_address', models.CharField(blank=True, max_length=255)), - ('city', models.CharField(blank=True, max_length=255)), - ('state', models.CharField(blank=True, max_length=255)), - ('country', models.CharField(blank=True, max_length=255)), - ('postal_code', models.CharField(blank=True, max_length=20)), - ('created_at', models.DateTimeField(auto_now_add=True, null=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parks', to='companies.company')), + ("id", models.BigIntegerField(blank=True, db_index=True)), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "status", + models.CharField( + choices=[ + ("OPERATING", "Operating"), + ("CLOSED_TEMP", "Temporarily Closed"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ], + default="OPERATING", + max_length=20, + ), + ), + ("opening_date", models.DateField(blank=True, null=True)), + ("closing_date", models.DateField(blank=True, null=True)), + ("operating_season", models.CharField(blank=True, max_length=255)), + ( + "size_acres", + models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + ("website", models.URLField(blank=True)), + ( + "average_rating", + models.DecimalField( + blank=True, decimal_places=2, max_digits=3, null=True + ), + ), + ("ride_count", models.IntegerField(blank=True, null=True)), + ("coaster_count", models.IntegerField(blank=True, null=True)), + ( + "created_at", + models.DateTimeField(blank=True, editable=False, null=True), + ), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="companies.company", + ), + ), ], options={ - 'ordering': ['name'], + "verbose_name": "historical park", + "verbose_name_plural": "historical parks", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="Park", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255, unique=True)), + ("description", models.TextField(blank=True)), + ( + "status", + models.CharField( + choices=[ + ("OPERATING", "Operating"), + ("CLOSED_TEMP", "Temporarily Closed"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ], + default="OPERATING", + max_length=20, + ), + ), + ("opening_date", models.DateField(blank=True, null=True)), + ("closing_date", models.DateField(blank=True, null=True)), + ("operating_season", models.CharField(blank=True, max_length=255)), + ( + "size_acres", + models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + ("website", models.URLField(blank=True)), + ( + "average_rating", + models.DecimalField( + blank=True, decimal_places=2, max_digits=3, null=True + ), + ), + ("ride_count", models.IntegerField(blank=True, null=True)), + ("coaster_count", models.IntegerField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True, null=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="parks", + to="companies.company", + ), + ), + ], + options={ + "ordering": ["name"], }, ), migrations.CreateModel( - name='ParkArea', + name="HistoricalParkArea", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255)), - ('description', models.TextField(blank=True)), - ('opening_date', models.DateField(blank=True, null=True)), - ('closing_date', models.DateField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True, null=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('park', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='areas', to='parks.park')), + ("id", models.BigIntegerField(blank=True, db_index=True)), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255)), + ("description", models.TextField(blank=True)), + ("opening_date", models.DateField(blank=True, null=True)), + ("closing_date", models.DateField(blank=True, null=True)), + ( + "created_at", + models.DateTimeField(blank=True, editable=False, null=True), + ), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "park", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="parks.park", + ), + ), ], options={ - 'ordering': ['name'], - 'unique_together': {('park', 'slug')}, + "verbose_name": "historical park area", + "verbose_name_plural": "historical park areas", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="ParkArea", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(max_length=255)), + ("description", models.TextField(blank=True)), + ("opening_date", models.DateField(blank=True, null=True)), + ("closing_date", models.DateField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True, null=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "park", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="areas", + to="parks.park", + ), + ), + ], + options={ + "ordering": ["name"], + "unique_together": {("park", "slug")}, }, ), ] diff --git a/parks/migrations/0002_historicalpark_historicalparkarea.py b/parks/migrations/0002_historicalpark_historicalparkarea.py deleted file mode 100644 index fe0a1c3f..00000000 --- a/parks/migrations/0002_historicalpark_historicalparkarea.py +++ /dev/null @@ -1,84 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-03 03:44 - -import django.db.models.deletion -import simple_history.models -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('companies', '0004_add_total_parks'), - ('parks', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='HistoricalPark', - fields=[ - ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255)), - ('description', models.TextField(blank=True)), - ('status', models.CharField(choices=[('OPERATING', 'Operating'), ('CLOSED_TEMP', 'Temporarily Closed'), ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), ('RELOCATED', 'Relocated')], default='OPERATING', max_length=20)), - ('opening_date', models.DateField(blank=True, null=True)), - ('closing_date', models.DateField(blank=True, null=True)), - ('operating_season', models.CharField(blank=True, max_length=255)), - ('size_acres', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), - ('website', models.URLField(blank=True)), - ('average_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)), - ('total_rides', models.IntegerField(blank=True, null=True)), - ('total_roller_coasters', models.IntegerField(blank=True, null=True)), - ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), - ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), - ('street_address', models.CharField(blank=True, max_length=255)), - ('city', models.CharField(blank=True, max_length=255)), - ('state', models.CharField(blank=True, max_length=255)), - ('country', models.CharField(blank=True, max_length=255)), - ('postal_code', models.CharField(blank=True, max_length=20)), - ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), - ('updated_at', models.DateTimeField(blank=True, editable=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField(db_index=True)), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='companies.company')), - ], - options={ - 'verbose_name': 'historical park', - 'verbose_name_plural': 'historical parks', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': ('history_date', 'history_id'), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - migrations.CreateModel( - name='HistoricalParkArea', - fields=[ - ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('slug', models.SlugField(max_length=255)), - ('description', models.TextField(blank=True)), - ('opening_date', models.DateField(blank=True, null=True)), - ('closing_date', models.DateField(blank=True, null=True)), - ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), - ('updated_at', models.DateTimeField(blank=True, editable=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField(db_index=True)), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('park', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='parks.park')), - ], - options={ - 'verbose_name': 'historical park area', - 'verbose_name_plural': 'historical park areas', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': ('history_date', 'history_id'), - }, - bases=(simple_history.models.HistoricalChanges, models.Model), - ), - ] diff --git a/parks/migrations/0003_update_coordinate_fields.py b/parks/migrations/0003_update_coordinate_fields.py deleted file mode 100644 index fd02e9e4..00000000 --- a/parks/migrations/0003_update_coordinate_fields.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('parks', '0002_historicalpark_historicalparkarea'), - ] - - operations = [ - migrations.AlterField( - model_name='park', - name='latitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - max_digits=9, # Changed to 9 to handle -90.000000 to 90.000000 - null=True, - help_text='Latitude coordinate (-90 to 90)', - ), - ), - migrations.AlterField( - model_name='park', - name='longitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - max_digits=10, # Changed to 10 to handle -180.000000 to 180.000000 - null=True, - help_text='Longitude coordinate (-180 to 180)', - ), - ), - migrations.AlterField( - model_name='historicalpark', - name='latitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - max_digits=9, # Changed to 9 to handle -90.000000 to 90.000000 - null=True, - help_text='Latitude coordinate (-90 to 90)', - ), - ), - migrations.AlterField( - model_name='historicalpark', - name='longitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - max_digits=10, # Changed to 10 to handle -180.000000 to 180.000000 - null=True, - help_text='Longitude coordinate (-180 to 180)', - ), - ), - ] diff --git a/parks/migrations/0004_add_coordinate_validators.py b/parks/migrations/0004_add_coordinate_validators.py deleted file mode 100644 index 6b7a6e77..00000000 --- a/parks/migrations/0004_add_coordinate_validators.py +++ /dev/null @@ -1,101 +0,0 @@ -from django.db import migrations, models -from django.core.validators import MinValueValidator, MaxValueValidator -from decimal import Decimal -from django.core.exceptions import ValidationError - - -def validate_coordinate_digits(value, max_digits): - """Validate total number of digits in a coordinate value""" - if value is not None: - # Convert to string and remove decimal point and sign - str_val = str(abs(value)).replace('.', '') - # Remove trailing zeros after decimal point - str_val = str_val.rstrip('0') - if len(str_val) > max_digits: - raise ValidationError( - f'Ensure that there are no more than {max_digits} digits in total.' - ) - - -def validate_latitude_digits(value): - """Validate total number of digits in latitude""" - validate_coordinate_digits(value, 9) - - -def validate_longitude_digits(value): - """Validate total number of digits in longitude""" - validate_coordinate_digits(value, 10) - - -class Migration(migrations.Migration): - - dependencies = [ - ('parks', '0003_update_coordinate_fields'), - ] - - operations = [ - migrations.AlterField( - model_name='park', - name='latitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text='Latitude coordinate (-90 to 90)', - max_digits=9, - null=True, - validators=[ - MinValueValidator(Decimal('-90')), - MaxValueValidator(Decimal('90')), - validate_latitude_digits, - ], - ), - ), - migrations.AlterField( - model_name='park', - name='longitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text='Longitude coordinate (-180 to 180)', - max_digits=10, - null=True, - validators=[ - MinValueValidator(Decimal('-180')), - MaxValueValidator(Decimal('180')), - validate_longitude_digits, - ], - ), - ), - migrations.AlterField( - model_name='historicalpark', - name='latitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text='Latitude coordinate (-90 to 90)', - max_digits=9, - null=True, - validators=[ - MinValueValidator(Decimal('-90')), - MaxValueValidator(Decimal('90')), - validate_latitude_digits, - ], - ), - ), - migrations.AlterField( - model_name='historicalpark', - name='longitude', - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text='Longitude coordinate (-180 to 180)', - max_digits=10, - null=True, - validators=[ - MinValueValidator(Decimal('-180')), - MaxValueValidator(Decimal('180')), - validate_longitude_digits, - ], - ), - ), - ] diff --git a/parks/migrations/0005_normalize_coordinates.py b/parks/migrations/0005_normalize_coordinates.py deleted file mode 100644 index 9a0e75b8..00000000 --- a/parks/migrations/0005_normalize_coordinates.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.db import migrations -from decimal import Decimal, ROUND_DOWN - - -def normalize_coordinate(value, max_digits, decimal_places): - """Normalize coordinate to have exactly 6 decimal places""" - try: - if value is None: - return None - - # Convert to Decimal for precise handling - value = Decimal(str(value)) - # Round to exactly 6 decimal places - value = value.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) - - return value - except (TypeError, ValueError): - return None - - -def normalize_existing_coordinates(apps, schema_editor): - Park = apps.get_model('parks', 'Park') - HistoricalPark = apps.get_model('parks', 'HistoricalPark') - - # Normalize coordinates in current parks - for park in Park.objects.all(): - if park.latitude is not None: - park.latitude = normalize_coordinate(park.latitude, 9, 6) - if park.longitude is not None: - park.longitude = normalize_coordinate(park.longitude, 10, 6) - park.save() - - # Normalize coordinates in historical records - for record in HistoricalPark.objects.all(): - if record.latitude is not None: - record.latitude = normalize_coordinate(record.latitude, 9, 6) - if record.longitude is not None: - record.longitude = normalize_coordinate(record.longitude, 10, 6) - record.save() - - -def reverse_normalize_coordinates(apps, schema_editor): - # No need to reverse normalization as it would only reduce precision - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('parks', '0004_add_coordinate_validators'), - ] - - operations = [ - migrations.RunPython( - normalize_existing_coordinates, - reverse_normalize_coordinates - ), - ] diff --git a/parks/migrations/0006_alter_historicalpark_latitude_and_more.py b/parks/migrations/0006_alter_historicalpark_latitude_and_more.py deleted file mode 100644 index e6b76a8b..00000000 --- a/parks/migrations/0006_alter_historicalpark_latitude_and_more.py +++ /dev/null @@ -1,75 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-03 19:59 - -import django.core.validators -from decimal import Decimal -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("parks", "0005_normalize_coordinates"), - ] - - operations = [ - migrations.AlterField( - model_name="historicalpark", - name="latitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate (-90 to 90)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(Decimal("-90")), - django.core.validators.MaxValueValidator(Decimal("90")), - ], - ), - ), - migrations.AlterField( - model_name="historicalpark", - name="longitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate (-180 to 180)", - max_digits=10, - null=True, - validators=[ - django.core.validators.MinValueValidator(Decimal("-180")), - django.core.validators.MaxValueValidator(Decimal("180")), - ], - ), - ), - migrations.AlterField( - model_name="park", - name="latitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate (-90 to 90)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(Decimal("-90")), - django.core.validators.MaxValueValidator(Decimal("90")), - ], - ), - ), - migrations.AlterField( - model_name="park", - name="longitude", - field=models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate (-180 to 180)", - max_digits=10, - null=True, - validators=[ - django.core.validators.MinValueValidator(Decimal("-180")), - django.core.validators.MaxValueValidator(Decimal("180")), - ], - ), - ), - ] diff --git a/parks/migrations/0007_remove_historicalparkarea_history_user_and_more.py b/parks/migrations/0007_remove_historicalparkarea_history_user_and_more.py deleted file mode 100644 index 39204c41..00000000 --- a/parks/migrations/0007_remove_historicalparkarea_history_user_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-03 20:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("parks", "0006_alter_historicalpark_latitude_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="historicalparkarea", - name="history_user", - ), - migrations.RemoveField( - model_name="historicalparkarea", - name="park", - ), - migrations.DeleteModel( - name="HistoricalPark", - ), - migrations.DeleteModel( - name="HistoricalParkArea", - ), - ] diff --git a/parks/migrations/0008_historicalpark_historicalparkarea.py b/parks/migrations/0008_historicalpark_historicalparkarea.py deleted file mode 100644 index 61826d1a..00000000 --- a/parks/migrations/0008_historicalpark_historicalparkarea.py +++ /dev/null @@ -1,209 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-03 20:38 - -import django.core.validators -import django.db.models.deletion -import history_tracking.mixins -import simple_history.models -from decimal import Decimal -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("companies", "0004_add_total_parks"), - ("parks", "0007_remove_historicalparkarea_history_user_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="HistoricalPark", - fields=[ - ( - "id", - models.BigIntegerField( - auto_created=True, blank=True, db_index=True, verbose_name="ID" - ), - ), - ("name", models.CharField(max_length=255)), - ("slug", models.SlugField(max_length=255)), - ("description", models.TextField(blank=True)), - ( - "status", - models.CharField( - choices=[ - ("OPERATING", "Operating"), - ("CLOSED_TEMP", "Temporarily Closed"), - ("CLOSED_PERM", "Permanently Closed"), - ("UNDER_CONSTRUCTION", "Under Construction"), - ("DEMOLISHED", "Demolished"), - ("RELOCATED", "Relocated"), - ], - default="OPERATING", - max_length=20, - ), - ), - ( - "latitude", - models.DecimalField( - blank=True, - decimal_places=6, - help_text="Latitude coordinate (-90 to 90)", - max_digits=9, - null=True, - validators=[ - django.core.validators.MinValueValidator(Decimal("-90")), - django.core.validators.MaxValueValidator(Decimal("90")), - ], - ), - ), - ( - "longitude", - models.DecimalField( - blank=True, - decimal_places=6, - help_text="Longitude coordinate (-180 to 180)", - max_digits=10, - null=True, - validators=[ - django.core.validators.MinValueValidator(Decimal("-180")), - django.core.validators.MaxValueValidator(Decimal("180")), - ], - ), - ), - ("street_address", models.CharField(blank=True, max_length=255)), - ("city", models.CharField(blank=True, max_length=255)), - ("state", models.CharField(blank=True, max_length=255)), - ("country", models.CharField(blank=True, max_length=255)), - ("postal_code", models.CharField(blank=True, max_length=20)), - ("opening_date", models.DateField(blank=True, null=True)), - ("closing_date", models.DateField(blank=True, null=True)), - ("operating_season", models.CharField(blank=True, max_length=255)), - ( - "size_acres", - models.DecimalField( - blank=True, decimal_places=2, max_digits=10, null=True - ), - ), - ("website", models.URLField(blank=True)), - ( - "average_rating", - models.DecimalField( - blank=True, decimal_places=2, max_digits=3, null=True - ), - ), - ("total_rides", models.IntegerField(blank=True, null=True)), - ("total_roller_coasters", models.IntegerField(blank=True, null=True)), - ( - "created_at", - models.DateTimeField(blank=True, editable=False, null=True), - ), - ("updated_at", models.DateTimeField(blank=True, editable=False)), - ("history_id", models.AutoField(primary_key=True, serialize=False)), - ("history_date", models.DateTimeField(db_index=True)), - ("history_change_reason", models.CharField(max_length=100, null=True)), - ( - "history_type", - models.CharField( - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], - max_length=1, - ), - ), - ( - "history_user", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "owner", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="companies.company", - ), - ), - ], - options={ - "verbose_name": "historical park", - "verbose_name_plural": "historical parks", - "ordering": ("-history_date", "-history_id"), - "get_latest_by": ("history_date", "history_id"), - }, - bases=( - history_tracking.mixins.HistoricalChangeMixin, - simple_history.models.HistoricalChanges, - models.Model, - ), - ), - migrations.CreateModel( - name="HistoricalParkArea", - fields=[ - ( - "id", - models.BigIntegerField( - auto_created=True, blank=True, db_index=True, verbose_name="ID" - ), - ), - ("name", models.CharField(max_length=255)), - ("slug", models.SlugField(max_length=255)), - ("description", models.TextField(blank=True)), - ("opening_date", models.DateField(blank=True, null=True)), - ("closing_date", models.DateField(blank=True, null=True)), - ( - "created_at", - models.DateTimeField(blank=True, editable=False, null=True), - ), - ("updated_at", models.DateTimeField(blank=True, editable=False)), - ("history_id", models.AutoField(primary_key=True, serialize=False)), - ("history_date", models.DateTimeField(db_index=True)), - ("history_change_reason", models.CharField(max_length=100, null=True)), - ( - "history_type", - models.CharField( - choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], - max_length=1, - ), - ), - ( - "history_user", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "park", - models.ForeignKey( - blank=True, - db_constraint=False, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name="+", - to="parks.park", - ), - ), - ], - options={ - "verbose_name": "historical park area", - "verbose_name_plural": "historical park areas", - "ordering": ("-history_date", "-history_id"), - "get_latest_by": ("history_date", "history_id"), - }, - bases=( - history_tracking.mixins.HistoricalChangeMixin, - simple_history.models.HistoricalChanges, - models.Model, - ), - ), - ] diff --git a/parks/migrations/0009_migrate_to_location_model.py b/parks/migrations/0009_migrate_to_location_model.py deleted file mode 100644 index 8b294677..00000000 --- a/parks/migrations/0009_migrate_to_location_model.py +++ /dev/null @@ -1,83 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 22:21 - -from django.db import migrations, transaction -from django.contrib.contenttypes.models import ContentType - -def forwards_func(apps, schema_editor): - """Move park location data to Location model""" - Park = apps.get_model("parks", "Park") - Location = apps.get_model("location", "Location") - ContentType = apps.get_model("contenttypes", "ContentType") - db_alias = schema_editor.connection.alias - - # Get or create content type for Park model - park_content_type, created = ContentType.objects.db_manager(db_alias).get_or_create( - app_label='parks', - model='park' - ) - - # Move location data for each park - with transaction.atomic(): - for park in Park.objects.using(db_alias).all(): - # Only create Location if park has coordinate data - if park.latitude is not None and park.longitude is not None: - Location.objects.using(db_alias).create( - content_type=park_content_type, - object_id=park.id, - name=park.name, - location_type='park', - latitude=park.latitude, - longitude=park.longitude, - street_address=park.street_address, - city=park.city, - state=park.state, - country=park.country, - postal_code=park.postal_code - ) - -def reverse_func(apps, schema_editor): - """Move location data back to Park model""" - Park = apps.get_model("parks", "Park") - Location = apps.get_model("location", "Location") - ContentType = apps.get_model("contenttypes", "ContentType") - db_alias = schema_editor.connection.alias - - # Get or create content type for Park model - park_content_type, created = ContentType.objects.db_manager(db_alias).get_or_create( - app_label='parks', - model='park' - ) - - # Move location data back to each park - with transaction.atomic(): - locations = Location.objects.using(db_alias).filter( - content_type=park_content_type - ) - for location in locations: - try: - park = Park.objects.using(db_alias).get(id=location.object_id) - park.latitude = location.latitude - park.longitude = location.longitude - park.street_address = location.street_address - park.city = location.city - park.state = location.state - park.country = location.country - park.postal_code = location.postal_code - park.save() - except Park.DoesNotExist: - continue - - # Delete all park locations - locations.delete() - -class Migration(migrations.Migration): - - dependencies = [ - ('parks', '0008_historicalpark_historicalparkarea'), - ('location', '0005_convert_coordinates_to_points'), - ('contenttypes', '0002_remove_content_type_name'), - ] - - operations = [ - migrations.RunPython(forwards_func, reverse_func, atomic=True), - ] diff --git a/parks/migrations/0010_remove_legacy_location_fields.py b/parks/migrations/0010_remove_legacy_location_fields.py deleted file mode 100644 index 9e42c036..00000000 --- a/parks/migrations/0010_remove_legacy_location_fields.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 22:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("parks", "0009_migrate_to_location_model"), - ] - - operations = [ - migrations.RemoveField( - model_name="historicalpark", - name="latitude", - ), - migrations.RemoveField( - model_name="historicalpark", - name="longitude", - ), - migrations.RemoveField( - model_name="historicalpark", - name="street_address", - ), - migrations.RemoveField( - model_name="historicalpark", - name="city", - ), - migrations.RemoveField( - model_name="historicalpark", - name="state", - ), - migrations.RemoveField( - model_name="historicalpark", - name="country", - ), - migrations.RemoveField( - model_name="historicalpark", - name="postal_code", - ), - migrations.RemoveField( - model_name="park", - name="latitude", - ), - migrations.RemoveField( - model_name="park", - name="longitude", - ), - migrations.RemoveField( - model_name="park", - name="street_address", - ), - migrations.RemoveField( - model_name="park", - name="city", - ), - migrations.RemoveField( - model_name="park", - name="state", - ), - migrations.RemoveField( - model_name="park", - name="country", - ), - migrations.RemoveField( - model_name="park", - name="postal_code", - ), - ] diff --git a/parks/models.py b/parks/models.py index f99819a5..d6ed5903 100644 --- a/parks/models.py +++ b/parks/models.py @@ -4,14 +4,16 @@ from django.utils.text import slugify from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from decimal import Decimal, ROUND_DOWN, InvalidOperation -from typing import Tuple, Optional, Any -from simple_history.models import HistoricalRecords +from typing import Tuple, Optional, Any, TYPE_CHECKING from companies.models import Company from media.models import Photo from history_tracking.models import HistoricalModel from location.models import Location +if TYPE_CHECKING: + from rides.models import Ride + class Park(HistoricalModel): id: int # Type hint for Django's automatic id field @@ -55,6 +57,8 @@ class Park(HistoricalModel): Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks" ) photos = GenericRelation(Photo, related_query_name="park") + areas: models.Manager['ParkArea'] # Type hint for reverse relation + rides: models.Manager['Ride'] # Type hint for reverse relation from rides app # Metadata created_at = models.DateTimeField(auto_now_add=True, null=True) diff --git a/parks/urls.py b/parks/urls.py index b0532682..28828577 100644 --- a/parks/urls.py +++ b/parks/urls.py @@ -8,13 +8,23 @@ urlpatterns = [ # Park views path("", views.ParkListView.as_view(), name="park_list"), path("create/", views.ParkCreateView.as_view(), name="park_create"), - path("/", views.ParkDetailView.as_view(), name="park_detail"), - path("/edit/", views.ParkUpdateView.as_view(), name="park_update"), + + # Add park button endpoint (moved before park detail pattern) + path("add-park-button/", views.add_park_button, name="add_park_button"), # Location search endpoints path("search/location/", views.location_search, name="location_search"), path("search/reverse-geocode/", views.reverse_geocode, name="reverse_geocode"), + # Areas and search endpoints for HTMX + path("areas/", views.get_park_areas, name="get_park_areas"), + path("search/", views.search_parks, name="search_parks"), + + # Park detail and related views + path("/", views.ParkDetailView.as_view(), name="park_detail"), + path("/edit/", views.ParkUpdateView.as_view(), name="park_update"), + path("/actions/", views.park_actions, name="park_actions"), + # Area views path("/areas//", views.ParkAreaDetailView.as_view(), name="area_detail"), @@ -26,6 +36,6 @@ urlpatterns = [ path("/transports/", ParkSingleCategoryListView.as_view(), {'category': 'TR'}, name="park_transports"), path("/others/", ParkSingleCategoryListView.as_view(), {'category': 'OT'}, name="park_others"), - # Include rides URLs + # Include rides URLs with park_slug path("/rides/", include("rides.urls", namespace="rides")), ] diff --git a/parks/views.py b/parks/views.py index fcf7d756..9b47623a 100644 --- a/parks/views.py +++ b/parks/views.py @@ -1,13 +1,15 @@ from decimal import Decimal, ROUND_DOWN, InvalidOperation +from typing import Any, Optional, cast, Type 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, Avg, Count +from django.db.models import Q, Avg, Count, QuerySet, Model +from django.core.exceptions import ObjectDoesNotExist 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 django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest import requests from .models import Park, ParkArea from .forms import ParkForm @@ -17,11 +19,49 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History from moderation.models import EditSubmission from media.models import Photo from location.models import Location -from reviews.models import Review # Import the Review model -from analytics.models import PageView # Import PageView for tracking views +from reviews.models import Review +from analytics.models import PageView -def location_search(request): +def park_actions(request: HttpRequest, slug: str) -> HttpResponse: + """Return the park actions partial template""" + park = get_object_or_404(Park, slug=slug) + return render(request, "parks/partials/park_actions.html", {"park": park}) + + +def get_park_areas(request: HttpRequest) -> HttpResponse: + """Return park areas as options for a select element""" + park_id = request.GET.get('park') + if not park_id: + return HttpResponse('') + + try: + park = Park.objects.get(id=park_id) + areas = park.areas.all() + options = [''] + options.extend([ + f'' + for area in areas + ]) + return HttpResponse('\n'.join(options)) + except Park.DoesNotExist: + return HttpResponse('') + + +def search_parks(request: HttpRequest) -> HttpResponse: + """Search parks and return results for HTMX""" + query = request.GET.get('q', '').strip() + + # If no query, show first 10 parks + if not query: + parks = Park.objects.all().order_by('name')[:10] + else: + parks = Park.objects.filter(name__icontains=query).order_by('name')[:10] + + return render(request, "parks/partials/park_search_results.html", {"parks": parks}) + + +def location_search(request: HttpRequest) -> JsonResponse: """Search for locations using OpenStreetMap Nominatim API""" query = request.GET.get("q", "") if not query: @@ -34,8 +74,8 @@ def location_search(request): "q": query, "format": "json", "addressdetails": 1, - "namedetails": 1, # Include name tags - "accept-language": "en", # Prefer English results + "namedetails": 1, + "accept-language": "en", "limit": 10, }, headers={"User-Agent": "ThrillWiki/1.0"}, @@ -43,16 +83,18 @@ def location_search(request): if response.status_code == 200: results = response.json() - # Normalize each result normalized_results = [normalize_osm_result(result) for result in results] - # Filter out any results with invalid coordinates - valid_results = [r for r in normalized_results if r['lat'] is not None and r['lon'] is not None] + valid_results = [ + r + for r in normalized_results + if r["lat"] is not None and r["lon"] is not None + ] return JsonResponse({"results": valid_results}) return JsonResponse({"results": []}) -def reverse_geocode(request): +def reverse_geocode(request: HttpRequest) -> JsonResponse: """Reverse geocode coordinates using OpenStreetMap Nominatim API""" try: lat = Decimal(request.GET.get("lat", "")) @@ -63,17 +105,18 @@ def reverse_geocode(request): if not lat or not lon: return JsonResponse({"error": "Missing coordinates"}, status=400) - # Normalize coordinates before geocoding - lat = lat.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) - lon = lon.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) + lat = lat.quantize(Decimal("0.000001"), rounding=ROUND_DOWN) + lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN) - # Validate ranges if lat < -90 or lat > 90: - return JsonResponse({"error": "Latitude must be between -90 and 90"}, status=400) + return JsonResponse( + {"error": "Latitude must be between -90 and 90"}, status=400 + ) if lon < -180 or lon > 180: - return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400) + return JsonResponse( + {"error": "Longitude must be between -180 and 180"}, status=400 + ) - # Call Nominatim API response = requests.get( "https://nominatim.openstreetmap.org/reverse", params={ @@ -81,30 +124,36 @@ def reverse_geocode(request): "lon": str(lon), "format": "json", "addressdetails": 1, - "namedetails": 1, # Include name tags - "accept-language": "en", # Prefer English results + "namedetails": 1, + "accept-language": "en", }, headers={"User-Agent": "ThrillWiki/1.0"}, ) if response.status_code == 200: result = response.json() - # Normalize the result normalized_result = normalize_osm_result(result) - if normalized_result['lat'] is None or normalized_result['lon'] is None: + if normalized_result["lat"] is None or normalized_result["lon"] is None: return JsonResponse({"error": "Invalid coordinates"}, status=400) return JsonResponse(normalized_result) return JsonResponse({"error": "Geocoding failed"}, status=500) +def add_park_button(request: HttpRequest) -> HttpResponse: + """Return the add park button partial template""" + return render(request, "parks/partials/add_park_button.html") + + class ParkListView(ListView): model = Park template_name = "parks/park_list.html" context_object_name = "parks" - def get_queryset(self): - queryset = Park.objects.select_related("owner").prefetch_related("photos", "location") + def get_queryset(self) -> QuerySet[Park]: + queryset = Park.objects.select_related("owner").prefetch_related( + "photos", "location" + ) search = self.request.GET.get("search", "").strip() country = self.request.GET.get("country", "").strip() @@ -114,10 +163,10 @@ class ParkListView(ListView): if search: queryset = queryset.filter( - Q(name__icontains=search) | - Q(location__city__icontains=search) | - Q(location__state__icontains=search) | - Q(location__country__icontains=search) + Q(name__icontains=search) + | Q(location__city__icontains=search) + | Q(location__state__icontains=search) + | Q(location__country__icontains=search) ) if country: @@ -132,16 +181,14 @@ class ParkListView(ListView): if statuses: queryset = queryset.filter(status__in=statuses) - # Annotate with ride count, coaster count, and average review rating queryset = queryset.annotate( - ride_count=Count('rides'), - coaster_count=Count('rides', filter=Q(rides__type='coaster')), - average_rating=Avg('reviews__rating') + total_rides=Count("rides"), + total_coasters=Count("rides", filter=Q(rides__category="RC")), ) return queryset.distinct() - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["current_filters"] = { "search": self.request.GET.get("search", ""), @@ -152,10 +199,8 @@ class ParkListView(ListView): } return context - def get(self, request, *args, **kwargs): - # Check if this is an HTMX request - if hasattr(request, 'htmx') and getattr(request, 'htmx', False): - # If it is, return just the parks list partial + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if hasattr(request, "htmx") and getattr(request, "htmx", False): self.template_name = "parks/partials/park_list.html" return super().get(request, *args, **kwargs) @@ -171,44 +216,43 @@ class ParkDetailView( template_name = "parks/park_detail.html" context_object_name = "park" - def get_object(self, queryset=None): + def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park: if queryset is None: queryset = self.get_queryset() slug = self.kwargs.get(self.slug_url_kwarg) - # Try to get by current or historical slug - return Park.get_by_slug(slug)[0] + if slug is None: + raise ObjectDoesNotExist("No slug provided") + park, _ = Park.get_by_slug(slug) + return park - def get_queryset(self): - return super().get_queryset().prefetch_related( - 'rides', - 'rides__manufacturer', - 'photos', - 'areas', - 'location' + def get_queryset(self) -> QuerySet[Park]: + return cast( + QuerySet[Park], + super() + .get_queryset() + .prefetch_related( + "rides", "rides__manufacturer", "photos", "areas", "location" + ), ) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - context["areas"] = self.object.areas.all() - # Get rides ordered by status (operating first) and name - context["rides"] = self.object.rides.all().order_by( - '-status', # OPERATING will come before others - 'name' - ) - - # Check if the user has reviewed the park + park = cast(Park, self.object) + context["areas"] = park.areas.all() + context["rides"] = park.rides.all().order_by("-status", "name") + if self.request.user.is_authenticated: context["has_reviewed"] = Review.objects.filter( user=self.request.user, content_type=ContentType.objects.get_for_model(Park), - object_id=self.object.id + object_id=park.id, ).exists() else: context["has_reviewed"] = False return context - def get_redirect_url_pattern(self): + def get_redirect_url_pattern(self) -> str: return "parks:park_detail" @@ -217,38 +261,36 @@ class ParkCreateView(LoginRequiredMixin, CreateView): form_class = ParkForm template_name = "parks/park_form.html" - def prepare_changes_data(self, cleaned_data): + def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]: data = cleaned_data.copy() - # Convert model instances to IDs for JSON serialization if data.get("owner"): data["owner"] = data["owner"].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() - # Convert Decimal fields to strings decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"] for field in decimal_fields: if data.get(field): data[field] = str(data[field]) return data - def normalize_coordinates(self, form): + def normalize_coordinates(self, form: ParkForm) -> None: if form.cleaned_data.get("latitude"): lat = Decimal(str(form.cleaned_data["latitude"])) - form.cleaned_data["latitude"] = lat.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) + form.cleaned_data["latitude"] = lat.quantize( + Decimal("0.000001"), rounding=ROUND_DOWN + ) if form.cleaned_data.get("longitude"): lon = Decimal(str(form.cleaned_data["longitude"])) - form.cleaned_data["longitude"] = lon.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) + form.cleaned_data["longitude"] = lon.quantize( + Decimal("0.000001"), rounding=ROUND_DOWN + ) - def form_valid(self, form): - # Normalize coordinates before saving + def form_valid(self, form: ParkForm) -> HttpResponse: self.normalize_coordinates(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), @@ -258,8 +300,9 @@ class ParkCreateView(LoginRequiredMixin, CreateView): source=self.request.POST.get("source", ""), ) - # If user is moderator or above, auto-approve - if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]: + if hasattr(self.request.user, "role") and getattr( + self.request.user, "role", None + ) in ["MODERATOR", "ADMIN", "SUPERUSER"]: try: self.object = form.save() submission.object_id = self.object.id @@ -267,23 +310,23 @@ class ParkCreateView(LoginRequiredMixin, CreateView): submission.handled_by = self.request.user submission.save() - # Create Location record - if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"): + if form.cleaned_data.get("latitude") and form.cleaned_data.get( + "longitude" + ): Location.objects.create( content_type=ContentType.objects.get_for_model(Park), object_id=self.object.id, name=self.object.name, - location_type='park', + location_type="park", latitude=form.cleaned_data["latitude"], longitude=form.cleaned_data["longitude"], street_address=form.cleaned_data.get("street_address", ""), city=form.cleaned_data.get("city", ""), state=form.cleaned_data.get("state", ""), country=form.cleaned_data.get("country", ""), - postal_code=form.cleaned_data.get("postal_code", "") + postal_code=form.cleaned_data.get("postal_code", ""), ) - # Handle photo uploads photos = self.request.FILES.getlist("photos") for photo_file in photos: try: @@ -319,7 +362,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView): ) return HttpResponseRedirect(reverse("parks:park_list")) - def form_invalid(self, form): + def form_invalid(self, form: ParkForm) -> HttpResponse: messages.error( self.request, "Please correct the errors below. Required fields are marked with an asterisk (*).", @@ -329,7 +372,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView): messages.error(self.request, f"{field}: {error}") return super().form_invalid(form) - def get_success_url(self): + def get_success_url(self) -> str: return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) @@ -338,43 +381,41 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): form_class = ParkForm template_name = "parks/park_form.html" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["is_edit"] = True return context - def prepare_changes_data(self, cleaned_data): + def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]: data = cleaned_data.copy() - # Convert model instances to IDs for JSON serialization if data.get("owner"): data["owner"] = data["owner"].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() - # Convert Decimal fields to strings decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"] for field in decimal_fields: if data.get(field): data[field] = str(data[field]) return data - def normalize_coordinates(self, form): + def normalize_coordinates(self, form: ParkForm) -> None: if form.cleaned_data.get("latitude"): lat = Decimal(str(form.cleaned_data["latitude"])) - form.cleaned_data["latitude"] = lat.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) + form.cleaned_data["latitude"] = lat.quantize( + Decimal("0.000001"), rounding=ROUND_DOWN + ) if form.cleaned_data.get("longitude"): lon = Decimal(str(form.cleaned_data["longitude"])) - form.cleaned_data["longitude"] = lon.quantize(Decimal('0.000001'), rounding=ROUND_DOWN) + form.cleaned_data["longitude"] = lon.quantize( + Decimal("0.000001"), rounding=ROUND_DOWN + ) - def form_valid(self, form): - # Normalize coordinates before saving + def form_valid(self, form: ParkForm) -> HttpResponse: self.normalize_coordinates(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), @@ -385,25 +426,25 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): source=self.request.POST.get("source", ""), ) - # If user is moderator or above, auto-approve - if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]: + if hasattr(self.request.user, "role") and getattr( + self.request.user, "role", None + ) in ["MODERATOR", "ADMIN", "SUPERUSER"]: try: self.object = form.save() submission.status = "APPROVED" submission.handled_by = self.request.user submission.save() - # Update or create Location record location_data = { - 'name': self.object.name, - 'location_type': 'park', - 'latitude': form.cleaned_data.get("latitude"), - 'longitude': form.cleaned_data.get("longitude"), - 'street_address': form.cleaned_data.get("street_address", ""), - 'city': form.cleaned_data.get("city", ""), - 'state': form.cleaned_data.get("state", ""), - 'country': form.cleaned_data.get("country", ""), - 'postal_code': form.cleaned_data.get("postal_code", "") + "name": self.object.name, + "location_type": "park", + "latitude": form.cleaned_data.get("latitude"), + "longitude": form.cleaned_data.get("longitude"), + "street_address": form.cleaned_data.get("street_address", ""), + "city": form.cleaned_data.get("city", ""), + "state": form.cleaned_data.get("state", ""), + "country": form.cleaned_data.get("country", ""), + "postal_code": form.cleaned_data.get("postal_code", ""), } if self.object.location.exists(): @@ -415,10 +456,9 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): Location.objects.create( content_type=ContentType.objects.get_for_model(Park), object_id=self.object.id, - **location_data + **location_data, ) - # Handle photo uploads photos = self.request.FILES.getlist("photos") uploaded_count = 0 for photo_file in photos: @@ -458,7 +498,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): reverse("parks:park_detail", kwargs={"slug": self.object.slug}) ) - def form_invalid(self, form): + def form_invalid(self, form: ParkForm) -> HttpResponse: messages.error( self.request, "Please correct the errors below. Required fields are marked with an asterisk (*).", @@ -468,7 +508,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): messages.error(self.request, f"{field}: {error}") return super().form_invalid(form) - def get_success_url(self): + def get_success_url(self) -> str: return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) @@ -484,23 +524,25 @@ class ParkAreaDetailView( context_object_name = "area" slug_url_kwarg = "area_slug" - def get_object(self, queryset=None): + def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea: if queryset is None: queryset = self.get_queryset() park_slug = self.kwargs.get("park_slug") area_slug = self.kwargs.get("area_slug") - # Try to get by current or historical slug - obj, is_old_slug = ParkArea.get_by_slug(area_slug) - if obj.park.slug != park_slug: - raise self.model.DoesNotExist("Park slug doesn't match") - return obj + if park_slug is None or area_slug is None: + raise ObjectDoesNotExist("Missing slug") + area, _ = ParkArea.get_by_slug(area_slug) + if area.park.slug != park_slug: + raise ObjectDoesNotExist("Park slug doesn't match") + return area - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) return context - def get_redirect_url_pattern(self): + def get_redirect_url_pattern(self) -> str: return "parks:park_detail" - def get_redirect_url_kwargs(self): - return {"park_slug": self.object.park.slug, "area_slug": self.object.slug} + def get_redirect_url_kwargs(self) -> dict[str, str]: + area = cast(ParkArea, self.object) + return {"park_slug": area.park.slug, "area_slug": area.slug} diff --git a/reviews/migrations/0001_initial.py b/reviews/migrations/0001_initial.py index 0d0cc346..05306194 100644 --- a/reviews/migrations/0001_initial.py +++ b/reviews/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-10-28 20:17 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.core.validators import django.db.models.deletion @@ -11,78 +11,187 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Review', + name="Review", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.PositiveIntegerField()), - ('rating', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])), - ('title', models.CharField(max_length=200)), - ('content', models.TextField()), - ('visit_date', models.DateField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('is_published', models.BooleanField(default=True)), - ('moderation_notes', models.TextField(blank=True)), - ('moderated_at', models.DateTimeField(blank=True, null=True)), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), - ('moderated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moderated_reviews', to=settings.AUTH_USER_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.PositiveIntegerField()), + ( + "rating", + models.PositiveSmallIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ] + ), + ), + ("title", models.CharField(max_length=200)), + ("content", models.TextField()), + ("visit_date", models.DateField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_published", models.BooleanField(default=True)), + ("moderation_notes", models.TextField(blank=True)), + ("moderated_at", models.DateTimeField(blank=True, null=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "moderated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moderated_reviews", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reviews", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'ordering': ['-created_at'], + "ordering": ["-created_at"], }, ), migrations.CreateModel( - name='ReviewImage', + name="ReviewImage", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('image', models.ImageField(upload_to='review_images/')), - ('caption', models.CharField(blank=True, max_length=200)), - ('order', models.PositiveIntegerField(default=0)), - ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='reviews.review')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("image", models.ImageField(upload_to="review_images/")), + ("caption", models.CharField(blank=True, max_length=200)), + ("order", models.PositiveIntegerField(default=0)), + ( + "review", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="reviews.review", + ), + ), ], options={ - 'ordering': ['order'], + "ordering": ["order"], }, ), migrations.CreateModel( - name='ReviewLike', + name="ReviewLike", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='reviews.review')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_likes', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "review", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="likes", + to="reviews.review", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_likes", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='ReviewReport', + name="ReviewReport", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('reason', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('resolved', models.BooleanField(default=False)), - ('resolution_notes', models.TextField(blank=True)), - ('resolved_at', models.DateTimeField(blank=True, null=True)), - ('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_review_reports', to=settings.AUTH_USER_MODEL)), - ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='reviews.review')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_reports', to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("reason", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("resolved", models.BooleanField(default=False)), + ("resolution_notes", models.TextField(blank=True)), + ("resolved_at", models.DateTimeField(blank=True, null=True)), + ( + "resolved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_review_reports", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "review", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reports", + to="reviews.review", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_reports", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'ordering': ['-created_at'], + "ordering": ["-created_at"], }, ), migrations.AddIndex( - model_name='review', - index=models.Index(fields=['content_type', 'object_id'], name='reviews_rev_content_627d80_idx'), + model_name="review", + index=models.Index( + fields=["content_type", "object_id"], + name="reviews_rev_content_627d80_idx", + ), ), migrations.AlterUniqueTogether( - name='reviewlike', - unique_together={('review', 'user')}, + name="reviewlike", + unique_together={("review", "user")}, ), ] diff --git a/reviews/templatetags/__init__.py b/reviews/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/reviews/templatetags/review_tags.py b/reviews/templatetags/review_tags.py new file mode 100644 index 00000000..00fa73df --- /dev/null +++ b/reviews/templatetags/review_tags.py @@ -0,0 +1,11 @@ +from django import template +from reviews.models import Review + +register = template.Library() + +@register.filter +def has_reviewed_park(user, park): + """Check if a user has reviewed a park""" + if not user.is_authenticated: + return False + return Review.objects.filter(user=user, content_type__model='park', object_id=park.id).exists() diff --git a/rides/apps.py b/rides/apps.py index 47597a01..c69cff0f 100644 --- a/rides/apps.py +++ b/rides/apps.py @@ -1,8 +1,9 @@ from django.apps import AppConfig + class RidesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'rides' def ready(self): - import rides.signals # noqa + import rides.signals diff --git a/rides/forms.py b/rides/forms.py index 4d7d177f..cf82a8e8 100644 --- a/rides/forms.py +++ b/rides/forms.py @@ -1,74 +1,283 @@ from django import forms -from .models import Ride +from django.forms import ModelChoiceField +from django.urls import reverse_lazy +from .models import Ride, RideModel +from parks.models import Park, ParkArea +from companies.models import Manufacturer, Designer + class RideForm(forms.ModelForm): + park_search = forms.CharField( + label="Park *", + 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": "Search for a park...", + "hx-get": "/parks/search/", + "hx-trigger": "click, input delay:200ms", + "hx-target": "#park-search-results", + "name": "q", + "autocomplete": "off", + } + ), + ) + + manufacturer_search = forms.CharField( + label="Manufacturer", + 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": "Search for a manufacturer...", + "hx-get": reverse_lazy("rides:search_manufacturers"), + "hx-trigger": "click, input delay:200ms", + "hx-target": "#manufacturer-search-results", + "name": "q", + "autocomplete": "off", + } + ), + ) + + designer_search = forms.CharField( + label="Designer", + 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": "Search for a designer...", + "hx-get": reverse_lazy("rides:search_designers"), + "hx-trigger": "click, input delay:200ms", + "hx-target": "#designer-search-results", + "name": "q", + "autocomplete": "off", + } + ), + ) + + ride_model_search = forms.CharField( + label="Ride Model", + 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": "Search for a ride model...", + "hx-get": reverse_lazy("rides:search_ride_models"), + "hx-trigger": "click, input delay:200ms", + "hx-target": "#ride-model-search-results", + "hx-include": "[name='manufacturer']", + "name": "q", + "autocomplete": "off", + } + ), + ) + + park = forms.ModelChoiceField( + queryset=Park.objects.all(), + required=True, + label="", + widget=forms.HiddenInput() + ) + + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label="", + widget=forms.HiddenInput() + ) + + designer = forms.ModelChoiceField( + queryset=Designer.objects.all(), + required=False, + label="", + widget=forms.HiddenInput() + ) + + ride_model = forms.ModelChoiceField( + queryset=RideModel.objects.all(), + required=False, + label="", + widget=forms.HiddenInput() + ) + + park_area = ModelChoiceField( + queryset=ParkArea.objects.none(), + required=False, + widget=forms.Select( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white", + "placeholder": "Select an area within the park..." + } + ), + ) + class Meta: model = Ride - fields = ['name', 'park_area', 'category', 'manufacturer', 'designer', 'model_name', 'status', - 'opening_date', 'closing_date', 'status_since', 'min_height_in', 'max_height_in', - 'accessibility_options', 'capacity_per_hour', 'ride_duration_seconds', 'description'] + fields = [ + "name", + "category", + "manufacturer", + "designer", + "ride_model", + "status", + "post_closing_status", + "opening_date", + "closing_date", + "status_since", + "min_height_in", + "max_height_in", + "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' - }), - 'designer': 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' - }), + "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", + "placeholder": "Official name of the ride" + } + ), + "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", + "hx-get": reverse_lazy("rides:coaster_fields"), + "hx-target": "#coaster-fields", + "hx-trigger": "change", + "hx-include": "this", + "hx-swap": "innerHTML" + } + ), + "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", + "placeholder": "Current operational status", + "x-model": "status", + "@change": "handleStatusChange" + } + ), + "post_closing_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", + "placeholder": "Status after closing", + "x-show": "status === 'CLOSING'" + } + ), + "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", + "placeholder": "Date when ride first opened" + } + ), + "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", + "placeholder": "Date when ride will close", + "x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)", + ":required": "status === 'CLOSING'" + } + ), + "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", + "placeholder": "Date when current status took effect" + } + ), + "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", + "placeholder": "Minimum height requirement in inches" + } + ), + "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", + "placeholder": "Maximum height limit in inches (if applicable)" + } + ), + "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", + "placeholder": "Theoretical hourly ride capacity" + } + ), + "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", + "placeholder": "Total duration of one ride cycle in seconds" + } + ), + "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", + "placeholder": "General description and notable features of the ride" + } + ), } def __init__(self, *args, **kwargs): - park = kwargs.pop('park', None) + park = kwargs.pop("park", None) super().__init__(*args, **kwargs) + + # Make category required + self.fields['category'].required = True + + # Clear any default values for date fields + self.fields["opening_date"].initial = None + self.fields["closing_date"].initial = None + self.fields["status_since"].initial = None + + # Move fields to the beginning in desired order + field_order = [ + "park_search", "park", "park_area", + "name", "manufacturer_search", "manufacturer", + "designer_search", "designer", "ride_model_search", + "ride_model", "category", "status", + "post_closing_status", "opening_date", "closing_date", "status_since", + "min_height_in", "max_height_in", "capacity_per_hour", + "ride_duration_seconds", "description" + ] + self.order_fields(field_order) + if park: - # Filter park_area choices to only show areas from the current park - self.fields['park_area'].queryset = park.areas.all() + # If park is provided, set it as the initial value + self.fields["park"].initial = park + # Hide the park search field since we know the park + del self.fields["park_search"] + # Create new park_area field with park's areas + self.fields["park_area"] = forms.ModelChoiceField( + queryset=park.areas.all(), + required=False, + widget=forms.Select( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white", + "placeholder": "Select an area within the park..." + } + ), + ) + else: + # If no park provided, show park search and disable park_area until park is selected + self.fields["park_area"].widget.attrs["disabled"] = True + # Initialize park search with current park name if editing + if self.instance and self.instance.pk and self.instance.park: + self.fields["park_search"].initial = self.instance.park.name + self.fields["park"].initial = self.instance.park + + # Initialize manufacturer, designer, and ride model search fields if editing + if self.instance and self.instance.pk: + if self.instance.manufacturer: + self.fields["manufacturer_search"].initial = self.instance.manufacturer.name + self.fields["manufacturer"].initial = self.instance.manufacturer + if self.instance.designer: + self.fields["designer_search"].initial = self.instance.designer.name + self.fields["designer"].initial = self.instance.designer + if self.instance.ride_model: + self.fields["ride_model_search"].initial = self.instance.ride_model.name + self.fields["ride_model"].initial = self.instance.ride_model diff --git a/rides/migrations/0001_initial.py b/rides/migrations/0001_initial.py index 3e847248..04eaf1ec 100644 --- a/rides/migrations/0001_initial.py +++ b/rides/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-10-28 21:53 +# Generated by Django 5.1.3 on 2024-11-12 18:07 import django.db.models.deletion import simple_history.models @@ -11,7 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("companies", "0002_stats_fields"), + ("companies", "0001_initial"), + ("designers", "0001_initial"), ("parks", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -20,12 +21,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name="HistoricalRide", fields=[ - ( - "id", - models.BigIntegerField( - auto_created=True, blank=True, db_index=True, verbose_name="ID" - ), - ), + ("id", models.BigIntegerField(blank=True, db_index=True)), ("name", models.CharField(max_length=255)), ("slug", models.SlugField(max_length=255)), ("description", models.TextField(blank=True)), @@ -92,6 +88,18 @@ class Migration(migrations.Migration): max_length=1, ), ), + ( + "designer", + models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The designer/engineering firm responsible for the ride", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="designers.designer", + ), + ), ( "history_user", models.ForeignKey( @@ -146,15 +154,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Ride", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), + ("id", models.BigAutoField(primary_key=True, serialize=False)), ("name", models.CharField(max_length=255)), ("slug", models.SlugField(max_length=255)), ("description", models.TextField(blank=True)), @@ -212,12 +212,20 @@ class Migration(migrations.Migration): ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ( - "manufacturer", + "designer", models.ForeignKey( blank=True, + help_text="The designer/engineering firm responsible for the ride", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="rides", + to="designers.designer", + ), + ), + ( + "manufacturer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="companies.manufacturer", ), ), @@ -248,12 +256,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name="HistoricalRollerCoasterStats", fields=[ - ( - "id", - models.BigIntegerField( - auto_created=True, blank=True, db_index=True, verbose_name="ID" - ), - ), + ("id", models.BigIntegerField(blank=True, db_index=True)), ( "height_ft", models.DecimalField( @@ -278,6 +281,57 @@ class Migration(migrations.Migration): models.PositiveIntegerField(blank=True, null=True), ), ("track_type", models.CharField(blank=True, max_length=255)), + ( + "track_material", + models.CharField( + blank=True, + choices=[ + ("STEEL", "Steel"), + ("WOOD", "Wood"), + ("HYBRID", "Hybrid"), + ("OTHER", "Other"), + ], + default="STEEL", + max_length=20, + ), + ), + ( + "roller_coaster_type", + models.CharField( + blank=True, + choices=[ + ("SITDOWN", "Sit-Down"), + ("INVERTED", "Inverted"), + ("FLYING", "Flying"), + ("STANDUP", "Stand-Up"), + ("WING", "Wing"), + ("SUSPENDED", "Suspended"), + ("BOBSLED", "Bobsled"), + ("PIPELINE", "Pipeline"), + ("MOTORBIKE", "Motorbike"), + ("FLOORLESS", "Floorless"), + ("DIVE", "Dive"), + ("FAMILY", "Family"), + ("WILD_MOUSE", "Wild Mouse"), + ("SPINNING", "Spinning"), + ("FOURTH_DIMENSION", "4th Dimension"), + ("OTHER", "Other"), + ], + default="SITDOWN", + help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)", + max_length=20, + ), + ), + ( + "max_drop_height_ft", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Maximum vertical drop height in feet", + max_digits=6, + null=True, + ), + ), ( "launch_type", models.CharField( @@ -340,15 +394,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name="RollerCoasterStats", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), + ("id", models.BigAutoField(primary_key=True, serialize=False)), ( "height_ft", models.DecimalField( @@ -373,6 +419,57 @@ class Migration(migrations.Migration): models.PositiveIntegerField(blank=True, null=True), ), ("track_type", models.CharField(blank=True, max_length=255)), + ( + "track_material", + models.CharField( + blank=True, + choices=[ + ("STEEL", "Steel"), + ("WOOD", "Wood"), + ("HYBRID", "Hybrid"), + ("OTHER", "Other"), + ], + default="STEEL", + max_length=20, + ), + ), + ( + "roller_coaster_type", + models.CharField( + blank=True, + choices=[ + ("SITDOWN", "Sit-Down"), + ("INVERTED", "Inverted"), + ("FLYING", "Flying"), + ("STANDUP", "Stand-Up"), + ("WING", "Wing"), + ("SUSPENDED", "Suspended"), + ("BOBSLED", "Bobsled"), + ("PIPELINE", "Pipeline"), + ("MOTORBIKE", "Motorbike"), + ("FLOORLESS", "Floorless"), + ("DIVE", "Dive"), + ("FAMILY", "Family"), + ("WILD_MOUSE", "Wild Mouse"), + ("SPINNING", "Spinning"), + ("FOURTH_DIMENSION", "4th Dimension"), + ("OTHER", "Other"), + ], + default="SITDOWN", + help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)", + max_length=20, + ), + ), + ( + "max_drop_height_ft", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Maximum vertical drop height in feet", + max_digits=6, + null=True, + ), + ), ( "launch_type", models.CharField( diff --git a/rides/migrations/0005_historicalride_designer_ride_designer.py b/rides/migrations/0002_alter_historicalride_designer_alter_ride_designer.py similarity index 75% rename from rides/migrations/0005_historicalride_designer_ride_designer.py rename to rides/migrations/0002_alter_historicalride_designer_alter_ride_designer.py index 4255cf13..d32a66a9 100644 --- a/rides/migrations/0005_historicalride_designer_ride_designer.py +++ b/rides/migrations/0002_alter_historicalride_designer_alter_ride_designer.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-04 00:28 +# Generated by Django 5.1.3 on 2024-11-12 20:23 import django.db.models.deletion from django.db import migrations, models @@ -7,12 +7,12 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("designers", "0001_initial"), - ("rides", "0004_historicalrollercoasterstats_roller_coaster_type_and_more"), + ("companies", "0002_add_designer_model"), + ("rides", "0001_initial"), ] operations = [ - migrations.AddField( + migrations.AlterField( model_name="historicalride", name="designer", field=models.ForeignKey( @@ -22,10 +22,10 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", - to="designers.designer", + to="companies.designer", ), ), - migrations.AddField( + migrations.AlterField( model_name="ride", name="designer", field=models.ForeignKey( @@ -34,7 +34,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="rides", - to="designers.designer", + to="companies.designer", ), ), ] diff --git a/rides/migrations/0002_alter_ride_manufacturer.py b/rides/migrations/0002_alter_ride_manufacturer.py deleted file mode 100644 index d06d7183..00000000 --- a/rides/migrations/0002_alter_ride_manufacturer.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-29 02:02 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("companies", "0004_add_total_parks"), - ("rides", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="ride", - name="manufacturer", - field=models.ForeignKey( - default=1, - on_delete=django.db.models.deletion.CASCADE, - to="companies.manufacturer", - ), - preserve_default=False, - ), - ] diff --git a/rides/migrations/0003_historicalrollercoasterstats_max_drop_height_ft_and_more.py b/rides/migrations/0003_historicalrollercoasterstats_max_drop_height_ft_and_more.py deleted file mode 100644 index 4994ce94..00000000 --- a/rides/migrations/0003_historicalrollercoasterstats_max_drop_height_ft_and_more.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 00:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("rides", "0002_alter_ride_manufacturer"), - ] - - operations = [ - migrations.AddField( - model_name="historicalrollercoasterstats", - name="max_drop_height_ft", - field=models.DecimalField( - blank=True, - decimal_places=2, - help_text="Maximum vertical drop height in feet", - max_digits=6, - null=True, - ), - ), - migrations.AddField( - model_name="historicalrollercoasterstats", - name="track_material", - field=models.CharField( - blank=True, - choices=[ - ("STEEL", "Steel"), - ("WOOD", "Wood"), - ("HYBRID", "Hybrid"), - ("OTHER", "Other"), - ], - default="STEEL", - max_length=20, - ), - ), - migrations.AddField( - model_name="rollercoasterstats", - name="max_drop_height_ft", - field=models.DecimalField( - blank=True, - decimal_places=2, - help_text="Maximum vertical drop height in feet", - max_digits=6, - null=True, - ), - ), - migrations.AddField( - model_name="rollercoasterstats", - name="track_material", - field=models.CharField( - blank=True, - choices=[ - ("STEEL", "Steel"), - ("WOOD", "Wood"), - ("HYBRID", "Hybrid"), - ("OTHER", "Other"), - ], - default="STEEL", - max_length=20, - ), - ), - ] diff --git a/rides/migrations/0003_remove_historicalride_accessibility_options_and_more.py b/rides/migrations/0003_remove_historicalride_accessibility_options_and_more.py new file mode 100644 index 00000000..cc6878f9 --- /dev/null +++ b/rides/migrations/0003_remove_historicalride_accessibility_options_and_more.py @@ -0,0 +1,160 @@ +# Generated by Django 5.1.3 on 2024-11-12 21:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("companies", "0002_add_designer_model"), + ("rides", "0002_alter_historicalride_designer_alter_ride_designer"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalride", + name="accessibility_options", + ), + migrations.RemoveField( + model_name="ride", + name="accessibility_options", + ), + migrations.AlterField( + model_name="historicalride", + name="category", + field=models.CharField( + blank=True, + choices=[ + ("", "Select ride type... *"), + ("RC", "Roller Coaster"), + ("DR", "Dark Ride"), + ("FR", "Flat Ride"), + ("WR", "Water Ride"), + ("TR", "Transport"), + ("OT", "Other"), + ], + default="", + max_length=2, + ), + ), + migrations.AlterField( + model_name="historicalride", + name="designer", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="companies.designer", + ), + ), + migrations.AlterField( + model_name="historicalrollercoasterstats", + name="max_drop_height_ft", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=6, null=True + ), + ), + migrations.AlterField( + model_name="historicalrollercoasterstats", + name="roller_coaster_type", + field=models.CharField( + blank=True, + choices=[ + ("SITDOWN", "Sit-Down"), + ("INVERTED", "Inverted"), + ("FLYING", "Flying"), + ("STANDUP", "Stand-Up"), + ("WING", "Wing"), + ("SUSPENDED", "Suspended"), + ("BOBSLED", "Bobsled"), + ("PIPELINE", "Pipeline"), + ("MOTORBIKE", "Motorbike"), + ("FLOORLESS", "Floorless"), + ("DIVE", "Dive"), + ("FAMILY", "Family"), + ("WILD_MOUSE", "Wild Mouse"), + ("SPINNING", "Spinning"), + ("FOURTH_DIMENSION", "4th Dimension"), + ("OTHER", "Other"), + ], + default="SITDOWN", + max_length=20, + ), + ), + migrations.AlterField( + model_name="ride", + name="category", + field=models.CharField( + blank=True, + choices=[ + ("", "Select ride type... *"), + ("RC", "Roller Coaster"), + ("DR", "Dark Ride"), + ("FR", "Flat Ride"), + ("WR", "Water Ride"), + ("TR", "Transport"), + ("OT", "Other"), + ], + default="", + max_length=2, + ), + ), + migrations.AlterField( + model_name="ride", + name="designer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rides", + to="companies.designer", + ), + ), + migrations.AlterField( + model_name="ride", + name="manufacturer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="companies.manufacturer", + ), + ), + migrations.AlterField( + model_name="rollercoasterstats", + name="max_drop_height_ft", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=6, null=True + ), + ), + migrations.AlterField( + model_name="rollercoasterstats", + name="roller_coaster_type", + field=models.CharField( + blank=True, + choices=[ + ("SITDOWN", "Sit-Down"), + ("INVERTED", "Inverted"), + ("FLYING", "Flying"), + ("STANDUP", "Stand-Up"), + ("WING", "Wing"), + ("SUSPENDED", "Suspended"), + ("BOBSLED", "Bobsled"), + ("PIPELINE", "Pipeline"), + ("MOTORBIKE", "Motorbike"), + ("FLOORLESS", "Floorless"), + ("DIVE", "Dive"), + ("FAMILY", "Family"), + ("WILD_MOUSE", "Wild Mouse"), + ("SPINNING", "Spinning"), + ("FOURTH_DIMENSION", "4th Dimension"), + ("OTHER", "Other"), + ], + default="SITDOWN", + max_length=20, + ), + ), + ] diff --git a/rides/migrations/0004_alter_historicalride_category_alter_ride_category.py b/rides/migrations/0004_alter_historicalride_category_alter_ride_category.py new file mode 100644 index 00000000..4e1ff81a --- /dev/null +++ b/rides/migrations/0004_alter_historicalride_category_alter_ride_category.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.3 on 2024-11-12 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0003_remove_historicalride_accessibility_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalride", + name="category", + field=models.CharField( + blank=True, + choices=[ + ("", "Select ride type"), + ("RC", "Roller Coaster"), + ("DR", "Dark Ride"), + ("FR", "Flat Ride"), + ("WR", "Water Ride"), + ("TR", "Transport"), + ("OT", "Other"), + ], + default="", + max_length=2, + ), + ), + migrations.AlterField( + model_name="ride", + name="category", + field=models.CharField( + blank=True, + choices=[ + ("", "Select ride type"), + ("RC", "Roller Coaster"), + ("DR", "Dark Ride"), + ("FR", "Flat Ride"), + ("WR", "Water Ride"), + ("TR", "Transport"), + ("OT", "Other"), + ], + default="", + max_length=2, + ), + ), + ] diff --git a/rides/migrations/0004_historicalrollercoasterstats_roller_coaster_type_and_more.py b/rides/migrations/0004_historicalrollercoasterstats_roller_coaster_type_and_more.py deleted file mode 100644 index 11003d24..00000000 --- a/rides/migrations/0004_historicalrollercoasterstats_roller_coaster_type_and_more.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 00:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("rides", "0003_historicalrollercoasterstats_max_drop_height_ft_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="historicalrollercoasterstats", - name="roller_coaster_type", - field=models.CharField( - blank=True, - choices=[ - ("SITDOWN", "Sit-Down"), - ("INVERTED", "Inverted"), - ("FLYING", "Flying"), - ("STANDUP", "Stand-Up"), - ("WING", "Wing"), - ("SUSPENDED", "Suspended"), - ("BOBSLED", "Bobsled"), - ("PIPELINE", "Pipeline"), - ("MOTORBIKE", "Motorbike"), - ("FLOORLESS", "Floorless"), - ("DIVE", "Dive"), - ("FAMILY", "Family"), - ("WILD_MOUSE", "Wild Mouse"), - ("SPINNING", "Spinning"), - ("FOURTH_DIMENSION", "4th Dimension"), - ("OTHER", "Other"), - ], - default="SITDOWN", - help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)", - max_length=20, - ), - ), - migrations.AddField( - model_name="rollercoasterstats", - name="roller_coaster_type", - field=models.CharField( - blank=True, - choices=[ - ("SITDOWN", "Sit-Down"), - ("INVERTED", "Inverted"), - ("FLYING", "Flying"), - ("STANDUP", "Stand-Up"), - ("WING", "Wing"), - ("SUSPENDED", "Suspended"), - ("BOBSLED", "Bobsled"), - ("PIPELINE", "Pipeline"), - ("MOTORBIKE", "Motorbike"), - ("FLOORLESS", "Floorless"), - ("DIVE", "Dive"), - ("FAMILY", "Family"), - ("WILD_MOUSE", "Wild Mouse"), - ("SPINNING", "Spinning"), - ("FOURTH_DIMENSION", "4th Dimension"), - ("OTHER", "Other"), - ], - default="SITDOWN", - help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)", - max_length=20, - ), - ), - ] diff --git a/rides/migrations/0005_alter_rollercoasterstats_id_and_more.py b/rides/migrations/0005_alter_rollercoasterstats_id_and_more.py new file mode 100644 index 00000000..84c89a9d --- /dev/null +++ b/rides/migrations/0005_alter_rollercoasterstats_id_and_more.py @@ -0,0 +1,259 @@ +# Generated by Django 5.1.3 on 2024-11-12 22:27 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("companies", "0002_add_designer_model"), + ("rides", "0004_alter_historicalride_category_alter_ride_category"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="rollercoasterstats", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="rollercoasterstats", + name="launch_type", + field=models.CharField( + choices=[ + ("CHAIN", "Chain Lift"), + ("LSM", "LSM Launch"), + ("HYDRAULIC", "Hydraulic Launch"), + ("GRAVITY", "Gravity"), + ("OTHER", "Other"), + ], + default="CHAIN", + max_length=20, + ), + ), + migrations.AlterField( + model_name="rollercoasterstats", + name="roller_coaster_type", + field=models.CharField( + blank=True, + choices=[ + ("SITDOWN", "Sit Down"), + ("INVERTED", "Inverted"), + ("FLYING", "Flying"), + ("STANDUP", "Stand Up"), + ("WING", "Wing"), + ("DIVE", "Dive"), + ("FAMILY", "Family"), + ("WILD_MOUSE", "Wild Mouse"), + ("SPINNING", "Spinning"), + ("FOURTH_DIMENSION", "4th Dimension"), + ("OTHER", "Other"), + ], + default="SITDOWN", + max_length=20, + ), + ), + migrations.AlterField( + model_name="rollercoasterstats", + name="track_material", + field=models.CharField( + blank=True, + choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")], + default="STEEL", + max_length=20, + ), + ), + migrations.CreateModel( + name="HistoricalRideModel", + fields=[ + ("id", models.BigIntegerField(blank=True, db_index=True)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "typical_height_ft", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Typical height of this model in feet", + max_digits=6, + null=True, + ), + ), + ( + "typical_speed_mph", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Typical speed of this model in mph", + max_digits=5, + null=True, + ), + ), + ( + "typical_capacity_per_hour", + models.PositiveIntegerField( + blank=True, + help_text="Typical hourly capacity of this model", + null=True, + ), + ), + ( + "category", + models.CharField( + blank=True, + choices=[ + ("", "Select ride type"), + ("RC", "Roller Coaster"), + ("DR", "Dark Ride"), + ("FR", "Flat Ride"), + ("WR", "Water Ride"), + ("TR", "Transport"), + ("OT", "Other"), + ], + default="", + max_length=2, + ), + ), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "manufacturer", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="companies.manufacturer", + ), + ), + ], + options={ + "verbose_name": "historical ride model", + "verbose_name_plural": "historical ride models", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="RideModel", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "typical_height_ft", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Typical height of this model in feet", + max_digits=6, + null=True, + ), + ), + ( + "typical_speed_mph", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Typical speed of this model in mph", + max_digits=5, + null=True, + ), + ), + ( + "typical_capacity_per_hour", + models.PositiveIntegerField( + blank=True, + help_text="Typical hourly capacity of this model", + null=True, + ), + ), + ( + "category", + models.CharField( + blank=True, + choices=[ + ("", "Select ride type"), + ("RC", "Roller Coaster"), + ("DR", "Dark Ride"), + ("FR", "Flat Ride"), + ("WR", "Water Ride"), + ("TR", "Transport"), + ("OT", "Other"), + ], + default="", + max_length=2, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "manufacturer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ride_models", + to="companies.manufacturer", + ), + ), + ], + options={ + "ordering": ["manufacturer", "name"], + "unique_together": {("manufacturer", "name")}, + }, + ), + migrations.AddField( + model_name="historicalride", + name="ride_model", + field=models.ForeignKey( + blank=True, + db_constraint=False, + help_text="The specific model/type of this ride", + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="rides.ridemodel", + ), + ), + migrations.AddField( + model_name="ride", + name="ride_model", + field=models.ForeignKey( + blank=True, + help_text="The specific model/type of this ride", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rides", + to="rides.ridemodel", + ), + ), + migrations.DeleteModel( + name="HistoricalRollerCoasterStats", + ), + ] diff --git a/rides/migrations/0006_remove_historicalridemodel_typical_capacity_per_hour_and_more.py b/rides/migrations/0006_remove_historicalridemodel_typical_capacity_per_hour_and_more.py new file mode 100644 index 00000000..aa8e716c --- /dev/null +++ b/rides/migrations/0006_remove_historicalridemodel_typical_capacity_per_hour_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.3 on 2024-11-13 00:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0005_alter_rollercoasterstats_id_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalridemodel", + name="typical_capacity_per_hour", + ), + migrations.RemoveField( + model_name="historicalridemodel", + name="typical_height_ft", + ), + migrations.RemoveField( + model_name="historicalridemodel", + name="typical_speed_mph", + ), + migrations.RemoveField( + model_name="ridemodel", + name="typical_capacity_per_hour", + ), + migrations.RemoveField( + model_name="ridemodel", + name="typical_height_ft", + ), + migrations.RemoveField( + model_name="ridemodel", + name="typical_speed_mph", + ), + ] diff --git a/rides/migrations/0007_alter_ridemodel_manufacturer.py b/rides/migrations/0007_alter_ridemodel_manufacturer.py new file mode 100644 index 00000000..2ac55f46 --- /dev/null +++ b/rides/migrations/0007_alter_ridemodel_manufacturer.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.3 on 2024-11-13 02:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("companies", "0002_add_designer_model"), + ("rides", "0006_remove_historicalridemodel_typical_capacity_per_hour_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="ridemodel", + name="manufacturer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ride_models", + to="companies.manufacturer", + ), + ), + ] diff --git a/rides/migrations/0008_historicalride_post_closing_status_and_more.py b/rides/migrations/0008_historicalride_post_closing_status_and_more.py new file mode 100644 index 00000000..86856ca8 --- /dev/null +++ b/rides/migrations/0008_historicalride_post_closing_status_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 5.1.3 on 2024-11-13 04:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0007_alter_ridemodel_manufacturer"), + ] + + operations = [ + migrations.AddField( + model_name="historicalride", + name="post_closing_status", + field=models.CharField( + blank=True, + choices=[ + ("SBNO", "Standing But Not Operating"), + ("CLOSED_PERM", "Permanently Closed"), + ], + help_text="Status to change to after closing date", + max_length=20, + null=True, + ), + ), + migrations.AddField( + model_name="ride", + name="post_closing_status", + field=models.CharField( + blank=True, + choices=[ + ("SBNO", "Standing But Not Operating"), + ("CLOSED_PERM", "Permanently Closed"), + ], + help_text="Status to change to after closing date", + max_length=20, + null=True, + ), + ), + migrations.AlterField( + model_name="historicalride", + name="status", + field=models.CharField( + choices=[ + ("OPERATING", "Operating"), + ("SBNO", "Standing But Not Operating"), + ("CLOSING", "Closing"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ], + default="OPERATING", + max_length=20, + ), + ), + migrations.AlterField( + model_name="ride", + name="status", + field=models.CharField( + choices=[ + ("OPERATING", "Operating"), + ("SBNO", "Standing But Not Operating"), + ("CLOSING", "Closing"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ], + default="OPERATING", + max_length=20, + ), + ), + ] diff --git a/rides/migrations/0009_remove_historicalride_model_name_and_more.py b/rides/migrations/0009_remove_historicalride_model_name_and_more.py new file mode 100644 index 00000000..da2d314f --- /dev/null +++ b/rides/migrations/0009_remove_historicalride_model_name_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.3 on 2024-11-13 04:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("rides", "0008_historicalride_post_closing_status_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalride", + name="model_name", + ), + migrations.RemoveField( + model_name="ride", + name="model_name", + ), + ] diff --git a/rides/models.py b/rides/models.py index 4fc21957..1374a759 100644 --- a/rides/models.py +++ b/rides/models.py @@ -1,29 +1,68 @@ -from typing import Tuple, Optional, Any from django.db import models -from django.contrib.contenttypes.fields import GenericRelation from django.utils.text import slugify -from simple_history.models import HistoricalRecords +from django.contrib.contenttypes.fields import GenericRelation from history_tracking.models import HistoricalModel + +# Shared choices that will be used by multiple models +CATEGORY_CHOICES = [ + ('', 'Select ride type'), + ('RC', 'Roller Coaster'), + ('DR', 'Dark Ride'), + ('FR', 'Flat Ride'), + ('WR', 'Water Ride'), + ('TR', 'Transport'), + ('OT', 'Other'), +] + + +class RideModel(HistoricalModel): + """ + Represents a specific model/type of ride that can be manufactured by different companies. + For example: B&M Dive Coaster, Vekoma Boomerang, etc. + """ + name = models.CharField(max_length=255) + manufacturer = models.ForeignKey( + 'companies.Manufacturer', + on_delete=models.SET_NULL, # Changed to SET_NULL since it's optional + related_name='ride_models', + null=True, # Made optional + blank=True # Made optional + ) + description = models.TextField(blank=True) + category = models.CharField( + max_length=2, + choices=CATEGORY_CHOICES, + default='', + blank=True + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['manufacturer', 'name'] + unique_together = ['manufacturer', 'name'] + + def __str__(self) -> str: + return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}" + + class Ride(HistoricalModel): - CATEGORY_CHOICES = [ - ('RC', 'Roller Coaster'), - ('DR', 'Dark Ride'), - ('FR', 'Flat Ride'), - ('WR', 'Water Ride'), - ('TR', 'Transport'), - ('OT', 'Other'), - ] - STATUS_CHOICES = [ ('OPERATING', 'Operating'), - ('CLOSED_TEMP', 'Temporarily Closed'), + ('SBNO', 'Standing But Not Operating'), + ('CLOSING', 'Closing'), ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), ('RELOCATED', 'Relocated'), ] + POST_CLOSING_STATUS_CHOICES = [ + ('SBNO', 'Standing But Not Operating'), + ('CLOSED_PERM', 'Permanently Closed'), + ] + name = models.CharField(max_length=255) slug = models.SlugField(max_length=255) description = models.TextField(blank=True) @@ -42,34 +81,47 @@ class Ride(HistoricalModel): category = models.CharField( max_length=2, choices=CATEGORY_CHOICES, - default='OT' + default='', + blank=True ) manufacturer = models.ForeignKey( - 'companies.manufacturer', + 'companies.Manufacturer', on_delete=models.CASCADE, - null=False, - blank=False + null=True, + blank=True ) designer = models.ForeignKey( - 'designers.Designer', + 'companies.Designer', + on_delete=models.SET_NULL, + related_name='rides', + null=True, + blank=True + ) + ride_model = models.ForeignKey( + 'RideModel', on_delete=models.SET_NULL, related_name='rides', null=True, blank=True, - help_text='The designer/engineering firm responsible for the ride' + help_text="The specific model/type of this ride" ) - model_name = models.CharField(max_length=255, blank=True) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='OPERATING' ) + post_closing_status = models.CharField( + max_length=20, + choices=POST_CLOSING_STATUS_CHOICES, + null=True, + blank=True, + help_text="Status to change to after closing date" + ) opening_date = models.DateField(null=True, blank=True) closing_date = models.DateField(null=True, blank=True) status_since = models.DateField(null=True, blank=True) min_height_in = models.PositiveIntegerField(null=True, blank=True) max_height_in = models.PositiveIntegerField(null=True, blank=True) - accessibility_options = models.TextField(blank=True) capacity_per_hour = models.PositiveIntegerField(null=True, blank=True) ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True) average_rating = models.DecimalField( @@ -90,64 +142,25 @@ class Ride(HistoricalModel): def __str__(self) -> str: return f"{self.name} at {self.park.name}" - def save(self, *args: Any, **kwargs: Any) -> None: + def save(self, *args, **kwargs) -> None: if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) - @classmethod - def get_by_slug(cls, slug: str) -> Tuple['Ride', bool]: - """Get ride by current or historical slug. - - Args: - slug: The slug to look up - - Returns: - A tuple of (Ride object, bool indicating if it's a historical slug) - - Raises: - cls.DoesNotExist: If no ride is found with the given slug - """ - try: - return cls.objects.get(slug=slug), False - except cls.DoesNotExist as e: - # Check historical slugs - if history := cls.history.filter(slug=slug).order_by('-history_date').first(): # type: ignore[attr-defined] - try: - return cls.objects.get(pk=history.instance.pk), True - except cls.DoesNotExist as inner_e: - raise cls.DoesNotExist("No ride found with this slug") from inner_e - raise cls.DoesNotExist("No ride found with this slug") from e - -class RollerCoasterStats(HistoricalModel): - LAUNCH_CHOICES = [ - ('CHAIN', 'Chain Lift'), - ('CABLE', 'Cable Launch'), - ('HYDRAULIC', 'Hydraulic Launch'), - ('LSM', 'Linear Synchronous Motor'), - ('LIM', 'Linear Induction Motor'), - ('GRAVITY', 'Gravity'), - ('OTHER', 'Other'), - ] +class RollerCoasterStats(models.Model): TRACK_MATERIAL_CHOICES = [ ('STEEL', 'Steel'), ('WOOD', 'Wood'), ('HYBRID', 'Hybrid'), - ('OTHER', 'Other'), ] COASTER_TYPE_CHOICES = [ - ('SITDOWN', 'Sit-Down'), + ('SITDOWN', 'Sit Down'), ('INVERTED', 'Inverted'), ('FLYING', 'Flying'), - ('STANDUP', 'Stand-Up'), + ('STANDUP', 'Stand Up'), ('WING', 'Wing'), - ('SUSPENDED', 'Suspended'), - ('BOBSLED', 'Bobsled'), - ('PIPELINE', 'Pipeline'), - ('MOTORBIKE', 'Motorbike'), - ('FLOORLESS', 'Floorless'), ('DIVE', 'Dive'), ('FAMILY', 'Family'), ('WILD_MOUSE', 'Wild Mouse'), @@ -156,6 +169,14 @@ class RollerCoasterStats(HistoricalModel): ('OTHER', 'Other'), ] + LAUNCH_CHOICES = [ + ('CHAIN', 'Chain Lift'), + ('LSM', 'LSM Launch'), + ('HYDRAULIC', 'Hydraulic Launch'), + ('GRAVITY', 'Gravity'), + ('OTHER', 'Other'), + ] + ride = models.OneToOneField( Ride, on_delete=models.CASCADE, @@ -192,15 +213,13 @@ class RollerCoasterStats(HistoricalModel): max_length=20, choices=COASTER_TYPE_CHOICES, default='SITDOWN', - blank=True, - help_text='The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)' + blank=True ) max_drop_height_ft = models.DecimalField( max_digits=6, decimal_places=2, null=True, - blank=True, - help_text='Maximum vertical drop height in feet' + blank=True ) launch_type = models.CharField( max_length=20, diff --git a/rides/signals.py b/rides/signals.py index e69de29b..dffd5598 100644 --- a/rides/signals.py +++ b/rides/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.utils import timezone +from .models import Ride + + +@receiver(pre_save, sender=Ride) +def handle_ride_status(sender, instance, **kwargs): + """Handle ride status changes based on closing date""" + if instance.closing_date: + today = timezone.now().date() + + # If we've reached the closing date and status is "Closing" + if today >= instance.closing_date and instance.status == 'CLOSING': + # Change to the selected post-closing status + instance.status = instance.post_closing_status or 'SBNO' + instance.status_since = instance.closing_date diff --git a/rides/urls.py b/rides/urls.py index d8358aac..d83b7482 100644 --- a/rides/urls.py +++ b/rides/urls.py @@ -1,21 +1,62 @@ from django.urls import path from . import views -app_name = 'rides' # Add namespace +app_name = "rides" urlpatterns = [ - # Global category URLs - path('', views.RideListView.as_view(), name='ride_list'), - path('all/', views.RideListView.as_view(), name='all_rides'), - path('roller_coasters/', views.SingleCategoryListView.as_view(), {'category': 'RC'}, name='roller_coasters'), - path('dark_rides/', views.SingleCategoryListView.as_view(), {'category': 'DR'}, name='dark_rides'), - path('flat_rides/', views.SingleCategoryListView.as_view(), {'category': 'FR'}, name='flat_rides'), - path('water_rides/', views.SingleCategoryListView.as_view(), {'category': 'WR'}, name='water_rides'), - path('transports/', views.SingleCategoryListView.as_view(), {'category': 'TR'}, name='transports'), - path('others/', views.SingleCategoryListView.as_view(), {'category': 'OT'}, name='others'), + # List views + path("", views.RideListView.as_view(), name="ride_list"), + path("create/", views.RideCreateView.as_view(), name="ride_create"), - # Basic ride URLs - 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'), + # Search endpoints + path( + "search/manufacturers/", views.search_manufacturers, name="search_manufacturers" + ), + path("search/designers/", views.search_designers, name="search_designers"), + path("search/models/", views.search_ride_models, name="search_ride_models"), + + # HTMX endpoints + path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"), + + # Category views for global listing + path( + "roller_coasters/", + views.SingleCategoryListView.as_view(), + {"category": "RC"}, + name="roller_coasters", + ), + path( + "dark_rides/", + views.SingleCategoryListView.as_view(), + {"category": "DR"}, + name="dark_rides", + ), + path( + "flat_rides/", + views.SingleCategoryListView.as_view(), + {"category": "FR"}, + name="flat_rides", + ), + path( + "water_rides/", + views.SingleCategoryListView.as_view(), + {"category": "WR"}, + name="water_rides", + ), + path( + "transports/", + views.SingleCategoryListView.as_view(), + {"category": "TR"}, + name="transports", + ), + path( + "others/", + views.SingleCategoryListView.as_view(), + {"category": "OT"}, + name="others", + ), + + # Detail and update views - must come after category views + path("/", views.RideDetailView.as_view(), name="ride_detail"), + path("/update/", views.RideUpdateView.as_view(), name="ride_update"), ] diff --git a/rides/views.py b/rides/views.py index ffba53bb..14a08ab4 100644 --- a/rides/views.py +++ b/rides/views.py @@ -1,10 +1,11 @@ -from typing import Any, Dict, Optional, Tuple, Union, cast -from django.views.generic import DetailView, ListView, CreateView, UpdateView -from django.shortcuts import get_object_or_404 +from typing import Any, Dict, Optional, Tuple, Union, cast, Type +from django.views.generic import DetailView, ListView, CreateView, UpdateView, RedirectView +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.db.models import Q, Model from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType from django.contrib import messages from django.http import ( @@ -17,7 +18,11 @@ from django.http import ( from django.db.models import Count from django.core.files.uploadedfile import UploadedFile from django.forms import ModelForm -from .models import Ride, RollerCoasterStats +from django.db.models.query import QuerySet +from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from .models import Ride, RollerCoasterStats, RideModel, CATEGORY_CHOICES from .forms import RideForm from parks.models import Park from core.views import SlugRedirectMixin @@ -25,481 +30,328 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History from moderation.models import EditSubmission from media.models import Photo from accounts.models import User +from companies.models import Manufacturer, Designer -def is_privileged_user(user: Any) -> bool: - """Check if the user has privileged access. - - Args: - user: The user to check - - Returns: - bool: True if user has privileged or higher privileges - """ - return isinstance(user, User) and user.role in ["MODERATOR", "ADMIN", "SUPERUSER"] - - -def handle_photo_uploads(request: HttpRequest, ride: Ride) -> int: - """Handle photo uploads for a ride. - - Args: - request: The HTTP request containing files - ride: The ride to attach photos to - - Returns: - int: Number of successfully uploaded photos - """ - uploaded_count = 0 - photos = request.FILES.getlist("photos") - for photo_file in photos: - try: - Photo.objects.create( - image=photo_file, - uploaded_by=request.user, - content_type=ContentType.objects.get_for_model(Ride), - object_id=ride.pk, - ) - uploaded_count += 1 - except Exception as e: - messages.error(request, f"Error uploading photo {photo_file.name}: {str(e)}") - return uploaded_count - - -def prepare_form_data(cleaned_data: Dict[str, Any], park: Park) -> Dict[str, Any]: - """Prepare form data for submission. - - Args: - cleaned_data: The form's cleaned data - park: The park instance - - Returns: - Dict[str, Any]: Processed form data ready for submission - """ - data = cleaned_data.copy() - data["park"] = park.pk - if data.get("park_area"): - data["park_area"] = data["park_area"].pk - if data.get("manufacturer"): - data["manufacturer"] = data["manufacturer"].pk - return data - - -def handle_form_errors(request: HttpRequest, form: ModelForm) -> None: - """Handle form validation errors by adding appropriate error messages. - - Args: - request: The HTTP request - form: The form containing validation errors - """ - messages.error( - request, - "Please correct the errors below. Required fields are marked with an asterisk (*).", - ) - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"{field}: {error}") - - -def create_edit_submission( - request: HttpRequest, - submission_type: str, - changes: Dict[str, Any], - object_id: Optional[int] = None, -) -> EditSubmission: - """Create an EditSubmission object for ride changes. - - Args: - request: The HTTP request - submission_type: Type of submission (CREATE or EDIT) - changes: The changes to be submitted - object_id: Optional ID of the existing object for edits - - Returns: - EditSubmission: The created submission object - """ - submission_data = { - "user": request.user, - "content_type": ContentType.objects.get_for_model(Ride), - "submission_type": submission_type, - "changes": changes, - "reason": request.POST.get("reason", ""), - "source": request.POST.get("source", ""), - } - - if object_id is not None: - submission_data["object_id"] = object_id - - return EditSubmission.objects.create(**submission_data) - - -def handle_privileged_save( - request: HttpRequest, form: RideForm, submission: EditSubmission -) -> Tuple[bool, str]: - """Handle saving form and updating submission for privileged users. - - Args: - request: The HTTP request - form: The form to save - submission: The edit submission to update - - Returns: - Tuple[bool, str]: Success status and error message (empty string if successful) - """ - try: - ride = form.save() - if submission.submission_type == "CREATE": - submission.object_id = ride.pk - submission.status = "APPROVED" - submission.handled_by = request.user - submission.save() - return True, "" - except Exception as e: - error_msg = ( - f"Error {submission.submission_type.lower()}ing ride: {str(e)}. " - "Please check your input and try again." - ) - return False, error_msg - - -class SingleCategoryListView(ListView): - model = Ride - template_name = "rides/ride_category_list.html" - context_object_name = "categories" - - def get_category_code(self) -> str: - if category := self.kwargs.get("category"): - return category - raise Http404("Category not found") - - def get_queryset(self): - category_code = self.get_category_code() - category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - - rides = ( - Ride.objects.filter(category=category_code) - .select_related("park", "manufacturer") - .order_by("name") - ) - - return {category_name: rides} if rides.exists() else {} - - def get_context_data(self, **kwargs) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - category_code = self.get_category_code() - category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - context["title"] = f"All {category_name}s" - context["category_code"] = category_code - return context - - -class ParkSingleCategoryListView(ListView): - model = Ride - template_name = "rides/ride_category_list.html" - context_object_name = "categories" - - def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: - super().setup(request, *args, **kwargs) - self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) - - def get_category_code(self) -> str: - if category := self.kwargs.get("category"): - return category - raise Http404("Category not found") - - def get_queryset(self): - category_code = self.get_category_code() - category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - - rides = ( - Ride.objects.filter(park=self.park, category=category_code) - .select_related("manufacturer") - .order_by("name") - ) - - return {category_name: rides} if rides.exists() else {} - - def get_context_data(self, **kwargs) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - context["park"] = self.park - category_code = self.get_category_code() - category_name = dict(Ride.CATEGORY_CHOICES)[category_code] - context["title"] = f"{category_name}s at {self.park.name}" - context["category_code"] = category_code - return context +def show_coaster_fields(request: HttpRequest) -> HttpResponse: + """Show roller coaster specific fields based on category selection""" + category = request.GET.get('category') + if category != 'RC': # Only show for roller coasters + return HttpResponse('') + return render(request, "rides/partials/coaster_fields.html") class RideCreateView(LoginRequiredMixin, CreateView): + """View for creating a new ride""" model = Ride form_class = RideForm - template_name = "rides/ride_form.html" + template_name = 'rides/ride_form.html' - def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: - super().setup(request, *args, **kwargs) - self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) + def get_success_url(self): + """Get URL to redirect to after successful creation""" + if hasattr(self, 'park'): + return reverse('parks:rides:ride_detail', kwargs={ + 'park_slug': self.park.slug, + 'ride_slug': self.object.slug + }) + return reverse('rides:ride_detail', kwargs={'ride_slug': self.object.slug}) - def get_form_kwargs(self) -> Dict[str, Any]: + def get_form_kwargs(self): + """Pass park to the form""" kwargs = super().get_form_kwargs() - kwargs["park"] = self.park + if 'park_slug' in self.kwargs: + self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + kwargs['park'] = self.park return kwargs - def handle_submission( - self, form: RideForm, cleaned_data: Dict[str, Any] - ) -> HttpResponseRedirect: - """Handle the form submission. - - Args: - form: The form to process - cleaned_data: The cleaned form data - - Returns: - HttpResponseRedirect to appropriate URL - """ - submission = create_edit_submission(self.request, "CREATE", cleaned_data) - - if is_privileged_user(self.request.user): - success, error_msg = handle_privileged_save(self.request, form, submission) - if success: - self.object = form.instance - uploaded_count = handle_photo_uploads(self.request, self.object) - messages.success( - self.request, - f"Successfully created {self.object.name} at {self.park.name}. " - f"Added {uploaded_count} photo(s).", - ) - return HttpResponseRedirect(self.get_success_url()) - else: - if error_msg: # Only add error message if there is one - messages.error(self.request, error_msg) - return cast(HttpResponseRedirect, self.form_invalid(form)) - - messages.success( - self.request, - "Your ride submission has been sent for review. " - "You will be notified when it is approved.", - ) - return HttpResponseRedirect( - reverse("parks:rides:ride_list", kwargs={"park_slug": self.park.slug}) - ) - - def form_valid(self, form: RideForm) -> HttpResponseRedirect: - form.instance.park = self.park - cleaned_data = prepare_form_data(form.cleaned_data, self.park) - return self.handle_submission(form, cleaned_data) - - def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]: - """Handle invalid form submission. - - Args: - form: The invalid form - - Returns: - Response with error messages - """ - handle_form_errors(self.request, form) - return super().form_invalid(form) - - def get_success_url(self) -> str: - return reverse( - "parks:rides:ride_detail", - kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug}, - ) - - def get_context_data(self, **kwargs) -> Dict[str, Any]: + def get_context_data(self, **kwargs): + """Add park and park_slug to context""" context = super().get_context_data(**kwargs) - context["park"] = self.park + if hasattr(self, 'park'): + context['park'] = self.park + context['park_slug'] = self.park.slug + context['is_edit'] = False return context - -class RideUpdateView(LoginRequiredMixin, UpdateView): - model = Ride - form_class = RideForm - template_name = "rides/ride_form.html" - slug_url_kwarg = "ride_slug" - - def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: - super().setup(request, *args, **kwargs) - self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) - - def get_form_kwargs(self) -> Dict[str, Any]: - kwargs = super().get_form_kwargs() - kwargs["park"] = self.park - return kwargs - - def get_context_data(self, **kwargs) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - context["park"] = self.park - context["is_edit"] = True - return context - - def handle_submission( - self, form: RideForm, cleaned_data: Dict[str, Any] - ) -> HttpResponseRedirect: - """Handle the form submission. - - Args: - form: The form to process - cleaned_data: The cleaned form data - - Returns: - HttpResponseRedirect to appropriate URL - """ - submission = create_edit_submission( - self.request, "EDIT", cleaned_data, self.object.pk - ) - - if is_privileged_user(self.request.user): - success, error_msg = handle_privileged_save(self.request, form, submission) - if success: - self.object = form.instance - uploaded_count = handle_photo_uploads(self.request, self.object) - messages.success( - self.request, - f"Successfully updated {self.object.name}. " - f"Added {uploaded_count} new photo(s).", - ) - return HttpResponseRedirect(self.get_success_url()) - else: - if error_msg: # Only add error message if there is one - messages.error(self.request, error_msg) - return cast(HttpResponseRedirect, self.form_invalid(form)) - - messages.success( - self.request, - f"Your changes to {self.object.name} have been sent for review. " - "You will be notified when they are approved.", - ) - return HttpResponseRedirect( - reverse( - "parks:rides:ride_detail", - kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug}, + def form_valid(self, form): + """Handle form submission including new items""" + # Check for new manufacturer + manufacturer_name = form.cleaned_data.get('manufacturer_search') + if manufacturer_name and not form.cleaned_data.get('manufacturer'): + # Create submission for new manufacturer + EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Manufacturer), + submission_type="CREATE", + changes={"name": manufacturer_name}, ) - ) - def form_valid(self, form: RideForm) -> HttpResponseRedirect: - cleaned_data = prepare_form_data(form.cleaned_data, self.park) - return self.handle_submission(form, cleaned_data) + # Check for new designer + designer_name = form.cleaned_data.get('designer_search') + if designer_name and not form.cleaned_data.get('designer'): + # Create submission for new designer + EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Designer), + submission_type="CREATE", + changes={"name": designer_name}, + ) - def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]: - """Handle invalid form submission. + # Check for new ride model + ride_model_name = form.cleaned_data.get('ride_model_search') + manufacturer = form.cleaned_data.get('manufacturer') + if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer: + # Create submission for new ride model + EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(RideModel), + submission_type="CREATE", + changes={ + "name": ride_model_name, + "manufacturer": manufacturer.id + }, + ) - Args: - form: The invalid form - - Returns: - Response with error messages - """ - handle_form_errors(self.request, form) - return super().form_invalid(form) - - def get_success_url(self) -> str: - return reverse( - "parks:rides:ride_detail", - kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug}, - ) + return super().form_valid(form) -class RideDetailView( - SlugRedirectMixin, - EditSubmissionMixin, - PhotoSubmissionMixin, - HistoryMixin, - DetailView, -): +class RideDetailView(DetailView): + """View for displaying ride details""" model = Ride - template_name = "rides/ride_detail.html" - context_object_name = "ride" - slug_url_kwarg = "ride_slug" + template_name = 'rides/ride_detail.html' + slug_url_kwarg = 'ride_slug' - def get_object(self, queryset=None): - if queryset is None: - queryset = self.get_queryset() - park_slug = self.kwargs.get("park_slug") - ride_slug = self.kwargs.get("ride_slug") - obj, is_old_slug = self.model.get_by_slug(ride_slug) # type: ignore[attr-defined] - if obj.park.slug != park_slug: - raise self.model.DoesNotExist("Park slug doesn't match") - return obj + def get_queryset(self): + """Get ride for the specific park if park_slug is provided""" + queryset = Ride.objects.all().select_related( + 'park', + 'ride_model', + 'ride_model__manufacturer' + ).prefetch_related('photos') - def get_context_data(self, **kwargs) -> Dict[str, Any]: + if 'park_slug' in self.kwargs: + queryset = queryset.filter(park__slug=self.kwargs['park_slug']) + + return queryset + + def get_context_data(self, **kwargs): + """Add park_slug to context if it exists""" context = super().get_context_data(**kwargs) - if self.object.category == "RC": - context["coaster_stats"] = RollerCoasterStats.objects.filter( - ride=self.object - ).first() + if 'park_slug' in self.kwargs: + context['park_slug'] = self.kwargs['park_slug'] return context - def get_redirect_url_pattern(self) -> str: - return "parks:rides:ride_detail" - def get_redirect_url_kwargs(self) -> Dict[str, Any]: - return {"park_slug": self.object.park.slug, "ride_slug": self.object.slug} +class RideUpdateView(LoginRequiredMixin, EditSubmissionMixin, UpdateView): + """View for updating an existing ride""" + model = Ride + form_class = RideForm + template_name = 'rides/ride_form.html' + slug_url_kwarg = 'ride_slug' + + def get_success_url(self): + """Get URL to redirect to after successful update""" + if hasattr(self, 'park'): + return reverse('parks:rides:ride_detail', kwargs={ + 'park_slug': self.park.slug, + 'ride_slug': self.object.slug + }) + return reverse('rides:ride_detail', kwargs={'ride_slug': self.object.slug}) + + def get_queryset(self): + """Get ride for the specific park if park_slug is provided""" + queryset = Ride.objects.all() + if 'park_slug' in self.kwargs: + queryset = queryset.filter(park__slug=self.kwargs['park_slug']) + return queryset + + def get_form_kwargs(self): + """Pass park to the form""" + kwargs = super().get_form_kwargs() + # For park-specific URLs, use the park from the URL + if 'park_slug' in self.kwargs: + self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + kwargs['park'] = self.park + # For global URLs, use the ride's park + else: + self.park = self.get_object().park + kwargs['park'] = self.park + return kwargs + + def get_context_data(self, **kwargs): + """Add park and park_slug to context""" + context = super().get_context_data(**kwargs) + if hasattr(self, 'park'): + context['park'] = self.park + context['park_slug'] = self.park.slug + context['is_edit'] = True + return context + + def form_valid(self, form): + """Handle form submission including new items""" + # Check for new manufacturer + manufacturer_name = form.cleaned_data.get('manufacturer_search') + if manufacturer_name and not form.cleaned_data.get('manufacturer'): + # Create submission for new manufacturer + EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Manufacturer), + submission_type="CREATE", + changes={"name": manufacturer_name}, + ) + + # Check for new designer + designer_name = form.cleaned_data.get('designer_search') + if designer_name and not form.cleaned_data.get('designer'): + # Create submission for new designer + EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Designer), + submission_type="CREATE", + changes={"name": designer_name}, + ) + + # Check for new ride model + ride_model_name = form.cleaned_data.get('ride_model_search') + manufacturer = form.cleaned_data.get('manufacturer') + if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer: + # Create submission for new ride model + EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(RideModel), + submission_type="CREATE", + changes={ + "name": ride_model_name, + "manufacturer": manufacturer.id + }, + ) + + return super().form_valid(form) class RideListView(ListView): + """View for displaying a list of rides""" model = Ride - template_name = "rides/ride_list.html" - context_object_name = "rides" - - def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: - super().setup(request, *args, **kwargs) - self.park = None - if "park_slug" in self.kwargs: - self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"]) + template_name = 'rides/ride_list.html' + context_object_name = 'rides' def get_queryset(self): - queryset = Ride.objects.select_related( - "park", "coaster_stats", "manufacturer" - ).prefetch_related("photos") + """Get all rides or filter by park if park_slug is provided""" + queryset = Ride.objects.all().select_related( + 'park', + 'ride_model', + 'ride_model__manufacturer' + ).prefetch_related('photos') - if self.park: + if 'park_slug' in self.kwargs: + self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) queryset = queryset.filter(park=self.park) - - search = self.request.GET.get("search", "").strip() or None - category = self.request.GET.get("category", "").strip() or None - status = self.request.GET.get("status", "").strip() or None - manufacturer = self.request.GET.get("manufacturer", "").strip() or None - - if search: - if self.park: - queryset = queryset.filter(name__icontains=search) - else: - queryset = queryset.filter( - Q(name__icontains=search) | Q(park__name__icontains=search) - ) - if category: - queryset = queryset.filter(category=category) - if status: - queryset = queryset.filter(status=status) - if manufacturer: - queryset = queryset.exclude(manufacturer__isnull=True) - + return queryset - def get_context_data(self, **kwargs) -> Dict[str, Any]: + def get_context_data(self, **kwargs): + """Add park to context if park_slug is provided""" context = super().get_context_data(**kwargs) - context["park"] = self.park - - manufacturer_query = Ride.objects - if self.park: - manufacturer_query = manufacturer_query.filter(park=self.park) - - context["manufacturers"] = list( - manufacturer_query.exclude(manufacturer__isnull=True) - .values_list("manufacturer__name", flat=True) - .distinct() - .order_by("manufacturer__name") - ) - - context["current_filters"] = { - "search": self.request.GET.get("search", ""), - "category": self.request.GET.get("category", ""), - "status": self.request.GET.get("status", ""), - "manufacturer": self.request.GET.get("manufacturer", ""), - } - + if hasattr(self, 'park'): + context['park'] = self.park + context['park_slug'] = self.kwargs['park_slug'] return context - def get(self, request: HttpRequest, *args: Any, **kwargs: Any): - if getattr(request, "htmx", False): # type: ignore[attr-defined] - self.template_name = "rides/partials/ride_list.html" - return super().get(request, *args, **kwargs) + +class SingleCategoryListView(ListView): + """View for displaying rides of a specific category""" + model = Ride + template_name = 'rides/park_category_list.html' + context_object_name = 'rides' + + def get_queryset(self): + """Get rides filtered by category and optionally by park""" + category = self.kwargs.get('category') + queryset = Ride.objects.filter( + category=category + ).select_related( + 'park', + 'ride_model', + 'ride_model__manufacturer' + ) + + if 'park_slug' in self.kwargs: + self.park = get_object_or_404(Park, slug=self.kwargs['park_slug']) + queryset = queryset.filter(park=self.park) + + return queryset + + def get_context_data(self, **kwargs): + """Add park and category information to context""" + context = super().get_context_data(**kwargs) + if hasattr(self, 'park'): + context['park'] = self.park + context['park_slug'] = self.kwargs['park_slug'] + context['category'] = dict(CATEGORY_CHOICES).get(self.kwargs['category']) + return context + + +# Alias for parks app to maintain backward compatibility +ParkSingleCategoryListView = SingleCategoryListView + + +def is_privileged_user(user: Any) -> bool: + """Check if user has privileged access""" + return bool(user and hasattr(user, 'is_staff') and (user.is_staff or user.is_superuser)) + + +@login_required +def search_manufacturers(request: HttpRequest) -> HttpResponse: + """Search manufacturers and return results for HTMX""" + query = request.GET.get("q", "").strip() + + # Show all manufacturers on click, filter on input + manufacturers = Manufacturer.objects.all().order_by("name") + if query: + manufacturers = manufacturers.filter(name__icontains=query) + manufacturers = manufacturers[:10] + + return render( + request, + "rides/partials/manufacturer_search_results.html", + {"manufacturers": manufacturers, "search_term": query}, + ) + + +@login_required +def search_designers(request: HttpRequest) -> HttpResponse: + """Search designers and return results for HTMX""" + query = request.GET.get("q", "").strip() + + # Show all designers on click, filter on input + designers = Designer.objects.all().order_by("name") + if query: + designers = designers.filter(name__icontains=query) + designers = designers[:10] + + return render( + request, + "rides/partials/designer_search_results.html", + {"designers": designers, "search_term": query}, + ) + + +@login_required +def search_ride_models(request: HttpRequest) -> HttpResponse: + """Search ride models and return results for HTMX""" + query = request.GET.get("q", "").strip() + manufacturer_id = request.GET.get("manufacturer") + + # Show all ride models on click, filter on input + ride_models = RideModel.objects.select_related("manufacturer").order_by("name") + if query: + ride_models = ride_models.filter(name__icontains=query) + if manufacturer_id: + ride_models = ride_models.filter(manufacturer_id=manufacturer_id) + ride_models = ride_models[:10] + + return render( + request, + "rides/partials/ride_model_search_results.html", + {"ride_models": ride_models, "search_term": query, "manufacturer_id": manufacturer_id}, + ) diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 5f1cecca..36b53529 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -749,35 +749,59 @@ select { --tw-contain-style: ; } +.\!container { + width: 100% !important; +} + .container { width: 100%; } @media (min-width: 640px) { + .\!container { + max-width: 640px !important; + } + .container { max-width: 640px; } } @media (min-width: 768px) { + .\!container { + max-width: 768px !important; + } + .container { max-width: 768px; } } @media (min-width: 1024px) { + .\!container { + max-width: 1024px !important; + } + .container { max-width: 1024px; } } @media (min-width: 1280px) { + .\!container { + max-width: 1280px !important; + } + .container { max-width: 1280px; } } @media (min-width: 1536px) { + .\!container { + max-width: 1536px !important; + } + .container { max-width: 1536px; } @@ -2652,6 +2676,12 @@ 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)); @@ -2696,6 +2726,10 @@ select { overflow: hidden; } +.overflow-y-auto { + overflow-y: auto; +} + .rounded { border-radius: 0.25rem; } @@ -2799,6 +2833,11 @@ select { border-color: rgb(34 197 94 / var(--tw-border-opacity)); } +.border-primary { + --tw-border-opacity: 1; + border-color: rgb(79 70 229 / var(--tw-border-opacity)); +} + .border-red-400 { --tw-border-opacity: 1; border-color: rgb(248 113 113 / var(--tw-border-opacity)); @@ -2861,6 +2900,11 @@ select { background-color: rgb(249 250 251 / var(--tw-bg-opacity)); } +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + .bg-green-100 { --tw-bg-opacity: 1; background-color: rgb(220 252 231 / var(--tw-bg-opacity)); @@ -2923,6 +2967,10 @@ select { --tw-bg-opacity: 0.5; } +.bg-opacity-75 { + --tw-bg-opacity: 0.75; +} + .bg-opacity-90 { --tw-bg-opacity: 0.9; } @@ -3071,6 +3119,10 @@ select { padding-bottom: 1rem; } +.text-left { + text-align: left; +} + .text-center { text-align: center; } @@ -3118,6 +3170,10 @@ select { font-weight: 500; } +.font-normal { + font-weight: 400; +} + .font-semibold { font-weight: 600; } @@ -3186,6 +3242,11 @@ select { color: rgb(22 163 74 / var(--tw-text-opacity)); } +.text-green-700 { + --tw-text-opacity: 1; + color: rgb(21 128 61 / var(--tw-text-opacity)); +} + .text-green-800 { --tw-text-opacity: 1; color: rgb(22 101 52 / var(--tw-text-opacity)); @@ -3245,6 +3306,11 @@ select { color: rgb(202 138 4 / var(--tw-text-opacity)); } +.text-yellow-700 { + --tw-text-opacity: 1; + color: rgb(161 98 7 / var(--tw-text-opacity)); +} + .text-yellow-800 { --tw-text-opacity: 1; color: rgb(133 77 14 / var(--tw-text-opacity)); @@ -3313,6 +3379,12 @@ select { backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); } +.backdrop-blur-sm { + --tw-backdrop-blur: blur(4px); + -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} + .transition { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; @@ -3486,6 +3558,11 @@ select { color: rgb(29 78 216 / var(--tw-text-opacity)); } +.hover\:text-blue-800:hover { + --tw-text-opacity: 1; + color: rgb(30 64 175 / var(--tw-text-opacity)); +} + .hover\:text-gray-300:hover { --tw-text-opacity: 1; color: rgb(209 213 219 / var(--tw-text-opacity)); @@ -3564,6 +3641,10 @@ select { --tw-ring-offset-width: 2px; } +.disabled\:opacity-50:disabled { + opacity: 0.5; +} + .group:hover .group-hover\:scale-105 { --tw-scale-x: 1.05; --tw-scale-y: 1.05; @@ -3669,6 +3750,11 @@ select { background-color: rgb(127 29 29 / var(--tw-bg-opacity)); } +.dark\:bg-yellow-200:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(254 240 138 / var(--tw-bg-opacity)); +} + .dark\:bg-yellow-400\/30:is(.dark *) { background-color: rgb(250 204 21 / 0.3); } @@ -3758,6 +3844,11 @@ select { color: rgb(240 253 244 / var(--tw-text-opacity)); } +.dark\:text-green-800:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(22 101 52 / var(--tw-text-opacity)); +} + .dark\:text-green-900:is(.dark *) { --tw-text-opacity: 1; color: rgb(20 83 45 / var(--tw-text-opacity)); @@ -3813,6 +3904,11 @@ select { color: rgb(254 252 232 / var(--tw-text-opacity)); } +.dark\:text-yellow-800:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(133 77 14 / var(--tw-text-opacity)); +} + .dark\:ring-1:is(.dark *) { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -3827,6 +3923,11 @@ select { --tw-ring-color: rgb(250 204 21 / 0.3); } +.dark\:hover\:bg-blue-500:hover:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + .dark\:hover\:bg-blue-600:hover:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity)); @@ -3876,6 +3977,11 @@ select { color: rgb(96 165 250 / var(--tw-text-opacity)); } +.dark\:hover\:text-gray-200:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + .dark\:hover\:text-gray-300:hover:is(.dark *) { --tw-text-opacity: 1; color: rgb(209 213 219 / var(--tw-text-opacity)); diff --git a/templates/account/partials/login_modal.html b/templates/account/partials/login_modal.html new file mode 100644 index 00000000..de075452 --- /dev/null +++ b/templates/account/partials/login_modal.html @@ -0,0 +1,70 @@ +{% load i18n %} +{% load account socialaccount %} +{% load static %} + +
+
+
+

{% trans "Welcome Back" %}

+ +
+ + {% get_providers as socialaccount_providers %} + {% if socialaccount_providers %} + + +
+ Or continue with email +
+ {% endif %} + + {% include "account/partials/login_form.html" %} + +
+

+ {% trans "Don't have an account?" %} + + {% trans "Sign up" %} + +

+
+
+
diff --git a/templates/account/partials/signup_modal.html b/templates/account/partials/signup_modal.html new file mode 100644 index 00000000..315aaee0 --- /dev/null +++ b/templates/account/partials/signup_modal.html @@ -0,0 +1,261 @@ +{% load i18n %} +{% load account socialaccount %} +{% load static %} +{% load turnstile_tags %} + +
+
+
+

{% trans "Create Account" %}

+ +
+ + {% get_providers as socialaccount_providers %} + {% if socialaccount_providers %} + + +
+ Or continue with email +
+ {% endif %} + +
+ {% csrf_token %} + {% if form.non_field_errors %} +
+
{{ form.non_field_errors }}
+
+ {% endif %} + +
+ + + {% if form.username.errors %} +

{{ form.username.errors }}

+ {% endif %} +
+ +
+ + + {% if form.email.errors %} +

{{ form.email.errors }}

+ {% endif %} +
+ +
+ + + {% if form.password1.errors %} +

{{ form.password1.errors }}

+ {% endif %} +
+
    +
  • + + Must be at least 8 characters long +
  • +
  • + + Can't be too similar to your personal information +
  • +
  • + + Can't be a commonly used password +
  • +
  • + + Can't be entirely numeric +
  • +
+
+
+ +
+ + + {% if form.password2.errors %} +

{{ form.password2.errors }}

+ {% endif %} +
+ + {% turnstile_widget %} + {% if redirect_field_value %} + + {% endif %} + +
+ +
+
+ + +
+
+
+
+
+ +
+

+ {% trans "Already have an account?" %} + + {% trans "Sign in" %} + +

+
+
+
+ + diff --git a/templates/base/base.html b/templates/base/base.html index 2379941e..e5155290 100644 --- a/templates/base/base.html +++ b/templates/base/base.html @@ -225,17 +225,22 @@ > - + {% endif %} diff --git a/templates/home.html b/templates/home.html index 26309f4e..24da3399 100644 --- a/templates/home.html +++ b/templates/home.html @@ -18,7 +18,7 @@ class="px-8 py-3 text-lg btn-primary"> Explore Parks - View Rides @@ -30,7 +30,7 @@
+
{{ stats.total_parks }}
@@ -40,7 +40,7 @@
-
{{ stats.ride_count }} diff --git a/templates/parks/park_detail.html b/templates/parks/park_detail.html index 051b834a..0873e9a9 100644 --- a/templates/parks/park_detail.html +++ b/templates/parks/park_detail.html @@ -22,40 +22,10 @@
- {% if user.is_authenticated %} -
- - Edit - - {% if perms.media.add_photo %} - - {% endif %} - - - {% if not park.reviews.exists %} - - Add Review - - {% else %} - {% if user|has_reviewed_park:park %} - - Edit Review - - {% else %} - - Add Review - - {% endif %} - {% endif %} +
- {% endif %}
diff --git a/templates/parks/park_list.html b/templates/parks/park_list.html index a9851c1c..efda2d33 100644 --- a/templates/parks/park_list.html +++ b/templates/parks/park_list.html @@ -7,11 +7,7 @@

All Parks

- {% if user.is_authenticated %} - - Add Park - - {% endif %} +
diff --git a/templates/parks/partials/add_park_button.html b/templates/parks/partials/add_park_button.html new file mode 100644 index 00000000..49953479 --- /dev/null +++ b/templates/parks/partials/add_park_button.html @@ -0,0 +1,5 @@ +{% if user.is_authenticated %} + + Add Park + +{% endif %} diff --git a/templates/parks/partials/park_actions.html b/templates/parks/partials/park_actions.html new file mode 100644 index 00000000..21b9e186 --- /dev/null +++ b/templates/parks/partials/park_actions.html @@ -0,0 +1,37 @@ +{% load review_tags %} + +{% if user.is_authenticated %} +
+ + Edit + + {% include "rides/partials/add_ride_modal.html" %} + {% if perms.media.add_photo %} + + {% endif %} + + + {% if not park.reviews.exists %} + + Add Review + + {% else %} + {% if user|has_reviewed_park:park %} + + Edit Review + + {% else %} + + Add Review + + {% endif %} + {% endif %} +
+{% endif %} diff --git a/templates/parks/partials/park_search_results.html b/templates/parks/partials/park_search_results.html new file mode 100644 index 00000000..1c6677c3 --- /dev/null +++ b/templates/parks/partials/park_search_results.html @@ -0,0 +1,27 @@ +
+ {% if parks %} + {% for park in parks %} + + {% endfor %} + {% else %} +
+ {% if search_term %} + No parks found + {% else %} + Start typing to search... + {% endif %} +
+ {% endif %} +
+ + diff --git a/templates/rides/park_category_list.html b/templates/rides/park_category_list.html new file mode 100644 index 00000000..5f6ede89 --- /dev/null +++ b/templates/rides/park_category_list.html @@ -0,0 +1,139 @@ +{% extends "base/base.html" %} +{% load static %} +{% load ride_tags %} + +{% block title %} + {% if park %} + {{ category }} at {{ park.name }} - ThrillWiki + {% else %} + {{ category }} - ThrillWiki + {% endif %} +{% endblock %} + +{% block content %} +
+
+ {% if park %} +

{{ category }} at {{ park.name }}

+ ← Back to {{ park.name }} + {% else %} +

{{ category }}

+ ← Back to All Rides + {% endif %} +
+ + {% if rides %} +
+ {% for ride in rides %} +
+
+ {% if ride.photos.exists %} + {{ ride.name }} + {% else %} + {{ ride.name }} + {% endif %} +
+ +
+

+ {% if park %} + + {{ ride.name }} + + {% else %} + + {{ ride.name }} + + {% endif %} +

+ + {% if not park %} +

+ at + {{ ride.park.name }} + +

+ {% endif %} + + {% if ride.ride_model %} +

+ Model: {{ ride.ride_model.name }} + {% if ride.ride_model.manufacturer %} + by {{ ride.ride_model.manufacturer.name }} + {% endif %} +

+ {% endif %} + +
+ + {{ ride.get_status_display }} + + {% if ride.average_rating %} + + + {{ ride.average_rating|floatformat:1 }}/10 + + {% endif %} +
+ + {% if ride.coaster_stats %} +
+ {% if ride.coaster_stats.height_ft %} +
+ Height: {{ ride.coaster_stats.height_ft }}ft +
+ {% endif %} + {% if ride.coaster_stats.speed_mph %} +
+ Speed: {{ ride.coaster_stats.speed_mph }}mph +
+ {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+ {% if park %} +

No {{ category|lower }} found at this park.

+ {% else %} +

No {{ category|lower }} found.

+ {% endif %} +
+ {% endif %} + + + {% if is_paginated %} +
+
+ {% if page_obj.has_previous %} + « First + Previous + {% endif %} + + + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + + + {% if page_obj.has_next %} + Next + Last » + {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/rides/partials/add_ride_modal.html b/templates/rides/partials/add_ride_modal.html new file mode 100644 index 00000000..18b2da7d --- /dev/null +++ b/templates/rides/partials/add_ride_modal.html @@ -0,0 +1,47 @@ + + + + + + + diff --git a/templates/rides/partials/coaster_fields.html b/templates/rides/partials/coaster_fields.html new file mode 100644 index 00000000..dafd3add --- /dev/null +++ b/templates/rides/partials/coaster_fields.html @@ -0,0 +1,110 @@ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/templates/rides/partials/create_ride_model_form.html b/templates/rides/partials/create_ride_model_form.html new file mode 100644 index 00000000..d181802d --- /dev/null +++ b/templates/rides/partials/create_ride_model_form.html @@ -0,0 +1,93 @@ +{% load static %} + + diff --git a/templates/rides/partials/designer_created.html b/templates/rides/partials/designer_created.html new file mode 100644 index 00000000..f2a41603 --- /dev/null +++ b/templates/rides/partials/designer_created.html @@ -0,0 +1,26 @@ +{% if error %} + +{% else %} + +{% endif %} diff --git a/templates/rides/partials/designer_form.html b/templates/rides/partials/designer_form.html new file mode 100644 index 00000000..129dabd3 --- /dev/null +++ b/templates/rides/partials/designer_form.html @@ -0,0 +1,84 @@ +{% load static %} + +
+ {% csrf_token %} + +
+ +
+ +
+ + +
+ + {% if not user.is_privileged %} + +
+ + +
+
+ + +
+ {% endif %} +
+ +
+ {% if modal %} + + {% endif %} + +
+
diff --git a/templates/rides/partials/designer_search_results.html b/templates/rides/partials/designer_search_results.html new file mode 100644 index 00000000..0702480a --- /dev/null +++ b/templates/rides/partials/designer_search_results.html @@ -0,0 +1,27 @@ +
+ {% if designers %} + {% for designer in designers %} + + {% endfor %} + {% else %} +
+ {% if search_term %} + No matches found. You can still submit this name. + {% else %} + Start typing to search... + {% endif %} +
+ {% endif %} +
+ + diff --git a/templates/rides/partials/manufacturer_created.html b/templates/rides/partials/manufacturer_created.html new file mode 100644 index 00000000..79cdad48 --- /dev/null +++ b/templates/rides/partials/manufacturer_created.html @@ -0,0 +1,26 @@ +{% if error %} + +{% else %} + +{% endif %} diff --git a/templates/rides/partials/manufacturer_form.html b/templates/rides/partials/manufacturer_form.html new file mode 100644 index 00000000..efac3ca5 --- /dev/null +++ b/templates/rides/partials/manufacturer_form.html @@ -0,0 +1,84 @@ +{% load static %} + +
+ {% csrf_token %} + +
+ +
+ +
+ + +
+ + {% if not user.is_privileged %} + +
+ + +
+
+ + +
+ {% endif %} +
+ +
+ {% if modal %} + + {% endif %} + +
+
diff --git a/templates/rides/partials/manufacturer_search_results.html b/templates/rides/partials/manufacturer_search_results.html new file mode 100644 index 00000000..f41ecff1 --- /dev/null +++ b/templates/rides/partials/manufacturer_search_results.html @@ -0,0 +1,33 @@ +
+ {% if manufacturers %} + {% for manufacturer in manufacturers %} + + {% endfor %} + {% else %} +
+ {% if search_term %} + No matches found. You can still submit this name. + {% else %} + Start typing to search... + {% endif %} +
+ {% endif %} +
+ + diff --git a/templates/rides/partials/ride_form.html b/templates/rides/partials/ride_form.html new file mode 100644 index 00000000..afddd83c --- /dev/null +++ b/templates/rides/partials/ride_form.html @@ -0,0 +1,291 @@ +{% load static %} + + + +
+ {% csrf_token %} + + + {% if form.park_area %} +
+ + {{ form.park_area }} + {% if form.park_area.errors %} +
+ {{ form.park_area.errors }} +
+ {% endif %} +
+ {% endif %} + + +
+ + {{ form.name }} + {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} +
+ + +
+
+ + {{ form.manufacturer_search }} + {{ form.manufacturer }} +
+ {% if form.manufacturer.errors %} +
+ {{ form.manufacturer.errors }} +
+ {% endif %} +
+
+ + +
+
+ + {{ form.designer_search }} + {{ form.designer }} +
+ {% if form.designer.errors %} +
+ {{ form.designer.errors }} +
+ {% endif %} +
+
+ + +
+
+ + {{ form.ride_model_search }} + {{ form.ride_model }} +
+ {% if form.ride_model.errors %} +
+ {{ form.ride_model.errors }} +
+ {% endif %} +
+
+ + +
+ + {{ form.model_name }} + {% if form.model_name.errors %} +
+ {{ form.model_name.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.category }} + {% if form.category.errors %} +
+ {{ form.category.errors }} +
+ {% endif %} +
+ + +
+ + +
+ + {{ form.status }} + {% if form.status.errors %} +
+ {{ form.status.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.opening_date }} + {% if form.opening_date.errors %} +
+ {{ form.opening_date.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.closing_date }} + {% if form.closing_date.errors %} +
+ {{ form.closing_date.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.status_since }} + {% if form.status_since.errors %} +
+ {{ form.status_since.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.min_height_in }} + {% if form.min_height_in.errors %} +
+ {{ form.min_height_in.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.max_height_in }} + {% if form.max_height_in.errors %} +
+ {{ form.max_height_in.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.capacity_per_hour }} + {% if form.capacity_per_hour.errors %} +
+ {{ form.capacity_per_hour.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.ride_duration_seconds }} + {% if form.ride_duration_seconds.errors %} +
+ {{ form.ride_duration_seconds.errors }} +
+ {% endif %} +
+ + +
+ + {{ form.description }} + {% if form.description.errors %} +
+ {{ form.description.errors }} +
+ {% endif %} +
+ + +
+ +
+
diff --git a/templates/rides/partials/ride_model_created.html b/templates/rides/partials/ride_model_created.html new file mode 100644 index 00000000..cf6d337d --- /dev/null +++ b/templates/rides/partials/ride_model_created.html @@ -0,0 +1,44 @@ +{% if error %} + +{% elif ride_model.id %} + + + +{% else %} + + + +{% endif %} diff --git a/templates/rides/partials/ride_model_form.html b/templates/rides/partials/ride_model_form.html new file mode 100644 index 00000000..d3ba5019 --- /dev/null +++ b/templates/rides/partials/ride_model_form.html @@ -0,0 +1,215 @@ +{% load static %} + +
+ {% csrf_token %} + +
+ +
+ +
+ + +
+ + +
+ +
+
+
+
+ +
+ {% if not prefilled_manufacturer and not create_ride_model %} + + {% endif %} +
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + {% if not user.is_privileged %} + +
+ + +
+
+ + +
+ {% endif %} +
+ +
+ {% if modal %} + + {% endif %} + +
+
+ + diff --git a/templates/rides/partials/ride_model_search_results.html b/templates/rides/partials/ride_model_search_results.html new file mode 100644 index 00000000..d90c38bc --- /dev/null +++ b/templates/rides/partials/ride_model_search_results.html @@ -0,0 +1,38 @@ +
+ {% if not manufacturer_id %} +
+ Please select a manufacturer first +
+ {% else %} + {% if ride_models %} + {% for ride_model in ride_models %} + + {% endfor %} + {% else %} +
+ {% if search_term %} + No matches found. You can still submit this name. + {% else %} + Start typing to search... + {% endif %} +
+ {% endif %} + {% endif %} +
+ + diff --git a/templates/rides/ride_detail.html b/templates/rides/ride_detail.html index a27cd5fd..16ced180 100644 --- a/templates/rides/ride_detail.html +++ b/templates/rides/ride_detail.html @@ -8,7 +8,7 @@ {% if user.is_authenticated %}
- Edit @@ -84,11 +84,7 @@
Length
{{ coaster_stats.length_ft }} ft
- {% else %} -
-
Length
-
N/A
-
+ {% endif %} {% endif %}
@@ -101,6 +97,10 @@
+ {{ ride.manufacturer.name }} + +
+
{% endif %} {% if ride.designer %} @@ -436,24 +436,22 @@

History

- {% for record in history %} + {% for record in ride.get_history %}
{{ record.history_date|date:"M d, Y H:i" }} - {% if record.history_user %} - by {{ record.history_user.username }} + {% if record.history_user_display %} + by {{ record.history_user_display }} {% endif %}
{% for field, changes in record.diff_against_previous.items %} - {% if field != "updated_at" %} -
- {{ field|title }}: - {{ changes.old }} - - {{ changes.new }} -
- {% endif %} +
+ {{ field|title }}: + {{ changes.old }} + + {{ changes.new }} +
{% endfor %}
@@ -485,7 +483,7 @@
{% endif %} -{% endblock %} +{% endblock content %} {% block extra_js %} diff --git a/templates/rides/ride_form.html b/templates/rides/ride_form.html index eeb1454e..6ab1c0bc 100644 --- a/templates/rides/ride_form.html +++ b/templates/rides/ride_form.html @@ -1,114 +1,245 @@ {% extends 'base/base.html' %} {% load static %} -{% block title %}{% if is_edit %}Edit {{ ride.name }} {% else %}Add Ride {% endif %}at {{ park.name }} - ThrillWiki{% endblock %} +{% block title %}{% if is_edit %}Edit{% else %}Add{% endif %} Ride - ThrillWiki{% endblock %} {% block content %} -
-
- -
-
-

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

- {% if is_edit %} - - Back to {{ ride.name }} - - {% else %} - - Back to {{ park.name }} Rides - - {% endif %} +
+
+

+ {% if is_edit %}Edit {{ object.name }}{% else %}Add New Ride{% endif %} + {% if park %} + + {% endif %} +

+ +
+ {% csrf_token %} + + {% if not park %} + {# Park Selection - Only shown when creating from global view #} +
+

Park Information

+
+
+ + {{ form.park_search }} + {{ form.park }} +
+ {% if form.park.errors %} +
+ {{ form.park.errors }} +
+ {% endif %} +
+
+
+ {% endif %} + + {# Basic Information #} +
+

Basic Information

+
+
+ + {{ form.name }} + {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} +
+ +
+ + {{ form.category }} + {% if form.category.errors %} +
+ {{ form.category.errors }} +
+ {% endif %} +
+ +
+
- - {% csrf_token %} - - {% for field in form %} - {% if field.name != 'park' %} -
- - {% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'date' or field.field.widget.input_type == 'number' %} - - {% elif field.field.widget.input_type == 'select' %} - - {% elif field.field.widget.input_type == 'textarea' %} - - {% endif %} - {% if field.help_text %} -

{{ field.help_text }}

- {% endif %} - {% if field.errors %} -
- {{ field.errors }} -
- {% endif %} -
- {% endif %} - {% endfor %} + {# Manufacturer and Model #} +
+

Manufacturer and Model

+
+
+ + {{ form.manufacturer_search }} + {{ form.manufacturer }} +
+
- {% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %} +
+ + {{ form.designer_search }} + {{ form.designer }} +
+
+ +
+ + {{ form.ride_model_search }} + {{ form.ride_model }} +
+
+
+
+ + {# Status and Dates #} +
+

Status and Dates

+
+
+ + {{ form.status }} +
+ +
+ + {{ form.status_since }} +
+ +
+ + {{ form.opening_date }} +
+ +
+ + {{ form.closing_date }} +
+ +
+ + {{ form.post_closing_status }} +
+
+
+ + {# Specifications #} +
+

Specifications

+
+
+ + {{ form.min_height_in }} +
+ +
+ + {{ form.max_height_in }} +
+ +
+ + {{ form.capacity_per_hour }} +
+ +
+ + {{ form.ride_duration_seconds }} +
+
+
+ + {# Description #} +
+

Description

+
+ + {{ form.description }} +
+
+ + {# Submission Details #} +
+

Submission Details

-
-
- {% endif %} - -
- - Cancel - - -
- -
- - - {% if is_edit %} -
- {% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="rides.ride" object_id=object.id %}
- {% endif %} + + {# Submit Button #} +
+ +
+
{% endblock %} diff --git a/templates/rides/ride_list.html b/templates/rides/ride_list.html index 23656787..5297d6c1 100644 --- a/templates/rides/ride_list.html +++ b/templates/rides/ride_list.html @@ -23,10 +23,22 @@

All Rides

{% endif %}
- {% if park and user.is_authenticated %} - - Add Ride - + {% if user.is_authenticated %} + {% if park %} + + + + + Add Ride + + {% else %} + + + + + Add Ride + + {% endif %} {% endif %}
diff --git a/thrillwiki/settings.py b/thrillwiki/settings.py index d62aba6d..6a6155b5 100644 --- a/thrillwiki/settings.py +++ b/thrillwiki/settings.py @@ -200,6 +200,7 @@ SOCIALACCOUNT_STORE_TOKENS = True # Email settings EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend" FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net" +SERVER_EMAIL = "django_webmaster@thrillwiki.com" # Custom User Model AUTH_USER_MODEL = "accounts.User"