mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
28
companies/migrations/0002_add_designer_model.py
Normal file
28
companies/migrations/0002_add_designer_model.py
Normal file
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"]},
|
||||
),
|
||||
]
|
||||
245
parks/management/commands/seed_initial_data.py
Normal file
245
parks/management/commands/seed_initial_data.py
Normal file
@@ -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'))
|
||||
321
parks/management/commands/seed_ride_data.py
Normal file
321
parks/management/commands/seed_ride_data.py
Normal file
@@ -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'))
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
@@ -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")),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||
path("<slug:slug>/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("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
|
||||
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
|
||||
path("<slug:slug>/actions/", views.park_actions, name="park_actions"),
|
||||
|
||||
# Area views
|
||||
path("<slug:park_slug>/areas/<slug:area_slug>/", views.ParkAreaDetailView.as_view(), name="area_detail"),
|
||||
|
||||
@@ -26,6 +36,6 @@ urlpatterns = [
|
||||
path("<slug:park_slug>/transports/", ParkSingleCategoryListView.as_view(), {'category': 'TR'}, name="park_transports"),
|
||||
path("<slug:park_slug>/others/", ParkSingleCategoryListView.as_view(), {'category': 'OT'}, name="park_others"),
|
||||
|
||||
# Include rides URLs
|
||||
# Include rides URLs with park_slug
|
||||
path("<slug:park_slug>/rides/", include("rides.urls", namespace="rides")),
|
||||
]
|
||||
|
||||
274
parks/views.py
274
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('<option value="">Select a park first</option>')
|
||||
|
||||
try:
|
||||
park = Park.objects.get(id=park_id)
|
||||
areas = park.areas.all()
|
||||
options = ['<option value="">No specific area</option>']
|
||||
options.extend([
|
||||
f'<option value="{area.id}">{area.name}</option>'
|
||||
for area in areas
|
||||
])
|
||||
return HttpResponse('\n'.join(options))
|
||||
except Park.DoesNotExist:
|
||||
return HttpResponse('<option value="">Invalid park selected</option>')
|
||||
|
||||
|
||||
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}
|
||||
|
||||
@@ -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")},
|
||||
),
|
||||
]
|
||||
|
||||
0
reviews/templatetags/__init__.py
Normal file
0
reviews/templatetags/__init__.py
Normal file
11
reviews/templatetags/review_tags.py
Normal file
11
reviews/templatetags/review_tags.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
335
rides/forms.py
335
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
259
rides/migrations/0005_alter_rollercoasterstats_id_and_more.py
Normal file
259
rides/migrations/0005_alter_rollercoasterstats_id_and_more.py
Normal file
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
26
rides/migrations/0007_alter_ridemodel_manufacturer.py
Normal file
26
rides/migrations/0007_alter_ridemodel_manufacturer.py
Normal file
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
155
rides/models.py
155
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<slug:ride_slug>/edit/', views.RideUpdateView.as_view(), name='ride_edit'),
|
||||
path('<slug:ride_slug>/', 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("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
|
||||
path("<slug:ride_slug>/update/", views.RideUpdateView.as_view(), name="ride_update"),
|
||||
]
|
||||
|
||||
736
rides/views.py
736
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},
|
||||
)
|
||||
|
||||
@@ -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));
|
||||
|
||||
70
templates/account/partials/login_modal.html
Normal file
70
templates/account/partials/login_modal.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load static %}
|
||||
|
||||
<div
|
||||
id="login-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.away="document.getElementById('login-modal').remove()"
|
||||
>
|
||||
<div class="w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{% trans "Welcome Back" %}</h2>
|
||||
<button
|
||||
onclick="this.closest('#login-modal').remove()"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="space-y-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<a
|
||||
href="{% provider_login_url provider.id process='login' %}"
|
||||
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
|
||||
>
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Google</span>
|
||||
{% elif provider.id == 'discord' %}
|
||||
<img
|
||||
src="{% static 'images/discord-icon.svg' %}"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Or continue with email</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "account/partials/login_form.html" %}
|
||||
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% trans "Don't have an account?" %}
|
||||
<a
|
||||
href="{% url 'account_signup' %}"
|
||||
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-none focus:underline"
|
||||
>
|
||||
{% trans "Sign up" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
261
templates/account/partials/signup_modal.html
Normal file
261
templates/account/partials/signup_modal.html
Normal file
@@ -0,0 +1,261 @@
|
||||
{% load i18n %}
|
||||
{% load account socialaccount %}
|
||||
{% load static %}
|
||||
{% load turnstile_tags %}
|
||||
|
||||
<div
|
||||
id="signup-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.away="document.getElementById('signup-modal').remove()"
|
||||
>
|
||||
<div class="w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{% trans "Create Account" %}</h2>
|
||||
<button
|
||||
onclick="this.closest('#signup-modal').remove()"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="space-y-3">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<a
|
||||
href="{% provider_login_url provider.id process='signup' %}"
|
||||
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
|
||||
>
|
||||
{% if provider.id == 'google' %}
|
||||
<img
|
||||
src="{% static 'images/google-icon.svg' %}"
|
||||
alt="Google"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Google</span>
|
||||
{% elif provider.id == 'discord' %}
|
||||
<img
|
||||
src="{% static 'images/discord-icon.svg' %}"
|
||||
alt="Discord"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span>Continue with Discord</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>Or continue with email</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post="{% url 'account_signup' %}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#signup-indicator"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="text-sm">{{ form.non_field_errors }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="id_username" class="form-label">
|
||||
{% trans "Username" %}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.username.errors %}
|
||||
<p class="form-error">{{ form.username.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_email" class="form-label">{% trans "Email" %}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="id_email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="form-input"
|
||||
/>
|
||||
{% if form.email.errors %}
|
||||
<p class="form-error">{{ form.email.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password1" class="form-label">
|
||||
{% trans "Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password1"
|
||||
id="id_password1"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="form-input"
|
||||
oninput="validatePassword(this.value)"
|
||||
/>
|
||||
{% if form.password1.errors %}
|
||||
<p class="form-error">{{ form.password1.errors }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-3 password-requirements">
|
||||
<ul id="passwordRequirements">
|
||||
<li class="invalid" id="req-length">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Must be at least 8 characters long</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-similar">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be too similar to your personal information</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-common">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be a commonly used password</span>
|
||||
</li>
|
||||
<li class="invalid" id="req-numeric">
|
||||
<i class="text-xs fas fa-circle"></i>
|
||||
<span>Can't be entirely numeric</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="id_password2" class="form-label">
|
||||
{% trans "Confirm Password" %}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password2"
|
||||
id="id_password2"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="form-input"
|
||||
oninput="validatePasswordMatch()"
|
||||
/>
|
||||
{% if form.password2.errors %}
|
||||
<p class="form-error">{{ form.password2.errors }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% turnstile_widget %}
|
||||
{% if redirect_field_value %}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ redirect_field_name }}"
|
||||
value="{{ redirect_field_value }}"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<button type="submit" class="w-full btn-primary">
|
||||
<i class="mr-2 fas fa-user-plus"></i>
|
||||
{% trans "Create Account" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="signup-indicator" class="htmx-indicator">
|
||||
<div class="flex items-center justify-center w-full py-4">
|
||||
<div class="w-8 h-8 border-4 rounded-full border-primary border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{% trans "Already have an account?" %}
|
||||
<a
|
||||
href="{% url 'account_login' %}"
|
||||
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-none focus:underline"
|
||||
onkeydown="if(event.key === 'Enter') { this.click(); }"
|
||||
>
|
||||
{% trans "Sign in" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function validatePassword(password) {
|
||||
// Length requirement
|
||||
const lengthReq = document.getElementById("req-length");
|
||||
if (password.length >= 8) {
|
||||
lengthReq.classList.remove("invalid");
|
||||
lengthReq.classList.add("valid");
|
||||
lengthReq.querySelector("i").classList.remove("fa-circle");
|
||||
lengthReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
lengthReq.classList.remove("valid");
|
||||
lengthReq.classList.add("invalid");
|
||||
lengthReq.querySelector("i").classList.remove("fa-check");
|
||||
lengthReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
|
||||
// Numeric requirement
|
||||
const numericReq = document.getElementById("req-numeric");
|
||||
if (!/^\d+$/.test(password)) {
|
||||
numericReq.classList.remove("invalid");
|
||||
numericReq.classList.add("valid");
|
||||
numericReq.querySelector("i").classList.remove("fa-circle");
|
||||
numericReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
numericReq.classList.remove("valid");
|
||||
numericReq.classList.add("invalid");
|
||||
numericReq.querySelector("i").classList.remove("fa-check");
|
||||
numericReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
|
||||
// Common password check (basic)
|
||||
const commonReq = document.getElementById("req-common");
|
||||
const commonPasswords = ["password", "12345678", "qwerty", "letmein"];
|
||||
if (!commonPasswords.includes(password.toLowerCase())) {
|
||||
commonReq.classList.remove("invalid");
|
||||
commonReq.classList.add("valid");
|
||||
commonReq.querySelector("i").classList.remove("fa-circle");
|
||||
commonReq.querySelector("i").classList.add("fa-check");
|
||||
} else {
|
||||
commonReq.classList.remove("valid");
|
||||
commonReq.classList.add("invalid");
|
||||
commonReq.querySelector("i").classList.remove("fa-check");
|
||||
commonReq.querySelector("i").classList.add("fa-circle");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePasswordMatch() {
|
||||
const password1 = document.getElementById("id_password1").value;
|
||||
const password2 = document.getElementById("id_password2").value;
|
||||
const password2Input = document.getElementById("id_password2");
|
||||
|
||||
if (password2.length > 0) {
|
||||
if (password1 === password2) {
|
||||
password2Input.classList.remove("border-red-500");
|
||||
password2Input.classList.add("border-green-500");
|
||||
} else {
|
||||
password2Input.classList.remove("border-green-500");
|
||||
password2Input.classList.add("border-red-500");
|
||||
}
|
||||
} else {
|
||||
password2Input.classList.remove("border-green-500", "border-red-500");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -225,17 +225,22 @@
|
||||
>
|
||||
<div
|
||||
hx-get="{% url 'account_login' %}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
class="cursor-pointer menu-item"
|
||||
>
|
||||
<i class="w-5 fas fa-sign-in-alt"></i>
|
||||
<span>Login</span>
|
||||
</div>
|
||||
<a href="{% url 'account_signup' %}" class="menu-item">
|
||||
<div
|
||||
hx-get="{% url 'account_signup' %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
class="cursor-pointer menu-item"
|
||||
>
|
||||
<i class="w-5 fas fa-user-plus"></i>
|
||||
<span>Register</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
class="px-8 py-3 text-lg btn-primary">
|
||||
Explore Parks
|
||||
</a>
|
||||
<a href="{% url 'rides:all_rides' %}"
|
||||
<a href="{% url 'rides:ride_list' %}"
|
||||
class="px-8 py-3 text-lg btn-secondary">
|
||||
View Rides
|
||||
</a>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="grid grid-cols-1 gap-6 mb-12 md:grid-cols-3">
|
||||
<!-- Total Parks -->
|
||||
<a href="{% url 'parks:park_list' %}"
|
||||
class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
|
||||
<div class="text-xl text-gray-600 dark:text-gray-300">
|
||||
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ stats.total_parks }}
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
</a>
|
||||
|
||||
<!-- Total Attractions -->
|
||||
<a href="{% url 'rides:all_rides' %}"
|
||||
<a href="{% url 'rides:ride_list' %}"
|
||||
class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1 hover:shadow-xl">
|
||||
<div class="mb-2 text-4xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ stats.ride_count }}
|
||||
|
||||
@@ -22,40 +22,10 @@
|
||||
|
||||
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Action Buttons - Above header -->
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex justify-end gap-2 mb-2">
|
||||
<a href="{% url 'parks:park_update' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
{% if perms.media.add_photo %}
|
||||
<button class="transition-transform btn-secondary hover:scale-105"
|
||||
@click="$dispatch('show-photo-upload')">
|
||||
<i class="mr-1 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add/Edit Review Button -->
|
||||
{% if not park.reviews.exists %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% else %}
|
||||
{% if user|has_reviewed_park:park %}
|
||||
<a href="{% url 'reviews:edit_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Edit Review
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div hx-get="{% url 'parks:park_actions' park.slug %}"
|
||||
hx-trigger="load, auth-changed from:body"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Header Grid -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-6 sm:grid-cols-12">
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">All Parks</h1>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}" class="btn-primary">
|
||||
<i class="mr-2 fas fa-plus"></i>Add Park
|
||||
</a>
|
||||
{% endif %}
|
||||
<div hx-get="{% url 'parks:add_park_button' %}" hx-trigger="load"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
|
||||
5
templates/parks/partials/add_park_button.html
Normal file
5
templates/parks/partials/add_park_button.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}" class="btn-primary">
|
||||
<i class="mr-2 fas fa-plus"></i>Add Park
|
||||
</a>
|
||||
{% endif %}
|
||||
37
templates/parks/partials/park_actions.html
Normal file
37
templates/parks/partials/park_actions.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% load review_tags %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex justify-end gap-2 mb-2">
|
||||
<a href="{% url 'parks:park_update' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
{% include "rides/partials/add_ride_modal.html" %}
|
||||
{% if perms.media.add_photo %}
|
||||
<button class="transition-transform btn-secondary hover:scale-105"
|
||||
@click="$dispatch('show-photo-upload')">
|
||||
<i class="mr-1 fas fa-camera"></i>Upload Photo
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add/Edit Review Button -->
|
||||
{% if not park.reviews.exists %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% else %}
|
||||
{% if user|has_reviewed_park:park %}
|
||||
<a href="{% url 'reviews:edit_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Edit Review
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'reviews:add_review' park.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-star"></i>Add Review
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
27
templates/parks/partials/park_search_results.html
Normal file
27
templates/parks/partials/park_search_results.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if parks %}
|
||||
{% for park in parks %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectPark('{{ park.id }}', '{{ park.name|escapejs }}')">
|
||||
{{ park.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No parks found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectPark(id, name) {
|
||||
document.getElementById('id_park').value = id;
|
||||
document.getElementById('id_park_search').value = name;
|
||||
document.getElementById('park-search-results').innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
139
templates/rides/park_category_list.html
Normal file
139
templates/rides/park_category_list.html
Normal file
@@ -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 %}
|
||||
<div class="container px-4 py-8 mx-auto">
|
||||
<div class="mb-8">
|
||||
{% if park %}
|
||||
<h1 class="mb-2 text-3xl font-bold">{{ category }} at {{ park.name }}</h1>
|
||||
<a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-800">← Back to {{ park.name }}</a>
|
||||
{% else %}
|
||||
<h1 class="mb-2 text-3xl font-bold">{{ category }}</h1>
|
||||
<a href="{% url 'rides:ride_list' %}" class="text-blue-600 hover:text-blue-800">← Back to All Rides</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if rides %}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for ride in rides %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
<div class="aspect-w-16 aspect-h-9">
|
||||
{% if ride.photos.exists %}
|
||||
<img src="{{ ride.photos.first.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="object-cover w-full">
|
||||
{% else %}
|
||||
<img src="{% get_ride_placeholder_image ride.category %}"
|
||||
alt="{{ ride.name }}"
|
||||
class="object-cover w-full">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
{% if park %}
|
||||
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'rides:ride_detail' ride.slug %}"
|
||||
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if not park %}
|
||||
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ ride.park.name }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.ride_model %}
|
||||
<p class="mb-2 text-gray-600 dark:text-gray-400">
|
||||
Model: {{ ride.ride_model.name }}
|
||||
{% if ride.ride_model.manufacturer %}
|
||||
by {{ ride.ride_model.manufacturer.name }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ ride.get_status_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if ride.coaster_stats %}
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
{% if ride.coaster_stats.height_ft %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Height: {{ ride.coaster_stats.height_ft }}ft
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ride.coaster_stats.speed_mph %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Speed: {{ ride.coaster_stats.speed_mph }}mph
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
{% if park %}
|
||||
<p class="text-gray-600 dark:text-gray-400">No {{ category|lower }} found at this park.</p>
|
||||
{% else %}
|
||||
<p class="text-gray-600 dark:text-gray-400">No {{ category|lower }} found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if is_paginated %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
templates/rides/partials/add_ride_modal.html
Normal file
47
templates/rides/partials/add_ride_modal.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!-- Add Ride Modal -->
|
||||
<div id="add-ride-modal" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<!-- Background overlay -->
|
||||
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true"></div>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div class="relative w-full max-w-3xl p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Add Ride at {{ park.name }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div id="modal-content">
|
||||
{% include "rides/partials/ride_form.html" with modal=True %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Toggle Button -->
|
||||
<button type="button"
|
||||
onclick="openModal('add-ride-modal')"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add Ride
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function openModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('add-ride-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('add-ride-modal').addEventListener('click', function(event) {
|
||||
if (event.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
110
templates/rides/partials/coaster_fields.html
Normal file
110
templates/rides/partials/coaster_fields.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="id_height_ft" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Height (ft)
|
||||
</label>
|
||||
<input type="number"
|
||||
name="height_ft"
|
||||
id="id_height_ft"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Total height of the coaster in feet"
|
||||
min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label for="id_length_ft" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Length (ft)
|
||||
</label>
|
||||
<input type="number"
|
||||
name="length_ft"
|
||||
id="id_length_ft"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Total track length in feet"
|
||||
min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label for="id_speed_mph" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Speed (mph)
|
||||
</label>
|
||||
<input type="number"
|
||||
name="speed_mph"
|
||||
id="id_speed_mph"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Maximum speed in miles per hour"
|
||||
min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label for="id_inversions" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Inversions
|
||||
</label>
|
||||
<input type="number"
|
||||
name="inversions"
|
||||
id="id_inversions"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Number of inversions"
|
||||
min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="id_track_material" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Track Material
|
||||
</label>
|
||||
<select name="track_material"
|
||||
id="id_track_material"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select track material...</option>
|
||||
<option value="STEEL">Steel</option>
|
||||
<option value="WOOD">Wood</option>
|
||||
<option value="HYBRID">Hybrid</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="id_roller_coaster_type" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Coaster Type
|
||||
</label>
|
||||
<select name="roller_coaster_type"
|
||||
id="id_roller_coaster_type"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select coaster type...</option>
|
||||
<option value="SITDOWN">Sit-Down</option>
|
||||
<option value="INVERTED">Inverted</option>
|
||||
<option value="FLYING">Flying</option>
|
||||
<option value="STANDUP">Stand-Up</option>
|
||||
<option value="WING">Wing</option>
|
||||
<option value="SUSPENDED">Suspended</option>
|
||||
<option value="BOBSLED">Bobsled</option>
|
||||
<option value="PIPELINE">Pipeline</option>
|
||||
<option value="MOTORBIKE">Motorbike</option>
|
||||
<option value="FLOORLESS">Floorless</option>
|
||||
<option value="DIVE">Dive</option>
|
||||
<option value="FAMILY">Family</option>
|
||||
<option value="WILD_MOUSE">Wild Mouse</option>
|
||||
<option value="SPINNING">Spinning</option>
|
||||
<option value="FOURTH_DIMENSION">4th Dimension</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="id_launch_type" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Launch Type
|
||||
</label>
|
||||
<select name="launch_type"
|
||||
id="id_launch_type"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select launch type...</option>
|
||||
<option value="CHAIN">Chain Lift</option>
|
||||
<option value="CABLE">Cable Launch</option>
|
||||
<option value="HYDRAULIC">Hydraulic Launch</option>
|
||||
<option value="LSM">Linear Synchronous Motor</option>
|
||||
<option value="LIM">Linear Induction Motor</option>
|
||||
<option value="GRAVITY">Gravity</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
93
templates/rides/partials/create_ride_model_form.html
Normal file
93
templates/rides/partials/create_ride_model_form.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="bg-white rounded-lg shadow-xl modal-content dark:bg-gray-800"
|
||||
x-data="{ showModal: true }"
|
||||
@click.outside="$dispatch('close-modal')"
|
||||
@keydown.escape.window="$dispatch('close-modal')">
|
||||
<div class="p-6">
|
||||
<h2 class="mb-4 text-xl font-semibold dark:text-white">Create New Ride Model</h2>
|
||||
|
||||
<form hx-post="{% url 'rides:create_ride_model' %}"
|
||||
hx-target="#modal-content"
|
||||
class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name *</label>
|
||||
<input type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value="{{ name }}"
|
||||
required
|
||||
class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
|
||||
<select id="manufacturer"
|
||||
name="manufacturer"
|
||||
class="w-full mt-1 border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select a manufacturer</option>
|
||||
{% for manufacturer in manufacturers %}
|
||||
<option value="{{ manufacturer.id }}">{{ manufacturer.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Category *</label>
|
||||
<select id="category"
|
||||
name="category"
|
||||
required
|
||||
class="w-full mt-1 border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||
<option value="">Select a category</option>
|
||||
{% for code, name in categories %}
|
||||
<option value="{{ code }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<textarea id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
class="w-full mt-1 border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Enter a description of this ride model"></textarea>
|
||||
</div>
|
||||
|
||||
{% if not user.is_privileged %}
|
||||
<div>
|
||||
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reason for Addition *</label>
|
||||
<textarea id="reason"
|
||||
name="reason"
|
||||
rows="2"
|
||||
required
|
||||
class="w-full mt-1 border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Why are you adding this ride model?"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Source</label>
|
||||
<input type="text"
|
||||
id="source"
|
||||
name="source"
|
||||
class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="URL or reference for this information">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex justify-end mt-6 space-x-3">
|
||||
<button type="button"
|
||||
@click="$dispatch('close-modal')"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500">
|
||||
Create Ride Model
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
26
templates/rides/partials/designer_created.html
Normal file
26
templates/rides/partials/designer_created.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% if error %}
|
||||
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
|
||||
{% if designer.id %}
|
||||
Designer "{{ designer.name }}" has been created successfully.
|
||||
<script>
|
||||
// Update the designer field in the parent form
|
||||
selectDesigner('{{ designer.id }}', '{{ designer.name }}');
|
||||
// Close the modal
|
||||
document.dispatchEvent(new CustomEvent('close-designer-modal'));
|
||||
</script>
|
||||
{% else %}
|
||||
Your designer submission "{{ designer.name }}" has been sent for review.
|
||||
You will be notified when it is approved.
|
||||
<script>
|
||||
// Close the modal after a short delay
|
||||
setTimeout(() => {
|
||||
document.dispatchEvent(new CustomEvent('close-designer-modal'));
|
||||
}, 2000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
84
templates/rides/partials/designer_form.html
Normal file
84
templates/rides/partials/designer_form.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% load static %}
|
||||
|
||||
<form method="post"
|
||||
class="space-y-6"
|
||||
x-data="{ submitting: false }"
|
||||
@submit.prevent="
|
||||
if (!submitting) {
|
||||
submitting = true;
|
||||
const formData = new FormData($event.target);
|
||||
htmx.ajax('POST', '/rides/designers/create/', {
|
||||
values: Object.fromEntries(formData),
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.detail) {
|
||||
const data = JSON.parse(response.detail.xhr.response);
|
||||
selectDesigner(data.id, data.name);
|
||||
}
|
||||
$dispatch('close-designer-modal');
|
||||
}).finally(() => {
|
||||
submitting = false;
|
||||
});
|
||||
}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div id="designer-form-notification"></div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="designer_name" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="designer_name"
|
||||
value="{{ search_term|default:'' }}"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
required>
|
||||
</div>
|
||||
|
||||
{% if not user.is_privileged %}
|
||||
<!-- Reason and Source for non-privileged users -->
|
||||
<div>
|
||||
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Addition *
|
||||
</label>
|
||||
<textarea name="reason"
|
||||
id="reason"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Please explain why you're adding this designer and provide any relevant details."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source (Optional)
|
||||
</label>
|
||||
<input type="text"
|
||||
name="source"
|
||||
id="source"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="URL or reference for this information">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6 space-x-3">
|
||||
{% if modal %}
|
||||
<button type="button"
|
||||
@click="$dispatch('close-designer-modal')"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit"
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500 disabled:opacity-50">
|
||||
<span x-show="!submitting">Create Designer</span>
|
||||
<span x-show="submitting">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
27
templates/rides/partials/designer_search_results.html
Normal file
27
templates/rides/partials/designer_search_results.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if designers %}
|
||||
{% for designer in designers %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectDesigner('{{ designer.id }}', '{{ designer.name|escapejs }}')">
|
||||
{{ designer.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No matches found. You can still submit this name.
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectDesigner(id, name) {
|
||||
document.getElementById('id_designer').value = id;
|
||||
document.getElementById('id_designer_search').value = name;
|
||||
document.getElementById('designer-search-results').innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
26
templates/rides/partials/manufacturer_created.html
Normal file
26
templates/rides/partials/manufacturer_created.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% if error %}
|
||||
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
|
||||
{% if manufacturer.id %}
|
||||
Manufacturer "{{ manufacturer.name }}" has been created successfully.
|
||||
<script>
|
||||
// Update the manufacturer field in the parent form
|
||||
selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name }}');
|
||||
// Close the modal
|
||||
document.dispatchEvent(new CustomEvent('close-manufacturer-modal'));
|
||||
</script>
|
||||
{% else %}
|
||||
Your manufacturer submission "{{ manufacturer.name }}" has been sent for review.
|
||||
You will be notified when it is approved.
|
||||
<script>
|
||||
// Close the modal after a short delay
|
||||
setTimeout(() => {
|
||||
document.dispatchEvent(new CustomEvent('close-manufacturer-modal'));
|
||||
}, 2000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
84
templates/rides/partials/manufacturer_form.html
Normal file
84
templates/rides/partials/manufacturer_form.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% load static %}
|
||||
|
||||
<form method="post"
|
||||
class="space-y-6"
|
||||
x-data="{ submitting: false }"
|
||||
@submit.prevent="
|
||||
if (!submitting) {
|
||||
submitting = true;
|
||||
const formData = new FormData($event.target);
|
||||
htmx.ajax('POST', '/rides/manufacturers/create/', {
|
||||
values: Object.fromEntries(formData),
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.detail) {
|
||||
const data = JSON.parse(response.detail.xhr.response);
|
||||
selectManufacturer(data.id, data.name);
|
||||
}
|
||||
$dispatch('close-manufacturer-modal');
|
||||
}).finally(() => {
|
||||
submitting = false;
|
||||
});
|
||||
}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div id="manufacturer-form-notification"></div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="manufacturer_name" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="manufacturer_name"
|
||||
value="{{ search_term|default:'' }}"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
required>
|
||||
</div>
|
||||
|
||||
{% if not user.is_privileged %}
|
||||
<!-- Reason and Source for non-privileged users -->
|
||||
<div>
|
||||
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Addition *
|
||||
</label>
|
||||
<textarea name="reason"
|
||||
id="reason"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Please explain why you're adding this manufacturer and provide any relevant details."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source (Optional)
|
||||
</label>
|
||||
<input type="text"
|
||||
name="source"
|
||||
id="source"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="URL or reference for this information">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6 space-x-3">
|
||||
{% if modal %}
|
||||
<button type="button"
|
||||
@click="$dispatch('close-manufacturer-modal')"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit"
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500 disabled:opacity-50">
|
||||
<span x-show="!submitting">Create Manufacturer</span>
|
||||
<span x-show="submitting">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
33
templates/rides/partials/manufacturer_search_results.html
Normal file
33
templates/rides/partials/manufacturer_search_results.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if manufacturers %}
|
||||
{% for manufacturer in manufacturers %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectManufacturer('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}')">
|
||||
{{ manufacturer.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No matches found. You can still submit this name.
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectManufacturer(id, name) {
|
||||
document.getElementById('id_manufacturer').value = id;
|
||||
document.getElementById('id_manufacturer_search').value = name;
|
||||
document.getElementById('manufacturer-search-results').innerHTML = '';
|
||||
|
||||
// Update ride model search to include manufacturer
|
||||
const rideModelSearch = document.getElementById('id_ride_model_search');
|
||||
if (rideModelSearch) {
|
||||
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
291
templates/rides/partials/ride_form.html
Normal file
291
templates/rides/partials/ride_form.html
Normal file
@@ -0,0 +1,291 @@
|
||||
{% load static %}
|
||||
|
||||
<script>
|
||||
function selectManufacturer(id, name) {
|
||||
document.getElementById('id_manufacturer').value = id;
|
||||
document.getElementById('id_manufacturer_search').value = name;
|
||||
document.getElementById('manufacturer-search-results').innerHTML = '';
|
||||
|
||||
// Update ride model search to include manufacturer
|
||||
const rideModelSearch = document.getElementById('id_ride_model_search');
|
||||
if (rideModelSearch) {
|
||||
rideModelSearch.setAttribute('hx-include', '[name="manufacturer"]');
|
||||
}
|
||||
}
|
||||
|
||||
function selectDesigner(id, name) {
|
||||
document.getElementById('id_designer').value = id;
|
||||
document.getElementById('id_designer_search').value = name;
|
||||
document.getElementById('designer-search-results').innerHTML = '';
|
||||
}
|
||||
|
||||
function selectRideModel(id, name) {
|
||||
document.getElementById('id_ride_model').value = id;
|
||||
document.getElementById('id_ride_model_search').value = name;
|
||||
document.getElementById('ride-model-search-results').innerHTML = '';
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.addEventListener('submit', function(e) {
|
||||
if (e.target.id === 'ride-form') {
|
||||
// Clear search results
|
||||
document.getElementById('manufacturer-search-results').innerHTML = '';
|
||||
document.getElementById('designer-search-results').innerHTML = '';
|
||||
document.getElementById('ride-model-search-results').innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle clicks outside search results
|
||||
document.addEventListener('click', function(e) {
|
||||
const manufacturerResults = document.getElementById('manufacturer-search-results');
|
||||
const designerResults = document.getElementById('designer-search-results');
|
||||
const rideModelResults = document.getElementById('ride-model-search-results');
|
||||
|
||||
if (!e.target.closest('#manufacturer-search-container')) {
|
||||
manufacturerResults.innerHTML = '';
|
||||
}
|
||||
if (!e.target.closest('#designer-search-container')) {
|
||||
designerResults.innerHTML = '';
|
||||
}
|
||||
if (!e.target.closest('#ride-model-search-container')) {
|
||||
rideModelResults.innerHTML = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<form method="post" id="ride-form" class="space-y-6" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Park Area -->
|
||||
{% if form.park_area %}
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.park_area.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Park Area
|
||||
</label>
|
||||
{{ form.park_area }}
|
||||
{% if form.park_area.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.park_area.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Name -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.name.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Manufacturer -->
|
||||
<div class="space-y-2">
|
||||
<div id="manufacturer-search-container" class="relative">
|
||||
<label for="{{ form.manufacturer_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Manufacturer
|
||||
</label>
|
||||
{{ form.manufacturer_search }}
|
||||
{{ form.manufacturer }}
|
||||
<div id="manufacturer-search-results" class="relative"></div>
|
||||
{% if form.manufacturer.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.manufacturer.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Designer -->
|
||||
<div class="space-y-2">
|
||||
<div id="designer-search-container" class="relative">
|
||||
<label for="{{ form.designer_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Designer
|
||||
</label>
|
||||
{{ form.designer_search }}
|
||||
{{ form.designer }}
|
||||
<div id="designer-search-results" class="relative"></div>
|
||||
{% if form.designer.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.designer.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride Model -->
|
||||
<div class="space-y-2">
|
||||
<div id="ride-model-search-container" class="relative">
|
||||
<label for="{{ form.ride_model_search.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ride Model
|
||||
</label>
|
||||
{{ form.ride_model_search }}
|
||||
{{ form.ride_model }}
|
||||
<div id="ride-model-search-results" class="relative"></div>
|
||||
{% if form.ride_model.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.ride_model.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Name -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.model_name.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Model Name
|
||||
</label>
|
||||
{{ form.model_name }}
|
||||
{% if form.model_name.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.model_name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.category.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Category *
|
||||
</label>
|
||||
{{ form.category }}
|
||||
{% if form.category.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.category.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Coaster Fields -->
|
||||
<div id="coaster-fields"></div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.status.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
{{ form.status }}
|
||||
{% if form.status.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.status.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Opening Date -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.opening_date.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Opening Date
|
||||
</label>
|
||||
{{ form.opening_date }}
|
||||
{% if form.opening_date.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.opening_date.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Closing Date -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.closing_date.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Closing Date
|
||||
</label>
|
||||
{{ form.closing_date }}
|
||||
{% if form.closing_date.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.closing_date.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Status Since -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.status_since.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status Since
|
||||
</label>
|
||||
{{ form.status_since }}
|
||||
{% if form.status_since.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.status_since.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Min Height -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.min_height_in.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Minimum Height (inches)
|
||||
</label>
|
||||
{{ form.min_height_in }}
|
||||
{% if form.min_height_in.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.min_height_in.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Max Height -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.max_height_in.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Maximum Height (inches)
|
||||
</label>
|
||||
{{ form.max_height_in }}
|
||||
{% if form.max_height_in.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.max_height_in.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Capacity -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.capacity_per_hour.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Hourly Capacity
|
||||
</label>
|
||||
{{ form.capacity_per_hour }}
|
||||
{% if form.capacity_per_hour.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.capacity_per_hour.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ride Duration -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.ride_duration_seconds.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ride Duration (seconds)
|
||||
</label>
|
||||
{{ form.ride_duration_seconds }}
|
||||
{% if form.ride_duration_seconds.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.ride_duration_seconds.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<label for="{{ form.description.id_for_label }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
{{ form.description }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.description.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||
{% if is_edit %}Save Changes{% else %}Add Ride{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
44
templates/rides/partials/ride_model_created.html
Normal file
44
templates/rides/partials/ride_model_created.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% if error %}
|
||||
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||
<span class="font-medium">Error:</span> {{ error }}
|
||||
</div>
|
||||
{% elif ride_model.id %}
|
||||
<div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
|
||||
<span class="font-medium">Success!</span> Ride model "{{ ride_model.name }}" has been created.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update the ride model field in the parent form
|
||||
const rideModelId = '{{ ride_model.id }}';
|
||||
const rideModelName = '{{ ride_model.name|escapejs }}';
|
||||
|
||||
document.getElementById('id_ride_model').value = rideModelId;
|
||||
document.getElementById('id_ride_model_search').value = rideModelName;
|
||||
|
||||
// Close the modal after a short delay to allow the user to see the success message
|
||||
setTimeout(() => {
|
||||
document.dispatchEvent(new CustomEvent('close-ride-model-modal'));
|
||||
|
||||
// Clear the notification after the modal closes
|
||||
setTimeout(() => {
|
||||
document.getElementById('ride-model-notification').innerHTML = '';
|
||||
}, 300);
|
||||
}, 1000);
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="p-4 mb-4 text-sm text-yellow-700 bg-yellow-100 rounded-lg dark:bg-yellow-200 dark:text-yellow-800" role="alert">
|
||||
<span class="font-medium">Note:</span> Your submission has been sent for review. You will be notified when it is approved.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Close the modal after a short delay to allow the user to see the message
|
||||
setTimeout(() => {
|
||||
document.dispatchEvent(new CustomEvent('close-ride-model-modal'));
|
||||
|
||||
// Clear the notification after the modal closes
|
||||
setTimeout(() => {
|
||||
document.getElementById('ride-model-notification').innerHTML = '';
|
||||
}, 300);
|
||||
}, 2000);
|
||||
</script>
|
||||
{% endif %}
|
||||
215
templates/rides/partials/ride_model_form.html
Normal file
215
templates/rides/partials/ride_model_form.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% load static %}
|
||||
|
||||
<form method="post"
|
||||
class="space-y-6"
|
||||
x-data="{
|
||||
submitting: false,
|
||||
manufacturerSearchTerm: '',
|
||||
setManufacturerModal(value, term = '') {
|
||||
const parentForm = document.querySelector('[x-data]');
|
||||
if (parentForm) {
|
||||
const parentData = Alpine.$data(parentForm);
|
||||
if (parentData && parentData.setManufacturerModal) {
|
||||
parentData.setManufacturerModal(value, term);
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
@submit.prevent="
|
||||
if (!submitting) {
|
||||
submitting = true;
|
||||
const formData = new FormData($event.target);
|
||||
htmx.ajax('POST', '/rides/models/create/', {
|
||||
values: Object.fromEntries(formData),
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.detail) {
|
||||
const data = JSON.parse(response.detail.xhr.response);
|
||||
selectRideModel(data.id, data.name);
|
||||
}
|
||||
const parentForm = document.querySelector('[x-data]');
|
||||
if (parentForm) {
|
||||
const parentData = Alpine.$data(parentForm);
|
||||
if (parentData && parentData.setRideModelModal) {
|
||||
parentData.setRideModelModal(false);
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
submitting = false;
|
||||
});
|
||||
}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div id="ride-model-notification"></div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ form.name.label }}{% if form.name.field.required %} *{% endif %}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
id="{{ form.name.id_for_label }}"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- Manufacturer Search -->
|
||||
<div>
|
||||
<label for="{{ form.manufacturer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ form.manufacturer_search.label }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div id="manufacturer-notification" class="mb-2"></div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-grow">
|
||||
<input type="text"
|
||||
id="{{ form.manufacturer_search.id_for_label }}"
|
||||
name="manufacturer_search"
|
||||
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="/rides/search/manufacturers/"
|
||||
hx-trigger="click, input changed delay:200ms"
|
||||
hx-target="#manufacturer-search-results"
|
||||
autocomplete="off"
|
||||
@input="manufacturerSearchTerm = $event.target.value"
|
||||
{% if prefilled_manufacturer %}
|
||||
value="{{ prefilled_manufacturer.name }}"
|
||||
readonly
|
||||
{% endif %}>
|
||||
</div>
|
||||
{% if not prefilled_manufacturer and not create_ride_model %}
|
||||
<button type="button"
|
||||
class="px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
@click.prevent="setManufacturerModal(true, manufacturerSearchTerm)">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="manufacturer-search-results" class="absolute z-50 w-full"></div>
|
||||
</div>
|
||||
<input type="hidden"
|
||||
name="manufacturer"
|
||||
id="{{ form.manufacturer.id_for_label }}"
|
||||
{% if prefilled_manufacturer %}
|
||||
value="{{ prefilled_manufacturer.id }}"
|
||||
{% endif %}>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="{{ form.category.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ form.category.label }}{% if form.category.field.required %} *{% endif %}
|
||||
</label>
|
||||
<select name="category"
|
||||
id="{{ form.category.id_for_label }}"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
required>
|
||||
{% for value, label in form.category.field.choices %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ form.description.label }}{% if form.description.field.required %} *{% endif %}
|
||||
</label>
|
||||
<textarea name="description"
|
||||
id="{{ form.description.id_for_label }}"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
{% if not user.is_privileged %}
|
||||
<!-- Reason and Source for non-privileged users -->
|
||||
<div>
|
||||
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Addition *
|
||||
</label>
|
||||
<textarea name="reason"
|
||||
id="reason"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Please explain why you're adding this ride model and provide any relevant details."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source (Optional)
|
||||
</label>
|
||||
<input type="text"
|
||||
name="source"
|
||||
id="source"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="URL or reference for this information">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6 space-x-3">
|
||||
{% if modal %}
|
||||
<button type="button"
|
||||
@click="$dispatch('close-ride-model-modal')"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit"
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 dark:hover:bg-blue-500 disabled:opacity-50">
|
||||
<span x-show="!submitting">Create Ride Model</span>
|
||||
<span x-show="submitting">Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function selectManufacturer(manufacturerId, manufacturerName) {
|
||||
// Update the hidden manufacturer field
|
||||
document.getElementById('id_manufacturer').value = manufacturerId;
|
||||
// Update the search input with the manufacturer name
|
||||
document.getElementById('id_manufacturer_search').value = manufacturerName;
|
||||
// Clear the search results
|
||||
document.getElementById('manufacturer-search-results').innerHTML = '';
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
// Get the parent form element that contains the Alpine.js data
|
||||
const formElement = event.target.closest('form[x-data]');
|
||||
if (!formElement) return;
|
||||
|
||||
// Get Alpine.js data from the form
|
||||
const formData = formElement.__x.$data;
|
||||
|
||||
// Don't handle clicks if manufacturer modal is open
|
||||
if (formData.showManufacturerModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = [
|
||||
{ input: 'id_manufacturer_search', results: 'manufacturer-search-results' }
|
||||
];
|
||||
|
||||
searchResults.forEach(function(item) {
|
||||
const input = document.getElementById(item.input);
|
||||
const results = document.getElementById(item.results);
|
||||
if (results && !results.contains(event.target) && event.target !== input) {
|
||||
results.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize form with any pre-filled values
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('id_ride_model_search');
|
||||
if (searchInput && searchInput.value) {
|
||||
document.getElementById('id_name').value = searchInput.value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
38
templates/rides/partials/ride_model_search_results.html
Normal file
38
templates/rides/partials/ride_model_search_results.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<div class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if not manufacturer_id %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
Please select a manufacturer first
|
||||
</div>
|
||||
{% else %}
|
||||
{% if ride_models %}
|
||||
{% for ride_model in ride_models %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectRideModel('{{ ride_model.id }}', '{{ ride_model.name|escapejs }}')">
|
||||
{{ ride_model.name }}
|
||||
{% if ride_model.manufacturer %}
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
by {{ ride_model.manufacturer.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No matches found. You can still submit this name.
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectRideModel(id, name) {
|
||||
document.getElementById('id_ride_model').value = id;
|
||||
document.getElementById('id_ride_model_search').value = name;
|
||||
document.getElementById('ride-model-search-results').innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- Action Buttons - Above header -->
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex justify-end gap-2 mb-2">
|
||||
<a href="{% url 'parks:rides:ride_edit' park_slug=ride.park.slug ride_slug=ride.slug %}"
|
||||
<a href="{% url 'parks:rides:ride_update' park_slug=ride.park.slug ride_slug=ride.slug %}"
|
||||
class="transition-transform btn-secondary hover:scale-105">
|
||||
<i class="mr-1 fas fa-pencil-alt"></i>Edit
|
||||
</a>
|
||||
@@ -84,11 +84,7 @@
|
||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Length</dt>
|
||||
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">{{ coaster_stats.length_ft }} ft</dd>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Length</dt>
|
||||
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">N/A</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -101,6 +97,10 @@
|
||||
<dd>
|
||||
<a href="{% url 'companies:manufacturer_detail' ride.manufacturer.slug %}"
|
||||
class="text-xs text-blue-600 sm:text-sm lg:text-base hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ ride.manufacturer.name }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.designer %}
|
||||
@@ -436,24 +436,22 @@
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
|
||||
<div class="space-y-4">
|
||||
{% for record in history %}
|
||||
{% for record in ride.get_history %}
|
||||
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ record.history_date|date:"M d, Y H:i" }}
|
||||
{% if record.history_user %}
|
||||
by {{ record.history_user.username }}
|
||||
{% if record.history_user_display %}
|
||||
by {{ record.history_user_display }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% for field, changes in record.diff_against_previous.items %}
|
||||
{% if field != "updated_at" %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ changes.old }}</span>
|
||||
<span class="mx-1">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ changes.new }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +483,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/photo-gallery.js' %}"></script>
|
||||
|
||||
@@ -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 %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Ride Form -->
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{% if is_edit %}Edit {{ ride.name }}{% else %}Add Ride{% endif %} at {{ park.name }}</h1>
|
||||
{% if is_edit %}
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
Back to {{ ride.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
Back to {{ park.name }} Rides
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="container px-4 py-8 mx-auto">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="mb-6 text-3xl font-bold">
|
||||
{% if is_edit %}Edit {{ object.name }}{% else %}Add New Ride{% endif %}
|
||||
{% if park %}
|
||||
<div class="mt-2 text-lg font-normal text-gray-600 dark:text-gray-400">
|
||||
at <a href="{% url 'parks:park_detail' park.slug %}" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">{{ park.name }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6" x-data="{
|
||||
status: '{{ form.instance.status|default:'OPERATING' }}',
|
||||
clearResults(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (container && !container.contains(event.target)) {
|
||||
container.querySelector('[id$=search-results]').innerHTML = '';
|
||||
}
|
||||
},
|
||||
handleStatusChange(event) {
|
||||
this.status = event.target.value;
|
||||
if (this.status === 'CLOSING') {
|
||||
document.getElementById('id_closing_date').required = true;
|
||||
} else {
|
||||
document.getElementById('id_closing_date').required = false;
|
||||
}
|
||||
},
|
||||
showClosingDate() {
|
||||
return ['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(this.status);
|
||||
}
|
||||
}">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if not park %}
|
||||
{# Park Selection - Only shown when creating from global view #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Park Information</h2>
|
||||
<div class="space-y-4">
|
||||
<div id="park-search-container" class="relative" @click.outside="clearResults('park-search-container')">
|
||||
<label for="{{ form.park_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Park *
|
||||
</label>
|
||||
{{ form.park_search }}
|
||||
{{ form.park }}
|
||||
<div id="park-search-results" class="relative"></div>
|
||||
{% if form.park.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.park.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Basic Information #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="col-span-2">
|
||||
<label for="{{ form.name.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.name.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label for="{{ form.category.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Category *
|
||||
</label>
|
||||
{{ form.category }}
|
||||
{% if form.category.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ form.category.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="coaster-fields" class="col-span-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
{% if field.name != 'park' %}
|
||||
<div>
|
||||
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'date' or field.field.widget.input_type == 'number' %}
|
||||
<input type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
value="{{ field.value|default:'' }}"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
{% if field.field.required %}required{% endif %}>
|
||||
{% elif field.field.widget.input_type == 'select' %}
|
||||
<select name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
{% if field.field.required %}required{% endif %}>
|
||||
{% for value, label in field.field.choices %}
|
||||
<option value="{{ value }}" {% if field.value == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field.field.widget.input_type == 'textarea' %}
|
||||
<textarea name="{{ field.name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="4"
|
||||
{% if field.field.required %}required{% endif %}>{{ field.value|default:'' }}</textarea>
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Manufacturer and Model #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Manufacturer and Model</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div id="manufacturer-search-container" class="relative" @click.outside="clearResults('manufacturer-search-container')">
|
||||
<label for="{{ form.manufacturer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Manufacturer
|
||||
</label>
|
||||
{{ form.manufacturer_search }}
|
||||
{{ form.manufacturer }}
|
||||
<div id="manufacturer-search-results" class="relative"></div>
|
||||
</div>
|
||||
|
||||
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
|
||||
<div id="designer-search-container" class="relative" @click.outside="clearResults('designer-search-container')">
|
||||
<label for="{{ form.designer_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Designer
|
||||
</label>
|
||||
{{ form.designer_search }}
|
||||
{{ form.designer }}
|
||||
<div id="designer-search-results" class="relative"></div>
|
||||
</div>
|
||||
|
||||
<div id="ride-model-search-container" class="relative col-span-2" @click.outside="clearResults('ride-model-search-container')">
|
||||
<label for="{{ form.ride_model_search.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ride Model
|
||||
</label>
|
||||
{{ form.ride_model_search }}
|
||||
{{ form.ride_model }}
|
||||
<div id="ride-model-search-results" class="relative"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Status and Dates #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Status and Dates</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="{{ form.status.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
{{ form.status }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.status_since.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status Since
|
||||
</label>
|
||||
{{ form.status_since }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.opening_date.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Opening Date
|
||||
</label>
|
||||
{{ form.opening_date }}
|
||||
</div>
|
||||
|
||||
<div x-show="showClosingDate()">
|
||||
<label for="{{ form.closing_date.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Closing Date <span x-show="status === 'CLOSING'" class="text-red-600">*</span>
|
||||
</label>
|
||||
{{ form.closing_date }}
|
||||
</div>
|
||||
|
||||
<div x-show="status === 'CLOSING'" class="col-span-2">
|
||||
<label for="{{ form.post_closing_status.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status After Closing *
|
||||
</label>
|
||||
{{ form.post_closing_status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Specifications #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Specifications</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="{{ form.min_height_in.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Minimum Height (inches)
|
||||
</label>
|
||||
{{ form.min_height_in }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.max_height_in.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Maximum Height (inches)
|
||||
</label>
|
||||
{{ form.max_height_in }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.capacity_per_hour.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Hourly Capacity
|
||||
</label>
|
||||
{{ form.capacity_per_hour }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ form.ride_duration_seconds.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ride Duration (seconds)
|
||||
</label>
|
||||
{{ form.ride_duration_seconds }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Description</h2>
|
||||
<div>
|
||||
<label for="{{ form.description.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Submission Details #}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold">Submission Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for {% if is_edit %}Edit{% else %}Addition{% endif %}
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason for Changes
|
||||
</label>
|
||||
<textarea name="reason"
|
||||
id="reason"
|
||||
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Please explain why you're {% if is_edit %}editing{% else %}adding{% endif %} this ride and provide any relevant details."></textarea>
|
||||
<textarea name="reason" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Explain why you're making these changes"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source (Optional)
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Source
|
||||
</label>
|
||||
<input type="text"
|
||||
name="source"
|
||||
id="source"
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Link to official website, news article, or other source">
|
||||
<input type="text" name="source"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Where did you get this information?">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<a href="{% if is_edit %}{% url 'parks:rides:ride_detail' park.slug object.slug %}{% else %}{% url 'parks:rides:ride_list' park.slug %}{% endif %}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||
{% if is_edit %}Save Changes{% else %}Submit{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Photos Section (only shown on edit) -->
|
||||
{% if is_edit %}
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800" id="photos">
|
||||
{% include "media/partials/photo_manager.html" with photos=object.photos.all content_type="rides.ride" object_id=object.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Submit Button #}
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800">
|
||||
{% if is_edit %}Save Changes{% else %}Add Ride{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,10 +23,22 @@
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">All Rides</h1>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if park and user.is_authenticated %}
|
||||
<a href="{% url 'parks:rides:ride_create' park.slug %}" class="btn-primary">
|
||||
<i class="mr-2 fas fa-plus"></i>Add Ride
|
||||
</a>
|
||||
{% if user.is_authenticated %}
|
||||
{% if park %}
|
||||
<a href="{% url 'parks:rides:ride_create' park.slug %}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add Ride
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'rides:ride_create' %}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add Ride
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user