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:
pacnpal
2024-11-13 14:38:38 +00:00
parent ee58e3e2e6
commit 2279e19cfd
98 changed files with 5073 additions and 3040 deletions

View File

@@ -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(

View File

@@ -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:

View File

@@ -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

View File

@@ -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)),
],
options={
'ordering': ['name'],
},
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
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)),
("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={
'verbose_name': 'historical manufacturer',
'verbose_name_plural': 'historical manufacturers',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
"ordering": ["name"],
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View 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'],
},
),
]

View File

@@ -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),
),
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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()

View File

@@ -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"
),
],
},
),
]

View File

@@ -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

View File

@@ -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",
},
),
]

View File

@@ -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"),
},
bases=(
history_tracking.mixins.HistoricalChangeMixin,
simple_history.models.HistoricalChanges,
models.Model,
"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")},
},
),
]

View File

@@ -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",
),
]

View File

@@ -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")},
},
),
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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),
],
),
),
]

View File

@@ -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),
),
]

View File

@@ -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",
),
]

View File

@@ -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),
]

View File

@@ -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,
),
),
]

View File

@@ -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",
)
],
},
),
]

View File

@@ -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,
),
),
]

View File

@@ -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),
]

View File

@@ -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
]

View File

@@ -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,
),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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"],

View File

@@ -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)"
),
),
]

View File

@@ -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',
),
),
]

View File

@@ -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"]},
),
]

View 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'))

View 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'))

View File

@@ -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")},
},
),
]

View File

@@ -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),
),
]

View File

@@ -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)',
),
),
]

View File

@@ -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,
],
),
),
]

View File

@@ -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
),
]

View File

@@ -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")),
],
),
),
]

View File

@@ -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",
),
]

View File

@@ -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,
),
),
]

View File

@@ -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),
]

View File

@@ -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",
),
]

View File

@@ -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)

View File

@@ -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")),
]

View File

@@ -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'
)
park = cast(Park, self.object)
context["areas"] = park.areas.all()
context["rides"] = park.rides.all().order_by("-status", "name")
# Check if the user has reviewed the park
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}

View File

@@ -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")},
),
]

View File

View 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()

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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",
),
),
]

View File

@@ -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,
),
]

View File

@@ -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,
),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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,
),
),
]

View 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",
),
]

View File

@@ -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",
),
]

View 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",
),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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",
),
]

View File

@@ -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
class Ride(HistoricalModel):
CATEGORY_CHOICES = [
# 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):
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,

View File

@@ -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

View File

@@ -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"),
]

View File

@@ -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
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},
)
class RideUpdateView(LoginRequiredMixin, UpdateView):
# 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 RideDetailView(DetailView):
"""View for displaying ride details"""
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: RideForm) -> HttpResponseRedirect:
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},
)
class RideDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView,
):
model = Ride
template_name = "rides/ride_detail.html"
context_object_name = "ride"
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_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
if self.object.category == "RC":
context["coaster_stats"] = RollerCoasterStats.objects.filter(
ride=self.object
).first()
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 RideListView(ListView):
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_detail.html'
slug_url_kwarg = 'ride_slug'
def get_queryset(self):
queryset = Ride.objects.select_related(
"park", "coaster_stats", "manufacturer"
).prefetch_related("photos")
"""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')
if self.park:
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)
if 'park_slug' in self.kwargs:
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
return queryset
def get_context_data(self, **kwargs) -> Dict[str, Any]:
def get_context_data(self, **kwargs):
"""Add park_slug to context if it exists"""
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 'park_slug' in self.kwargs:
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 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 get_queryset(self):
"""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 '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 to context if park_slug is provided"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
return context
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},
)

View File

@@ -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));

View 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>

View 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>

View File

@@ -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 %}

View File

@@ -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 }}

View File

@@ -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">

View File

@@ -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 -->

View 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 %}

View 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 %}

View 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>

View 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">&laquo; 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 &raquo;</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View 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>

View 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>

View 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>

View 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 %}

View 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>

View 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>

View 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 %}

View 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>

View 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>

View 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>

View 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 %}

View 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>

View 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>

View File

@@ -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 %}
{% endfor %}
</div>
</div>
@@ -485,7 +483,7 @@
</div>
</div>
{% endif %}
{% endblock %}
{% endblock content %}
{% block extra_js %}
<script src="{% static 'js/photo-gallery.js' %}"></script>

View File

@@ -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" class="space-y-6">
<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 %}
{% 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 }}
{% 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>
{% 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 %}
{{ 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">
{{ field.errors }}
{{ form.park.errors }}
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
{# 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>
{# 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>
<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>
</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 %}
{# 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>
<!-- 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 %}
</div>
</div>
{% endblock %}

View File

@@ -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
{% 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>

View File

@@ -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"