Compare commits
131 Commits
cbe1dd726f
...
pixeebot/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56c4a0b37 | ||
|
|
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 |
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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,3 +55,8 @@ class PageView(models.Model):
|
||||
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
|
||||
|
||||
return model_class.objects.none()
|
||||
|
||||
def __str__(self):
|
||||
model_name = self.__class__.__name__
|
||||
fields_str = ", ".join((f"{field.name}={getattr(self, field.name)}" for field in self._meta.fields))
|
||||
return f"{model_name}({fields_str})"
|
||||
|
||||
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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,75 @@
|
||||
# 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"""
|
||||
|
||||
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
|
||||
|
||||
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 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()
|
||||
|
||||
def __str__(self):
|
||||
model_name = self.__class__.__name__
|
||||
fields_str = ", ".join((f"{field.name}={getattr(self, field.name)}" for field in self._meta.fields))
|
||||
return f"{model_name}({fields_str})"
|
||||
|
||||
class HistoricalSlug(models.Model):
|
||||
"""Track historical slugs for models"""
|
||||
@@ -37,6 +78,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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
28
memory-bank/activeContext.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Active Context - Park View Modularization
|
||||
|
||||
**Objective:** Refactor parks view to use reusable card component and implement grid/list view toggle
|
||||
|
||||
**Current Implementation Analysis:**
|
||||
- Park cards rendered via `park_list_item.html` partial
|
||||
- Existing layout uses flex-based list structure
|
||||
- Search functionality uses HTMX for dynamic updates
|
||||
|
||||
**Planned Changes:**
|
||||
1. **Create `park_card.html` Partial**
|
||||
- Extract card markup from `park_list_item.html`
|
||||
- Add responsive grid/list view classes
|
||||
- Include view mode toggle state
|
||||
|
||||
2. **View Toggle Implementation**
|
||||
- Add grid/list toggle UI with HTMX
|
||||
- Store view preference in cookie/localStorage
|
||||
- Update CSS for grid (grid-cols) vs list (flex) layouts
|
||||
|
||||
3. **Backend Updates**
|
||||
- Add view_mode parameter to park list view
|
||||
- Modify context processor to handle layout preference
|
||||
|
||||
**Next Steps:**
|
||||
- Implement card partial with responsive classes
|
||||
- Create view toggle component
|
||||
- Update HTMX handlers to preserve view mode
|
||||
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
|
||||
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)
|
||||
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
|
||||
57
memory-bank/features/history-visualization.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## Feature: Unified History Timeline (HTMX Integrated)
|
||||
|
||||
### HTMX Template Pattern
|
||||
```django
|
||||
{# history/partials/history_timeline.html #}
|
||||
<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">...</svg>
|
||||
<span>{{ event.pgh_context.metadata.user }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### View Integration (Class-Based with HTMX)
|
||||
```python
|
||||
# history/views.py
|
||||
class HistoryTimelineView(View):
|
||||
def get(self, request, content_type_id, object_id):
|
||||
events = ModelHistory.objects.filter(
|
||||
pgh_obj_model=content_type_id,
|
||||
pgh_obj_id=object_id
|
||||
).order_by('-pgh_created_at')[:25]
|
||||
|
||||
if request.htmx:
|
||||
return render(request, "history/partials/history_timeline.html", {
|
||||
"events": events
|
||||
})
|
||||
|
||||
return JsonResponse({
|
||||
'history': [serialize_event(e) for e in events]
|
||||
})
|
||||
```
|
||||
|
||||
### Event Trigger Pattern
|
||||
```python
|
||||
# parks/signals.py
|
||||
from django.dispatch import Signal
|
||||
history_updated = Signal()
|
||||
|
||||
# In model save/delete handlers:
|
||||
history_updated.send(sender=Model, instance=instance)
|
||||
55
memory-bank/features/moderation/frontend-improvements.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Frontend Moderation Panel Improvements
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Performance Optimization
|
||||
- Added debouncing to search inputs
|
||||
- Optimized list rendering with virtual scrolling
|
||||
- Improved loading states with skeleton screens
|
||||
- Added result caching for common searches
|
||||
|
||||
### 2. Loading States
|
||||
- Enhanced loading indicators with progress bars
|
||||
- Added skeleton screens for content loading
|
||||
- Improved HTMX loading states visual feedback
|
||||
- Added transition animations for smoother UX
|
||||
|
||||
### 3. Error Handling
|
||||
- Added error states for failed operations
|
||||
- Improved error messages with recovery actions
|
||||
- Added retry functionality for failed requests
|
||||
- Enhanced validation feedback
|
||||
|
||||
### 4. Mobile Responsiveness
|
||||
- Optimized layouts for mobile devices
|
||||
- Added responsive navigation patterns
|
||||
- Improved touch interactions
|
||||
- Enhanced filter UI for small screens
|
||||
|
||||
### 5. Accessibility
|
||||
- Added ARIA labels and roles
|
||||
- Improved keyboard navigation
|
||||
- Enhanced focus management
|
||||
- Added screen reader announcements
|
||||
|
||||
## Key Components Modified
|
||||
|
||||
1. Dashboard Layout
|
||||
2. Submission Cards
|
||||
3. Filter Interface
|
||||
4. Action Buttons
|
||||
5. Form Components
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
1. Used CSS Grid for responsive layouts
|
||||
2. Implemented AlpineJS for state management
|
||||
3. Used HTMX for dynamic updates
|
||||
4. Added Tailwind utilities for consistent styling
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Browser compatibility testing
|
||||
2. Mobile device testing
|
||||
3. Accessibility testing
|
||||
4. Performance benchmarking
|
||||
115
memory-bank/features/moderation/implementation.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Moderation Panel Implementation
|
||||
|
||||
## Completed Improvements
|
||||
|
||||
### 1. Loading States & Performance
|
||||
- Added skeleton loading screens for better UX during content loading
|
||||
- Implemented debounced search inputs to reduce server load
|
||||
- Added virtual scrolling for large submission lists
|
||||
- Enhanced error handling with clear feedback
|
||||
- Optimized HTMX requests and responses
|
||||
|
||||
### 2. Mobile Responsiveness
|
||||
- Created collapsible filter interface for mobile
|
||||
- Improved action button layouts on small screens
|
||||
- Enhanced touch interactions
|
||||
- Optimized grid layouts for different screen sizes
|
||||
|
||||
### 3. Accessibility
|
||||
- Added proper ARIA labels and roles
|
||||
- Enhanced keyboard navigation
|
||||
- Added screen reader announcements for state changes
|
||||
- Improved focus management
|
||||
- Added reduced motion support
|
||||
|
||||
### 4. State Management
|
||||
- Implemented Alpine.js store for filter management
|
||||
- Added URL-based state persistence
|
||||
- Enhanced filter UX with visual indicators
|
||||
- Improved form handling and validation
|
||||
|
||||
### 5. Error Handling
|
||||
- Added comprehensive error states
|
||||
- Implemented retry functionality
|
||||
- Enhanced error feedback
|
||||
- Added toast notifications for actions
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Key Files Modified
|
||||
1. `templates/moderation/dashboard.html`
|
||||
- Enhanced base template structure
|
||||
- Added improved loading and error states
|
||||
- Added accessibility enhancements
|
||||
|
||||
2. `templates/moderation/partials/loading_skeleton.html`
|
||||
- Created skeleton loading screens
|
||||
- Added responsive layout structure
|
||||
- Implemented loading animations
|
||||
|
||||
3. `templates/moderation/partials/dashboard_content.html`
|
||||
- Enhanced filter interface
|
||||
- Improved mobile responsiveness
|
||||
- Added accessibility features
|
||||
|
||||
4. `templates/moderation/partials/filters_store.html`
|
||||
- Implemented Alpine.js store
|
||||
- Added filter state management
|
||||
- Enhanced URL handling
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Tested Scenarios
|
||||
- Mobile device compatibility
|
||||
- Screen reader functionality
|
||||
- Keyboard navigation
|
||||
- Loading states and error handling
|
||||
- Filter functionality
|
||||
- Form submissions and validation
|
||||
|
||||
### Browser Support
|
||||
- Chrome 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Performance Optimization
|
||||
- [ ] Implement server-side caching for frequent queries
|
||||
- [ ] Add client-side caching for filter results
|
||||
- [ ] Optimize image loading and processing
|
||||
|
||||
### 2. User Experience
|
||||
- [ ] Add bulk action support
|
||||
- [ ] Enhance filter combinations
|
||||
- [ ] Add sorting options
|
||||
- [ ] Implement saved filters
|
||||
|
||||
### 3. Accessibility
|
||||
- [ ] Conduct full WCAG audit
|
||||
- [ ] Add keyboard shortcuts
|
||||
- [ ] Enhance screen reader support
|
||||
|
||||
### 4. Features
|
||||
- [ ] Add advanced search capabilities
|
||||
- [ ] Implement moderation statistics
|
||||
- [ ] Add user activity tracking
|
||||
- [ ] Enhance notification system
|
||||
|
||||
## Documentation Updates Needed
|
||||
- Update user guide with new features
|
||||
- Add keyboard shortcut documentation
|
||||
- Update accessibility guidelines
|
||||
- Add performance benchmarks
|
||||
|
||||
## Known Issues
|
||||
- Filter reset might not clear all states
|
||||
- Mobile scroll performance with many items
|
||||
- Loading skeleton flicker on fast connections
|
||||
|
||||
## Dependencies
|
||||
- HTMX
|
||||
- AlpineJS
|
||||
- TailwindCSS
|
||||
- Leaflet (for maps)
|
||||
131
memory-bank/features/moderation/overview.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Moderation System Overview
|
||||
|
||||
## Purpose
|
||||
The moderation system ensures high-quality, accurate content across the ThrillWiki platform by implementing a structured review process for user-generated content.
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Content Queue Management
|
||||
- Submission categorization
|
||||
- Priority assignment
|
||||
- Review distribution
|
||||
- Queue monitoring
|
||||
|
||||
### 2. Review Process
|
||||
- Multi-step verification
|
||||
- Content validation rules
|
||||
- Media review workflow
|
||||
- Quality metrics
|
||||
|
||||
### 3. Moderator Tools
|
||||
- Review interface
|
||||
- Action tracking
|
||||
- Decision history
|
||||
- Performance metrics
|
||||
|
||||
## Implementation
|
||||
|
||||
### Models
|
||||
```python
|
||||
# Key models in moderation/models.py
|
||||
- ModeratedContent
|
||||
- ModeratorAction
|
||||
- ContentQueue
|
||||
- QualityMetric
|
||||
```
|
||||
|
||||
### Workflows
|
||||
|
||||
1. Content Submission
|
||||
- Content validation
|
||||
- Automated checks
|
||||
- Queue assignment
|
||||
- Submitter notification
|
||||
|
||||
2. Review Process
|
||||
- Moderator assignment
|
||||
- Content evaluation
|
||||
- Decision making
|
||||
- Action recording
|
||||
|
||||
3. Quality Control
|
||||
- Metric tracking
|
||||
- Performance monitoring
|
||||
- Accuracy assessment
|
||||
- Review auditing
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. User System
|
||||
- Submission tracking
|
||||
- Status notifications
|
||||
- User reputation
|
||||
- Appeal process
|
||||
|
||||
### 2. Content Systems
|
||||
- Parks content
|
||||
- Ride information
|
||||
- Review system
|
||||
- Media handling
|
||||
|
||||
### 3. Analytics
|
||||
- Quality metrics
|
||||
- Processing times
|
||||
- Accuracy rates
|
||||
- User satisfaction
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Content Standards
|
||||
1. Accuracy Requirements
|
||||
- Factual verification
|
||||
- Source validation
|
||||
- Update frequency
|
||||
- Completeness checks
|
||||
|
||||
2. Quality Guidelines
|
||||
- Writing standards
|
||||
- Media requirements
|
||||
- Information depth
|
||||
- Format compliance
|
||||
|
||||
### Moderation Rules
|
||||
1. Review Criteria
|
||||
- Content accuracy
|
||||
- Quality standards
|
||||
- Community guidelines
|
||||
- Legal compliance
|
||||
|
||||
2. Action Framework
|
||||
- Approval process
|
||||
- Rejection handling
|
||||
- Revision requests
|
||||
- Appeals management
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Improvements
|
||||
1. Short-term
|
||||
- Enhanced automation
|
||||
- Improved metrics
|
||||
- UI refinements
|
||||
- Performance optimization
|
||||
|
||||
2. Long-term
|
||||
- AI assistance
|
||||
- Advanced analytics
|
||||
- Workflow automation
|
||||
- Community integration
|
||||
|
||||
### Integration Opportunities
|
||||
1. Machine Learning
|
||||
- Content classification
|
||||
- Quality prediction
|
||||
- Spam detection
|
||||
- Priority assignment
|
||||
|
||||
2. Community Features
|
||||
- Trusted reviewers
|
||||
- Expert validation
|
||||
- Community flags
|
||||
- Reputation system
|
||||
76
memory-bank/features/park-search-integration.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Park Search Integration
|
||||
|
||||
## Overview
|
||||
Integrated the parks app with the site-wide search system to provide consistent filtering and search capabilities across the platform.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Filter Configuration
|
||||
```python
|
||||
# parks/filters.py
|
||||
ParkFilter = create_model_filter(
|
||||
model=Park,
|
||||
search_fields=['name', 'description', 'location__city', 'location__state', 'location__country'],
|
||||
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||
additional_filters={
|
||||
'status': {
|
||||
'field_class': 'django_filters.ChoiceFilter',
|
||||
'field_kwargs': {'choices': Park._meta.get_field('status').choices}
|
||||
},
|
||||
'opening_date': {
|
||||
'field_class': 'django_filters.DateFromToRangeFilter',
|
||||
},
|
||||
'owner': {
|
||||
'field_class': 'django_filters.ModelChoiceFilter',
|
||||
'field_kwargs': {'queryset': 'companies.Company.objects.all()'}
|
||||
},
|
||||
'min_rides': {
|
||||
'field_class': 'django_filters.NumberFilter',
|
||||
'field_kwargs': {'field_name': 'ride_count', 'lookup_expr': 'gte'}
|
||||
},
|
||||
'min_coasters': {
|
||||
'field_class': 'django_filters.NumberFilter',
|
||||
'field_kwargs': {'field_name': 'coaster_count', 'lookup_expr': 'gte'}
|
||||
},
|
||||
'min_size': {
|
||||
'field_class': 'django_filters.NumberFilter',
|
||||
'field_kwargs': {'field_name': 'size_acres', 'lookup_expr': 'gte'}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. View Integration
|
||||
- Updated `ParkListView` to use `HTMXFilterableMixin`
|
||||
- Configured proper queryset optimization with `select_related` and `prefetch_related`
|
||||
- Added pagination support
|
||||
- Maintained ride count annotations
|
||||
|
||||
### 3. Template Structure
|
||||
- Created `search/templates/search/partials/park_results.html` for consistent result display
|
||||
- Includes:
|
||||
- Park image thumbnails
|
||||
- Basic park information
|
||||
- Location details
|
||||
- Status indicators
|
||||
- Ride count badges
|
||||
- Rating display
|
||||
|
||||
### 4. Quick Search Support
|
||||
- Modified `search_parks` view for dropdown/quick search scenarios
|
||||
- Uses the same filter system but with simplified output
|
||||
- Limited to 10 results for performance
|
||||
- Added location preloading
|
||||
|
||||
## Benefits
|
||||
1. Consistent filtering across the platform
|
||||
2. Enhanced search capabilities with location and rating filters
|
||||
3. Improved performance through proper query optimization
|
||||
4. Better maintainability using the site-wide search system
|
||||
5. HTMX-powered dynamic updates
|
||||
|
||||
## Technical Notes
|
||||
- Uses django-filter backend
|
||||
- Integrates with location and rating mixins
|
||||
- Supports both full search and quick search use cases
|
||||
- Maintains existing functionality while improving code organization
|
||||
129
memory-bank/features/ride-search-improvements.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Ride Search HTMX Improvements
|
||||
|
||||
## Implementation Status: ✅ COMPLETED AND VERIFIED
|
||||
|
||||
### Current Implementation
|
||||
|
||||
#### 1. Smart Search (Implemented)
|
||||
- Split search terms for flexible matching (e.g. "steel dragon" matches "Steel Dragon 2000")
|
||||
- Searches across multiple fields:
|
||||
- Ride name
|
||||
- Park name
|
||||
- Description
|
||||
- Uses Django Q objects for complex queries
|
||||
- Real-time HTMX-powered updates
|
||||
|
||||
#### 2. Search Suggestions (Implemented)
|
||||
- Real-time suggestions with 200ms delay
|
||||
- Three types of suggestions:
|
||||
- Common matching ride names (with count)
|
||||
- Matching parks (with location)
|
||||
- Matching categories (with ride count)
|
||||
- Styled dropdown with icons and hover states
|
||||
- Keyboard navigation support
|
||||
|
||||
#### 3. Quick Filters (Implemented)
|
||||
- Category filters from CATEGORY_CHOICES
|
||||
- Operating status filter
|
||||
- All filters use HTMX for instant updates
|
||||
- Maintains search context when filtering
|
||||
- Visual active state on selected filter
|
||||
|
||||
#### 4. Active Filter Tags (Implemented)
|
||||
- Shows currently active filters:
|
||||
- Search terms
|
||||
- Selected category
|
||||
- Operating status
|
||||
- One-click removal via HTMX
|
||||
- Updates URL for bookmarking/sharing
|
||||
|
||||
#### 5. Visual Feedback (Implemented)
|
||||
- Loading spinner during HTMX requests
|
||||
- Clear visual states for filter buttons
|
||||
- Real-time feedback on search/filter actions
|
||||
- Dark mode compatible styling
|
||||
|
||||
### Technical Details
|
||||
|
||||
#### View Implementation
|
||||
```python
|
||||
def get_queryset(self):
|
||||
"""Get filtered rides based on search and filters"""
|
||||
queryset = Ride.objects.all().select_related(
|
||||
'park',
|
||||
'ride_model',
|
||||
'ride_model__manufacturer'
|
||||
).prefetch_related('photos')
|
||||
|
||||
# Search term handling
|
||||
search = self.request.GET.get('q', '').strip()
|
||||
if search:
|
||||
# Split search terms for more flexible matching
|
||||
search_terms = search.split()
|
||||
search_query = Q()
|
||||
|
||||
for term in search_terms:
|
||||
term_query = Q(
|
||||
name__icontains=term
|
||||
) | Q(
|
||||
park__name__icontains=term
|
||||
) | Q(
|
||||
description__icontains=term
|
||||
)
|
||||
search_query &= term_query
|
||||
|
||||
queryset = queryset.filter(search_query)
|
||||
|
||||
# Category filter
|
||||
category = self.request.GET.get('category')
|
||||
if category and category != 'all':
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
# Operating status filter
|
||||
if self.request.GET.get('operating') == 'true':
|
||||
queryset = queryset.filter(status='operating')
|
||||
|
||||
return queryset
|
||||
```
|
||||
|
||||
#### Template Structure
|
||||
- `ride_list.html`: Main template with search and filters
|
||||
- `search_suggestions.html`: Dropdown suggestion UI
|
||||
- `ride_list_results.html`: Results grid (HTMX target)
|
||||
|
||||
#### Key Fixes Applied
|
||||
1. Template Path Resolution
|
||||
- CRITICAL FIX: Resolved template inheritance confusion
|
||||
- Removed duplicate base.html templates
|
||||
- Moved template to correct location: templates/base/base.html
|
||||
- All templates now correctly extend "base/base.html"
|
||||
- Template loading order matches Django's settings
|
||||
|
||||
2. URL Resolution
|
||||
- Replaced all relative "." URLs with explicit URLs using {% url %}
|
||||
- Example: `hx-get="{% url 'rides:global_ride_list' %}"`
|
||||
- Prevents conflicts with global search in base template
|
||||
|
||||
3. HTMX Configuration
|
||||
- All HTMX triggers properly configured
|
||||
- Fixed grid layout persistence:
|
||||
* Removed duplicate grid classes from parent template
|
||||
* Grid classes now only in partial template
|
||||
* Prevents layout breaking during HTMX updates
|
||||
- Proper event delegation for dynamic content
|
||||
|
||||
### Verification Points
|
||||
1. ✅ Search updates in real-time
|
||||
2. ✅ Filters work independently and combined
|
||||
3. ✅ Suggestions appear as you type
|
||||
4. ✅ Loading states show during requests
|
||||
5. ✅ Dark mode properly supported
|
||||
6. ✅ URL state maintained for sharing
|
||||
7. ✅ No conflicts with global search
|
||||
8. ✅ All templates resolve correctly
|
||||
|
||||
### Future Considerations
|
||||
1. Consider caching frequent searches
|
||||
2. Monitor performance with large datasets
|
||||
3. Add analytics for most used filters
|
||||
4. Consider adding saved searches feature
|
||||
121
memory-bank/features/search-system.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Site-Wide Search System Architecture
|
||||
|
||||
## 1. Architectural Overview
|
||||
- **Filter-First Approach**: Utilizes django-filter for robust filtering capabilities
|
||||
- **Modular Design**:
|
||||
```python
|
||||
# filters.py
|
||||
class ParkFilter(django_filters.FilterSet):
|
||||
search = django_filters.CharFilter(method='filter_search')
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = {
|
||||
'state': ['exact', 'in'],
|
||||
'rating': ['gte', 'lte'],
|
||||
}
|
||||
|
||||
def filter_search(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
```
|
||||
|
||||
## 2. Enhanced Backend Components
|
||||
|
||||
### Search Endpoint (`/search/`)
|
||||
```python
|
||||
# views.py
|
||||
class AdaptiveSearchView(TemplateView):
|
||||
template_name = "search/results.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return Park.objects.all()
|
||||
|
||||
def get_filterset(self):
|
||||
return ParkFilter(self.request.GET, queryset=self.get_queryset())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
filterset = self.get_filterset()
|
||||
context['results'] = filterset.qs
|
||||
context['filters'] = filterset.form
|
||||
return context
|
||||
```
|
||||
|
||||
## 3. Plugin Integration
|
||||
|
||||
### Recommended django-filter Extensions
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS += [
|
||||
'django_filters',
|
||||
'django_filters_addons', # For custom widgets
|
||||
'rangefilter', # For date/number ranges
|
||||
]
|
||||
|
||||
# filters.py
|
||||
class EnhancedParkFilter(ParkFilter):
|
||||
rating_range = django_filters.RangeFilter(field_name='rating')
|
||||
features = django_filters.MultipleChoiceFilter(
|
||||
field_name='features__slug',
|
||||
widget=HorizontalCheckboxSelectMultiple,
|
||||
lookup_expr='contains'
|
||||
)
|
||||
|
||||
class Meta(ParkFilter.Meta):
|
||||
fields = ParkFilter.Meta.fields + ['rating_range', 'features']
|
||||
```
|
||||
|
||||
## 4. Frontend Filter Rendering
|
||||
```html
|
||||
<!-- templates/search/filters.html -->
|
||||
<form hx-get="/search/" hx-target="#search-results" hx-swap="outerHTML">
|
||||
{{ filters.form.as_p }}
|
||||
<button type="submit">Apply Filters</button>
|
||||
</form>
|
||||
|
||||
<!-- Dynamic filter updates -->
|
||||
<div hx-trigger="filter-update from:body"
|
||||
hx-get="/search/filters/"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
```
|
||||
|
||||
## 5. Benefits of django-filter Integration
|
||||
- Built-in validation for filter parameters
|
||||
- Automatic form generation
|
||||
- Complex lookup expressions
|
||||
- Reusable filter components
|
||||
- Plugin ecosystem support
|
||||
|
||||
## 6. Security Considerations
|
||||
- Input sanitization using django's built-in escaping
|
||||
- Query parameter whitelisting via FilterSet definitions
|
||||
- Rate limiting on autocomplete endpoint (using django-ratelimit)
|
||||
- Permission-aware queryset filtering
|
||||
|
||||
## 7. Performance Optimization
|
||||
- Select related/prefetch_related in FilterSet querysets
|
||||
- Caching filter configurations
|
||||
- Indexing recommendations for filtered fields
|
||||
- Pagination integration with django-filter
|
||||
|
||||
## 8. Testing Strategy
|
||||
- FilterSet validation tests
|
||||
- HTMX interaction tests
|
||||
- Cross-browser filter UI tests
|
||||
- Performance load testing
|
||||
|
||||
## 9. Style Integration
|
||||
- Custom filter form templates matching Tailwind design
|
||||
- Responsive filter controls grid
|
||||
- Accessible form labels and error messages
|
||||
- Dark mode support
|
||||
|
||||
## 10. Expansion Framework
|
||||
- Registry pattern for adding new FilterSets
|
||||
- Dynamic filter discovery system
|
||||
- Plugin configuration templates
|
||||
- Analytics integration points
|
||||
170
memory-bank/features/search/park-search.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Park Search Implementation
|
||||
|
||||
## Overview
|
||||
Integration of the parks app with the site-wide search system, providing both full search functionality and quick search for dropdowns.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Filter Configuration (parks/filters.py)
|
||||
```python
|
||||
ParkFilter = create_model_filter(
|
||||
model=Park,
|
||||
search_fields=['name', 'description', 'location__city', 'location__state', 'location__country'],
|
||||
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||
additional_filters={
|
||||
'status': {
|
||||
'field_class': 'django_filters.ChoiceFilter',
|
||||
'field_kwargs': {
|
||||
'choices': Park._meta.get_field('status').choices,
|
||||
'empty_label': 'Any status',
|
||||
'null_label': 'Unknown'
|
||||
}
|
||||
},
|
||||
'opening_date': {
|
||||
'field_class': 'django_filters.DateFromToRangeFilter',
|
||||
'field_kwargs': {
|
||||
'label': 'Opening date range',
|
||||
'help_text': 'Enter dates in YYYY-MM-DD format'
|
||||
}
|
||||
},
|
||||
# Additional filters for rides, size, etc.
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. View Implementation (parks/views.py)
|
||||
|
||||
#### Full Search (ParkListView)
|
||||
```python
|
||||
class ParkListView(HTMXFilterableMixin, ListView):
|
||||
model = Park
|
||||
filter_class = ParkFilter
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
try:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.select_related("owner")
|
||||
.prefetch_related(
|
||||
"photos",
|
||||
"location",
|
||||
"rides",
|
||||
"rides__manufacturer"
|
||||
)
|
||||
.annotate(
|
||||
total_rides=Count("rides"),
|
||||
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||
return Park.objects.none()
|
||||
```
|
||||
|
||||
#### Quick Search
|
||||
```python
|
||||
def search_parks(request):
|
||||
try:
|
||||
queryset = (
|
||||
Park.objects.prefetch_related('location', 'photos')
|
||||
.order_by('name')
|
||||
)
|
||||
filter_params = {'search': request.GET.get('q', '').strip()}
|
||||
park_filter = ParkFilter(filter_params, queryset=queryset)
|
||||
parks = park_filter.qs[:10]
|
||||
|
||||
return render(request, "parks/partials/park_search_results.html", {
|
||||
"parks": parks,
|
||||
"is_quick_search": True
|
||||
})
|
||||
except Exception as e:
|
||||
return render(..., {"error": str(e)})
|
||||
```
|
||||
|
||||
### 3. Template Structure
|
||||
|
||||
#### Main Search Page (parks/templates/parks/park_list.html)
|
||||
- Extends: search/layouts/filtered_list.html
|
||||
- Blocks:
|
||||
* filter_errors: Validation error display
|
||||
* list_header: Park list header + actions
|
||||
* filter_section: Filter form with clear option
|
||||
* results_section: Park results with pagination
|
||||
|
||||
#### Results Display (search/templates/search/partials/park_results.html)
|
||||
- Full park information
|
||||
- Status indicators
|
||||
- Ride statistics
|
||||
- Location details
|
||||
- Error state handling
|
||||
|
||||
#### Quick Search Results (parks/partials/park_search_results.html)
|
||||
- Simplified park display
|
||||
- Basic location info
|
||||
- Fallback for missing images
|
||||
- Error handling
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
#### View Level
|
||||
- Try/except blocks around queryset operations
|
||||
- Filter validation errors captured
|
||||
- Generic error states handled
|
||||
- User-friendly error messages
|
||||
|
||||
#### Template Level
|
||||
- Error states in both quick and full search
|
||||
- Safe data access (using with and conditionals)
|
||||
- Fallback content for missing data
|
||||
- Clear error messaging
|
||||
|
||||
### 5. Query Optimization
|
||||
|
||||
#### Full Search
|
||||
- select_related: owner
|
||||
- prefetch_related: photos, location, rides, rides__manufacturer
|
||||
- Proper annotations for counts
|
||||
- Pagination for large results
|
||||
|
||||
#### Quick Search
|
||||
- Limited to 10 results
|
||||
- Minimal related data loading
|
||||
- Basic ordering optimization
|
||||
|
||||
### 6. Known Limitations
|
||||
|
||||
1. Testing Coverage
|
||||
- Need unit tests for filters
|
||||
- Need integration tests for error cases
|
||||
- Need performance testing
|
||||
|
||||
2. Performance
|
||||
- Large dataset behavior unknown
|
||||
- Complex filter combinations untested
|
||||
|
||||
3. Security
|
||||
- SQL injection prevention needs review
|
||||
- Permission checks need audit
|
||||
|
||||
4. Accessibility
|
||||
- ARIA labels needed
|
||||
- Color contrast validation needed
|
||||
|
||||
### 7. Next Steps
|
||||
|
||||
1. Testing
|
||||
- Implement comprehensive test suite
|
||||
- Add performance benchmarks
|
||||
- Test edge cases
|
||||
|
||||
2. Monitoring
|
||||
- Add error logging
|
||||
- Implement performance tracking
|
||||
- Add usage analytics
|
||||
|
||||
3. Optimization
|
||||
- Profile query performance
|
||||
- Optimize filter combinations
|
||||
- Consider caching strategies
|
||||
142
memory-bank/features/search/testing-implementation.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Park Search Testing Implementation
|
||||
|
||||
## Test Structure
|
||||
|
||||
### 1. Model Tests (parks/tests/test_models.py)
|
||||
|
||||
#### Park Model Tests
|
||||
- Basic CRUD Operations
|
||||
* Creation with required fields
|
||||
* Update operations
|
||||
* Deletion and cascading
|
||||
* Validation rules
|
||||
|
||||
- Slug Operations
|
||||
* Auto-generation on creation
|
||||
* Historical slug tracking and lookup (via HistoricalSlug model)
|
||||
* pghistory integration for model tracking
|
||||
* Uniqueness constraints
|
||||
* Fallback lookup strategies
|
||||
|
||||
- Location Integration
|
||||
* Formatted location string
|
||||
* Coordinates retrieval
|
||||
* Location relationship integrity
|
||||
|
||||
- Status Management
|
||||
* Default status
|
||||
* Status color mapping
|
||||
* Status transitions
|
||||
|
||||
- Property Methods
|
||||
* formatted_location
|
||||
* coordinates
|
||||
* get_status_color
|
||||
|
||||
### 2. Filter Tests (parks/tests/test_filters.py)
|
||||
|
||||
#### Search Functionality
|
||||
- Text Search Fields
|
||||
* Name searching
|
||||
* Description searching
|
||||
* Location field searching (city, state, country)
|
||||
* Combined field searching
|
||||
|
||||
#### Filter Operations
|
||||
- Status Filtering
|
||||
* Each status value
|
||||
* Empty/null handling
|
||||
* Invalid status values
|
||||
|
||||
- Date Range Filtering
|
||||
* Opening date ranges
|
||||
* Invalid date formats
|
||||
* Edge cases (future dates, very old dates)
|
||||
|
||||
- Company/Owner Filtering
|
||||
* Existing company
|
||||
* No owner (null)
|
||||
* Invalid company IDs
|
||||
|
||||
- Numeric Filtering
|
||||
* Minimum rides count
|
||||
* Minimum coasters count
|
||||
* Minimum size validation
|
||||
* Negative value handling
|
||||
|
||||
#### Mixin Integration
|
||||
- LocationFilterMixin
|
||||
* Distance-based filtering
|
||||
* Location search functionality
|
||||
|
||||
- RatingFilterMixin
|
||||
* Rating range filtering
|
||||
* Invalid rating values
|
||||
|
||||
- DateRangeFilterMixin
|
||||
* Date range application
|
||||
* Invalid date handling
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Completed
|
||||
1. ✓ Created test directory structure
|
||||
2. ✓ Set up test fixtures in both test files
|
||||
3. ✓ Implemented Park model tests
|
||||
- Basic CRUD operations
|
||||
- Advanced slug functionality:
|
||||
* Automatic slug generation from name
|
||||
* Historical slug tracking with HistoricalSlug model
|
||||
* Dual tracking with pghistory integration
|
||||
* Comprehensive lookup system with fallbacks
|
||||
- Status color mapping with complete coverage
|
||||
- Location integration with error handling
|
||||
- Property methods with null safety
|
||||
4. ✓ Implemented ParkFilter tests
|
||||
- Text search with multiple field support
|
||||
- Status filtering with validation and choice handling
|
||||
- Date range filtering with format validation
|
||||
- Company/owner filtering with comprehensive null handling
|
||||
- Numeric filtering with integer validation and bounds checking
|
||||
- Empty value handling across all filters
|
||||
- Test coverage for edge cases and invalid inputs
|
||||
- Performance validation for complex filter combinations
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Performance Optimization
|
||||
- [ ] Add query count assertions to tests
|
||||
- [ ] Profile filter combinations impact
|
||||
- [ ] Implement caching for common filters
|
||||
- [ ] Add database indexes for frequently filtered fields
|
||||
|
||||
2. Monitoring and Analytics
|
||||
- [ ] Add filter usage tracking
|
||||
- [ ] Implement performance monitoring
|
||||
- [ ] Track common filter combinations
|
||||
- [ ] Monitor query execution times
|
||||
|
||||
3. Documentation and Maintenance
|
||||
- [ ] Add filter example documentation
|
||||
- [ ] Document filter combinations and best practices
|
||||
- [ ] Create performance troubleshooting guide
|
||||
- [ ] Add test coverage reports and analysis
|
||||
|
||||
4. Future Enhancements
|
||||
- [ ] Add saved filter support
|
||||
- [ ] Implement filter presets
|
||||
- [ ] Add advanced combination operators (AND/OR)
|
||||
- [ ] Support dynamic field filtering
|
||||
|
||||
### Running the Tests
|
||||
|
||||
To run the test suite:
|
||||
```bash
|
||||
python manage.py test parks.tests
|
||||
```
|
||||
|
||||
To run specific test classes:
|
||||
```bash
|
||||
python manage.py test parks.tests.test_models.ParkModelTests
|
||||
python manage.py test parks.tests.test_filters.ParkFilterTests
|
||||
```
|
||||
85
memory-bank/productContext.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Product Context
|
||||
|
||||
## Overview
|
||||
ThrillWiki is a comprehensive platform for theme park enthusiasts to discover parks, share experiences, and access verified information through a moderated knowledge base.
|
||||
|
||||
## User Types and Needs
|
||||
|
||||
### Park Enthusiasts
|
||||
- **Problem**: Difficulty finding accurate, comprehensive theme park information
|
||||
- **Solution**: Centralized, moderated platform with verified park/ride data
|
||||
- **Key Features**: Park discovery, ride details, location services
|
||||
|
||||
### Reviewers
|
||||
- **Problem**: No dedicated platform for sharing detailed ride experiences
|
||||
- **Solution**: Structured review system with rich media support
|
||||
- **Key Features**: Media uploads, rating system, review workflow
|
||||
|
||||
### Park Operators
|
||||
- **Problem**: Limited channels for authentic presence and information
|
||||
- **Solution**: Verified company profiles and official park information
|
||||
- **Key Features**: Company verification, official updates, park management
|
||||
|
||||
## Core Workflows
|
||||
|
||||
1. Park Discovery & Information
|
||||
- Geographic search and browsing
|
||||
- Detailed park profiles
|
||||
- Operating hours and details
|
||||
|
||||
2. Ride Management
|
||||
- Comprehensive ride database
|
||||
- Technical specifications
|
||||
- Historical information
|
||||
- Designer attribution
|
||||
|
||||
3. Review System
|
||||
- User-generated content
|
||||
- Media integration
|
||||
- Rating framework
|
||||
- Moderation workflow
|
||||
|
||||
4. Content Moderation
|
||||
- Submission review
|
||||
- Quality control
|
||||
- Content verification
|
||||
- User management
|
||||
|
||||
5. Location Services
|
||||
- Geographic search
|
||||
- Proximity features
|
||||
- Regional categorization
|
||||
|
||||
## Strategic Direction
|
||||
|
||||
### Current Focus
|
||||
1. Content Quality
|
||||
- Robust moderation
|
||||
- Information verification
|
||||
- Rich media support
|
||||
|
||||
2. User Trust
|
||||
- Review authenticity
|
||||
- Company verification
|
||||
- Expert contributions
|
||||
|
||||
3. Data Completeness
|
||||
- Park coverage
|
||||
- Ride information
|
||||
- Historical records
|
||||
|
||||
### Future Roadmap
|
||||
1. Community Features
|
||||
- Enhanced profiles
|
||||
- Contribution recognition
|
||||
- Expert designation
|
||||
|
||||
2. Analytics
|
||||
- Usage patterns
|
||||
- Quality metrics
|
||||
- Engagement tracking
|
||||
|
||||
3. Media
|
||||
- Image improvements
|
||||
- Video support
|
||||
- Virtual tours
|
||||
34
memory-bank/projects/history-tracking/implementation-plan.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# History Tracking Implementation Plan
|
||||
|
||||
## Phase Order & Document Links
|
||||
|
||||
1. **Architecture Design**
|
||||
- [Integration Strategy](/decisions/pghistory-integration.md)
|
||||
- [System Patterns Update](/systemPatterns.md#historical-tracking)
|
||||
|
||||
2. **Model Layer Implementation**
|
||||
- [Migration Protocol](/workflows/model-migrations.md)
|
||||
- [Base Model Configuration](/decisions/pghistory-integration.md#model-layer-integration)
|
||||
|
||||
3. **Moderation System Update**
|
||||
- [Approval Workflow](/workflows/moderation.md#updated-moderation-workflow-with-django-pghistory)
|
||||
- [Admin Integration](/workflows/moderation.md#moderation-admin-integration)
|
||||
|
||||
4. **Frontend Visualization**
|
||||
- [Timeline Component](/features/history-visualization.md#template-components)
|
||||
- [API Endpoints](/features/history-visualization.md#ajax-endpoints)
|
||||
|
||||
5. **Deployment Checklist**
|
||||
- [Context Middleware](/systemPatterns.md#request-context-tracking)
|
||||
- [QA Procedures](/workflows/model-migrations.md#quality-assurance)
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
memory-bank/
|
||||
projects/
|
||||
history-tracking/
|
||||
implementation-plan.md
|
||||
decisions.md -> ../../decisions/pghistory-integration.md
|
||||
frontend.md -> ../../features/history-visualization.md
|
||||
migrations.md -> ../../workflows/model-migrations.md
|
||||
moderation.md -> ../../workflows/moderation.md
|
||||
13
memory-bank/state/history_tracking_migration.md
Normal file
@@ -0,0 +1,13 @@
|
||||
Current State at Mon Feb 10 00:19:42 EST 2025:
|
||||
|
||||
1. In process of migrating history tracking system
|
||||
2. Created initial migration for HistoricalSlug model
|
||||
3. Interrupted during attempt to handle auto_now_add field migration
|
||||
4. Migration files in progress:
|
||||
- history_tracking/migrations/0001_initial.py
|
||||
- rides/migrations/0002_event_models_unmanaged.py
|
||||
|
||||
Next planned steps (awaiting confirmation):
|
||||
1. Complete history_tracking migrations
|
||||
2. Update rides event models
|
||||
3. Test history tracking functionality
|
||||