Compare commits
150 Commits
cbe1dd726f
...
pixeebot/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b0e31124 | ||
|
|
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:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.12]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
python-version: [3.13.1]
|
||||
|
||||
steps:
|
||||
- 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 }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
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})')
|
||||
|
||||
# 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(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.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -15,6 +17,7 @@ class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -229,15 +232,7 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TopList",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("title", models.CharField(max_length=100)),
|
||||
(
|
||||
"category",
|
||||
@@ -268,6 +263,145 @@ class Migration(migrations.Migration):
|
||||
"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(
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
@@ -318,40 +452,66 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TopListItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="toplist",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
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;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_26546",
|
||||
table="accounts_toplist",
|
||||
when="AFTER",
|
||||
),
|
||||
("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",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="toplist",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
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",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="accounts.toplist",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="toplistitem",
|
||||
unique_together={("top_list", "rank")},
|
||||
),
|
||||
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",
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["rank"],
|
||||
"unique_together": {("top_list", "rank")},
|
||||
},
|
||||
),
|
||||
),
|
||||
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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data)
|
||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||
result = response.json()
|
||||
|
||||
if not result.get('success'):
|
||||
|
||||
@@ -2,22 +2,24 @@ from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import random
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
from history_tracking.models import TrackedModel
|
||||
import pghistory
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||
while True:
|
||||
# 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():
|
||||
return new_id
|
||||
|
||||
# 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():
|
||||
return new_id
|
||||
|
||||
@@ -158,7 +160,8 @@ class PasswordReset(models.Model):
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
class TopList(models.Model):
|
||||
@pghistory.track()
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
||||
DARK_RIDE = 'DR', _('Dark Ride')
|
||||
@@ -186,7 +189,8 @@ class TopList(models.Model):
|
||||
def __str__(self):
|
||||
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(
|
||||
TopList,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -31,7 +31,7 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
|
||||
if avatar_url:
|
||||
try:
|
||||
response = requests.get(avatar_url)
|
||||
response = requests.get(avatar_url, timeout=60)
|
||||
if response.status_code == 200:
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
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
|
||||
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
|
||||
1. HTMX
|
||||
- Dynamic updates
|
||||
- Partial rendering
|
||||
- Server-side processing
|
||||
- Progressive enhancement
|
||||
- Dynamic updates and server interactions
|
||||
- Partial rendering and progressive enhancement
|
||||
- Server-side processing and form handling
|
||||
- See frontendArchitecture.md for detailed implementation
|
||||
|
||||
2. AlpineJS
|
||||
- UI state management
|
||||
- Component behavior
|
||||
- Event handling
|
||||
- DOM manipulation
|
||||
- UI state management and reactivity
|
||||
- Component behavior and lifecycle
|
||||
- Event handling and DOM manipulation
|
||||
- See frontendArchitecture.md for component patterns
|
||||
|
||||
3. Tailwind CSS
|
||||
- Utility-first styling
|
||||
- Component design
|
||||
- Responsive layouts
|
||||
- Custom configuration
|
||||
- Utility-first styling with custom configuration
|
||||
- Component design system
|
||||
- Responsive layouts and dark mode support
|
||||
- See frontendArchitecture.md for styling guide
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
@@ -87,16 +87,24 @@
|
||||
|
||||
### Frontend Libraries
|
||||
1. CSS Framework
|
||||
- Tailwind CSS
|
||||
- Custom plugins
|
||||
- Theme configuration
|
||||
- Utility classes
|
||||
- Tailwind CSS with custom configuration
|
||||
- Theme system with light/dark mode support
|
||||
- Component-specific style patterns
|
||||
- See frontendArchitecture.md for complete styling guide
|
||||
|
||||
2. JavaScript
|
||||
- AlpineJS core
|
||||
- HTMX library
|
||||
- Utility functions
|
||||
- Custom components
|
||||
- AlpineJS for reactive components
|
||||
- HTMX for server interactions
|
||||
- Location autocomplete system
|
||||
- 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
|
||||
|
||||
@@ -143,10 +151,14 @@
|
||||
|
||||
### Technology Limitations
|
||||
1. Frontend
|
||||
- HTMX/AlpineJS only
|
||||
- No additional frameworks
|
||||
- Browser compatibility
|
||||
- Performance requirements
|
||||
- HTMX/AlpineJS only (no React/Vue/Angular)
|
||||
- Progressive enhancement approach required
|
||||
- Must support latest 2 versions of major browsers
|
||||
- See frontendArchitecture.md for detailed browser support
|
||||
- Performance targets:
|
||||
* First contentful paint < 1.5s
|
||||
* Time to interactive < 2s
|
||||
* Core Web Vitals compliance
|
||||
|
||||
2. Backend
|
||||
- Django version constraints
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from django.contrib import admin
|
||||
from simple_history.admin import SimpleHistoryAdmin
|
||||
from .models import Company, Manufacturer
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(SimpleHistoryAdmin):
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
||||
search_fields = ('name', 'headquarters', 'description')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
@admin.register(Manufacturer)
|
||||
class ManufacturerAdmin(SimpleHistoryAdmin):
|
||||
class ManufacturerAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
||||
search_fields = ('name', 'headquarters', 'description')
|
||||
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
|
||||
|
||||
|
||||
@@ -7,21 +10,15 @@ class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
@@ -37,18 +34,31 @@ class Migration(migrations.Migration):
|
||||
"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(
|
||||
name="Manufacturer",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
@@ -63,4 +73,125 @@ class Migration(migrations.Migration):
|
||||
"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.urls import reverse
|
||||
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
||||
import pghistory
|
||||
from history_tracking.models import TrackedModel, HistoricalSlug
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from history_tracking.models import HistoricalSlug
|
||||
|
||||
class Company(models.Model):
|
||||
@pghistory.track()
|
||||
class Company(TrackedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
@@ -37,8 +37,18 @@ class Company(models.Model):
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check historical slugs
|
||||
from history_tracking.models import HistoricalSlug
|
||||
# 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
|
||||
|
||||
# Check manual slug history as fallback
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model='company',
|
||||
@@ -48,7 +58,8 @@ class Company(models.Model):
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
|
||||
class Manufacturer(models.Model):
|
||||
@pghistory.track()
|
||||
class Manufacturer(TrackedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
@@ -78,8 +89,18 @@ class Manufacturer(models.Model):
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check historical slugs
|
||||
from history_tracking.models import HistoricalSlug
|
||||
# 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
|
||||
|
||||
# Check manual slug history as fallback
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model='manufacturer',
|
||||
@@ -88,43 +109,3 @@ class Manufacturer(models.Model):
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
|
||||
class Designer(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
total_rides = models.IntegerField(default=0)
|
||||
total_roller_coasters = models.IntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects: ClassVar[models.Manager['Designer']]
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
||||
"""Get designer by slug, checking historical slugs if needed"""
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check historical slugs
|
||||
from history_tracking.models import HistoricalSlug
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model='designer',
|
||||
slug=slug
|
||||
)
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
|
||||
@@ -206,7 +206,7 @@ class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmis
|
||||
|
||||
|
||||
def _handle_submission(
|
||||
request: Any, form: Any, model: ModelType, success_url: str
|
||||
request: Any, form: Any, model: ModelType, success_url: str = ""
|
||||
) -> HttpResponseRedirect:
|
||||
"""Helper method to handle form submissions"""
|
||||
cleaned_data = form.cleaned_data.copy()
|
||||
@@ -214,6 +214,7 @@ def _handle_submission(
|
||||
user=request.user,
|
||||
content_type=ContentType.objects.get_for_model(model),
|
||||
submission_type="CREATE",
|
||||
status="NEW",
|
||||
changes=cleaned_data,
|
||||
reason=request.POST.get("reason", ""),
|
||||
source=request.POST.get("source", ""),
|
||||
@@ -229,6 +230,12 @@ def _handle_submission(
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = request.user
|
||||
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", "")}')
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
@@ -244,10 +251,7 @@ class CompanyCreateView(LoginRequiredMixin, CreateView):
|
||||
object: Optional[Company]
|
||||
|
||||
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
||||
success_url = reverse(
|
||||
"companies:company_detail", kwargs={"slug": form.instance.slug}
|
||||
)
|
||||
return _handle_submission(self.request, form, self.model, success_url)
|
||||
return _handle_submission(self.request, form, self.model, "")
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
if self.object is None:
|
||||
@@ -262,10 +266,7 @@ class ManufacturerCreateView(LoginRequiredMixin, CreateView):
|
||||
object: Optional[Manufacturer]
|
||||
|
||||
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
||||
success_url = reverse(
|
||||
"companies:manufacturer_detail", kwargs={"slug": form.instance.slug}
|
||||
)
|
||||
return _handle_submission(self.request, form, self.model, success_url)
|
||||
return _handle_submission(self.request, form, self.model, "")
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
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
|
||||
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.models import ContentType
|
||||
from django.utils.text import slugify
|
||||
from history_tracking.models import TrackedModel
|
||||
|
||||
class SlugHistory(models.Model):
|
||||
"""
|
||||
@@ -26,7 +27,7 @@ class SlugHistory(models.Model):
|
||||
def __str__(self):
|
||||
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.
|
||||
"""
|
||||
@@ -76,7 +77,18 @@ class SluggedModel(models.Model):
|
||||
# Try to get by current slug first
|
||||
return cls.objects.get(slug=slug), False
|
||||
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(
|
||||
content_type=ContentType.objects.get_for_model(cls),
|
||||
old_slug=slug
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from simple_history.admin import SimpleHistoryAdmin
|
||||
from django.utils.text import slugify
|
||||
from .models import Designer
|
||||
|
||||
@admin.register(Designer)
|
||||
class DesignerAdmin(SimpleHistoryAdmin):
|
||||
class DesignerAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'headquarters', 'founded_date', 'website')
|
||||
search_fields = ('name', 'headquarters')
|
||||
list_filter = ('founded_date',)
|
||||
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 simple_history.models
|
||||
from django.conf import settings
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -11,22 +11,14 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Designer",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
("description", models.TextField(blank=True)),
|
||||
@@ -41,48 +33,73 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="HistoricalDesigner",
|
||||
name="DesignerEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigIntegerField(
|
||||
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("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(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
("founded_date", models.DateField(blank=True, null=True)),
|
||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
||||
("created_at", models.DateTimeField(blank=True, editable=False)),
|
||||
("updated_at", models.DateTimeField(blank=True, editable=False)),
|
||||
("history_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("history_date", models.DateTimeField(db_index=True)),
|
||||
("history_change_reason", models.CharField(max_length=100, null=True)),
|
||||
(
|
||||
"history_type",
|
||||
models.CharField(
|
||||
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
(
|
||||
"history_user",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "historical designer",
|
||||
"verbose_name_plural": "historical designers",
|
||||
"ordering": ("-history_date", "-history_id"),
|
||||
"get_latest_by": ("history_date", "history_id"),
|
||||
"abstract": False,
|
||||
},
|
||||
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.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)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
@@ -11,7 +13,6 @@ class Designer(models.Model):
|
||||
headquarters = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -30,8 +31,13 @@ class Designer(models.Model):
|
||||
try:
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check historical slugs
|
||||
history = cls.history.filter(slug=slug).order_by('-history_date').first()
|
||||
# Check historical slugs using pghistory
|
||||
history_model = cls.get_history_model()
|
||||
history = (
|
||||
history_model.objects.filter(slug=slug)
|
||||
.order_by('-pgh_created_at')
|
||||
.first()
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -77,7 +77,7 @@ class Command(BaseCommand):
|
||||
# If no recipient specified, use the from_email address for testing
|
||||
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' To: {to_email}')
|
||||
self.stdout.write(f' API Key: {"*" * len(api_key)}')
|
||||
@@ -146,8 +146,8 @@ class Command(BaseCommand):
|
||||
},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
)
|
||||
},
|
||||
timeout=60)
|
||||
|
||||
if response.status_code == 200:
|
||||
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 pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
("sites", "0002_alter_domain_unique"),
|
||||
]
|
||||
|
||||
@@ -16,15 +19,7 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="EmailConfiguration",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("api_key", models.CharField(max_length=255)),
|
||||
("from_email", models.EmailField(max_length=254)),
|
||||
(
|
||||
@@ -49,4 +44,86 @@ class Migration(migrations.Migration):
|
||||
"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.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)
|
||||
from_email = models.EmailField()
|
||||
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",
|
||||
json=data,
|
||||
headers=headers,
|
||||
)
|
||||
timeout=60)
|
||||
|
||||
# Debug output
|
||||
print(f"Response Status: {response.status_code}")
|
||||
|
||||
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"
|
||||
|
||||
def ready(self):
|
||||
from django.apps import apps
|
||||
from .mixins import HistoricalChangeMixin
|
||||
|
||||
# Get the Park model
|
||||
try:
|
||||
Park = apps.get_model('parks', 'Park')
|
||||
ParkArea = apps.get_model('parks', 'ParkArea')
|
||||
|
||||
# Apply mixin to historical models
|
||||
if HistoricalChangeMixin not in Park.history.model.__bases__:
|
||||
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
|
||||
|
||||
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
|
||||
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
|
||||
except LookupError:
|
||||
# Models might not be loaded yet
|
||||
pass
|
||||
"""
|
||||
No initialization needed for pghistory tracking.
|
||||
History tracking is handled by the @pghistory.track() decorator
|
||||
and triggers installed in migrations.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -1,50 +1,32 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import django.db.models.deletion
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="HistoricalSlug",
|
||||
name='HistoricalSlug',
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("slug", models.SlugField(max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('slug', models.SlugField(max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='historical_slugs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="history_tra_content_63013c_idx",
|
||||
),
|
||||
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
|
||||
'unique_together': {('content_type', 'slug')},
|
||||
'indexes': [
|
||||
models.Index(fields=['content_type', 'object_id'], name='history_tra_content_1234ab_idx'),
|
||||
models.Index(fields=['slug'], name='history_tra_slug_1234ab_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.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from simple_history.models import HistoricalRecords
|
||||
from .mixins import HistoricalChangeMixin
|
||||
from typing import Any, Type, TypeVar, cast
|
||||
from django.conf import settings
|
||||
from typing import Any, Dict, Optional
|
||||
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):
|
||||
"""Abstract base class for models with history tracking"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
history: HistoricalRecords = HistoricalRecords(
|
||||
inherit=True,
|
||||
bases=(HistoricalChangeMixin,)
|
||||
)
|
||||
def get_prev_record(self) -> Optional[Any]:
|
||||
"""Get the previous record for this instance"""
|
||||
try:
|
||||
return type(self).objects.filter(
|
||||
pgh_created_at__lt=self.pgh_created_at,
|
||||
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:
|
||||
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:
|
||||
"""Get all history records for this instance"""
|
||||
model = self._history_model
|
||||
return model.objects.filter(id=self.pk).order_by('-history_date')
|
||||
"""Get all history records for this instance in chronological order"""
|
||||
event_model = self.events.model # pghistory provides this automatically
|
||||
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):
|
||||
"""Track historical slugs for models"""
|
||||
@@ -37,6 +73,13 @@ class HistoricalSlug(models.Model):
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
slug = models.SlugField(max_length=255)
|
||||
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:
|
||||
unique_together = ('content_type', 'slug')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
import os
|
||||
|
||||
class LocationConfig(AppConfig):
|
||||
path = os.path.dirname(os.path.abspath(__file__))
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
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.core.validators
|
||||
import django.db.models.deletion
|
||||
import simple_history.models
|
||||
from django.conf import settings
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -14,140 +14,14 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
]
|
||||
|
||||
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(
|
||||
name="Location",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
(
|
||||
"name",
|
||||
@@ -228,16 +102,163 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="location_lo_content_9ee1bd_idx",
|
||||
),
|
||||
models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
|
||||
models.Index(
|
||||
fields=["country"], name="location_lo_country_b75eba_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
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"],
|
||||
name="location_lo_content_9ee1bd_idx",
|
||||
),
|
||||
),
|
||||
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"
|
||||
),
|
||||
),
|
||||
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.models import ContentType
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from simple_history.models import HistoricalRecords
|
||||
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
|
||||
using GenericForeignKey. Stores detailed location information
|
||||
@@ -63,7 +65,6 @@ class Location(models.Model):
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
history = HistoricalRecords()
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.views.decorators.http import require_http_methods
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.db.models import Q
|
||||
|
||||
from location.forms import LocationForm
|
||||
from .models import Location
|
||||
|
||||
class LocationSearchView(View):
|
||||
@@ -52,8 +54,8 @@ class LocationSearchView(View):
|
||||
response = requests.get(
|
||||
'https://nominatim.openstreetmap.org/search',
|
||||
params=params,
|
||||
headers={'User-Agent': 'ThrillWiki/1.0'}
|
||||
)
|
||||
headers={'User-Agent': 'ThrillWiki/1.0'},
|
||||
timeout=60)
|
||||
response.raise_for_status()
|
||||
results = response.json()
|
||||
except requests.RequestException as e:
|
||||
@@ -170,8 +172,8 @@ def reverse_geocode(request):
|
||||
'format': 'json',
|
||||
'addressdetails': 1
|
||||
},
|
||||
headers={'User-Agent': 'ThrillWiki/1.0'}
|
||||
)
|
||||
headers={'User-Agent': 'ThrillWiki/1.0'},
|
||||
timeout=60)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
# Download image
|
||||
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:
|
||||
# Delete any existing photos for this park
|
||||
Photo.objects.filter(
|
||||
@@ -74,7 +74,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
# Download image
|
||||
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:
|
||||
# Delete any existing photos for this ride
|
||||
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 media.models
|
||||
import media.storage
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -13,6 +15,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@@ -20,15 +23,7 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="Photo",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
@@ -64,12 +59,110 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
options={
|
||||
"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 rides.models import Ride
|
||||
from django.utils import timezone
|
||||
from history_tracking.models import TrackedModel
|
||||
import pghistory
|
||||
|
||||
def photo_upload_path(instance: models.Model, filename: str) -> str:
|
||||
"""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
|
||||
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"""
|
||||
image = models.ImageField(
|
||||
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 |
BIN
media/submissions/photos/test_CkzwNAH.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_DrkLE8K.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_Dsmp6DI.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_IsnDmeY.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_LPkIUyk.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_Oha7RwK.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_RpGxPCG.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_S2JX5Nx.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_TLfYnGO.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_U37Ca0y.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_UBDlVqD.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_aIafM6s.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_aOIN0P5.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_dUweQ8o.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_ez4Nn2l.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_gsmEklC.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_ilc2eam.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_m5s47vB.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_mklzbsE.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_oijLFZj.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_uYqqwBO.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_ubzj2io.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_wG2WT2A.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_wHX7LMT.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_x2OOj5F.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_zt9OmDv.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_zxg9z0k.gif
Normal file
|
After Width: | Height: | Size: 35 B |
@@ -8,7 +8,7 @@ from django.test.utils import override_settings
|
||||
from django.db import models
|
||||
from datetime import datetime
|
||||
from PIL import Image
|
||||
import piexif
|
||||
import piexif # type: ignore
|
||||
import io
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
74
memory-bank/activeContext.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Active Development Context
|
||||
|
||||
## Recently Completed
|
||||
|
||||
### Park Search Implementation (2024-02-22)
|
||||
|
||||
1. Autocomplete Base:
|
||||
- Created BaseAutocomplete in core/forms.py
|
||||
- Configured project-wide auth requirement
|
||||
- Added test coverage for base functionality
|
||||
|
||||
2. Park Search:
|
||||
- Implemented ParkAutocomplete class
|
||||
- Created ParkSearchForm with autocomplete widget
|
||||
- Updated views and templates for integration
|
||||
- Added comprehensive test suite
|
||||
|
||||
3. Documentation:
|
||||
- Updated memory-bank/features/parks/search.md
|
||||
- Added test documentation
|
||||
- Created user interface guidelines
|
||||
|
||||
## Active Tasks
|
||||
|
||||
1. Testing:
|
||||
- [ ] Run the test suite with `uv run pytest parks/tests/`
|
||||
- [ ] Monitor test coverage with pytest-cov
|
||||
- [ ] Verify HTMX interactions work as expected
|
||||
|
||||
2. Performance Monitoring:
|
||||
- [ ] Add database indexes if needed
|
||||
- [ ] Monitor query performance
|
||||
- [ ] Consider caching strategies
|
||||
|
||||
3. User Experience:
|
||||
- [ ] Get feedback on search responsiveness
|
||||
- [ ] Monitor error rates
|
||||
- [ ] Check accessibility compliance
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Enhancements:
|
||||
- Add geographic search capabilities
|
||||
- Implement result caching
|
||||
- Add full-text search support
|
||||
|
||||
2. Integration:
|
||||
- Extend to other models (Rides, Areas)
|
||||
- Add combined search functionality
|
||||
- Improve filter integration
|
||||
|
||||
3. Testing:
|
||||
- Add Playwright e2e tests
|
||||
- Implement performance benchmarks
|
||||
- Add accessibility tests
|
||||
|
||||
## Technical Debt
|
||||
|
||||
None currently identified for the search implementation.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- django-htmx-autocomplete
|
||||
- pytest-django
|
||||
- pytest-cov
|
||||
|
||||
## Notes
|
||||
|
||||
The implementation follows these principles:
|
||||
- Authentication-first approach
|
||||
- Performance optimization
|
||||
- Accessibility compliance
|
||||
- Test coverage
|
||||
- Clean documentation
|
||||
162
memory-bank/decisions/001-frontend-architecture.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# ADR 001: Frontend Architecture - HTMX + AlpineJS
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The ThrillWiki platform needs a frontend architecture that:
|
||||
- Provides dynamic user interactions
|
||||
- Maintains server-side rendering benefits
|
||||
- Enables progressive enhancement
|
||||
- Keeps complexity manageable
|
||||
- Ensures fast page loads
|
||||
- Supports SEO requirements
|
||||
|
||||
## Decision
|
||||
Implement frontend using HTMX + AlpineJS + Tailwind CSS instead of a traditional SPA framework (React, Vue, Angular).
|
||||
|
||||
### Technology Choices
|
||||
1. HTMX
|
||||
- Server-side rendering with dynamic updates
|
||||
- Progressive enhancement
|
||||
- Simple integration with Django templates
|
||||
- Reduced JavaScript complexity
|
||||
|
||||
2. AlpineJS
|
||||
- Lightweight client-side interactivity
|
||||
- Simple state management
|
||||
- Easy integration with HTMX
|
||||
- Minimal learning curve
|
||||
|
||||
3. Tailwind CSS
|
||||
- Utility-first styling
|
||||
- Consistent design system
|
||||
- Easy customization
|
||||
- Optimized production builds
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
1. Performance
|
||||
- Faster initial page loads
|
||||
- Reduced client-side processing
|
||||
- Smaller JavaScript bundle
|
||||
- Better Core Web Vitals
|
||||
|
||||
2. Development
|
||||
- Simpler architecture
|
||||
- Faster development cycles
|
||||
- Easier debugging
|
||||
- Better Django integration
|
||||
|
||||
3. Maintenance
|
||||
- Less complex state management
|
||||
- Reduced dependency management
|
||||
- Easier team onboarding
|
||||
- More maintainable codebase
|
||||
|
||||
4. SEO
|
||||
- Server-rendered content
|
||||
- Better crawler compatibility
|
||||
- Improved accessibility
|
||||
- Faster indexing
|
||||
|
||||
### Negative
|
||||
1. Limited Complex UI
|
||||
- More complex for rich interactions
|
||||
- Less ecosystem support
|
||||
- Fewer UI components available
|
||||
- Some patterns need custom solutions
|
||||
|
||||
2. Development Patterns
|
||||
- New patterns needed
|
||||
- Different mental model
|
||||
- Some developer familiarity issues
|
||||
- Custom solutions needed
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### React SPA
|
||||
- Pros:
|
||||
* Rich ecosystem
|
||||
* Component libraries
|
||||
* Developer familiarity
|
||||
* Advanced tooling
|
||||
- Cons:
|
||||
* Complex setup
|
||||
* Heavy client-side
|
||||
* SEO challenges
|
||||
* Performance overhead
|
||||
|
||||
### Vue.js
|
||||
- Pros:
|
||||
* Progressive framework
|
||||
* Good ecosystem
|
||||
* Easy learning curve
|
||||
* Good performance
|
||||
- Cons:
|
||||
* Still too heavy
|
||||
* Complex build setup
|
||||
* Server integration challenges
|
||||
* Unnecessary complexity
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Integration Strategy
|
||||
1. Server-Side
|
||||
```python
|
||||
# Django View
|
||||
class ParksView(TemplateView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
return JsonResponse() if is_htmx() else render()
|
||||
```
|
||||
|
||||
2. Client-Side
|
||||
```html
|
||||
<!-- Template -->
|
||||
<div hx-get="/parks"
|
||||
hx-trigger="load"
|
||||
x-data="{ filtered: false }">
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
1. Initial Load
|
||||
- Server-side rendering
|
||||
- Progressive enhancement
|
||||
- Critical CSS inline
|
||||
- Deferred JavaScript
|
||||
|
||||
2. Subsequent Interactions
|
||||
- Partial page updates
|
||||
- Smart caching
|
||||
- Optimistic UI updates
|
||||
- Background processing
|
||||
|
||||
## Monitoring and Success Metrics
|
||||
|
||||
### Performance Metrics
|
||||
- First Contentful Paint < 1.5s
|
||||
- Time to Interactive < 2s
|
||||
- Core Web Vitals compliance
|
||||
- Server response times
|
||||
|
||||
### Development Metrics
|
||||
- Development velocity
|
||||
- Bug frequency
|
||||
- Code complexity
|
||||
- Build times
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Enhancement Opportunities
|
||||
1. Short-term
|
||||
- Component library
|
||||
- Pattern documentation
|
||||
- Performance optimization
|
||||
- Developer tools
|
||||
|
||||
2. Long-term
|
||||
- Advanced patterns
|
||||
- Custom extensions
|
||||
- Build optimizations
|
||||
- Tool improvements
|
||||
90
memory-bank/decisions/history-tracking-migration.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# History Tracking Migration
|
||||
|
||||
## Context
|
||||
The project is transitioning from django-simple-history to django-pghistory for model history tracking.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Base Implementation (history_tracking/models.py)
|
||||
- Both old and new implementations maintained during transition:
|
||||
- `HistoricalModel` - Legacy base class using django-simple-history
|
||||
- `TrackedModel` - New base class using django-pghistory
|
||||
- Custom `DiffMixin` for comparing historical records
|
||||
- Maintained `HistoricalSlug` for backward compatibility
|
||||
|
||||
### Transition Strategy
|
||||
1. Maintain Backward Compatibility
|
||||
- Keep both HistoricalModel and TrackedModel during transition
|
||||
- Update models one at a time to use TrackedModel
|
||||
- Ensure no breaking changes during migration
|
||||
|
||||
2. Model Updates
|
||||
- Designer (Completed)
|
||||
- Migrated to TrackedModel
|
||||
- Updated get_by_slug to use pghistory queries
|
||||
- Removed SimpleHistoryAdmin dependency
|
||||
|
||||
- Pending Model Updates
|
||||
- Companies (Company, Manufacturer)
|
||||
- Parks (Park, ParkArea)
|
||||
- Rides (Ride, RollerCoasterStats)
|
||||
- Location models
|
||||
|
||||
### Migration Process
|
||||
1. For Each Model:
|
||||
- Switch base class from HistoricalModel to TrackedModel
|
||||
- Update admin.py to remove SimpleHistoryAdmin
|
||||
- Create and apply migrations
|
||||
- Test history tracking functionality
|
||||
- Update any history-related queries
|
||||
|
||||
2. Testing Steps
|
||||
- Create test objects
|
||||
- Make changes
|
||||
- Verify history records
|
||||
- Check diff functionality
|
||||
- Validate historical slug lookup
|
||||
|
||||
3. Admin Integration
|
||||
- Remove SimpleHistoryAdmin
|
||||
- Use standard ModelAdmin
|
||||
- Keep existing list displays and search fields
|
||||
|
||||
## Benefits
|
||||
- Native PostgreSQL trigger-based tracking
|
||||
- More efficient storage and querying
|
||||
- Better performance characteristics
|
||||
- Context tracking capabilities
|
||||
|
||||
## Rollback Plan
|
||||
Since both implementations are maintained:
|
||||
1. Revert model inheritance to HistoricalModel
|
||||
2. Restore SimpleHistoryAdmin
|
||||
3. Keep existing migrations
|
||||
|
||||
## Next Steps
|
||||
1. Create migrations for Designer model
|
||||
2. Update remaining models in this order:
|
||||
a. Companies app
|
||||
b. Parks app
|
||||
c. Rides app
|
||||
d. Location app
|
||||
3. Test historical functionality
|
||||
4. Once all models are migrated:
|
||||
- Remove HistoricalModel class
|
||||
- Remove django-simple-history dependency
|
||||
- Update documentation
|
||||
|
||||
## Technical Notes
|
||||
- Uses pghistory's default tracking configuration
|
||||
- Maintains compatibility with existing code patterns
|
||||
- Custom diff functionality preserved
|
||||
- Historical slug tracking unchanged
|
||||
- Both tracking systems can coexist during migration
|
||||
|
||||
## Completion Criteria
|
||||
1. All models migrated to TrackedModel
|
||||
2. All functionality tested and working
|
||||
3. No dependencies on django-simple-history
|
||||
4. Documentation updated to reflect new implementation
|
||||
5. All migrations applied successfully
|
||||
254
memory-bank/decisions/laravel_migration_analysis.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Laravel Migration Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After thorough analysis of the ThrillWiki Django codebase, this document presents a comprehensive evaluation of migrating to Laravel. The analysis considers technical compatibility, implementation impact, and business implications.
|
||||
|
||||
### Quick Overview
|
||||
|
||||
**Current Stack:**
|
||||
- Framework: Django (MVT Architecture)
|
||||
- Frontend: HTMX + AlpineJS + Tailwind CSS
|
||||
- Database: PostgreSQL with Django ORM
|
||||
- Authentication: Django Built-in Auth
|
||||
|
||||
**Recommendation:** ⛔️ DO NOT PROCEED with Laravel migration
|
||||
|
||||
The analysis reveals that the costs, risks, and disruption of migration outweigh potential benefits, particularly given the project's mature Django codebase and specialized features.
|
||||
|
||||
## Technical Analysis
|
||||
|
||||
### Core Functionality Compatibility
|
||||
|
||||
#### Data Model Migration Complexity: HIGH
|
||||
- Complex Django models with inheritance (TrackedModel)
|
||||
- Custom user model with role-based permissions
|
||||
- Extensive use of Django-specific model features
|
||||
- Migration challenges:
|
||||
* Different ORM paradigms
|
||||
* Custom model behaviors
|
||||
* Signal system reimplementation
|
||||
* Complex queries and annotations
|
||||
|
||||
#### Authentication System: HIGH
|
||||
- Currently leverages Django's auth framework extensively
|
||||
- Custom adapters for social authentication
|
||||
- Role-based permission system
|
||||
- Migration challenges:
|
||||
* Laravel's auth system differs fundamentally
|
||||
* Custom middleware rewrites needed
|
||||
* Session handling differences
|
||||
* Social auth integration rework
|
||||
|
||||
#### Template Engine: MEDIUM
|
||||
- Heavy use of Django template inheritance
|
||||
- HTMX integration for dynamic updates
|
||||
- Migration challenges:
|
||||
* Blade syntax differences
|
||||
* Different template inheritance patterns
|
||||
* HTMX integration patterns
|
||||
* Custom template tags rewrite
|
||||
|
||||
#### ORM and Database Layer: VERY HIGH
|
||||
- Extensive use of Django ORM features
|
||||
- Complex model relationships
|
||||
- Custom model managers
|
||||
- Migration challenges:
|
||||
* Different query builder syntax
|
||||
* Relationship definition differences
|
||||
* Transaction handling variations
|
||||
* Custom field type conversions
|
||||
|
||||
### Architecture Impact
|
||||
|
||||
#### Routing and Middleware: HIGH
|
||||
- Complex URL patterns with nested resources
|
||||
- Custom middleware for analytics and tracking
|
||||
- Migration challenges:
|
||||
* Different routing paradigms
|
||||
* Middleware architecture differences
|
||||
* Request/Response cycle variations
|
||||
|
||||
#### File Structure Changes: MEDIUM
|
||||
- Current Django apps need restructuring
|
||||
- Different convention requirements
|
||||
- Migration challenges:
|
||||
* Resource organization
|
||||
* Namespace handling
|
||||
* Service provider implementation
|
||||
|
||||
#### API and Service Layer: HIGH
|
||||
- Custom API implementation
|
||||
- Complex service layer integration
|
||||
- Migration challenges:
|
||||
* Different API architecture
|
||||
* Service container differences
|
||||
* Dependency injection patterns
|
||||
|
||||
## Implementation Impact
|
||||
|
||||
### Development Timeline
|
||||
Estimated timeline: 4-6 months minimum
|
||||
- Phase 1 (Data Layer): 6-8 weeks
|
||||
- Phase 2 (Business Logic): 8-10 weeks
|
||||
- Phase 3 (Frontend Integration): 4-6 weeks
|
||||
- Phase 4 (Testing & Deployment): 4-6 weeks
|
||||
|
||||
### Resource Requirements
|
||||
- 2-3 Senior Laravel Developers
|
||||
- 1 DevOps Engineer
|
||||
- 1 QA Engineer
|
||||
- Project Manager
|
||||
|
||||
### Testing Strategy Updates
|
||||
- Complete test suite rewrite needed
|
||||
- New testing frameworks required
|
||||
- Integration test complexity
|
||||
- Performance testing rework
|
||||
|
||||
### Deployment Modifications
|
||||
- CI/CD pipeline updates
|
||||
- Environment configuration changes
|
||||
- Server requirement updates
|
||||
- Monitoring system adjustments
|
||||
|
||||
## Business Impact
|
||||
|
||||
### Cost Analysis
|
||||
1. Direct Costs:
|
||||
- Development Resources: ~$150,000-200,000
|
||||
- Training: ~$20,000
|
||||
- Infrastructure Updates: ~$10,000
|
||||
- Total: ~$180,000-230,000
|
||||
|
||||
2. Indirect Costs:
|
||||
- Productivity loss during transition
|
||||
- Potential downtime
|
||||
- Bug risk increase
|
||||
- Learning curve impact
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
#### Technical Risks (HIGH)
|
||||
- Data integrity during migration
|
||||
- Performance regressions
|
||||
- Unknown edge cases
|
||||
- Integration failures
|
||||
|
||||
#### Business Risks (HIGH)
|
||||
- Service disruption
|
||||
- Feature parity gaps
|
||||
- User experience inconsistency
|
||||
- Timeline uncertainty
|
||||
|
||||
#### Mitigation Strategies
|
||||
- Phased migration approach
|
||||
- Comprehensive testing
|
||||
- Rollback procedures
|
||||
- User communication plan
|
||||
|
||||
## Detailed Technical Challenges
|
||||
|
||||
### Critical Areas
|
||||
|
||||
1. History Tracking System
|
||||
- Custom implementation in Django
|
||||
- Complex diff tracking
|
||||
- Temporal data management
|
||||
|
||||
2. Authentication System
|
||||
- Role-based access control
|
||||
- Social authentication integration
|
||||
- Custom user profiles
|
||||
|
||||
3. Geographic Features
|
||||
- Location services
|
||||
- Coordinate normalization
|
||||
- Geographic queries
|
||||
|
||||
4. Media Management
|
||||
- Custom storage backends
|
||||
- Image processing
|
||||
- Upload handling
|
||||
|
||||
## Conclusion
|
||||
|
||||
### Key Findings
|
||||
1. High Technical Debt: Migration would require substantial rewrite
|
||||
2. Complex Domain Logic: Specialized features need careful translation
|
||||
3. Resource Intensive: Significant time and budget required
|
||||
4. High Risk: Critical business functions affected
|
||||
|
||||
### Recommendation
|
||||
**Do Not Proceed with Migration**
|
||||
|
||||
Rationale:
|
||||
1. Current Django implementation is stable and mature
|
||||
2. Migration costs outweigh potential benefits
|
||||
3. High risk to business continuity
|
||||
4. Significant resource requirement
|
||||
|
||||
### Alternative Recommendations
|
||||
|
||||
1. **Modernize Current Stack**
|
||||
- Update Django version
|
||||
- Enhance current architecture
|
||||
- Improve performance in place
|
||||
|
||||
2. **Gradual Enhancement**
|
||||
- Add Laravel microservices if needed
|
||||
- Keep core Django system
|
||||
- Hybrid approach for new features
|
||||
|
||||
3. **Focus on Business Value**
|
||||
- Invest in feature development
|
||||
- Improve user experience
|
||||
- Enhance current system
|
||||
|
||||
## Success Metrics (If Migration Proceeded)
|
||||
|
||||
1. Technical Metrics
|
||||
- Performance parity or improvement
|
||||
- Code quality metrics
|
||||
- Test coverage
|
||||
- Deployment success rate
|
||||
|
||||
2. Business Metrics
|
||||
- User satisfaction
|
||||
- System availability
|
||||
- Feature parity
|
||||
- Development velocity
|
||||
|
||||
## Timeline and Resource Allocation
|
||||
|
||||
### Phase 1: Planning and Setup (4-6 weeks)
|
||||
- Architecture design
|
||||
- Environment setup
|
||||
- Team training
|
||||
|
||||
### Phase 2: Core Migration (12-16 weeks)
|
||||
- Database migration
|
||||
- Authentication system
|
||||
- Core business logic
|
||||
|
||||
### Phase 3: Frontend Integration (8-10 weeks)
|
||||
- Template conversion
|
||||
- HTMX integration
|
||||
- UI testing
|
||||
|
||||
### Phase 4: Testing and Deployment (6-8 weeks)
|
||||
- System testing
|
||||
- Performance optimization
|
||||
- Production deployment
|
||||
|
||||
### Total Timeline: 30-40 weeks
|
||||
|
||||
## Final Verdict
|
||||
|
||||
Given the extensive analysis, the recommendation is to **maintain and enhance the current Django implementation** rather than pursuing a Laravel migration. The current system is stable, well-architected, and effectively serves business needs. The high costs, risks, and potential disruption of migration outweigh any potential benefits that Laravel might offer.
|
||||
|
||||
Focus should instead be directed toward:
|
||||
1. Optimizing current Django implementation
|
||||
2. Enhancing feature set and user experience
|
||||
3. Updating dependencies and security
|
||||
4. Improving development workflows
|
||||
41
memory-bank/decisions/migration-progress.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Foreign Key Constraint Resolution - 2025-02-09 (Updated)
|
||||
|
||||
## Revision Note
|
||||
Corrected migration sequence conflict:
|
||||
- Original 0002 migration conflicted with existing 0002 file
|
||||
- Created new migration as 0012_cleanup_invalid_designers.py
|
||||
- Deleted conflicting 0002_cleanup_invalid_designers.py
|
||||
|
||||
## Updated Resolution Steps
|
||||
1. Created conflict-free migration 0012
|
||||
2. Verified migration dependencies:
|
||||
```python
|
||||
dependencies = [
|
||||
('rides', '0011_merge_20250209_1143'),
|
||||
('designers', '0001_initial'),
|
||||
]
|
||||
```
|
||||
3. New migration command:
|
||||
```bash
|
||||
python manage.py migrate rides 0012_cleanup_invalid_designers
|
||||
```
|
||||
|
||||
## PGHistory Migration Fix - 2025-02-09
|
||||
Foreign key constraint violation during pghistory migration:
|
||||
1. Issue: `rides_ride_designer_id_172b997d_fk_designers_designer_id` constraint violation during 0010_rideevent migration
|
||||
2. Resolution:
|
||||
- Created new cleanup migration (0009_cleanup_invalid_designers_pre_events.py) to run before event table creation
|
||||
- Updated migration dependencies to ensure proper sequencing:
|
||||
```python
|
||||
# 0009_cleanup_invalid_designers_pre_events.py
|
||||
dependencies = [
|
||||
('rides', '0008_historicalride_post_closing_status_and_more'),
|
||||
('designers', '0001_initial'),
|
||||
]
|
||||
```
|
||||
- Created merge migration (0013_merge_20250209_1214.py) to resolve multiple leaf nodes
|
||||
3. Final Migration Sequence:
|
||||
- Base migrations up to 0008
|
||||
- Cleanup migration (0009_cleanup_invalid_designers_pre_events)
|
||||
- Event table creation (0010_rideevent_ridemodelevent_and_more)
|
||||
- Merge migrations (0011, 0012, 0013)
|
||||
71
memory-bank/decisions/park-search-improvements.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Park Search Implementation Improvements
|
||||
|
||||
## Context
|
||||
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
|
||||
|
||||
## Decision
|
||||
Implemented a unified search pattern that:
|
||||
1. Uses only built-in HTMX and Alpine.js features
|
||||
2. Matches location search pattern
|
||||
3. Removes any custom JavaScript files
|
||||
4. Maintains consistency across the application
|
||||
|
||||
### Benefits
|
||||
1. **Simplified Architecture:**
|
||||
- No custom JavaScript files needed
|
||||
- Direct template-based implementation
|
||||
- Reduced maintenance burden
|
||||
- Smaller codebase
|
||||
|
||||
2. **Framework Alignment:**
|
||||
- Uses HTMX for AJAX requests
|
||||
- Uses Alpine.js for state management
|
||||
- All functionality in templates
|
||||
- Follows project patterns
|
||||
|
||||
3. **Better Maintainability:**
|
||||
- Single source of truth in templates
|
||||
- Reduced complexity
|
||||
- Easier to understand
|
||||
- Consistent with other features
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Template Features
|
||||
1. HTMX Integration:
|
||||
- Debounced search requests (300ms)
|
||||
- Loading indicators
|
||||
- JSON response handling
|
||||
|
||||
2. Alpine.js Usage:
|
||||
- State management in template
|
||||
- Event handling
|
||||
- UI updates
|
||||
- Keyboard interactions
|
||||
|
||||
### Backend Changes
|
||||
1. JSON API:
|
||||
- Consistent response format
|
||||
- Type validation
|
||||
- Limited results (8 items)
|
||||
- Performance optimization
|
||||
|
||||
2. View Updates:
|
||||
- Search filtering
|
||||
- Result formatting
|
||||
- Error handling
|
||||
- State preservation
|
||||
|
||||
## Benefits
|
||||
1. Better adherence to project standards
|
||||
2. Simplified codebase
|
||||
3. Reduced technical debt
|
||||
4. Easier maintenance
|
||||
5. Consistent user experience
|
||||
|
||||
## Testing
|
||||
1. API response format
|
||||
2. Empty search handling
|
||||
3. Field validation
|
||||
4. UI interactions
|
||||
5. State management
|
||||
59
memory-bank/decisions/park_count_fields.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Park Count Fields Implementation
|
||||
|
||||
## Context
|
||||
While implementing park views, we encountered errors where `ride_count` and `coaster_count` annotations conflicted with existing model fields of the same names. Additionally, we discovered inconsistencies in how these counts were being used across different views.
|
||||
|
||||
## Decision
|
||||
We decided to use both approaches but with distinct names:
|
||||
|
||||
1. **Model Fields**:
|
||||
- `ride_count`: Stored count of all rides
|
||||
- `coaster_count`: Stored count of roller coasters
|
||||
- Used in models and database schema
|
||||
- Required for backward compatibility
|
||||
|
||||
2. **Annotations**:
|
||||
- `current_ride_count`: Real-time count of all rides
|
||||
- `current_coaster_count`: Real-time count of roller coasters
|
||||
- Provide accurate, up-to-date counts
|
||||
- Used in templates and filters
|
||||
|
||||
This approach allows us to:
|
||||
- Maintain existing database schema
|
||||
- Show accurate, real-time counts in the UI
|
||||
- Avoid name conflicts between fields and annotations
|
||||
- Keep consistent naming pattern for both types of counts
|
||||
|
||||
## Implementation
|
||||
1. Views:
|
||||
- Added base queryset method with annotations
|
||||
- Used 'current_' prefix for annotated counts
|
||||
- Ensured all views use the base queryset
|
||||
|
||||
2. Filters:
|
||||
- Updated filter fields to use annotated counts
|
||||
- Configured filter class to always use base queryset
|
||||
- Maintained filter functionality with new field names
|
||||
|
||||
3. Templates:
|
||||
- Updated templates to use computed counts
|
||||
|
||||
## Why This Pattern
|
||||
1. **Consistency**: Using the 'current_' prefix clearly indicates which values are computed in real-time
|
||||
2. **Compatibility**: Maintains support for existing code that relies on the stored fields
|
||||
3. **Flexibility**: Allows gradual migration from stored to computed counts if desired
|
||||
4. **Performance Option**: Keeps the option to use stored counts for expensive queries
|
||||
|
||||
## Future Considerations
|
||||
We might want to:
|
||||
1. Add periodic tasks to sync stored counts with computed values
|
||||
2. Consider deprecating stored fields if they're not needed for performance
|
||||
3. Add validation to ensure stored counts stay in sync with reality
|
||||
4. Create a management command to update stored counts
|
||||
|
||||
## Related Files
|
||||
- parks/models.py
|
||||
- parks/views.py
|
||||
- parks/filters.py
|
||||
- parks/templates/parks/partials/park_list_item.html
|
||||
- parks/tests/test_filters.py
|
||||
45
memory-bank/decisions/pghistory-integration.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## Decision: Universal Model History via django-pghistory
|
||||
|
||||
### Pattern Implementation
|
||||
- **Tracking Method**: `pghistory.Snapshot()` applied to all concrete models
|
||||
- **Inheritance Strategy**: Base model class with history tracking
|
||||
- **Context Capture**:
|
||||
```python
|
||||
# core/models.py
|
||||
import pghistory
|
||||
|
||||
class HistoricalModel(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@pghistory.track(pghistory.Snapshot())
|
||||
def save(self, *args, **kwargs):
|
||||
return super().save(*args, **kwargs)
|
||||
```
|
||||
|
||||
### Integration Scope
|
||||
1. **Model Layer**:
|
||||
- All concrete models inherit from `HistoricalModel`
|
||||
- Automatic event labeling:
|
||||
```python
|
||||
@pghistory.track(
|
||||
pghistory.Snapshot('model.create'),
|
||||
pghistory.AfterInsert('model.update'),
|
||||
pghistory.BeforeDelete('model.delete')
|
||||
)
|
||||
```
|
||||
|
||||
2. **Context Middleware**:
|
||||
```python
|
||||
# core/middleware.py
|
||||
pghistory.context(lambda request: {
|
||||
'user': str(request.user) if request.user.is_authenticated else None,
|
||||
'ip': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT'),
|
||||
'session_key': request.session.session_key
|
||||
})
|
||||
```
|
||||
|
||||
3. **Admin Integration**:
|
||||
- Custom history view for Django Admin
|
||||
- Version comparison interface
|
||||
39
memory-bank/decisions/ride_count_field.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ride Count Field Implementation
|
||||
|
||||
## Context
|
||||
While implementing park views, we encountered an error where a `ride_count` annotation conflicted with an existing model field of the same name. This raised a question about how to handle real-time ride counts versus stored counts.
|
||||
|
||||
## Decision
|
||||
We decided to use both approaches but with distinct names:
|
||||
|
||||
1. **Model Field (`ride_count`)**:
|
||||
- Kept the original field for backward compatibility
|
||||
- Used in test fixtures and filtering system
|
||||
- Can serve as a cached/denormalized value
|
||||
|
||||
2. **Annotation (`current_ride_count`)**:
|
||||
- Added new annotation with a distinct name
|
||||
- Provides real-time count of rides
|
||||
- Used in templates for display purposes
|
||||
|
||||
This approach allows us to:
|
||||
- Maintain existing functionality in tests and filters
|
||||
- Show accurate, real-time counts in the UI
|
||||
- Avoid name conflicts between fields and annotations
|
||||
|
||||
## Implementation
|
||||
- Kept the `ride_count` IntegerField in the Park model
|
||||
- Added `current_ride_count = Count('rides', distinct=True)` annotation in views
|
||||
- Updated templates to use `current_ride_count` for display
|
||||
|
||||
## Future Considerations
|
||||
We might want to:
|
||||
1. Add a periodic task to sync the stored `ride_count` with the computed value
|
||||
2. Consider deprecating the stored field if it's not needed for performance
|
||||
3. Add validation to ensure the stored count stays in sync with reality
|
||||
|
||||
## Related Files
|
||||
- parks/models.py
|
||||
- parks/views.py
|
||||
- parks/templates/parks/partials/park_list_item.html
|
||||
- parks/tests/test_filters.py
|
||||
24
memory-bank/decisions/search-form-fix.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Search Form Fix
|
||||
|
||||
## Issue
|
||||
Search results were being duplicated because selecting a suggestion triggered both:
|
||||
1. The suggestions form submission (to /suggest_parks/)
|
||||
2. The filter form submission (to /park_list/)
|
||||
|
||||
## Root Cause
|
||||
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
|
||||
|
||||
## Solution
|
||||
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
|
||||
|
||||
## Implementation
|
||||
1. Modified the `@search-selected` handler to:
|
||||
- Set the search query in filter form
|
||||
- Submit filter form to update results
|
||||
- Hide suggestions dropdown
|
||||
2. Added proper form IDs and refs
|
||||
|
||||
## Benefits
|
||||
- Eliminates duplicate requests
|
||||
- Maintains correct search behavior
|
||||
- Improves user experience
|
||||