Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
cbe1dd726f [DEPENDABOT] Update: Bump django-allauth from 65.2.0 to 65.3.0
Bumps [django-allauth](https://github.com/sponsors/pennersr) from 65.2.0 to 65.3.0.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 11:44:28 +00:00
293 changed files with 2947 additions and 27430 deletions

View File

@@ -1,30 +0,0 @@
# Project Startup Rules
## Development Server
IMPORTANT: Always follow these instructions exactly when starting the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
## Package Management
IMPORTANT: When a Python package is needed, only use UV to add it:
```bash
uv add <package>
```
Do not attempt to install packages using any other method.
## Django Management Commands
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
```bash
uv run manage.py <command>
```
This applies to all management commands including but not limited to:
- Making migrations: `uv run manage.py makemigrations`
- Applying migrations: `uv run manage.py migrate`
- Creating superuser: `uv run manage.py createsuperuser`
- Starting shell: `uv run manage.py shell`
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.

View File

@@ -2,40 +2,29 @@ name: Django CI
on: on:
push: push:
branches: [ main ] branches: [ "main" ]
pull_request: pull_request:
branches: [ main ] branches: [ "main" ]
jobs: jobs:
test: build:
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
strategy: strategy:
max-parallel: 4
matrix: matrix:
os: [ubuntu-latest, macos-latest] python-version: [3.12]
python-version: [3.13.1]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Homebrew on Linux
if: runner.os == 'Linux'
run: |
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
- name: Install GDAL with Homebrew
run: brew install gdal
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v3
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
- name: Run Tests - name: Run Tests
run: | run: |
python manage.py test python manage.py test

View File

@@ -1,34 +0,0 @@
name: Claude Code Review
permissions:
contents: read
pull-requests: write
on:
# Run on new/updated PRs
pull_request:
types: [opened, reopened, synchronize]
# Allow manual triggers for existing PRs
workflow_dispatch:
inputs:
pr_number:
description: 'Pull Request Number'
required: true
type: string
jobs:
code-review:
runs-on: ubuntu-latest
environment: development_environment
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Review
uses: pacnpal/claude-code-review@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
ThrillWiki.com

View File

@@ -1,67 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from reviews.models import Review
from parks.models import Park
from rides.models import Ride
from media.models import Photo
User = get_user_model()
class Command(BaseCommand):
help = "Cleans up test users and data created during e2e testing"
def handle(self, *args, **kwargs):
# Delete test users
test_users = User.objects.filter(username__in=["testuser", "moderator"])
count = test_users.count()
test_users.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
# Delete test reviews
reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
count = reviews.count()
reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
count = photos.count()
photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
# Delete test parks
parks = Park.objects.filter(name__startswith="Test Park")
count = parks.count()
parks.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test parks"))
# Delete test rides
rides = Ride.objects.filter(name__startswith="Test Ride")
count = rides.count()
rides.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
# Clean up test files
import os
import glob
# Clean up test uploads
media_patterns = [
"media/uploads/test_*",
"media/avatars/test_*",
"media/park/test_*",
"media/rides/test_*",
]
for pattern in media_patterns:
files = glob.glob(pattern)
for f in files:
try:
os.remove(f)
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
except OSError as e:
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))

View File

@@ -1,56 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
User = get_user_model()
class Command(BaseCommand):
help = "Creates test users for e2e testing"
def handle(self, *args, **kwargs):
# Create regular test user
if not User.objects.filter(username="testuser").exists():
user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
[PASSWORD-REMOVED]",
)
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
else:
self.stdout.write(self.style.WARNING("Test user already exists"))
# Create moderator user
if not User.objects.filter(username="moderator").exists():
moderator = User.objects.create_user(
username="moderator",
email="moderator@example.com",
[PASSWORD-REMOVED]",
)
# Create moderator group if it doesn't exist
moderator_group, created = Group.objects.get_or_create(name="Moderators")
# Add relevant permissions
permissions = Permission.objects.filter(
codename__in=[
"change_review",
"delete_review",
"change_park",
"change_ride",
"moderate_photos",
"moderate_comments",
]
)
moderator_group.permissions.add(*permissions)
# Add user to moderator group
moderator.groups.add(moderator_group)
self.stdout.write(
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
)
else:
self.stdout.write(self.style.WARNING("Moderator user already exists"))
self.stdout.write(self.style.SUCCESS("Test users setup complete"))

View File

@@ -22,7 +22,7 @@ class Command(BaseCommand):
self.stdout.write(f'- {site.domain} ({site.name})') self.stdout.write(f'- {site.domain} ({site.name})')
# Show callback URL # Show callback URL
callback_url = 'http://localhost:8000/accounts/discord/login/callback/' callback_url = f'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:') self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
self.stdout.write(callback_url) self.stdout.write(callback_url)

View File

@@ -1,11 +1,9 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -17,7 +15,6 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("auth", "0012_alter_user_first_name_max_length"), ("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"), ("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
] ]
operations = [ operations = [
@@ -232,7 +229,15 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="TopList", name="TopList",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)), ("title", models.CharField(max_length=100)),
( (
"category", "category",
@@ -263,145 +268,6 @@ class Migration(migrations.Migration):
"ordering": ["-updated_at"], "ordering": ["-updated_at"],
}, },
), ),
migrations.CreateModel(
name="TopListEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("title", models.CharField(max_length=100)),
(
"category",
models.CharField(
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
max_length=2,
),
),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplist",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TopListItem",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"top_list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="accounts.toplist",
),
),
],
options={
"ordering": ["rank"],
},
),
migrations.CreateModel(
name="TopListItemEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplistitem",
),
),
(
"top_list",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="accounts.toplist",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel( migrations.CreateModel(
name="UserProfile", name="UserProfile",
fields=[ fields=[
@@ -452,66 +318,40 @@ class Migration(migrations.Migration):
), ),
], ],
), ),
pgtrigger.migrations.AddTrigger( migrations.CreateModel(
model_name="toplist", name="TopListItem",
trigger=pgtrigger.compiler.Trigger( fields=[
name="insert_insert", (
sql=pgtrigger.compiler.UpsertTriggerSql( "id",
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;', models.BigAutoField(
hash="[AWS-SECRET-REMOVED]", auto_created=True,
operation="INSERT", primary_key=True,
pgid="pgtrigger_insert_insert_26546", serialize=False,
table="accounts_toplist", verbose_name="ID",
when="AFTER",
), ),
), ),
), ("object_id", models.PositiveIntegerField()),
pgtrigger.migrations.AddTrigger( ("rank", models.PositiveIntegerField()),
model_name="toplist", ("notes", models.TextField(blank=True)),
trigger=pgtrigger.compiler.Trigger( (
name="update_update", "content_type",
sql=pgtrigger.compiler.UpsertTriggerSql( models.ForeignKey(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", on_delete=django.db.models.deletion.CASCADE,
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;', to="contenttypes.contenttype",
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_84849",
table="accounts_toplist",
when="AFTER",
), ),
), ),
), (
migrations.AlterUniqueTogether( "top_list",
name="toplistitem", models.ForeignKey(
unique_together={("top_list", "rank")}, on_delete=django.db.models.deletion.CASCADE,
), related_name="items",
pgtrigger.migrations.AddTrigger( to="accounts.toplist",
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
), ),
), ),
],
options={
"ordering": ["rank"],
"unique_together": {("top_list", "rank")},
},
), ),
] ]

View File

@@ -1,93 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.AddField(
model_name="toplistitem",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitem",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="toplistitemevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitemevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="toplist",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="toplistitem",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

@@ -26,7 +26,7 @@ class TurnstileMixin:
'remoteip': request.META.get('REMOTE_ADDR'), 'remoteip': request.META.get('REMOTE_ADDR'),
} }
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60) response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data)
result = response.json() result = response.json()
if not result.get('success'): if not result.get('success'):

View File

@@ -2,24 +2,22 @@ from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import random
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from io import BytesIO from io import BytesIO
import base64 import base64
import os import os
import secrets
from history_tracking.models import TrackedModel
import pghistory
def generate_random_id(model_class, id_field): def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed""" """Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True: while True:
# Try to get a 4-digit number first # Try to get a 4-digit number first
new_id = str(secrets.SystemRandom().randint(1000, 9999)) new_id = str(random.randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists(): if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id return new_id
# If all 4-digit numbers are taken, try 5 digits # If all 4-digit numbers are taken, try 5 digits
new_id = str(secrets.SystemRandom().randint(10000, 99999)) new_id = str(random.randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists(): if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id return new_id
@@ -160,8 +158,7 @@ class PasswordReset(models.Model):
verbose_name = "Password Reset" verbose_name = "Password Reset"
verbose_name_plural = "Password Resets" verbose_name_plural = "Password Resets"
@pghistory.track() class TopList(models.Model):
class TopList(TrackedModel):
class Categories(models.TextChoices): class Categories(models.TextChoices):
ROLLER_COASTER = 'RC', _('Roller Coaster') ROLLER_COASTER = 'RC', _('Roller Coaster')
DARK_RIDE = 'DR', _('Dark Ride') DARK_RIDE = 'DR', _('Dark Ride')
@@ -189,8 +186,7 @@ class TopList(TrackedModel):
def __str__(self): def __str__(self):
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}" return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
@pghistory.track() class TopListItem(models.Model):
class TopListItem(TrackedModel):
top_list = models.ForeignKey( top_list = models.ForeignKey(
TopList, TopList,
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@@ -31,7 +31,7 @@ def create_user_profile(sender, instance, created, **kwargs):
if avatar_url: if avatar_url:
try: try:
response = requests.get(avatar_url, timeout=60) response = requests.get(avatar_url)
if response.status_code == 200: if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True) img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content) img_temp.write(response.content)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@@ -1,208 +0,0 @@
# Frontend Architecture Documentation
Last Updated: 2024-02-21
## Core Technologies
### 1. HTMX
- Used for dynamic updates and server interactions
- Enables partial page updates without full reloads
- Integrated with Django backend for seamless data exchange
- Used for form submissions and dynamic content loading
### 2. AlpineJS
- Handles client-side interactivity and state management
- Used for dropdowns, modals, and other interactive components
- Provides reactive data binding and event handling
- Key features used:
- x-data for component state
- x-show/x-if for conditional rendering
- x-model for two-way data binding
- x-on for event handling
### 3. Tailwind CSS
- Utility-first CSS framework for styling
- Custom configuration in tailwind.config.js
- Responsive design utilities
- Dark mode support with class-based implementation
- Custom color scheme with primary/secondary colors
## Styling System
### 1. Base Styles
- Font: Poppins (400, 500, 600, 700 weights)
- Color Scheme:
- Primary: Indigo (#4F46E5)
- Secondary: Rose (#E11D48)
- Gradients for interactive elements
- Dark mode compatible color palette
### 2. Component Classes
- Button Variants:
- .btn-primary: Gradient background with hover effects
- .btn-secondary: Light/dark mode aware styling
- Social login buttons with brand colors
- Form Elements:
- .form-input: Styled input fields
- .form-label: Consistent label styling
- .form-error: Error message styling
- Cards:
- .card: Base card styling with shadows
- .auth-card: Special styling for authentication forms
- Status Badges:
- .status-operating: Green success state
- .status-closed: Red error state
- .status-construction: Yellow warning state
### 3. Layout Components
- Responsive container system
- Grid system using Tailwind's grid utilities
- Flexbox-based navigation and content layouts
- Mobile-first responsive design
## Interactive Components
### 1. Navigation
- Responsive header with mobile menu
- User dropdown menu with authentication states
- Theme toggle (light/dark mode)
- Mobile-optimized navigation drawer
### 2. Forms
- Location autocomplete system
- Form validation with error states
- CSRF protection integration
- File upload handling
### 3. Alerts System
- Timed auto-dismissing alerts
- Slide animations for entry/exit
- Context-aware styling (success, error, info, warning)
- Accessible notifications
### 4. Modal System
- HTMX-powered dynamic content loading
- Alpine.js state management
- Backdrop blur effects
- Keyboard navigation support
## JavaScript Architecture
### 1. Core Functionality
- Theme management with local storage persistence
- HTMX configuration and setup
- Alpine.js component initialization
- Event delegation and handling
### 2. Location Autocomplete
- Progressive enhancement for location fields
- Country/Region/City hierarchical selection
- Dynamic filtering based on parent selections
- AJAX-powered suggestions
### 3. Form Handling
- Client-side validation
- File upload preview
- Dynamic form updates
- Error state management
## Performance Optimizations
### 1. Asset Loading
- Deferred script loading
- Preloaded critical assets
- Minified production assets
- Cached static resources
### 2. Rendering
- Progressive enhancement
- Partial page updates
- Lazy loading of images
- Optimized animation performance
### 3. State Management
- Efficient DOM updates
- Debounced search inputs
- Throttled scroll handlers
- Memory leak prevention
## Accessibility Features
### 1. Semantic HTML
- Proper heading hierarchy
- ARIA labels and roles
- Semantic landmark regions
- Meaningful alt text
### 2. Keyboard Navigation
- Focus management
- Skip links
- Keyboard shortcuts
- Focus trapping in modals
### 3. Screen Readers
- ARIA live regions for alerts
- Status role for notifications
- Description text for icons
- Form label associations
## Development Workflow
### 1. CSS Organization
- Utility-first approach
- Component-specific styles
- Shared design tokens
- Dark mode variants
### 2. JavaScript Patterns
- Event delegation
- Component encapsulation
- State management
- Error handling
### 3. Testing Considerations
- Browser compatibility
- Responsive design testing
- Accessibility testing
- Performance monitoring
## Browser Support
### 1. Supported Browsers
- Chrome (latest 2 versions)
- Firefox (latest 2 versions)
- Safari (latest 2 versions)
- Edge (latest version)
### 2. Fallbacks
- Graceful degradation
- No-script support
- Legacy browser handling
- Progressive enhancement
## Security Measures
### 1. CSRF Protection
- Token validation
- Secure form submission
- Protected AJAX requests
- Session handling
### 2. XSS Prevention
- Content sanitization
- Escaped output
- Secure cookie handling
- Input validation
## Future Considerations
### 1. Potential Improvements
- Component library development
- Enhanced type checking
- Performance monitoring
- Automated testing
### 2. Maintenance
- Regular dependency updates
- Browser compatibility checks
- Performance optimization
- Security audits

View File

@@ -20,22 +20,22 @@
### Frontend Technologies ### Frontend Technologies
1. HTMX 1. HTMX
- Dynamic updates and server interactions - Dynamic updates
- Partial rendering and progressive enhancement - Partial rendering
- Server-side processing and form handling - Server-side processing
- See frontendArchitecture.md for detailed implementation - Progressive enhancement
2. AlpineJS 2. AlpineJS
- UI state management and reactivity - UI state management
- Component behavior and lifecycle - Component behavior
- Event handling and DOM manipulation - Event handling
- See frontendArchitecture.md for component patterns - DOM manipulation
3. Tailwind CSS 3. Tailwind CSS
- Utility-first styling with custom configuration - Utility-first styling
- Component design system - Component design
- Responsive layouts and dark mode support - Responsive layouts
- See frontendArchitecture.md for styling guide - Custom configuration
## Integration Patterns ## Integration Patterns
@@ -87,24 +87,16 @@
### Frontend Libraries ### Frontend Libraries
1. CSS Framework 1. CSS Framework
- Tailwind CSS with custom configuration - Tailwind CSS
- Theme system with light/dark mode support - Custom plugins
- Component-specific style patterns - Theme configuration
- See frontendArchitecture.md for complete styling guide - Utility classes
2. JavaScript 2. JavaScript
- AlpineJS for reactive components - AlpineJS core
- HTMX for server interactions - HTMX library
- Location autocomplete system - Utility functions
- Alert and modal components - Custom components
- See frontendArchitecture.md for component documentation
3. UI Components
- Form elements and validation
- Navigation and menus
- Status indicators and badges
- Modal and alert system
- See frontendArchitecture.md for implementation details
## Infrastructure Choices ## Infrastructure Choices
@@ -151,14 +143,10 @@
### Technology Limitations ### Technology Limitations
1. Frontend 1. Frontend
- HTMX/AlpineJS only (no React/Vue/Angular) - HTMX/AlpineJS only
- Progressive enhancement approach required - No additional frameworks
- Must support latest 2 versions of major browsers - Browser compatibility
- See frontendArchitecture.md for detailed browser support - Performance requirements
- Performance targets:
* First contentful paint < 1.5s
* Time to interactive < 2s
* Core Web Vitals compliance
2. Backend 2. Backend
- Django version constraints - Django version constraints

View File

@@ -1,15 +1,16 @@
from django.contrib import admin from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from .models import Company, Manufacturer from .models import Company, Manufacturer
@admin.register(Company) @admin.register(Company)
class CompanyAdmin(admin.ModelAdmin): class CompanyAdmin(SimpleHistoryAdmin):
list_display = ('id', 'name', 'headquarters', 'website', 'created_at') list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
search_fields = ('name', 'headquarters', 'description') search_fields = ('name', 'headquarters', 'description')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at') readonly_fields = ('created_at', 'updated_at')
@admin.register(Manufacturer) @admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin): class ManufacturerAdmin(SimpleHistoryAdmin):
list_display = ('id', 'name', 'headquarters', 'website', 'created_at') list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
search_fields = ('name', 'headquarters', 'description') search_fields = ('name', 'headquarters', 'description')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}

View File

@@ -1,8 +1,5 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models from django.db import migrations, models
@@ -10,15 +7,21 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
("pghistory", "0006_delete_aggregateevent"),
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="Company", name="Company",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)), ("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)), ("slug", models.SlugField(max_length=255, unique=True)),
("website", models.URLField(blank=True)), ("website", models.URLField(blank=True)),
@@ -34,31 +37,18 @@ class Migration(migrations.Migration):
"ordering": ["name"], "ordering": ["name"],
}, },
), ),
migrations.CreateModel(
name="CompanyEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)),
("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={
"abstract": False,
},
),
migrations.CreateModel( migrations.CreateModel(
name="Manufacturer", name="Manufacturer",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)), ("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)), ("slug", models.SlugField(max_length=255, unique=True)),
("website", models.URLField(blank=True)), ("website", models.URLField(blank=True)),
@@ -73,125 +63,4 @@ class Migration(migrations.Migration):
"ordering": ["name"], "ordering": ["name"],
}, },
), ),
migrations.CreateModel(
name="ManufacturerEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)),
("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={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "companies_companyevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_parks", "total_rides", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."total_parks", NEW."total_rides", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_a4101",
table="companies_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "companies_companyevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_parks", "total_rides", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."total_parks", NEW."total_rides", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_3d5ae",
table="companies_company",
when="AFTER",
),
),
),
migrations.AddField(
model_name="companyevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="companyevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="companies.company",
),
),
pgtrigger.migrations.AddTrigger(
model_name="manufacturer",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "companies_manufacturerevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_rides", "total_roller_coasters", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."total_rides", NEW."total_roller_coasters", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_5c0b6",
table="companies_manufacturer",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="manufacturer",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "companies_manufacturerevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_rides", "total_roller_coasters", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."total_rides", NEW."total_roller_coasters", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_81971",
table="companies_manufacturer",
when="AFTER",
),
),
),
migrations.AddField(
model_name="manufacturerevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="manufacturerevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="companies.manufacturer",
),
),
] ]

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,27 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="company",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="manufacturer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -2,11 +2,11 @@ from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from django.urls import reverse from django.urls import reverse
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
import pghistory
from history_tracking.models import TrackedModel, HistoricalSlug
@pghistory.track() if TYPE_CHECKING:
class Company(TrackedModel): from history_tracking.models import HistoricalSlug
class Company(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True) slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True) website = models.URLField(blank=True)
@@ -37,18 +37,8 @@ class Company(TrackedModel):
try: try:
return cls.objects.get(slug=slug), False return cls.objects.get(slug=slug), False
except cls.DoesNotExist: except cls.DoesNotExist:
# Check pghistory first # Check historical slugs
history_model = cls.get_history_model() from history_tracking.models import HistoricalSlug
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Check manual slug history as fallback
try: try:
historical = HistoricalSlug.objects.get( historical = HistoricalSlug.objects.get(
content_type__model='company', content_type__model='company',
@@ -58,8 +48,7 @@ class Company(TrackedModel):
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist): except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist() raise cls.DoesNotExist()
@pghistory.track() class Manufacturer(models.Model):
class Manufacturer(TrackedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True) slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True) website = models.URLField(blank=True)
@@ -89,18 +78,8 @@ class Manufacturer(TrackedModel):
try: try:
return cls.objects.get(slug=slug), False return cls.objects.get(slug=slug), False
except cls.DoesNotExist: except cls.DoesNotExist:
# Check pghistory first # Check historical slugs
history_model = cls.get_history_model() from history_tracking.models import HistoricalSlug
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Check manual slug history as fallback
try: try:
historical = HistoricalSlug.objects.get( historical = HistoricalSlug.objects.get(
content_type__model='manufacturer', content_type__model='manufacturer',
@@ -109,3 +88,43 @@ class Manufacturer(TrackedModel):
return cls.objects.get(pk=historical.object_id), True return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist): except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise 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

@@ -206,7 +206,7 @@ class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmis
def _handle_submission( def _handle_submission(
request: Any, form: Any, model: ModelType, success_url: str = "" request: Any, form: Any, model: ModelType, success_url: str
) -> HttpResponseRedirect: ) -> HttpResponseRedirect:
"""Helper method to handle form submissions""" """Helper method to handle form submissions"""
cleaned_data = form.cleaned_data.copy() cleaned_data = form.cleaned_data.copy()
@@ -214,7 +214,6 @@ def _handle_submission(
user=request.user, user=request.user,
content_type=ContentType.objects.get_for_model(model), content_type=ContentType.objects.get_for_model(model),
submission_type="CREATE", submission_type="CREATE",
status="NEW",
changes=cleaned_data, changes=cleaned_data,
reason=request.POST.get("reason", ""), reason=request.POST.get("reason", ""),
source=request.POST.get("source", ""), source=request.POST.get("source", ""),
@@ -230,12 +229,6 @@ def _handle_submission(
submission.status = "APPROVED" submission.status = "APPROVED"
submission.handled_by = request.user submission.handled_by = request.user
submission.save() submission.save()
# Generate success URL if not provided
if not success_url:
success_url = reverse(
f"companies:{model.__name__.lower()}_detail", kwargs={"slug": obj.slug}
)
messages.success(request, f'Successfully created {getattr(obj, "name", "")}') messages.success(request, f'Successfully created {getattr(obj, "name", "")}')
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@@ -251,7 +244,10 @@ class CompanyCreateView(LoginRequiredMixin, CreateView):
object: Optional[Company] object: Optional[Company]
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect: def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
return _handle_submission(self.request, form, self.model, "") success_url = reverse(
"companies:company_detail", kwargs={"slug": form.instance.slug}
)
return _handle_submission(self.request, form, self.model, success_url)
def get_success_url(self) -> str: def get_success_url(self) -> str:
if self.object is None: if self.object is None:
@@ -266,7 +262,10 @@ class ManufacturerCreateView(LoginRequiredMixin, CreateView):
object: Optional[Manufacturer] object: Optional[Manufacturer]
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect: def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
return _handle_submission(self.request, form, self.model, "") success_url = reverse(
"companies:manufacturer_detail", kwargs={"slug": form.instance.slug}
)
return _handle_submission(self.request, form, self.model, success_url)
def get_success_url(self) -> str: def get_success_url(self) -> str:
if self.object is None: if self.object is None:

View File

@@ -1,39 +0,0 @@
"""Core forms and form components."""
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from autocomplete import Autocomplete
class BaseAutocomplete(Autocomplete):
"""Base autocomplete class for consistent autocomplete behavior across the project.
This class extends django-htmx-autocomplete's base Autocomplete class to provide:
- Project-wide defaults for autocomplete behavior
- Translation strings
- Authentication enforcement
- Sensible search configuration
"""
# Search configuration
minimum_search_length = 2 # More responsive than default 3
max_results = 10 # Reasonable limit for performance
# UI text configuration using gettext for i18n
no_result_text = _("No matches found")
narrow_search_text = _("Showing %(page_size)s of %(total)s matches. Please refine your search.")
type_at_least_n_characters = _("Type at least %(n)s characters...")
# Project-wide component settings
placeholder = _("Search...")
@staticmethod
def auth_check(request):
"""Enforce authentication by default.
This can be overridden in subclasses if public access is needed.
Configure AUTOCOMPLETE_BLOCK_UNAUTHENTICATED in settings to disable.
"""
block_unauth = getattr(settings, 'AUTOCOMPLETE_BLOCK_UNAUTHENTICATED', True)
if block_unauth and not request.user.is_authenticated:
raise PermissionDenied(_("Authentication required"))

View File

@@ -1,27 +0,0 @@
import pghistory
from django.contrib.auth.models import AnonymousUser
from django.core.handlers.wsgi import WSGIRequest
class RequestContextProvider(pghistory.context):
"""Custom context provider for pghistory that extracts information from the request."""
def __call__(self, request: WSGIRequest) -> dict:
return {
'user': str(request.user) if request.user and not isinstance(request.user, AnonymousUser) else None,
'ip': request.META.get('REMOTE_ADDR'),
'user_agent': request.META.get('HTTP_USER_AGENT'),
'session_key': request.session.session_key if hasattr(request, 'session') else None
}
# Initialize the context provider
request_context = RequestContextProvider()
class PgHistoryContextMiddleware:
"""
Middleware that ensures request object is available to pghistory context.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@@ -2,7 +2,6 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify from django.utils.text import slugify
from history_tracking.models import TrackedModel
class SlugHistory(models.Model): class SlugHistory(models.Model):
""" """
@@ -27,7 +26,7 @@ class SlugHistory(models.Model):
def __str__(self): def __str__(self):
return f"Old slug '{self.old_slug}' for {self.content_object}" return f"Old slug '{self.old_slug}' for {self.content_object}"
class SluggedModel(TrackedModel): class SluggedModel(models.Model):
""" """
Abstract base model that provides slug functionality with history tracking. Abstract base model that provides slug functionality with history tracking.
""" """
@@ -77,18 +76,7 @@ class SluggedModel(TrackedModel):
# Try to get by current slug first # Try to get by current slug first
return cls.objects.get(slug=slug), False return cls.objects.get(slug=slug), False
except cls.DoesNotExist: except cls.DoesNotExist:
# Check pghistory first # Try to find in slug history
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Try to find in manual slug history as fallback
history = SlugHistory.objects.filter( history = SlugHistory.objects.filter(
content_type=ContentType.objects.get_for_model(cls), content_type=ContentType.objects.get_for_model(cls),
old_slug=slug old_slug=slug

View File

@@ -1,13 +1,10 @@
from django.contrib import admin from django.contrib import admin
from django.utils.text import slugify from simple_history.admin import SimpleHistoryAdmin
from .models import Designer from .models import Designer
@admin.register(Designer) @admin.register(Designer)
class DesignerAdmin(admin.ModelAdmin): class DesignerAdmin(SimpleHistoryAdmin):
list_display = ('name', 'headquarters', 'founded_date', 'website') list_display = ('name', 'headquarters', 'founded_date', 'website')
search_fields = ('name', 'headquarters') search_fields = ('name', 'headquarters')
list_filter = ('founded_date',)
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at')
def get_queryset(self, request):
return super().get_queryset(request).select_related()

View File

@@ -1,8 +1,8 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion import django.db.models.deletion
import pgtrigger.compiler import simple_history.models
import pgtrigger.migrations from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -11,14 +11,22 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("pghistory", "0006_delete_aggregateevent"), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="Designer", name="Designer",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)), ("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)), ("slug", models.SlugField(max_length=255, unique=True)),
("description", models.TextField(blank=True)), ("description", models.TextField(blank=True)),
@@ -33,73 +41,48 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name="DesignerEvent", name="HistoricalDesigner",
fields=[ fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)), (
("pgh_created_at", models.DateTimeField(auto_now_add=True)), "id",
("pgh_label", models.TextField(help_text="The event label.")), models.BigIntegerField(
("id", models.BigIntegerField()), auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("name", models.CharField(max_length=255)), ("name", models.CharField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)), ("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)), ("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)), ("website", models.URLField(blank=True)),
("founded_date", models.DateField(blank=True, null=True)), ("founded_date", models.DateField(blank=True, null=True)),
("headquarters", models.CharField(blank=True, max_length=255)), ("headquarters", models.CharField(blank=True, max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)), ("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(auto_now=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,
),
),
], ],
options={ options={
"abstract": False, "verbose_name": "historical designer",
"verbose_name_plural": "historical designers",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
}, },
), bases=(simple_history.models.HistoricalChanges, models.Model),
pgtrigger.migrations.AddTrigger(
model_name="designer",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_9be65",
table="designers_designer",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="designer",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_b5f91",
table="designers_designer",
when="AFTER",
),
),
),
migrations.AddField(
model_name="designerevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="designerevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="designers.designer",
),
), ),
] ]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("designers", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="designer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -1,10 +1,8 @@
from django.db import models from django.db import models
from django.utils.text import slugify from django.utils.text import slugify
from history_tracking.models import TrackedModel from simple_history.models import HistoricalRecords
import pghistory
@pghistory.track() class Designer(models.Model):
class Designer(TrackedModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True) slug = models.SlugField(max_length=255, unique=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
@@ -13,6 +11,7 @@ class Designer(TrackedModel):
headquarters = models.CharField(max_length=255, blank=True) headquarters = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
history = HistoricalRecords()
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@@ -31,13 +30,8 @@ class Designer(TrackedModel):
try: try:
return cls.objects.get(slug=slug), False return cls.objects.get(slug=slug), False
except cls.DoesNotExist: except cls.DoesNotExist:
# Check historical slugs using pghistory # Check historical slugs
history_model = cls.get_history_model() history = cls.history.filter(slug=slug).order_by('-history_date').first()
history = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history: if history:
return cls.objects.get(id=history.pgh_obj_id), True return cls.objects.get(id=history.id), True
raise cls.DoesNotExist("No designer found with this slug") raise cls.DoesNotExist("No designer found with this slug")

View File

@@ -77,7 +77,7 @@ class Command(BaseCommand):
# If no recipient specified, use the from_email address for testing # If no recipient specified, use the from_email address for testing
to_email = options['to'] or 'test@thrillwiki.com' to_email = options['to'] or 'test@thrillwiki.com'
self.stdout.write(self.style.SUCCESS('Using configuration:')) self.stdout.write(self.style.SUCCESS(f'Using configuration:'))
self.stdout.write(f' From: {from_email}') self.stdout.write(f' From: {from_email}')
self.stdout.write(f' To: {to_email}') self.stdout.write(f' To: {to_email}')
self.stdout.write(f' API Key: {"*" * len(api_key)}') self.stdout.write(f' API Key: {"*" * len(api_key)}')
@@ -146,8 +146,8 @@ class Command(BaseCommand):
}, },
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, }
timeout=60) )
if response.status_code == 200: if response.status_code == 200:
self.stdout.write(self.style.SUCCESS('✓ API endpoint test successful')) self.stdout.write(self.style.SUCCESS('✓ API endpoint test successful'))

View File

@@ -1,8 +1,6 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models from django.db import migrations, models
@@ -11,7 +9,6 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("sites", "0002_alter_domain_unique"), ("sites", "0002_alter_domain_unique"),
] ]
@@ -19,7 +16,15 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="EmailConfiguration", name="EmailConfiguration",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("api_key", models.CharField(max_length=255)), ("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)), ("from_email", models.EmailField(max_length=254)),
( (
@@ -44,86 +49,4 @@ class Migration(migrations.Migration):
"verbose_name_plural": "Email Configurations", "verbose_name_plural": "Email Configurations",
}, },
), ),
migrations.CreateModel(
name="EmailConfigurationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("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)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="email_service.emailconfiguration",
),
),
(
"site",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="sites.site",
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_08c59",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_992a4",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
] ]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("email_service", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="emailconfiguration",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -1,10 +1,7 @@
from django.db import models from django.db import models
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from history_tracking.models import TrackedModel
import pghistory
@pghistory.track() class EmailConfiguration(models.Model):
class EmailConfiguration(TrackedModel):
api_key = models.CharField(max_length=255) api_key = models.CharField(max_length=255)
from_email = models.EmailField() from_email = models.EmailField()
from_name = models.CharField(max_length=255, help_text="The name that will appear in the From field of emails") from_name = models.CharField(max_length=255, help_text="The name that will appear in the From field of emails")

View File

@@ -74,7 +74,7 @@ class EmailService:
f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails", f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails",
json=data, json=data,
headers=headers, headers=headers,
timeout=60) )
# Debug output # Debug output
print(f"Response Status: {response.status_code}") print(f"Response Status: {response.status_code}")

41
frontend/.gitignore vendored
View File

@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
***REMOVED***
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
***REMOVED****
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -1,16 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:reset": "ts-node src/scripts/db-reset.ts",
"db:seed": "ts-node prisma/seed.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^5.8.0",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"prisma": "^5.8.0",
"tailwindcss": "^3.3.0",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}

View File

@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -1,116 +0,0 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateEnum
CREATE TYPE "ParkStatus" AS ENUM ('OPERATING', 'CLOSED_TEMP', 'CLOSED_PERM', 'UNDER_CONSTRUCTION', 'DEMOLISHED', 'RELOCATED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL PRIMARY KEY,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT,
"dateJoined" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isStaff" BOOLEAN NOT NULL DEFAULT false,
"isSuperuser" BOOLEAN NOT NULL DEFAULT false,
"lastLogin" TIMESTAMP(3)
);
-- CreateTable
CREATE TABLE "Park" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"status" "ParkStatus" NOT NULL DEFAULT 'OPERATING',
"location" JSONB,
"opening_date" DATE,
"closing_date" DATE,
"operating_season" TEXT,
"size_acres" DECIMAL(10,2),
"website" TEXT,
"average_rating" DECIMAL(3,2),
"ride_count" INTEGER,
"coaster_count" INTEGER,
"creatorId" INTEGER,
"ownerId" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "ParkArea" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"opening_date" DATE,
"closing_date" DATE,
"parkId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Company" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"website" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL PRIMARY KEY,
"content" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"parkId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL PRIMARY KEY,
"url" TEXT NOT NULL,
"caption" TEXT,
"parkId" INTEGER,
"reviewId" INTEGER,
"userId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "Park_slug_key" ON "Park"("slug");
CREATE UNIQUE INDEX "ParkArea_parkId_slug_key" ON "ParkArea"("parkId", "slug");
-- AddForeignKey
ALTER TABLE "Park" ADD CONSTRAINT "Park_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Park" ADD CONSTRAINT "Park_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ParkArea" ADD CONSTRAINT "ParkArea_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "Park_slug_idx" ON "Park"("slug");
CREATE INDEX "ParkArea_slug_idx" ON "ParkArea"("slug");
CREATE INDEX "Review_parkId_idx" ON "Review"("parkId");
CREATE INDEX "Review_userId_idx" ON "Review"("userId");
CREATE INDEX "Photo_parkId_idx" ON "Photo"("parkId");
CREATE INDEX "Photo_reviewId_idx" ON "Photo"("reviewId");
CREATE INDEX "Photo_userId_idx" ON "Photo"("userId");

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -1,137 +0,0 @@
// This is your Prisma schema file
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
// Core user model
model User {
id Int @id @default(autoincrement())
email String @unique
username String @unique
password String?
dateJoined DateTime @default(now())
isActive Boolean @default(true)
isStaff Boolean @default(false)
isSuperuser Boolean @default(false)
lastLogin DateTime?
createdParks Park[] @relation("ParkCreator")
reviews Review[]
photos Photo[]
}
// Park model
model Park {
id Int @id @default(autoincrement())
name String
slug String @unique
description String? @db.Text
status ParkStatus @default(OPERATING)
location Json? // Store PostGIS point as JSON for now
// Details
opening_date DateTime? @db.Date
closing_date DateTime? @db.Date
operating_season String?
size_acres Decimal? @db.Decimal(10, 2)
website String?
// Statistics
average_rating Decimal? @db.Decimal(3, 2)
ride_count Int?
coaster_count Int?
// Relationships
creator User? @relation("ParkCreator", fields: [creatorId], references: [id])
creatorId Int?
owner Company? @relation(fields: [ownerId], references: [id])
ownerId Int?
areas ParkArea[]
reviews Review[]
photos Photo[]
// Metadata
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([slug])
}
// Park Area model
model ParkArea {
id Int @id @default(autoincrement())
name String
slug String
description String? @db.Text
opening_date DateTime? @db.Date
closing_date DateTime? @db.Date
park Park @relation(fields: [parkId], references: [id], onDelete: Cascade)
parkId Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([parkId, slug])
@@index([slug])
}
// Company model (for park owners)
model Company {
id Int @id @default(autoincrement())
name String
website String?
parks Park[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
// Review model
model Review {
id Int @id @default(autoincrement())
content String @db.Text
rating Int
park Park @relation(fields: [parkId], references: [id])
parkId Int
user User @relation(fields: [userId], references: [id])
userId Int
photos Photo[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([parkId])
@@index([userId])
}
// Photo model
model Photo {
id Int @id @default(autoincrement())
url String
caption String?
park Park? @relation(fields: [parkId], references: [id])
parkId Int?
review Review? @relation(fields: [reviewId], references: [id])
reviewId Int?
user User @relation(fields: [userId], references: [id])
userId Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([parkId])
@@index([reviewId])
@@index([userId])
}
enum ParkStatus {
OPERATING
CLOSED_TEMP
CLOSED_PERM
UNDER_CONSTRUCTION
DEMOLISHED
RELOCATED
}

View File

@@ -1,148 +0,0 @@
import { PrismaClient, ParkStatus } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Create test users
const user1 = await prisma.user.create({
data: {
email: 'admin@thrillwiki.com',
username: 'admin',
password: 'hashed_password_here',
isActive: true,
isStaff: true,
isSuperuser: true,
},
});
const user2 = await prisma.user.create({
data: {
email: 'testuser@thrillwiki.com',
username: 'testuser',
password: 'hashed_password_here',
isActive: true,
},
});
// Create test companies
const company1 = await prisma.company.create({
data: {
name: 'Universal Parks & Resorts',
website: 'https://www.universalparks.com',
},
});
const company2 = await prisma.company.create({
data: {
name: 'Cedar Fair Entertainment Company',
website: 'https://www.cedarfair.com',
},
});
// Create test parks
const park1 = await prisma.park.create({
data: {
name: 'Universal Studios Florida',
slug: 'universal-studios-florida',
description: 'Movie and TV-based theme park in Orlando, Florida',
status: ParkStatus.OPERATING,
location: {
latitude: 28.4742,
longitude: -81.4678,
},
opening_date: new Date('1990-06-07'),
operating_season: 'Year-round',
size_acres: 108,
website: 'https://www.universalorlando.com',
average_rating: 4.5,
ride_count: 25,
coaster_count: 2,
creatorId: user1.id,
ownerId: company1.id,
areas: {
create: [
{
name: 'Production Central',
slug: 'production-central',
description: 'The main entrance area of the park',
opening_date: new Date('1990-06-07'),
},
{
name: 'New York',
slug: 'new-york',
description: 'Themed after New York City streets',
opening_date: new Date('1990-06-07'),
},
],
},
},
});
const park2 = await prisma.park.create({
data: {
name: 'Cedar Point',
slug: 'cedar-point',
description: 'The Roller Coaster Capital of the World',
status: ParkStatus.OPERATING,
location: {
latitude: 41.4822,
longitude: -82.6837,
},
opening_date: new Date('1870-05-01'),
operating_season: 'May through October',
size_acres: 364,
website: 'https://www.cedarpoint.com',
average_rating: 4.8,
ride_count: 71,
coaster_count: 17,
creatorId: user1.id,
ownerId: company2.id,
areas: {
create: [
{
name: 'FrontierTown',
slug: 'frontiertown',
description: 'Western-themed area of the park',
opening_date: new Date('1968-05-01'),
},
{
name: 'Millennium Island',
slug: 'millennium-island',
description: 'Home of the Millennium Force',
opening_date: new Date('2000-05-13'),
},
],
},
},
});
// Create test reviews
await prisma.review.create({
data: {
content: 'Amazing theme park with great attention to detail!',
rating: 5,
parkId: park1.id,
userId: user2.id,
},
});
await prisma.review.create({
data: {
content: 'Best roller coasters in the world!',
rating: 5,
parkId: park2.id,
userId: user2.id,
},
});
console.log('Seed data created successfully');
}
main()
.catch((e) => {
console.error('Error creating seed data:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,103 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { ParkDetailResponse } from '@/types/api';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
): Promise<NextResponse<ParkDetailResponse>> {
try {
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Find park by slug with all relationships
const park = await prisma.park.findUnique({
where: { slug: params.slug },
include: {
creator: {
select: {
id: true,
username: true,
email: true,
},
},
owner: true,
areas: true,
reviews: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
photos: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
},
});
// Return 404 if park not found
if (!park) {
return NextResponse.json(
{
success: false,
error: 'Park not found',
},
{ status: 404 }
);
}
// Format dates consistently with list endpoint
const formattedPark = {
...park,
opening_date: park.opening_date?.toISOString().split('T')[0],
closing_date: park.closing_date?.toISOString().split('T')[0],
created_at: park.created_at.toISOString(),
updated_at: park.updated_at.toISOString(),
// Format nested dates
areas: park.areas.map(area => ({
...area,
opening_date: area.opening_date?.toISOString().split('T')[0],
closing_date: area.closing_date?.toISOString().split('T')[0],
created_at: area.created_at.toISOString(),
updated_at: area.updated_at.toISOString(),
})),
reviews: park.reviews.map(review => ({
...review,
created_at: review.created_at.toISOString(),
updated_at: review.updated_at.toISOString(),
})),
photos: park.photos.map(photo => ({
...photo,
created_at: photo.created_at.toISOString(),
updated_at: photo.updated_at.toISOString(),
})),
};
return NextResponse.json({
success: true,
data: formattedPark,
});
} catch (error) {
console.error('Error fetching park:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch park',
},
{ status: 500 }
);
}
}

View File

@@ -1,88 +0,0 @@
import { NextResponse } from 'next/server';
import { Prisma } from '@prisma/client';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
// Test raw query first
try {
console.log('Testing database connection...');
const rawResult = await prisma.$queryRaw`SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public'`;
console.log('Available tables:', rawResult);
} catch (connectionError) {
console.error('Raw query test failed:', connectionError);
throw new Error('Database connection test failed');
}
// Basic query with explicit types
try {
const queryResult = await prisma.$transaction(async (tx) => {
// Count total parks
const totalCount = await tx.park.count();
console.log('Total parks count:', totalCount);
// Fetch parks with minimal fields
const parks = await tx.park.findMany({
take: 10,
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
id: true,
name: true
}
}
},
orderBy: {
name: 'asc'
}
} satisfies Prisma.ParkFindManyArgs);
return { totalCount, parks };
});
return NextResponse.json({
success: true,
data: queryResult.parks,
meta: {
total: queryResult.totalCount
}
});
} catch (queryError) {
if (queryError instanceof Prisma.PrismaClientKnownRequestError) {
console.error('Known Prisma error:', {
code: queryError.code,
meta: queryError.meta,
message: queryError.message
});
throw new Error(`Database query failed: ${queryError.code}`);
}
throw queryError;
}
} catch (error) {
console.error('Error in /api/parks:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch parks'
},
{
status: 500,
headers: {
'Cache-Control': 'no-store, must-revalidate',
'Content-Type': 'application/json'
}
}
);
}
}

View File

@@ -1,50 +0,0 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search')?.trim();
if (!search) {
return NextResponse.json({
success: true,
data: []
});
}
const parks = await prisma.park.findMany({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ owner: { name: { contains: search, mode: 'insensitive' } } }
]
},
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
name: true,
slug: true
}
}
},
take: 8 // Limit quick search results like Django
});
return NextResponse.json({
success: true,
data: parks
});
} catch (error) {
console.error('Error in /api/parks/suggest:', error);
return NextResponse.json({
success: false,
error: 'Failed to fetch park suggestions'
}, { status: 500 });
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,21 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,62 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "ThrillWiki - Theme Park Information & Reviews",
description: "Discover theme parks, share experiences, and read reviews from park enthusiasts.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<div className="min-h-screen bg-white">
<header className="bg-indigo-600">
<nav className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" aria-label="Top">
<div className="flex w-full items-center justify-between border-b border-indigo-500 py-6">
<div className="flex items-center">
<a href="/" className="text-white text-2xl font-bold">
ThrillWiki
</a>
</div>
<div className="ml-10 space-x-4">
<a
href="/parks"
className="inline-block rounded-md border border-transparent bg-indigo-500 py-2 px-4 text-base font-medium text-white hover:bg-opacity-75"
>
Parks
</a>
<a
href="/login"
className="inline-block rounded-md border border-transparent bg-white py-2 px-4 text-base font-medium text-indigo-600 hover:bg-indigo-50"
>
Login
</a>
</div>
</div>
</nav>
</header>
{children}
<footer className="bg-white">
<div className="mx-auto max-w-7xl px-6 py-12 md:flex md:items-center md:justify-between lg:px-8">
<div className="mt-8 md:order-1 md:mt-0">
<p className="text-center text-xs leading-5 text-gray-500">
&copy; {new Date().getFullYear()} ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</body>
</html>
);
}

View File

@@ -1,84 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park } from '@/types/api';
export default function Home() {
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchParks() {
try {
const response = await fetch('/api/parks');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch parks');
}
setParks(data.data || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}
fetchParks();
}, []);
if (loading) {
return (
<main className="min-h-screen p-8">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
<p className="mt-4 text-gray-600">Loading parks...</p>
</div>
</main>
);
}
if (error) {
return (
<main className="min-h-screen p-8">
<div className="rounded-lg bg-red-50 p-4 border border-red-200">
<h2 className="text-red-800 font-semibold">Error</h2>
<p className="text-red-600 mt-2">{error}</p>
</div>
</main>
);
}
return (
<main className="min-h-screen p-8">
<h1 className="text-3xl font-bold mb-8">ThrillWiki Parks</h1>
{parks.length === 0 ? (
<p className="text-gray-600">No parks found</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{parks.map((park) => (
<div
key={park.id}
className="rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold mb-2">{park.name}</h2>
{park.description && (
<p className="text-gray-600 mb-4">
{park.description.length > 150
? `${park.description.slice(0, 150)}...`
: park.description}
</p>
)}
<div className="text-sm text-gray-500">
Added: {new Date(park.createdAt).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</main>
);
}

View File

@@ -1,99 +0,0 @@
export default function ParkDetailLoading() {
return (
<main className="container mx-auto px-4 py-8 animate-pulse">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Header skeleton */}
<header className="mb-8">
<div className="h-10 w-3/4 bg-gray-200 rounded mb-4"></div>
<div className="flex items-center gap-4">
<div className="h-6 w-24 bg-gray-200 rounded-full"></div>
<div className="h-6 w-32 bg-gray-200 rounded"></div>
</div>
</header>
{/* Description skeleton */}
<section className="mb-8">
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
</div>
</section>
{/* Details skeleton */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="grid grid-cols-2 gap-4">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="bg-gray-100 p-4 rounded-lg">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</section>
{/* Areas skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</section>
{/* Reviews skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded w-32"></div>
<div className="h-4 bg-gray-200 rounded w-24"></div>
</div>
<div className="h-6 w-24 bg-gray-200 rounded"></div>
</div>
<div className="space-y-2 mt-4">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
<div className="mt-2 flex gap-2">
{[...Array(2)].map((_, j) => (
<div key={j} className="w-24 h-24 bg-gray-200 rounded"></div>
))}
</div>
</div>
))}
</div>
</section>
{/* Photos skeleton */}
<section>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-lg"></div>
))}
</div>
</section>
</article>
</main>
);
}

View File

@@ -1,194 +0,0 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Park, ParkDetailResponse } from '@/types/api';
// Dynamically generate metadata for park pages
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
try {
const park = await fetchParkData(params.slug);
return {
title: `${park.name} | ThrillWiki`,
description: park.description || `Details about ${park.name}`,
};
} catch (error) {
return {
title: 'Park Not Found | ThrillWiki',
description: 'The requested park could not be found.',
};
}
}
// Fetch park data from API
async function fetchParkData(slug: string): Promise<Park> {
const response = await fetch(`${process***REMOVED***.NEXT_PUBLIC_API_URL}/api/parks/${slug}`, {
next: { revalidate: 60 }, // Cache for 1 minute
});
if (!response.ok) {
if (response.status === 404) {
notFound();
}
throw new Error('Failed to fetch park data');
}
const { data }: ParkDetailResponse = await response.json();
return data;
}
// Park detail page component
export default async function ParkDetailPage({ params }: { params: { slug: string } }) {
const park = await fetchParkData(params.slug);
return (
<main className="container mx-auto px-4 py-8">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Park header */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{park.name}</h1>
<div className="flex items-center gap-4 text-gray-600">
<span className={`px-3 py-1 rounded-full text-sm ${
park.status === 'OPERATING' ? 'bg-green-100 text-green-800' :
park.status === 'CLOSED_TEMP' ? 'bg-yellow-100 text-yellow-800' :
park.status === 'UNDER_CONSTRUCTION' ? 'bg-blue-100 text-blue-800' :
'bg-red-100 text-red-800'
}`}>
{park.status.replace('_', ' ')}
</span>
{park.opening_date && (
<span>Opened: {park.opening_date}</span>
)}
</div>
</header>
{/* Park description */}
{park.description && (
<section className="mb-8">
<p className="text-gray-700 leading-relaxed">{park.description}</p>
</section>
)}
{/* Park details */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h2 className="text-2xl font-semibold mb-4">Details</h2>
<dl className="grid grid-cols-2 gap-4">
{park.size_acres && (
<>
<dt className="font-medium text-gray-600">Size</dt>
<dd>{park.size_acres} acres</dd>
</>
)}
{park.operating_season && (
<>
<dt className="font-medium text-gray-600">Season</dt>
<dd>{park.operating_season}</dd>
</>
)}
{park.ride_count && (
<>
<dt className="font-medium text-gray-600">Total Rides</dt>
<dd>{park.ride_count}</dd>
</>
)}
{park.coaster_count && (
<>
<dt className="font-medium text-gray-600">Roller Coasters</dt>
<dd>{park.coaster_count}</dd>
</>
)}
{park.average_rating && (
<>
<dt className="font-medium text-gray-600">Average Rating</dt>
<dd>{park.average_rating.toFixed(1)} / 5.0</dd>
</>
)}
</dl>
</div>
{/* Location */}
{park.location && (
<div>
<h2 className="text-2xl font-semibold mb-4">Location</h2>
<div className="bg-gray-100 p-4 rounded-lg">
<p>Latitude: {park.location.latitude}</p>
<p>Longitude: {park.location.longitude}</p>
</div>
</div>
)}
</section>
{/* Areas */}
{park.areas.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Areas</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{park.areas.map(area => (
<div key={area.id} className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2">{area.name}</h3>
{area.description && (
<p className="text-sm text-gray-600">{area.description}</p>
)}
</div>
))}
</div>
</section>
)}
{/* Reviews */}
{park.reviews.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Reviews</h2>
<div className="space-y-4">
{park.reviews.map(review => (
<div key={review.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-medium">{review.user.username}</span>
<span className="text-gray-600 text-sm ml-2">
{new Date(review.created_at).toLocaleDateString()}
</span>
</div>
<div className="text-yellow-500">{
'★'.repeat(review.rating) + '☆'.repeat(5 - review.rating)
}</div>
</div>
<p className="text-gray-700">{review.content}</p>
{review.photos.length > 0 && (
<div className="mt-2 flex gap-2 flex-wrap">
{review.photos.map(photo => (
<img
key={photo.id}
src={photo.url}
alt={photo.caption || `Photo by ${photo.user.username}`}
className="w-24 h-24 object-cover rounded"
/>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Photos */}
{park.photos.length > 0 && (
<section>
<h2 className="text-2xl font-semibold mb-4">Photos</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{park.photos.map(photo => (
<div key={photo.id} className="aspect-square relative">
<img
src={photo.url}
alt={photo.caption || `Photo of ${park.name}`}
className="absolute inset-0 w-full h-full object-cover rounded-lg"
/>
</div>
))}
</div>
</section>
)}
</article>
</main>
);
}

View File

@@ -1,150 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park, ParkFilterValues, Company } from '@/types/api';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ViewToggle } from '@/components/parks/ViewToggle';
import { ParkList } from '@/components/parks/ParkList';
import { ParkFilters } from '@/components/parks/ParkFilters';
export default function ParksPage() {
const [parks, setParks] = useState<Park[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<ParkFilterValues>({});
// Fetch companies for filter dropdown
useEffect(() => {
async function fetchCompanies() {
try {
const response = await fetch('/api/companies');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch companies');
}
setCompanies(data.data || []);
} catch (err) {
console.error('Failed to fetch companies:', err);
// Don't set error state for companies - just show empty list
setCompanies([]);
}
}
fetchCompanies();
}, []);
// Fetch parks with filters
useEffect(() => {
async function fetchParks() {
try {
setLoading(true);
setError(null);
const queryParams = new URLSearchParams();
// Only add defined parameters
if (searchQuery?.trim()) queryParams.set('search', searchQuery.trim());
if (filters.status) queryParams.set('status', filters.status);
if (filters.ownerId) queryParams.set('ownerId', filters.ownerId);
if (filters.hasOwner !== undefined) queryParams.set('hasOwner', filters.hasOwner.toString());
if (filters.minRides) queryParams.set('minRides', filters.minRides.toString());
if (filters.minCoasters) queryParams.set('minCoasters', filters.minCoasters.toString());
if (filters.minSize) queryParams.set('minSize', filters.minSize.toString());
if (filters.openingDateStart) queryParams.set('openingDateStart', filters.openingDateStart);
if (filters.openingDateEnd) queryParams.set('openingDateEnd', filters.openingDateEnd);
const response = await fetch(`/api/parks?${queryParams}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch parks');
}
setParks(data.data || []);
setError(null);
} catch (err) {
console.error('Error fetching parks:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching parks');
setParks([]);
} finally {
setLoading(false);
}
}
fetchParks();
}, [searchQuery, filters]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleFiltersChange = (newFilters: ParkFilterValues) => {
setFilters(newFilters);
};
if (loading) {
return (
<div className="min-h-screen p-8">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
<p className="mt-4 text-gray-600">Loading parks...</p>
</div>
</div>
);
}
if (error && !parks.length) {
return (
<div className="p-4" data-testid="park-list-error">
<div className="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/>
</svg>
{error}
</div>
</div>
);
}
return (
<div className="min-h-screen p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">Parks</h1>
<ViewToggle currentView={viewMode} onViewChange={setViewMode} />
</div>
</div>
<div className="mb-6">
<ParkSearch onSearch={handleSearch} />
<ParkFilters onFiltersChange={handleFiltersChange} companies={companies} />
</div>
<div
id="park-results"
className="bg-white rounded-lg shadow overflow-hidden"
data-view-mode={viewMode}
>
<div className="transition-all duration-300 ease-in-out">
<ParkList
parks={parks}
viewMode={viewMode}
searchQuery={searchQuery}
/>
</div>
</div>
{error && parks.length > 0 && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800">
<p className="text-sm">
Some data might be incomplete or outdated: {error}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,81 +0,0 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error
};
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Something went wrong
</h3>
{this.state.error && (
<div className="mt-2 text-sm text-red-700">
<p>{this.state.error.message}</p>
</div>
)}
<div className="mt-4">
<button
type="button"
onClick={() => window.location.reload()}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Retry
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,55 +0,0 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkCardProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkCard({ park }: ParkCardProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div className="p-4">
<h2 className="mb-2 text-xl font-bold">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="mt-4 text-sm">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,182 +0,0 @@
'use client';
import { useState } from 'react';
import type { Company, ParkStatus } from '@/types/api';
const STATUS_OPTIONS = {
OPERATING: 'Operating',
CLOSED_TEMP: 'Temporarily Closed',
CLOSED_PERM: 'Permanently Closed',
UNDER_CONSTRUCTION: 'Under Construction',
DEMOLISHED: 'Demolished',
RELOCATED: 'Relocated'
} as const;
interface ParkFiltersProps {
onFiltersChange: (filters: ParkFilterValues) => void;
companies: Company[];
}
interface ParkFilterValues {
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
export function ParkFilters({ onFiltersChange, companies }: ParkFiltersProps) {
const [filters, setFilters] = useState<ParkFilterValues>({});
const handleFilterChange = (field: keyof ParkFilterValues, value: any) => {
const newFilters = {
...filters,
[field]: value === '' ? undefined : value
};
setFilters(newFilters);
onFiltersChange(newFilters);
};
return (
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Status Filter */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Operating Status
</label>
<select
id="status"
name="status"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value="">Any status</option>
{Object.entries(STATUS_OPTIONS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Owner Filter */}
<div>
<label htmlFor="owner" className="block text-sm font-medium text-gray-700">
Operating Company
</label>
<select
id="owner"
name="owner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.ownerId || ''}
onChange={(e) => handleFilterChange('ownerId', e.target.value)}
>
<option value="">Any company</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
{/* Has Owner Filter */}
<div>
<label htmlFor="hasOwner" className="block text-sm font-medium text-gray-700">
Company Status
</label>
<select
id="hasOwner"
name="hasOwner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.hasOwner === undefined ? '' : filters.hasOwner.toString()}
onChange={(e) => handleFilterChange('hasOwner', e.target.value === '' ? undefined : e.target.value === 'true')}
>
<option value="">Show all</option>
<option value="true">Has company</option>
<option value="false">No company</option>
</select>
</div>
{/* Min Rides Filter */}
<div>
<label htmlFor="minRides" className="block text-sm font-medium text-gray-700">
Minimum Rides
</label>
<input
type="number"
id="minRides"
name="minRides"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minRides || ''}
onChange={(e) => handleFilterChange('minRides', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Coasters Filter */}
<div>
<label htmlFor="minCoasters" className="block text-sm font-medium text-gray-700">
Minimum Roller Coasters
</label>
<input
type="number"
id="minCoasters"
name="minCoasters"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minCoasters || ''}
onChange={(e) => handleFilterChange('minCoasters', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Size Filter */}
<div>
<label htmlFor="minSize" className="block text-sm font-medium text-gray-700">
Minimum Size (acres)
</label>
<input
type="number"
id="minSize"
name="minSize"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minSize || ''}
onChange={(e) => handleFilterChange('minSize', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Opening Date Range */}
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700">Opening Date Range</label>
<div className="mt-1 grid grid-cols-2 gap-4">
<input
type="date"
id="openingDateStart"
name="openingDateStart"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateStart || ''}
onChange={(e) => handleFilterChange('openingDateStart', e.target.value)}
/>
<input
type="date"
id="openingDateEnd"
name="openingDateEnd"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateEnd || ''}
onChange={(e) => handleFilterChange('openingDateEnd', e.target.value)}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,41 +0,0 @@
import type { Park } from '@/types/api';
import { ParkCard } from './ParkCard';
import { ParkListItem } from './ParkListItem';
interface ParkListProps {
parks: Park[];
viewMode: 'grid' | 'list';
searchQuery?: string;
}
export function ParkList({ parks, viewMode, searchQuery }: ParkListProps) {
if (parks.length === 0) {
return (
<div className="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
{searchQuery ? (
<>No parks found matching "{searchQuery}". Try adjusting your search terms.</>
) : (
<>No parks found matching your criteria. Try adjusting your filters.</>
)}
</div>
);
}
if (viewMode === 'list') {
return (
<div className="divide-y divide-gray-200">
{parks.map((park) => (
<ParkListItem key={park.id} park={park} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
);
}

View File

@@ -1,70 +0,0 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkListItemProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkListItem({ park }: ParkListItemProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-gray-200 last:border-b-0">
<div className="flex-1">
<div className="flex items-start justify-between">
<h2 className="text-lg font-semibold mb-1">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="text-sm mb-2">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
<div className="text-sm text-gray-600 space-x-4">
{park.location && (
<span>
{[
park.location.city,
park.location.state,
park.location.country
].filter(Boolean).join(', ')}
</span>
)}
<span>{park.ride_count} rides</span>
{park.opening_date && (
<span>Opened: {new Date(park.opening_date).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,105 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
interface ParkSearchProps {
onSearch: (query: string) => void;
}
export function ParkSearch({ onSearch }: ParkSearchProps) {
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ id: string; name: string; slug: string }>>([]);
const debouncedFetchSuggestions = useCallback(
debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setSuggestions([]);
return;
}
try {
setIsLoading(true);
const response = await fetch(`/api/parks/suggest?search=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data || []);
}
} catch (error) {
console.error('Failed to fetch suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
}, 300),
[]
);
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
debouncedFetchSuggestions(searchQuery);
onSearch(searchQuery);
};
const handleSuggestionClick = (suggestion: { name: string; slug: string }) => {
setQuery(suggestion.name);
setSuggestions([]);
onSearch(suggestion.name);
};
return (
<div className="max-w-3xl mx-auto relative mb-8">
<div className="w-full relative">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search parks..."
className="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
aria-label="Search parks"
aria-controls="search-results"
aria-expanded={suggestions.length > 0}
/>
{isLoading && (
<div
className="absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results"
>
<svg className="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span className="sr-only">Searching...</span>
</div>
)}
</div>
{suggestions.length > 0 && (
<div
id="search-results"
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox"
>
<ul>
{suggestions.map((suggestion) => (
<li
key={suggestion.id}
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
role="option"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.name}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
'use client';
interface ViewToggleProps {
currentView: 'grid' | 'list';
onViewChange: (view: 'grid' | 'list') => void;
}
export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) {
return (
<fieldset className="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<legend className="sr-only">View mode selection</legend>
<button
onClick={() => onViewChange('grid')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'grid' ? 'bg-white shadow-sm' : ''
}`}
aria-label="Grid view"
aria-pressed={currentView === 'grid'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
</button>
<button
onClick={() => onViewChange('list')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'list' ? 'bg-white shadow-sm' : ''
}`}
aria-label="List view"
aria-pressed={currentView === 'list'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h7"
/>
</svg>
</button>
</fieldset>
);
}

View File

@@ -1,25 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// Add additional headers
response.headers.set('x-middleware-cache', 'no-cache');
// CORS headers for API routes
if (request.nextUrl.pathname.startsWith('/api/')) {
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
return response;
}
// Configure routes that need middleware
export const config = {
matcher: [
'/api/:path*',
]
};

View File

@@ -1,38 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function reset() {
try {
console.log('Starting database reset...');
// Drop all tables in the correct order
const tableOrder = [
'Photo',
'Review',
'ParkArea',
'Park',
'Company',
'User'
];
for (const table of tableOrder) {
console.log(`Deleting all records from ${table}...`);
// @ts-ignore - Dynamic table name
await prisma[table.toLowerCase()].deleteMany();
}
console.log('Database reset complete. Running seed...');
// Run the seed script
await import('../prisma/seed');
console.log('Database reset and seed completed successfully');
} catch (error) {
console.error('Error during database reset:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
reset();

View File

@@ -1,83 +0,0 @@
// General API response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// Park status type
export type ParkStatus =
| 'OPERATING'
| 'CLOSED_TEMP'
| 'CLOSED_PERM'
| 'UNDER_CONSTRUCTION'
| 'DEMOLISHED'
| 'RELOCATED';
// Company (owner) type
export interface Company {
id: string;
name: string;
slug: string;
}
// Location type
export interface Location {
id: string;
city?: string;
state?: string;
country?: string;
postal_code?: string;
street_address?: string;
latitude?: number;
longitude?: number;
}
// Park type
export interface Park {
id: string;
name: string;
slug: string;
description?: string;
status: ParkStatus;
owner?: Company;
location?: Location;
opening_date?: string;
closing_date?: string;
operating_season?: string;
website?: string;
size_acres?: number;
ride_count: number;
coaster_count?: number;
average_rating?: number;
}
// Park filter values type
export interface ParkFilterValues {
search?: string;
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
// Park list response type
export type ParkListResponse = ApiResponse<Park[]>;
// Park suggestion response type
export interface ParkSuggestion {
id: string;
name: string;
slug: string;
status: ParkStatus;
owner?: {
name: string;
slug: string;
};
}
export type ParkSuggestionResponse = ApiResponse<ParkSuggestion[]>;

View File

@@ -1,18 +0,0 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,3 +0,0 @@
const locators = {};
module.exports = { locators };

View File

@@ -1,12 +0,0 @@
from django.apps import AppConfig
class HistoryConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'history'
verbose_name = 'History Tracking'
def ready(self):
"""Initialize app and signal handlers"""
from django.dispatch import Signal
# Create a signal for history updates
self.history_updated = Signal()

View File

@@ -1,29 +0,0 @@
<div id="history-timeline"
hx-get="{% url 'history:timeline' content_type_id=content_type.id object_id=object.id %}"
hx-trigger="every 30s, historyUpdate from:body">
<div class="space-y-4">
{% for event in events %}
<div class="component-wrapper bg-white p-4 shadow-sm">
<div class="component-header flex items-center gap-2 mb-2">
<span class="text-sm font-medium">{{ event.pgh_label|title }}</span>
<time class="text-xs text-gray-500">{{ event.pgh_created_at|date:"M j, Y H:i" }}</time>
</div>
<div class="component-content text-sm">
{% if event.pgh_context.metadata.user %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
</svg>
<span>{{ event.pgh_context.metadata.user }}</span>
</div>
{% endif %}
{% if event.pgh_data %}
<div class="mt-2 text-gray-600">
<pre class="text-xs">{{ event.pgh_data|pprint }}</pre>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>

View File

@@ -1,17 +0,0 @@
from django import template
import json
register = template.Library()
@register.filter
def pprint(value):
"""Pretty print JSON data"""
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return value
if isinstance(value, (dict, list)):
return json.dumps(value, indent=2)
return str(value)

View File

@@ -1,10 +0,0 @@
from django.urls import path
from .views import HistoryTimelineView
app_name = 'history'
urlpatterns = [
path('timeline/<int:content_type_id>/<int:object_id>/',
HistoryTimelineView.as_view(),
name='timeline'),
]

View File

@@ -1,41 +0,0 @@
from django.views import View
from django.shortcuts import render
from django.http import JsonResponse
from django.contrib.contenttypes.models import ContentType
import pghistory
def serialize_event(event):
"""Serialize a history event for JSON response"""
return {
'label': event.pgh_label,
'created_at': event.pgh_created_at.isoformat(),
'context': event.pgh_context,
'data': event.pgh_data,
}
class HistoryTimelineView(View):
"""View for displaying object history timeline"""
def get(self, request, content_type_id, object_id):
# Get content type and object
content_type = ContentType.objects.get_for_id(content_type_id)
obj = content_type.get_object_for_this_type(id=object_id)
# Get history events
events = pghistory.models.Event.objects.filter(
pgh_obj_model=content_type.model_class(),
pgh_obj_id=object_id
).order_by('-pgh_created_at')[:25]
context = {
'events': events,
'content_type': content_type,
'object': obj,
}
if request.htmx:
return render(request, "history/partials/history_timeline.html", context)
return JsonResponse({
'history': [serialize_event(e) for e in events]
})

View File

@@ -7,9 +7,20 @@ class HistoryTrackingConfig(AppConfig):
name = "history_tracking" name = "history_tracking"
def ready(self): def ready(self):
""" from django.apps import apps
No initialization needed for pghistory tracking. from .mixins import HistoricalChangeMixin
History tracking is handled by the @pghistory.track() decorator
and triggers installed in migrations. # Get the Park model
""" try:
Park = apps.get_model('parks', 'Park')
ParkArea = apps.get_model('parks', 'ParkArea')
# Apply mixin to historical models
if HistoricalChangeMixin not in Park.history.model.__bases__:
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
except LookupError:
# Models might not be loaded yet
pass pass

View File

@@ -1,32 +1,50 @@
from django.conf import settings # Generated by Django 5.1.3 on 2024-11-12 18:07
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("contenttypes", "0002_remove_content_type_name"),
('contenttypes', '0002_remove_content_type_name'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='HistoricalSlug', name="HistoricalSlug",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('object_id', models.PositiveIntegerField()), "id",
('slug', models.SlugField(max_length=255)), models.BigAutoField(
('created_at', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)), auto_created=True,
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), primary_key=True,
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='historical_slugs', to=settings.AUTH_USER_MODEL)), 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={ options={
'unique_together': {('content_type', 'slug')}, "indexes": [
'indexes': [ models.Index(
models.Index(fields=['content_type', 'object_id'], name='history_tra_content_1234ab_idx'), fields=["content_type", "object_id"],
models.Index(fields=['slug'], name='history_tra_slug_1234ab_idx'), name="history_tra_content_63013c_idx",
),
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
], ],
"unique_together": {("content_type", "slug")},
}, },
), ),
] ]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("history_tracking", "0001_initial"),
]
operations = [
migrations.RenameIndex(
model_name="historicalslug",
new_name="history_tra_content_63013c_idx",
old_name="history_tra_content_1234ab_idx",
),
migrations.RenameIndex(
model_name="historicalslug",
new_name="history_tra_slug_f843aa_idx",
old_name="history_tra_slug_1234ab_idx",
),
migrations.AlterField(
model_name="historicalslug",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -0,0 +1,74 @@
# 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']
@property
def prev_record(self):
"""Get the previous record for this instance"""
try:
return self.__class__.objects.filter(
history_date__lt=self.history_date,
id=self.id
).order_by('-history_date').first()
except (AttributeError, TypeError):
return None
@property
def diff_against_previous(self):
prev_record = self.prev_record
if not prev_record:
return {}
changes = {}
for field in self.__dict__:
if field not in [
"history_date",
"history_id",
"history_type",
"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": 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

@@ -1,70 +1,34 @@
# history_tracking/models.py
from django.db import models from django.db import models
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.conf import settings from simple_history.models import HistoricalRecords
from typing import Any, Dict, Optional from .mixins import HistoricalChangeMixin
from typing import Any, Type, TypeVar, cast
from django.db.models import QuerySet from django.db.models import QuerySet
class DiffMixin: T = TypeVar('T', bound=models.Model)
"""Mixin to add diffing capabilities to models"""
def get_prev_record(self) -> Optional[Any]: class HistoricalModel(models.Model):
"""Get the previous record for this instance""" """Abstract base class for models with history tracking"""
try: id = models.BigAutoField(primary_key=True)
return type(self).objects.filter( history: HistoricalRecords = HistoricalRecords(
pgh_created_at__lt=self.pgh_created_at, inherit=True,
pgh_obj_id=self.pgh_obj_id bases=(HistoricalChangeMixin,)
).order_by('-pgh_created_at').first() )
except (AttributeError, TypeError):
return None
def diff_against_previous(self) -> Dict:
"""Compare this record against the previous one"""
prev_record = self.get_prev_record()
if not prev_record:
return {}
skip_fields = {
'pgh_id', 'pgh_created_at', 'pgh_label',
'pgh_obj_id', 'pgh_context_id', '_state',
'created_at', 'updated_at'
}
changes = {}
for field, value in self.__dict__.items():
# Skip internal fields and those we don't want to track
if field.startswith('_') or field in skip_fields or field.endswith('_id'):
continue
try:
old_value = getattr(prev_record, field)
new_value = value
if old_value != new_value:
changes[field] = {
"old": str(old_value) if old_value is not None else "None",
"new": str(new_value) if new_value is not None else "None"
}
except AttributeError:
continue
return changes
class TrackedModel(models.Model):
"""Abstract base class for models that need history tracking"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
abstract = True abstract = True
@property
def _history_model(self) -> Type[T]:
"""Get the history model class"""
return cast(Type[T], self.history.model) # type: ignore
def get_history(self) -> QuerySet: def get_history(self) -> QuerySet:
"""Get all history records for this instance in chronological order""" """Get all history records for this instance"""
event_model = self.events.model # pghistory provides this automatically model = self._history_model
if event_model: return model.objects.filter(id=self.pk).order_by('-history_date')
return event_model.objects.filter(
pgh_obj_id=self.pk
).order_by('-pgh_created_at')
return self.__class__.objects.none()
class HistoricalSlug(models.Model): class HistoricalSlug(models.Model):
"""Track historical slugs for models""" """Track historical slugs for models"""
@@ -73,13 +37,6 @@ class HistoricalSlug(models.Model):
content_object = GenericForeignKey('content_type', 'object_id') content_object = GenericForeignKey('content_type', 'object_id')
slug = models.SlugField(max_length=255) slug = models.SlugField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='historical_slugs'
)
class Meta: class Meta:
unique_together = ('content_type', 'slug') unique_together = ('content_type', 'slug')

View File

@@ -1,7 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
import os
class LocationConfig(AppConfig): class LocationConfig(AppConfig):
path = os.path.dirname(os.path.abspath(__file__))
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'location' name = 'location'

View File

@@ -1,10 +1,10 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.contrib.gis.db.models.fields import django.contrib.gis.db.models.fields
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import pgtrigger.compiler import simple_history.models
import pgtrigger.migrations from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -14,14 +14,140 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("contenttypes", "0002_remove_content_type_name"), ("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ 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,
),
),
(
"content_type",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="contenttypes.contenttype",
),
),
(
"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 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.CreateModel( migrations.CreateModel(
name="Location", name="Location",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()), ("object_id", models.PositiveIntegerField()),
( (
"name", "name",
@@ -102,163 +228,16 @@ class Migration(migrations.Migration):
], ],
options={ options={
"ordering": ["name"], "ordering": ["name"],
}, "indexes": [
), models.Index(
migrations.CreateModel(
name="LocationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("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(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="location.location",
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["content_type", "object_id"], fields=["content_type", "object_id"],
name="location_lo_content_9ee1bd_idx", name="location_lo_content_9ee1bd_idx",
), ),
), models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
migrations.AddIndex( models.Index(
model_name="location",
index=models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["country"], name="location_lo_country_b75eba_idx" fields=["country"], name="location_lo_country_b75eba_idx"
), ),
), ],
pgtrigger.migrations.AddTrigger( },
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_98cd4",
table="location_location",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_471d2",
table="location_location",
when="AFTER",
),
),
), ),
] ]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="location",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -3,12 +3,10 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from simple_history.models import HistoricalRecords
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
import pghistory
from history_tracking.models import TrackedModel
@pghistory.track() class Location(models.Model):
class Location(TrackedModel):
""" """
A generic location model that can be associated with any model A generic location model that can be associated with any model
using GenericForeignKey. Stores detailed location information using GenericForeignKey. Stores detailed location information
@@ -65,6 +63,7 @@ class Location(TrackedModel):
# Metadata # Metadata
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
history = HistoricalRecords()
class Meta: class Meta:
indexes = [ indexes = [

View File

@@ -9,8 +9,6 @@ from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from django.db.models import Q from django.db.models import Q
from location.forms import LocationForm
from .models import Location from .models import Location
class LocationSearchView(View): class LocationSearchView(View):
@@ -54,8 +52,8 @@ class LocationSearchView(View):
response = requests.get( response = requests.get(
'https://nominatim.openstreetmap.org/search', 'https://nominatim.openstreetmap.org/search',
params=params, params=params,
headers={'User-Agent': 'ThrillWiki/1.0'}, headers={'User-Agent': 'ThrillWiki/1.0'}
timeout=60) )
response.raise_for_status() response.raise_for_status()
results = response.json() results = response.json()
except requests.RequestException as e: except requests.RequestException as e:
@@ -172,8 +170,8 @@ def reverse_geocode(request):
'format': 'json', 'format': 'json',
'addressdetails': 1 'addressdetails': 1
}, },
headers={'User-Agent': 'ThrillWiki/1.0'}, headers={'User-Agent': 'ThrillWiki/1.0'}
timeout=60) )
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()

View File

@@ -33,7 +33,7 @@ class Command(BaseCommand):
try: try:
# Download image # Download image
self.stdout.write(f'Downloading from URL: {photo_url}') self.stdout.write(f'Downloading from URL: {photo_url}')
response = requests.get(photo_url, timeout=60) response = requests.get(photo_url)
if response.status_code == 200: if response.status_code == 200:
# Delete any existing photos for this park # Delete any existing photos for this park
Photo.objects.filter( Photo.objects.filter(
@@ -74,7 +74,7 @@ class Command(BaseCommand):
try: try:
# Download image # Download image
self.stdout.write(f'Downloading from URL: {photo_url}') self.stdout.write(f'Downloading from URL: {photo_url}')
response = requests.get(photo_url, timeout=60) response = requests.get(photo_url)
if response.status_code == 200: if response.status_code == 200:
# Delete any existing photos for this ride # Delete any existing photos for this ride
Photo.objects.filter( Photo.objects.filter(

View File

@@ -1,10 +1,8 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10 # Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion import django.db.models.deletion
import media.models import media.models
import media.storage import media.storage
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -15,7 +13,6 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("contenttypes", "0002_remove_content_type_name"), ("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -23,7 +20,15 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="Photo", name="Photo",
fields=[ fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)), (
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
( (
"image", "image",
models.ImageField( models.ImageField(
@@ -59,110 +64,12 @@ class Migration(migrations.Migration):
], ],
options={ options={
"ordering": ["-is_primary", "-created_at"], "ordering": ["-is_primary", "-created_at"],
}, "indexes": [
), models.Index(
migrations.CreateModel(
name="PhotoEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"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(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="media.photo",
),
),
(
"uploaded_by",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="photo",
index=models.Index(
fields=["content_type", "object_id"], fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx", name="media_photo_content_0187f5_idx",
), )
), ],
pgtrigger.migrations.AddTrigger( },
model_name="photo",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_e1ca0",
table="media_photo",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photo",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_6ff7d",
table="media_photo",
when="AFTER",
),
),
), ),
] ]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="photo",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -11,8 +11,6 @@ from datetime import datetime
from .storage import MediaStorage from .storage import MediaStorage
from rides.models import Ride from rides.models import Ride
from django.utils import timezone from django.utils import timezone
from history_tracking.models import TrackedModel
import pghistory
def photo_upload_path(instance: models.Model, filename: str) -> str: def photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for photos using normalized filenames""" """Generate upload path for photos using normalized filenames"""
@@ -40,8 +38,7 @@ def photo_upload_path(instance: models.Model, filename: str) -> str:
# For park photos, store directly in park directory # For park photos, store directly in park directory
return f"park/{identifier}/{base_filename}" return f"park/{identifier}/{base_filename}"
@pghistory.track() class Photo(models.Model):
class Photo(TrackedModel):
"""Generic photo model that can be attached to any model""" """Generic photo model that can be attached to any model"""
image = models.ImageField( image = models.ImageField(
upload_to=photo_upload_path, # type: ignore[arg-type] upload_to=photo_upload_path, # type: ignore[arg-type]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Some files were not shown because too many files have changed in this diff Show More