Compare commits
152 Commits
cbe1dd726f
...
nestjs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
046257d06c | ||
|
|
c9ab1f40ed | ||
|
|
730b165f9c | ||
|
|
401449201c | ||
|
|
1ca1362fee | ||
|
|
02e4b82beb | ||
|
|
4339c5c5e0 | ||
|
|
5278ad39d0 | ||
|
|
4d145ebabe | ||
|
|
e4959b7a04 | ||
|
|
ef2437b7f4 | ||
|
|
3523274cbd | ||
|
|
d7951756dc | ||
|
|
518fcbee22 | ||
|
|
41f1738cc1 | ||
|
|
645a74a4c3 | ||
|
|
8c85b2afd4 | ||
|
|
063398d220 | ||
|
|
20ae4862e4 | ||
|
|
5541a5f02d | ||
|
|
78f465b273 | ||
|
|
0b51ee123a | ||
|
|
c19aaf2f4b | ||
|
|
9d6f6dab2c | ||
|
|
bba707fa98 | ||
|
|
c197051b25 | ||
|
|
1fe299fb4b | ||
|
|
af57592496 | ||
|
|
62723d0e33 | ||
|
|
f5c063b76f | ||
|
|
59efc39143 | ||
|
|
2e0d32819a | ||
|
|
6034227796 | ||
|
|
fb6c6ec37b | ||
|
|
99b935da19 | ||
|
|
2756079010 | ||
|
|
5195c234c6 | ||
|
|
c861d4f6ae | ||
|
|
ac71e5f047 | ||
|
|
bdbb864cef | ||
|
|
b46e13426a | ||
|
|
39c8fe2c57 | ||
|
|
c27f320a49 | ||
|
|
f4c6cd99db | ||
|
|
467f7ba3f8 | ||
|
|
d4a1f88644 | ||
|
|
369c5e698e | ||
|
|
19b7aee707 | ||
|
|
a09fd66d70 | ||
|
|
910762722e | ||
|
|
79e34473a4 | ||
|
|
872f3378a1 | ||
|
|
df91eb97b8 | ||
|
|
ad33332506 | ||
|
|
69cdb0a554 | ||
|
|
d2b6b712bf | ||
|
|
1784644a52 | ||
|
|
3c40a32925 | ||
|
|
29392f0de1 | ||
|
|
d0bd0e1bf9 | ||
|
|
11e643a47a | ||
|
|
db78de4cfe | ||
|
|
4a495182bd | ||
|
|
2add4c7fc2 | ||
|
|
f1c37f2bc1 | ||
|
|
1c71ad9b6b | ||
|
|
7e8c40db0d | ||
|
|
7211c17aae | ||
|
|
a16b0444d4 | ||
|
|
a01cda306e | ||
|
|
808deb82e2 | ||
|
|
2d2d832e07 | ||
|
|
b4c474c496 | ||
|
|
9ed28b15b4 | ||
|
|
4b32580b13 | ||
|
|
228eeeb3c8 | ||
|
|
b7f6c60682 | ||
|
|
7ecf43f1a4 | ||
|
|
a148d34cf9 | ||
|
|
71b73522ae | ||
|
|
03f9df4bab | ||
|
|
75f5b07129 | ||
|
|
86ae24bbac | ||
|
|
0e0ed01cee | ||
|
|
2c4d2daf34 | ||
|
|
d353f24f9d | ||
|
|
9c65df12bb | ||
|
|
ecf94bf84e | ||
|
|
f3d28817a5 | ||
|
|
6fa807f4b6 | ||
|
|
323aa561a5 | ||
|
|
7d25d6f992 | ||
|
|
19852207f6 | ||
|
|
185af7fd17 | ||
|
|
768f05b783 | ||
|
|
411c6f6f68 | ||
|
|
789a6386a5 | ||
|
|
dbd76785b5 | ||
|
|
4215e14b5e | ||
|
|
dee7c61320 | ||
|
|
2f26061170 | ||
|
|
bc68eaf4d9 | ||
|
|
1ef38f4a96 | ||
|
|
1f3f94702e | ||
|
|
63b484b724 | ||
|
|
1182e894e3 | ||
|
|
cda755ea59 | ||
|
|
8bbfce3f2a | ||
|
|
0e97fdc96b | ||
|
|
ebc38228e6 | ||
|
|
45c40c720d | ||
|
|
696d26acdd | ||
|
|
96857ad1d4 | ||
|
|
ef40184e07 | ||
|
|
7aa706d12a | ||
|
|
de6146f812 | ||
|
|
1bfbe4a8b4 | ||
|
|
209c3e4d21 | ||
|
|
886b275f65 | ||
|
|
c9ebf4c833 | ||
|
|
672749d109 | ||
|
|
a5c3e56046 | ||
|
|
d728ba6e9c | ||
|
|
f819a1f07c | ||
|
|
d91f79e29c | ||
|
|
304812d43f | ||
|
|
3f7296d7a5 | ||
|
|
e60f73de9d | ||
|
|
af7ea6b4ce | ||
|
|
36478c7a1b | ||
|
|
c8628984e0 | ||
|
|
1bfe08a0a7 | ||
|
|
901a1c421d | ||
|
|
280ad4d6da | ||
|
|
9634bac155 | ||
|
|
69094a9af8 | ||
|
|
d338917ca1 | ||
|
|
5acc74d34c | ||
|
|
8b7ad53cbd | ||
|
|
7553752f0d | ||
|
|
1ca84208ef | ||
|
|
fdd7a4fcf1 | ||
|
|
ae8710c157 | ||
|
|
56d9174bb5 | ||
|
|
5c62b41070 | ||
|
|
8014bcc368 | ||
|
|
1a60658f17 | ||
|
|
311c0e999c | ||
|
|
6af9f7332a | ||
|
|
7c81d8e8eb | ||
|
|
04daf9573b | ||
|
|
96e2f097cd |
30
.clinerules
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 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.
|
||||||
27
.github/workflows/django.yml
vendored
@@ -2,29 +2,40 @@ name: Django CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.12]
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
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@v3
|
uses: actions/setup-python@v5
|
||||||
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
|
||||||
|
|||||||
34
.github/workflows/review.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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 }}
|
||||||
67
accounts/management/commands/cleanup_test_data.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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"))
|
||||||
56
accounts/management/commands/create_test_users.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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"))
|
||||||
@@ -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 = f'http://localhost:8000/accounts/discord/login/callback/'
|
callback_url = '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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ 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 = [
|
||||||
@@ -229,15 +232,7 @@ 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",
|
||||||
@@ -268,6 +263,145 @@ 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=[
|
||||||
@@ -318,40 +452,66 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
pgtrigger.migrations.AddTrigger(
|
||||||
name="TopListItem",
|
model_name="toplist",
|
||||||
fields=[
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
(
|
name="insert_insert",
|
||||||
"id",
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
models.BigAutoField(
|
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;',
|
||||||
auto_created=True,
|
hash="[AWS-SECRET-REMOVED]",
|
||||||
primary_key=True,
|
operation="INSERT",
|
||||||
serialize=False,
|
pgid="pgtrigger_insert_insert_26546",
|
||||||
verbose_name="ID",
|
table="accounts_toplist",
|
||||||
|
when="AFTER",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("object_id", models.PositiveIntegerField()),
|
),
|
||||||
("rank", models.PositiveIntegerField()),
|
pgtrigger.migrations.AddTrigger(
|
||||||
("notes", models.TextField(blank=True)),
|
model_name="toplist",
|
||||||
(
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
"content_type",
|
name="update_update",
|
||||||
models.ForeignKey(
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
to="contenttypes.contenttype",
|
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;',
|
||||||
|
hash="[AWS-SECRET-REMOVED]",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_84849",
|
||||||
|
table="accounts_toplist",
|
||||||
|
when="AFTER",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
),
|
||||||
"top_list",
|
migrations.AlterUniqueTogether(
|
||||||
models.ForeignKey(
|
name="toplistitem",
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
unique_together={("top_list", "rank")},
|
||||||
related_name="items",
|
),
|
||||||
to="accounts.toplist",
|
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", "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")},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
if not result.get('success'):
|
if not result.get('success'):
|
||||||
|
|||||||
@@ -2,22 +2,24 @@ 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(random.randint(1000, 9999))
|
new_id = str(secrets.SystemRandom().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(random.randint(10000, 99999))
|
new_id = str(secrets.SystemRandom().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
|
||||||
|
|
||||||
@@ -158,7 +160,8 @@ class PasswordReset(models.Model):
|
|||||||
verbose_name = "Password Reset"
|
verbose_name = "Password Reset"
|
||||||
verbose_name_plural = "Password Resets"
|
verbose_name_plural = "Password Resets"
|
||||||
|
|
||||||
class TopList(models.Model):
|
@pghistory.track()
|
||||||
|
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')
|
||||||
@@ -186,7 +189,8 @@ class TopList(models.Model):
|
|||||||
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}"
|
||||||
|
|
||||||
class TopListItem(models.Model):
|
@pghistory.track()
|
||||||
|
class TopListItem(TrackedModel):
|
||||||
top_list = models.ForeignKey(
|
top_list = models.ForeignKey(
|
||||||
TopList,
|
TopList,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
|||||||
@@ -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)
|
response = requests.get(avatar_url, timeout=60)
|
||||||
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)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
208
cline_docs/frontendArchitecture.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 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
|
||||||
@@ -20,22 +20,22 @@
|
|||||||
|
|
||||||
### Frontend Technologies
|
### Frontend Technologies
|
||||||
1. HTMX
|
1. HTMX
|
||||||
- Dynamic updates
|
- Dynamic updates and server interactions
|
||||||
- Partial rendering
|
- Partial rendering and progressive enhancement
|
||||||
- Server-side processing
|
- Server-side processing and form handling
|
||||||
- Progressive enhancement
|
- See frontendArchitecture.md for detailed implementation
|
||||||
|
|
||||||
2. AlpineJS
|
2. AlpineJS
|
||||||
- UI state management
|
- UI state management and reactivity
|
||||||
- Component behavior
|
- Component behavior and lifecycle
|
||||||
- Event handling
|
- Event handling and DOM manipulation
|
||||||
- DOM manipulation
|
- See frontendArchitecture.md for component patterns
|
||||||
|
|
||||||
3. Tailwind CSS
|
3. Tailwind CSS
|
||||||
- Utility-first styling
|
- Utility-first styling with custom configuration
|
||||||
- Component design
|
- Component design system
|
||||||
- Responsive layouts
|
- Responsive layouts and dark mode support
|
||||||
- Custom configuration
|
- See frontendArchitecture.md for styling guide
|
||||||
|
|
||||||
## Integration Patterns
|
## Integration Patterns
|
||||||
|
|
||||||
@@ -87,16 +87,24 @@
|
|||||||
|
|
||||||
### Frontend Libraries
|
### Frontend Libraries
|
||||||
1. CSS Framework
|
1. CSS Framework
|
||||||
- Tailwind CSS
|
- Tailwind CSS with custom configuration
|
||||||
- Custom plugins
|
- Theme system with light/dark mode support
|
||||||
- Theme configuration
|
- Component-specific style patterns
|
||||||
- Utility classes
|
- See frontendArchitecture.md for complete styling guide
|
||||||
|
|
||||||
2. JavaScript
|
2. JavaScript
|
||||||
- AlpineJS core
|
- AlpineJS for reactive components
|
||||||
- HTMX library
|
- HTMX for server interactions
|
||||||
- Utility functions
|
- Location autocomplete system
|
||||||
- Custom components
|
- Alert and modal 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
|
||||||
|
|
||||||
@@ -143,10 +151,14 @@
|
|||||||
|
|
||||||
### Technology Limitations
|
### Technology Limitations
|
||||||
1. Frontend
|
1. Frontend
|
||||||
- HTMX/AlpineJS only
|
- HTMX/AlpineJS only (no React/Vue/Angular)
|
||||||
- No additional frameworks
|
- Progressive enhancement approach required
|
||||||
- Browser compatibility
|
- Must support latest 2 versions of major browsers
|
||||||
- Performance requirements
|
- See frontendArchitecture.md for detailed browser support
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
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(SimpleHistoryAdmin):
|
class CompanyAdmin(admin.ModelAdmin):
|
||||||
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(SimpleHistoryAdmin):
|
class ManufacturerAdmin(admin.ModelAdmin):
|
||||||
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',)}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -7,21 +10,15 @@ 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)),
|
||||||
@@ -37,18 +34,31 @@ 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)),
|
||||||
@@ -63,4 +73,125 @@ 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
@pghistory.track()
|
||||||
from history_tracking.models import HistoricalSlug
|
class Company(TrackedModel):
|
||||||
|
|
||||||
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,8 +37,18 @@ class Company(models.Model):
|
|||||||
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
|
# Check pghistory first
|
||||||
from history_tracking.models import HistoricalSlug
|
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
|
||||||
|
|
||||||
|
# Check manual slug history as fallback
|
||||||
try:
|
try:
|
||||||
historical = HistoricalSlug.objects.get(
|
historical = HistoricalSlug.objects.get(
|
||||||
content_type__model='company',
|
content_type__model='company',
|
||||||
@@ -48,7 +58,8 @@ class Company(models.Model):
|
|||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
class Manufacturer(models.Model):
|
@pghistory.track()
|
||||||
|
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)
|
||||||
@@ -78,8 +89,18 @@ class Manufacturer(models.Model):
|
|||||||
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
|
# Check pghistory first
|
||||||
from history_tracking.models import HistoricalSlug
|
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
|
||||||
|
|
||||||
|
# Check manual slug history as fallback
|
||||||
try:
|
try:
|
||||||
historical = HistoricalSlug.objects.get(
|
historical = HistoricalSlug.objects.get(
|
||||||
content_type__model='manufacturer',
|
content_type__model='manufacturer',
|
||||||
@@ -88,43 +109,3 @@ class Manufacturer(models.Model):
|
|||||||
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()
|
|
||||||
|
|||||||
@@ -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,6 +214,7 @@ 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", ""),
|
||||||
@@ -229,6 +230,12 @@ 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)
|
||||||
|
|
||||||
@@ -244,10 +251,7 @@ class CompanyCreateView(LoginRequiredMixin, CreateView):
|
|||||||
object: Optional[Company]
|
object: Optional[Company]
|
||||||
|
|
||||||
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
||||||
success_url = reverse(
|
return _handle_submission(self.request, form, self.model, "")
|
||||||
"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:
|
||||||
@@ -262,10 +266,7 @@ class ManufacturerCreateView(LoginRequiredMixin, CreateView):
|
|||||||
object: Optional[Manufacturer]
|
object: Optional[Manufacturer]
|
||||||
|
|
||||||
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
||||||
success_url = reverse(
|
return _handle_submission(self.request, form, self.model, "")
|
||||||
"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:
|
||||||
|
|||||||
39
core/forms.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""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"))
|
||||||
27
core/middleware.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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):
|
||||||
"""
|
"""
|
||||||
@@ -26,7 +27,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(models.Model):
|
class SluggedModel(TrackedModel):
|
||||||
"""
|
"""
|
||||||
Abstract base model that provides slug functionality with history tracking.
|
Abstract base model that provides slug functionality with history tracking.
|
||||||
"""
|
"""
|
||||||
@@ -76,7 +77,18 @@ class SluggedModel(models.Model):
|
|||||||
# 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:
|
||||||
# Try to find in slug history
|
# Check pghistory first
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from simple_history.admin import SimpleHistoryAdmin
|
from django.utils.text import slugify
|
||||||
from .models import Designer
|
from .models import Designer
|
||||||
|
|
||||||
@admin.register(Designer)
|
@admin.register(Designer)
|
||||||
class DesignerAdmin(SimpleHistoryAdmin):
|
class DesignerAdmin(admin.ModelAdmin):
|
||||||
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()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import simple_history.models
|
import pgtrigger.compiler
|
||||||
from django.conf import settings
|
import pgtrigger.migrations
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -11,22 +11,14 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
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)),
|
||||||
@@ -41,48 +33,73 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="HistoricalDesigner",
|
name="DesignerEvent",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
"id",
|
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
models.BigIntegerField(
|
("pgh_label", models.TextField(help_text="The event label.")),
|
||||||
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
("id", models.BigIntegerField()),
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(max_length=255)),
|
("slug", models.SlugField(db_index=False, 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(blank=True, editable=False)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(blank=True, editable=False)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
("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={
|
||||||
"verbose_name": "historical designer",
|
"abstract": False,
|
||||||
"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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
20
designers/migrations/0002_alter_designer_id.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from simple_history.models import HistoricalRecords
|
from history_tracking.models import TrackedModel
|
||||||
|
import pghistory
|
||||||
|
|
||||||
class Designer(models.Model):
|
@pghistory.track()
|
||||||
|
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)
|
||||||
@@ -11,7 +13,6 @@ class Designer(models.Model):
|
|||||||
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']
|
||||||
@@ -30,8 +31,13 @@ class Designer(models.Model):
|
|||||||
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
|
# Check historical slugs using pghistory
|
||||||
history = cls.history.filter(slug=slug).order_by('-history_date').first()
|
history_model = cls.get_history_model()
|
||||||
|
history = (
|
||||||
|
history_model.objects.filter(slug=slug)
|
||||||
|
.order_by('-pgh_created_at')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if history:
|
if history:
|
||||||
return cls.objects.get(id=history.id), True
|
return cls.objects.get(id=history.pgh_obj_id), True
|
||||||
raise cls.DoesNotExist("No designer found with this slug")
|
raise cls.DoesNotExist("No designer found with this slug")
|
||||||
|
|||||||
@@ -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(f'Using configuration:'))
|
self.stdout.write(self.style.SUCCESS('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'))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
("sites", "0002_alter_domain_unique"),
|
("sites", "0002_alter_domain_unique"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -16,15 +19,7 @@ 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)),
|
||||||
(
|
(
|
||||||
@@ -49,4 +44,86 @@ 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
20
email_service/migrations/0002_alter_emailconfiguration_id.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
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
|
||||||
|
|
||||||
class EmailConfiguration(models.Model):
|
@pghistory.track()
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 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
|
||||||
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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.
|
||||||
16
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
7
frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6898
frontend/package-lock.json
generated
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
116
frontend/prisma/migrations/20250223184307_init/migration.sql
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
-- 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");
|
||||||
3
frontend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
137
frontend/prisma/schema.prisma
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
148
frontend/prisma/seed.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 385 B |
103
frontend/src/app/api/parks/[slug]/route.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend/src/app/api/parks/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/src/app/api/parks/suggest/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
21
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
62
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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">
|
||||||
|
© {new Date().getFullYear()} ThrillWiki. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/app/parks/[slug]/loading.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
frontend/src/app/parks/[slug]/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/app/parks/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
frontend/src/components/parks/ParkCard.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
frontend/src/components/parks/ParkFilters.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/parks/ParkList.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/parks/ParkListItem.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/components/parks/ParkSearch.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/parks/ViewToggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/middleware.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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*',
|
||||||
|
]
|
||||||
|
};
|
||||||
38
frontend/src/scripts/db-reset.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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();
|
||||||
83
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// 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[]>;
|
||||||
18
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
3
globalLocators.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const locators = {};
|
||||||
|
|
||||||
|
module.exports = { locators };
|
||||||
12
history/apps.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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()
|
||||||
29
history/templates/history/partials/history_timeline.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<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>
|
||||||
17
history/templatetags/history_tags.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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)
|
||||||
10
history/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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'),
|
||||||
|
]
|
||||||
41
history/views.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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]
|
||||||
|
})
|
||||||
@@ -7,20 +7,9 @@ class HistoryTrackingConfig(AppConfig):
|
|||||||
name = "history_tracking"
|
name = "history_tracking"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from django.apps import apps
|
"""
|
||||||
from .mixins import HistoricalChangeMixin
|
No initialization needed for pghistory tracking.
|
||||||
|
History tracking is handled by the @pghistory.track() decorator
|
||||||
# Get the Park model
|
and triggers installed in migrations.
|
||||||
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
|
||||||
|
|||||||
@@ -1,50 +1,32 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
from django.conf import settings
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('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')),
|
||||||
"id",
|
('object_id', models.PositiveIntegerField()),
|
||||||
models.BigAutoField(
|
('slug', models.SlugField(max_length=255)),
|
||||||
auto_created=True,
|
('created_at', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
|
||||||
primary_key=True,
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||||
serialize=False,
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='historical_slugs', to=settings.AUTH_USER_MODEL)),
|
||||||
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={
|
||||||
"indexes": [
|
'unique_together': {('content_type', 'slug')},
|
||||||
models.Index(
|
'indexes': [
|
||||||
fields=["content_type", "object_id"],
|
models.Index(fields=['content_type', 'object_id'], name='history_tra_content_1234ab_idx'),
|
||||||
name="history_tra_content_63013c_idx",
|
models.Index(fields=['slug'], name='history_tra_slug_1234ab_idx'),
|
||||||
),
|
|
||||||
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
|
|
||||||
],
|
],
|
||||||
"unique_together": {("content_type", "slug")},
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,34 +1,70 @@
|
|||||||
# 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 simple_history.models import HistoricalRecords
|
from django.conf import settings
|
||||||
from .mixins import HistoricalChangeMixin
|
from typing import Any, Dict, Optional
|
||||||
from typing import Any, Type, TypeVar, cast
|
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
T = TypeVar('T', bound=models.Model)
|
class DiffMixin:
|
||||||
|
"""Mixin to add diffing capabilities to models"""
|
||||||
|
|
||||||
class HistoricalModel(models.Model):
|
def get_prev_record(self) -> Optional[Any]:
|
||||||
"""Abstract base class for models with history tracking"""
|
"""Get the previous record for this instance"""
|
||||||
id = models.BigAutoField(primary_key=True)
|
try:
|
||||||
history: HistoricalRecords = HistoricalRecords(
|
return type(self).objects.filter(
|
||||||
inherit=True,
|
pgh_created_at__lt=self.pgh_created_at,
|
||||||
bases=(HistoricalChangeMixin,)
|
pgh_obj_id=self.pgh_obj_id
|
||||||
)
|
).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"""
|
"""Get all history records for this instance in chronological order"""
|
||||||
model = self._history_model
|
event_model = self.events.model # pghistory provides this automatically
|
||||||
return model.objects.filter(id=self.pk).order_by('-history_date')
|
if event_model:
|
||||||
|
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"""
|
||||||
@@ -37,6 +73,13 @@ 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')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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'
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
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 simple_history.models
|
import pgtrigger.compiler
|
||||||
from django.conf import settings
|
import pgtrigger.migrations
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -14,140 +14,14 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
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",
|
||||||
@@ -228,16 +102,163 @@ 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"),
|
),
|
||||||
models.Index(
|
migrations.AddIndex(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
20
location/migrations/0002_alter_location_id.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,10 +3,12 @@ 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
|
||||||
|
|
||||||
class Location(models.Model):
|
@pghistory.track()
|
||||||
|
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
|
||||||
@@ -63,7 +65,6 @@ class Location(models.Model):
|
|||||||
# 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 = [
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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):
|
||||||
@@ -52,8 +54,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:
|
||||||
@@ -170,8 +172,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
response = requests.get(photo_url, timeout=60)
|
||||||
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)
|
response = requests.get(photo_url, timeout=60)
|
||||||
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(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ 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),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -20,15 +23,7 @@ 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(
|
||||||
@@ -64,12 +59,110 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"ordering": ["-is_primary", "-created_at"],
|
"ordering": ["-is_primary", "-created_at"],
|
||||||
"indexes": [
|
|
||||||
models.Index(
|
|
||||||
fields=["content_type", "object_id"],
|
|
||||||
name="media_photo_content_0187f5_idx",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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"],
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
20
media/migrations/0002_alter_photo_id.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,6 +11,8 @@ 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"""
|
||||||
@@ -38,7 +40,8 @@ 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}"
|
||||||
|
|
||||||
class Photo(models.Model):
|
@pghistory.track()
|
||||||
|
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]
|
||||||
|
|||||||
BIN
media/submissions/photos/test_0KnoSgG.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_3LucxDK.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_4TRPE6Y.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_5KD0KRW.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_66qSjsN.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_67QBAK0.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_Ac49khM.gif
Normal file
|
After Width: | Height: | Size: 35 B |