Compare commits
1 Commits
pixeebot/d
...
cbe1dd726f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe1dd726f |
27
.github/workflows/django.yml
vendored
@@ -2,40 +2,29 @@ name: Django CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ "main" ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
build:
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest]
|
python-version: [3.12]
|
||||||
python-version: [3.13.1]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Homebrew on Linux
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
||||||
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Install GDAL with Homebrew
|
|
||||||
run: brew install gdal
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: |
|
run: |
|
||||||
python manage.py test
|
python manage.py test
|
||||||
|
|||||||
34
.github/workflows/review.yml
vendored
@@ -1,34 +0,0 @@
|
|||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Run on new/updated PRs
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
|
|
||||||
# Allow manual triggers for existing PRs
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
pr_number:
|
|
||||||
description: 'Pull Request Number'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
code-review:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: development_environment
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Run Claude Review
|
|
||||||
uses: pacnpal/claude-code-review@main
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
from reviews.models import Review
|
|
||||||
from parks.models import Park
|
|
||||||
from rides.models import Ride
|
|
||||||
from media.models import Photo
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Cleans up test users and data created during e2e testing"
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
|
||||||
# Delete test users
|
|
||||||
test_users = User.objects.filter(username__in=["testuser", "moderator"])
|
|
||||||
count = test_users.count()
|
|
||||||
test_users.delete()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
|
||||||
|
|
||||||
# Delete test reviews
|
|
||||||
reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
|
|
||||||
count = reviews.count()
|
|
||||||
reviews.delete()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
|
||||||
|
|
||||||
# Delete test photos
|
|
||||||
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
|
|
||||||
count = photos.count()
|
|
||||||
photos.delete()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
|
||||||
|
|
||||||
# Delete test parks
|
|
||||||
parks = Park.objects.filter(name__startswith="Test Park")
|
|
||||||
count = parks.count()
|
|
||||||
parks.delete()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test parks"))
|
|
||||||
|
|
||||||
# Delete test rides
|
|
||||||
rides = Ride.objects.filter(name__startswith="Test Ride")
|
|
||||||
count = rides.count()
|
|
||||||
rides.delete()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
|
||||||
|
|
||||||
# Clean up test files
|
|
||||||
import os
|
|
||||||
import glob
|
|
||||||
|
|
||||||
# Clean up test uploads
|
|
||||||
media_patterns = [
|
|
||||||
"media/uploads/test_*",
|
|
||||||
"media/avatars/test_*",
|
|
||||||
"media/park/test_*",
|
|
||||||
"media/rides/test_*",
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in media_patterns:
|
|
||||||
files = glob.glob(pattern)
|
|
||||||
for f in files:
|
|
||||||
try:
|
|
||||||
os.remove(f)
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
|
||||||
except OSError as e:
|
|
||||||
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.auth.models import Group, Permission
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Creates test users for e2e testing"
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
|
||||||
# Create regular test user
|
|
||||||
if not User.objects.filter(username="testuser").exists():
|
|
||||||
user = User.objects.create_user(
|
|
||||||
username="testuser",
|
|
||||||
email="testuser@example.com",
|
|
||||||
[PASSWORD-REMOVED]",
|
|
||||||
)
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
|
|
||||||
else:
|
|
||||||
self.stdout.write(self.style.WARNING("Test user already exists"))
|
|
||||||
|
|
||||||
# Create moderator user
|
|
||||||
if not User.objects.filter(username="moderator").exists():
|
|
||||||
moderator = User.objects.create_user(
|
|
||||||
username="moderator",
|
|
||||||
email="moderator@example.com",
|
|
||||||
[PASSWORD-REMOVED]",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create moderator group if it doesn't exist
|
|
||||||
moderator_group, created = Group.objects.get_or_create(name="Moderators")
|
|
||||||
|
|
||||||
# Add relevant permissions
|
|
||||||
permissions = Permission.objects.filter(
|
|
||||||
codename__in=[
|
|
||||||
"change_review",
|
|
||||||
"delete_review",
|
|
||||||
"change_park",
|
|
||||||
"change_ride",
|
|
||||||
"moderate_photos",
|
|
||||||
"moderate_comments",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
moderator_group.permissions.add(*permissions)
|
|
||||||
|
|
||||||
# Add user to moderator group
|
|
||||||
moderator.groups.add(moderator_group)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Test users setup complete"))
|
|
||||||
@@ -22,7 +22,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f'- {site.domain} ({site.name})')
|
self.stdout.write(f'- {site.domain} ({site.name})')
|
||||||
|
|
||||||
# Show callback URL
|
# Show callback URL
|
||||||
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
|
callback_url = f'http://localhost:8000/accounts/discord/login/callback/'
|
||||||
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
|
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
|
||||||
self.stdout.write(callback_url)
|
self.stdout.write(callback_url)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
import pgtrigger.compiler
|
|
||||||
import pgtrigger.migrations
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -17,7 +15,6 @@ class Migration(migrations.Migration):
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
("auth", "0012_alter_user_first_name_max_length"),
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
("pghistory", "0006_delete_aggregateevent"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -232,7 +229,15 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="TopList",
|
name="TopList",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
("title", models.CharField(max_length=100)),
|
("title", models.CharField(max_length=100)),
|
||||||
(
|
(
|
||||||
"category",
|
"category",
|
||||||
@@ -263,145 +268,6 @@ class Migration(migrations.Migration):
|
|||||||
"ordering": ["-updated_at"],
|
"ordering": ["-updated_at"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name="TopListEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
("title", models.CharField(max_length=100)),
|
|
||||||
(
|
|
||||||
"category",
|
|
||||||
models.CharField(
|
|
||||||
choices=[
|
|
||||||
("RC", "Roller Coaster"),
|
|
||||||
("DR", "Dark Ride"),
|
|
||||||
("FR", "Flat Ride"),
|
|
||||||
("WR", "Water Ride"),
|
|
||||||
("PK", "Park"),
|
|
||||||
],
|
|
||||||
max_length=2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
(
|
|
||||||
"pgh_context",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_obj",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="accounts.toplist",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="TopListItem",
|
|
||||||
fields=[
|
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
|
||||||
("object_id", models.PositiveIntegerField()),
|
|
||||||
("rank", models.PositiveIntegerField()),
|
|
||||||
("notes", models.TextField(blank=True)),
|
|
||||||
(
|
|
||||||
"content_type",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="contenttypes.contenttype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"top_list",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="items",
|
|
||||||
to="accounts.toplist",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ["rank"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="TopListItemEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
("object_id", models.PositiveIntegerField()),
|
|
||||||
("rank", models.PositiveIntegerField()),
|
|
||||||
("notes", models.TextField(blank=True)),
|
|
||||||
(
|
|
||||||
"content_type",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to="contenttypes.contenttype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_context",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_obj",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="accounts.toplistitem",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"top_list",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to="accounts.toplist",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="UserProfile",
|
name="UserProfile",
|
||||||
fields=[
|
fields=[
|
||||||
@@ -452,66 +318,40 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
pgtrigger.migrations.AddTrigger(
|
migrations.CreateModel(
|
||||||
model_name="toplist",
|
name="TopListItem",
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
fields=[
|
||||||
name="insert_insert",
|
(
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
"id",
|
||||||
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
models.BigAutoField(
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
auto_created=True,
|
||||||
operation="INSERT",
|
primary_key=True,
|
||||||
pgid="pgtrigger_insert_insert_26546",
|
serialize=False,
|
||||||
table="accounts_toplist",
|
verbose_name="ID",
|
||||||
when="AFTER",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
("object_id", models.PositiveIntegerField()),
|
||||||
pgtrigger.migrations.AddTrigger(
|
("rank", models.PositiveIntegerField()),
|
||||||
model_name="toplist",
|
("notes", models.TextField(blank=True)),
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
(
|
||||||
name="update_update",
|
"content_type",
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
models.ForeignKey(
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
to="contenttypes.contenttype",
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_84849",
|
|
||||||
table="accounts_toplist",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
(
|
||||||
migrations.AlterUniqueTogether(
|
"top_list",
|
||||||
name="toplistitem",
|
models.ForeignKey(
|
||||||
unique_together={("top_list", "rank")},
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
),
|
related_name="items",
|
||||||
pgtrigger.migrations.AddTrigger(
|
to="accounts.toplist",
|
||||||
model_name="toplistitem",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_56dfc",
|
|
||||||
table="accounts_toplistitem",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="toplistitem",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_2b6e3",
|
|
||||||
table="accounts_toplistitem",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["rank"],
|
||||||
|
"unique_together": {("top_list", "rank")},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class TurnstileMixin:
|
|||||||
'remoteip': request.META.get('REMOTE_ADDR'),
|
'remoteip': request.META.get('REMOTE_ADDR'),
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
if not result.get('success'):
|
if not result.get('success'):
|
||||||
|
|||||||
@@ -2,24 +2,22 @@ from django.contrib.auth.models import AbstractUser
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
import random
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import secrets
|
|
||||||
from history_tracking.models import TrackedModel
|
|
||||||
import pghistory
|
|
||||||
|
|
||||||
def generate_random_id(model_class, id_field):
|
def generate_random_id(model_class, id_field):
|
||||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||||
while True:
|
while True:
|
||||||
# Try to get a 4-digit number first
|
# Try to get a 4-digit number first
|
||||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
new_id = str(random.randint(1000, 9999))
|
||||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
# If all 4-digit numbers are taken, try 5 digits
|
# If all 4-digit numbers are taken, try 5 digits
|
||||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
new_id = str(random.randint(10000, 99999))
|
||||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
@@ -160,8 +158,7 @@ class PasswordReset(models.Model):
|
|||||||
verbose_name = "Password Reset"
|
verbose_name = "Password Reset"
|
||||||
verbose_name_plural = "Password Resets"
|
verbose_name_plural = "Password Resets"
|
||||||
|
|
||||||
@pghistory.track()
|
class TopList(models.Model):
|
||||||
class TopList(TrackedModel):
|
|
||||||
class Categories(models.TextChoices):
|
class Categories(models.TextChoices):
|
||||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
||||||
DARK_RIDE = 'DR', _('Dark Ride')
|
DARK_RIDE = 'DR', _('Dark Ride')
|
||||||
@@ -189,8 +186,7 @@ class TopList(TrackedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||||
|
|
||||||
@pghistory.track()
|
class TopListItem(models.Model):
|
||||||
class TopListItem(TrackedModel):
|
|
||||||
top_list = models.ForeignKey(
|
top_list = models.ForeignKey(
|
||||||
TopList,
|
TopList,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def create_user_profile(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
if avatar_url:
|
if avatar_url:
|
||||||
try:
|
try:
|
||||||
response = requests.get(avatar_url, timeout=60)
|
response = requests.get(avatar_url)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
img_temp = NamedTemporaryFile(delete=True)
|
img_temp = NamedTemporaryFile(delete=True)
|
||||||
img_temp.write(response.content)
|
img_temp.write(response.content)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@@ -55,8 +55,3 @@ class PageView(models.Model):
|
|||||||
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
|
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
|
||||||
|
|
||||||
return model_class.objects.none()
|
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})"
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
# Frontend Architecture Documentation
|
|
||||||
Last Updated: 2024-02-21
|
|
||||||
|
|
||||||
## Core Technologies
|
|
||||||
|
|
||||||
### 1. HTMX
|
|
||||||
- Used for dynamic updates and server interactions
|
|
||||||
- Enables partial page updates without full reloads
|
|
||||||
- Integrated with Django backend for seamless data exchange
|
|
||||||
- Used for form submissions and dynamic content loading
|
|
||||||
|
|
||||||
### 2. AlpineJS
|
|
||||||
- Handles client-side interactivity and state management
|
|
||||||
- Used for dropdowns, modals, and other interactive components
|
|
||||||
- Provides reactive data binding and event handling
|
|
||||||
- Key features used:
|
|
||||||
- x-data for component state
|
|
||||||
- x-show/x-if for conditional rendering
|
|
||||||
- x-model for two-way data binding
|
|
||||||
- x-on for event handling
|
|
||||||
|
|
||||||
### 3. Tailwind CSS
|
|
||||||
- Utility-first CSS framework for styling
|
|
||||||
- Custom configuration in tailwind.config.js
|
|
||||||
- Responsive design utilities
|
|
||||||
- Dark mode support with class-based implementation
|
|
||||||
- Custom color scheme with primary/secondary colors
|
|
||||||
|
|
||||||
## Styling System
|
|
||||||
|
|
||||||
### 1. Base Styles
|
|
||||||
- Font: Poppins (400, 500, 600, 700 weights)
|
|
||||||
- Color Scheme:
|
|
||||||
- Primary: Indigo (#4F46E5)
|
|
||||||
- Secondary: Rose (#E11D48)
|
|
||||||
- Gradients for interactive elements
|
|
||||||
- Dark mode compatible color palette
|
|
||||||
|
|
||||||
### 2. Component Classes
|
|
||||||
- Button Variants:
|
|
||||||
- .btn-primary: Gradient background with hover effects
|
|
||||||
- .btn-secondary: Light/dark mode aware styling
|
|
||||||
- Social login buttons with brand colors
|
|
||||||
- Form Elements:
|
|
||||||
- .form-input: Styled input fields
|
|
||||||
- .form-label: Consistent label styling
|
|
||||||
- .form-error: Error message styling
|
|
||||||
- Cards:
|
|
||||||
- .card: Base card styling with shadows
|
|
||||||
- .auth-card: Special styling for authentication forms
|
|
||||||
- Status Badges:
|
|
||||||
- .status-operating: Green success state
|
|
||||||
- .status-closed: Red error state
|
|
||||||
- .status-construction: Yellow warning state
|
|
||||||
|
|
||||||
### 3. Layout Components
|
|
||||||
- Responsive container system
|
|
||||||
- Grid system using Tailwind's grid utilities
|
|
||||||
- Flexbox-based navigation and content layouts
|
|
||||||
- Mobile-first responsive design
|
|
||||||
|
|
||||||
## Interactive Components
|
|
||||||
|
|
||||||
### 1. Navigation
|
|
||||||
- Responsive header with mobile menu
|
|
||||||
- User dropdown menu with authentication states
|
|
||||||
- Theme toggle (light/dark mode)
|
|
||||||
- Mobile-optimized navigation drawer
|
|
||||||
|
|
||||||
### 2. Forms
|
|
||||||
- Location autocomplete system
|
|
||||||
- Form validation with error states
|
|
||||||
- CSRF protection integration
|
|
||||||
- File upload handling
|
|
||||||
|
|
||||||
### 3. Alerts System
|
|
||||||
- Timed auto-dismissing alerts
|
|
||||||
- Slide animations for entry/exit
|
|
||||||
- Context-aware styling (success, error, info, warning)
|
|
||||||
- Accessible notifications
|
|
||||||
|
|
||||||
### 4. Modal System
|
|
||||||
- HTMX-powered dynamic content loading
|
|
||||||
- Alpine.js state management
|
|
||||||
- Backdrop blur effects
|
|
||||||
- Keyboard navigation support
|
|
||||||
|
|
||||||
## JavaScript Architecture
|
|
||||||
|
|
||||||
### 1. Core Functionality
|
|
||||||
- Theme management with local storage persistence
|
|
||||||
- HTMX configuration and setup
|
|
||||||
- Alpine.js component initialization
|
|
||||||
- Event delegation and handling
|
|
||||||
|
|
||||||
### 2. Location Autocomplete
|
|
||||||
- Progressive enhancement for location fields
|
|
||||||
- Country/Region/City hierarchical selection
|
|
||||||
- Dynamic filtering based on parent selections
|
|
||||||
- AJAX-powered suggestions
|
|
||||||
|
|
||||||
### 3. Form Handling
|
|
||||||
- Client-side validation
|
|
||||||
- File upload preview
|
|
||||||
- Dynamic form updates
|
|
||||||
- Error state management
|
|
||||||
|
|
||||||
## Performance Optimizations
|
|
||||||
|
|
||||||
### 1. Asset Loading
|
|
||||||
- Deferred script loading
|
|
||||||
- Preloaded critical assets
|
|
||||||
- Minified production assets
|
|
||||||
- Cached static resources
|
|
||||||
|
|
||||||
### 2. Rendering
|
|
||||||
- Progressive enhancement
|
|
||||||
- Partial page updates
|
|
||||||
- Lazy loading of images
|
|
||||||
- Optimized animation performance
|
|
||||||
|
|
||||||
### 3. State Management
|
|
||||||
- Efficient DOM updates
|
|
||||||
- Debounced search inputs
|
|
||||||
- Throttled scroll handlers
|
|
||||||
- Memory leak prevention
|
|
||||||
|
|
||||||
## Accessibility Features
|
|
||||||
|
|
||||||
### 1. Semantic HTML
|
|
||||||
- Proper heading hierarchy
|
|
||||||
- ARIA labels and roles
|
|
||||||
- Semantic landmark regions
|
|
||||||
- Meaningful alt text
|
|
||||||
|
|
||||||
### 2. Keyboard Navigation
|
|
||||||
- Focus management
|
|
||||||
- Skip links
|
|
||||||
- Keyboard shortcuts
|
|
||||||
- Focus trapping in modals
|
|
||||||
|
|
||||||
### 3. Screen Readers
|
|
||||||
- ARIA live regions for alerts
|
|
||||||
- Status role for notifications
|
|
||||||
- Description text for icons
|
|
||||||
- Form label associations
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
### 1. CSS Organization
|
|
||||||
- Utility-first approach
|
|
||||||
- Component-specific styles
|
|
||||||
- Shared design tokens
|
|
||||||
- Dark mode variants
|
|
||||||
|
|
||||||
### 2. JavaScript Patterns
|
|
||||||
- Event delegation
|
|
||||||
- Component encapsulation
|
|
||||||
- State management
|
|
||||||
- Error handling
|
|
||||||
|
|
||||||
### 3. Testing Considerations
|
|
||||||
- Browser compatibility
|
|
||||||
- Responsive design testing
|
|
||||||
- Accessibility testing
|
|
||||||
- Performance monitoring
|
|
||||||
|
|
||||||
## Browser Support
|
|
||||||
|
|
||||||
### 1. Supported Browsers
|
|
||||||
- Chrome (latest 2 versions)
|
|
||||||
- Firefox (latest 2 versions)
|
|
||||||
- Safari (latest 2 versions)
|
|
||||||
- Edge (latest version)
|
|
||||||
|
|
||||||
### 2. Fallbacks
|
|
||||||
- Graceful degradation
|
|
||||||
- No-script support
|
|
||||||
- Legacy browser handling
|
|
||||||
- Progressive enhancement
|
|
||||||
|
|
||||||
## Security Measures
|
|
||||||
|
|
||||||
### 1. CSRF Protection
|
|
||||||
- Token validation
|
|
||||||
- Secure form submission
|
|
||||||
- Protected AJAX requests
|
|
||||||
- Session handling
|
|
||||||
|
|
||||||
### 2. XSS Prevention
|
|
||||||
- Content sanitization
|
|
||||||
- Escaped output
|
|
||||||
- Secure cookie handling
|
|
||||||
- Input validation
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
### 1. Potential Improvements
|
|
||||||
- Component library development
|
|
||||||
- Enhanced type checking
|
|
||||||
- Performance monitoring
|
|
||||||
- Automated testing
|
|
||||||
|
|
||||||
### 2. Maintenance
|
|
||||||
- Regular dependency updates
|
|
||||||
- Browser compatibility checks
|
|
||||||
- Performance optimization
|
|
||||||
- Security audits
|
|
||||||
@@ -20,22 +20,22 @@
|
|||||||
|
|
||||||
### Frontend Technologies
|
### Frontend Technologies
|
||||||
1. HTMX
|
1. HTMX
|
||||||
- Dynamic updates and server interactions
|
- Dynamic updates
|
||||||
- Partial rendering and progressive enhancement
|
- Partial rendering
|
||||||
- Server-side processing and form handling
|
- Server-side processing
|
||||||
- See frontendArchitecture.md for detailed implementation
|
- Progressive enhancement
|
||||||
|
|
||||||
2. AlpineJS
|
2. AlpineJS
|
||||||
- UI state management and reactivity
|
- UI state management
|
||||||
- Component behavior and lifecycle
|
- Component behavior
|
||||||
- Event handling and DOM manipulation
|
- Event handling
|
||||||
- See frontendArchitecture.md for component patterns
|
- DOM manipulation
|
||||||
|
|
||||||
3. Tailwind CSS
|
3. Tailwind CSS
|
||||||
- Utility-first styling with custom configuration
|
- Utility-first styling
|
||||||
- Component design system
|
- Component design
|
||||||
- Responsive layouts and dark mode support
|
- Responsive layouts
|
||||||
- See frontendArchitecture.md for styling guide
|
- Custom configuration
|
||||||
|
|
||||||
## Integration Patterns
|
## Integration Patterns
|
||||||
|
|
||||||
@@ -87,24 +87,16 @@
|
|||||||
|
|
||||||
### Frontend Libraries
|
### Frontend Libraries
|
||||||
1. CSS Framework
|
1. CSS Framework
|
||||||
- Tailwind CSS with custom configuration
|
- Tailwind CSS
|
||||||
- Theme system with light/dark mode support
|
- Custom plugins
|
||||||
- Component-specific style patterns
|
- Theme configuration
|
||||||
- See frontendArchitecture.md for complete styling guide
|
- Utility classes
|
||||||
|
|
||||||
2. JavaScript
|
2. JavaScript
|
||||||
- AlpineJS for reactive components
|
- AlpineJS core
|
||||||
- HTMX for server interactions
|
- HTMX library
|
||||||
- Location autocomplete system
|
- Utility functions
|
||||||
- Alert and modal components
|
- Custom components
|
||||||
- See frontendArchitecture.md for component documentation
|
|
||||||
|
|
||||||
3. UI Components
|
|
||||||
- Form elements and validation
|
|
||||||
- Navigation and menus
|
|
||||||
- Status indicators and badges
|
|
||||||
- Modal and alert system
|
|
||||||
- See frontendArchitecture.md for implementation details
|
|
||||||
|
|
||||||
## Infrastructure Choices
|
## Infrastructure Choices
|
||||||
|
|
||||||
@@ -151,14 +143,10 @@
|
|||||||
|
|
||||||
### Technology Limitations
|
### Technology Limitations
|
||||||
1. Frontend
|
1. Frontend
|
||||||
- HTMX/AlpineJS only (no React/Vue/Angular)
|
- HTMX/AlpineJS only
|
||||||
- Progressive enhancement approach required
|
- No additional frameworks
|
||||||
- Must support latest 2 versions of major browsers
|
- Browser compatibility
|
||||||
- See frontendArchitecture.md for detailed browser support
|
- Performance requirements
|
||||||
- Performance targets:
|
|
||||||
* First contentful paint < 1.5s
|
|
||||||
* Time to interactive < 2s
|
|
||||||
* Core Web Vitals compliance
|
|
||||||
|
|
||||||
2. Backend
|
2. Backend
|
||||||
- Django version constraints
|
- Django version constraints
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from simple_history.admin import SimpleHistoryAdmin
|
||||||
from .models import Company, Manufacturer
|
from .models import Company, Manufacturer
|
||||||
|
|
||||||
@admin.register(Company)
|
@admin.register(Company)
|
||||||
class CompanyAdmin(admin.ModelAdmin):
|
class CompanyAdmin(SimpleHistoryAdmin):
|
||||||
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
||||||
search_fields = ('name', 'headquarters', 'description')
|
search_fields = ('name', 'headquarters', 'description')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
readonly_fields = ('created_at', 'updated_at')
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
|
||||||
@admin.register(Manufacturer)
|
@admin.register(Manufacturer)
|
||||||
class ManufacturerAdmin(admin.ModelAdmin):
|
class ManufacturerAdmin(SimpleHistoryAdmin):
|
||||||
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
||||||
search_fields = ('name', 'headquarters', 'description')
|
search_fields = ('name', 'headquarters', 'description')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import pgtrigger.compiler
|
|
||||||
import pgtrigger.migrations
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -10,15 +7,21 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
("pghistory", "0006_delete_aggregateevent"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Company",
|
name="Company",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(max_length=255, unique=True)),
|
("slug", models.SlugField(max_length=255, unique=True)),
|
||||||
("website", models.URLField(blank=True)),
|
("website", models.URLField(blank=True)),
|
||||||
@@ -34,31 +37,18 @@ class Migration(migrations.Migration):
|
|||||||
"ordering": ["name"],
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name="CompanyEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
("name", models.CharField(max_length=255)),
|
|
||||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
|
||||||
("website", models.URLField(blank=True)),
|
|
||||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("total_parks", models.IntegerField(default=0)),
|
|
||||||
("total_rides", models.IntegerField(default=0)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Manufacturer",
|
name="Manufacturer",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(max_length=255, unique=True)),
|
("slug", models.SlugField(max_length=255, unique=True)),
|
||||||
("website", models.URLField(blank=True)),
|
("website", models.URLField(blank=True)),
|
||||||
@@ -73,125 +63,4 @@ class Migration(migrations.Migration):
|
|||||||
"ordering": ["name"],
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name="ManufacturerEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
("name", models.CharField(max_length=255)),
|
|
||||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
|
||||||
("website", models.URLField(blank=True)),
|
|
||||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("total_rides", models.IntegerField(default=0)),
|
|
||||||
("total_roller_coasters", models.IntegerField(default=0)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="company",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "companies_companyevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_parks", "total_rides", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."total_parks", NEW."total_rides", NEW."updated_at", NEW."website"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_a4101",
|
|
||||||
table="companies_company",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="company",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "companies_companyevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_parks", "total_rides", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."total_parks", NEW."total_rides", NEW."updated_at", NEW."website"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_3d5ae",
|
|
||||||
table="companies_company",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="companyevent",
|
|
||||||
name="pgh_context",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="companyevent",
|
|
||||||
name="pgh_obj",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="companies.company",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="manufacturer",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "companies_manufacturerevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_rides", "total_roller_coasters", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."total_rides", NEW."total_roller_coasters", NEW."updated_at", NEW."website"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_5c0b6",
|
|
||||||
table="companies_manufacturer",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="manufacturer",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "companies_manufacturerevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_rides", "total_roller_coasters", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."total_rides", NEW."total_roller_coasters", NEW."updated_at", NEW."website"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_81971",
|
|
||||||
table="companies_manufacturer",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="manufacturerevent",
|
|
||||||
name="pgh_context",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="manufacturerevent",
|
|
||||||
name="pgh_obj",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="companies.manufacturer",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
28
companies/migrations/0002_add_designer_model.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('companies', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Designer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('slug', models.SlugField(max_length=255, unique=True)),
|
||||||
|
('website', models.URLField(blank=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('total_rides', models.IntegerField(default=0)),
|
||||||
|
('total_roller_coasters', models.IntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,11 +2,11 @@ from django.db import models
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
||||||
import pghistory
|
|
||||||
from history_tracking.models import TrackedModel, HistoricalSlug
|
|
||||||
|
|
||||||
@pghistory.track()
|
if TYPE_CHECKING:
|
||||||
class Company(TrackedModel):
|
from history_tracking.models import HistoricalSlug
|
||||||
|
|
||||||
|
class Company(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -37,18 +37,8 @@ class Company(TrackedModel):
|
|||||||
try:
|
try:
|
||||||
return cls.objects.get(slug=slug), False
|
return cls.objects.get(slug=slug), False
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
# Check pghistory first
|
# Check historical slugs
|
||||||
history_model = cls.get_history_model()
|
from history_tracking.models import HistoricalSlug
|
||||||
history_entry = (
|
|
||||||
history_model.objects.filter(slug=slug)
|
|
||||||
.order_by('-pgh_created_at')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if history_entry:
|
|
||||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
|
||||||
|
|
||||||
# Check manual slug history as fallback
|
|
||||||
try:
|
try:
|
||||||
historical = HistoricalSlug.objects.get(
|
historical = HistoricalSlug.objects.get(
|
||||||
content_type__model='company',
|
content_type__model='company',
|
||||||
@@ -58,8 +48,7 @@ class Company(TrackedModel):
|
|||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
@pghistory.track()
|
class Manufacturer(models.Model):
|
||||||
class Manufacturer(TrackedModel):
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -89,18 +78,8 @@ class Manufacturer(TrackedModel):
|
|||||||
try:
|
try:
|
||||||
return cls.objects.get(slug=slug), False
|
return cls.objects.get(slug=slug), False
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
# Check pghistory first
|
# Check historical slugs
|
||||||
history_model = cls.get_history_model()
|
from history_tracking.models import HistoricalSlug
|
||||||
history_entry = (
|
|
||||||
history_model.objects.filter(slug=slug)
|
|
||||||
.order_by('-pgh_created_at')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if history_entry:
|
|
||||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
|
||||||
|
|
||||||
# Check manual slug history as fallback
|
|
||||||
try:
|
try:
|
||||||
historical = HistoricalSlug.objects.get(
|
historical = HistoricalSlug.objects.get(
|
||||||
content_type__model='manufacturer',
|
content_type__model='manufacturer',
|
||||||
@@ -109,3 +88,43 @@ class Manufacturer(TrackedModel):
|
|||||||
return cls.objects.get(pk=historical.object_id), True
|
return cls.objects.get(pk=historical.object_id), True
|
||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
|
class Designer(models.Model):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
total_rides = models.IntegerField(default=0)
|
||||||
|
total_roller_coasters = models.IntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects: ClassVar[models.Manager['Designer']]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs) -> None:
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
||||||
|
"""Get designer by slug, checking historical slugs if needed"""
|
||||||
|
try:
|
||||||
|
return cls.objects.get(slug=slug), False
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
# Check historical slugs
|
||||||
|
from history_tracking.models import HistoricalSlug
|
||||||
|
try:
|
||||||
|
historical = HistoricalSlug.objects.get(
|
||||||
|
content_type__model='designer',
|
||||||
|
slug=slug
|
||||||
|
)
|
||||||
|
return cls.objects.get(pk=historical.object_id), True
|
||||||
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
|
raise cls.DoesNotExist()
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmis
|
|||||||
|
|
||||||
|
|
||||||
def _handle_submission(
|
def _handle_submission(
|
||||||
request: Any, form: Any, model: ModelType, success_url: str = ""
|
request: Any, form: Any, model: ModelType, success_url: str
|
||||||
) -> HttpResponseRedirect:
|
) -> HttpResponseRedirect:
|
||||||
"""Helper method to handle form submissions"""
|
"""Helper method to handle form submissions"""
|
||||||
cleaned_data = form.cleaned_data.copy()
|
cleaned_data = form.cleaned_data.copy()
|
||||||
@@ -214,7 +214,6 @@ def _handle_submission(
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
content_type=ContentType.objects.get_for_model(model),
|
content_type=ContentType.objects.get_for_model(model),
|
||||||
submission_type="CREATE",
|
submission_type="CREATE",
|
||||||
status="NEW",
|
|
||||||
changes=cleaned_data,
|
changes=cleaned_data,
|
||||||
reason=request.POST.get("reason", ""),
|
reason=request.POST.get("reason", ""),
|
||||||
source=request.POST.get("source", ""),
|
source=request.POST.get("source", ""),
|
||||||
@@ -230,12 +229,6 @@ def _handle_submission(
|
|||||||
submission.status = "APPROVED"
|
submission.status = "APPROVED"
|
||||||
submission.handled_by = request.user
|
submission.handled_by = request.user
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Generate success URL if not provided
|
|
||||||
if not success_url:
|
|
||||||
success_url = reverse(
|
|
||||||
f"companies:{model.__name__.lower()}_detail", kwargs={"slug": obj.slug}
|
|
||||||
)
|
|
||||||
messages.success(request, f'Successfully created {getattr(obj, "name", "")}')
|
messages.success(request, f'Successfully created {getattr(obj, "name", "")}')
|
||||||
return HttpResponseRedirect(success_url)
|
return HttpResponseRedirect(success_url)
|
||||||
|
|
||||||
@@ -251,7 +244,10 @@ class CompanyCreateView(LoginRequiredMixin, CreateView):
|
|||||||
object: Optional[Company]
|
object: Optional[Company]
|
||||||
|
|
||||||
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
||||||
return _handle_submission(self.request, form, self.model, "")
|
success_url = reverse(
|
||||||
|
"companies:company_detail", kwargs={"slug": form.instance.slug}
|
||||||
|
)
|
||||||
|
return _handle_submission(self.request, form, self.model, success_url)
|
||||||
|
|
||||||
def get_success_url(self) -> str:
|
def get_success_url(self) -> str:
|
||||||
if self.object is None:
|
if self.object is None:
|
||||||
@@ -266,7 +262,10 @@ class ManufacturerCreateView(LoginRequiredMixin, CreateView):
|
|||||||
object: Optional[Manufacturer]
|
object: Optional[Manufacturer]
|
||||||
|
|
||||||
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
||||||
return _handle_submission(self.request, form, self.model, "")
|
success_url = reverse(
|
||||||
|
"companies:manufacturer_detail", kwargs={"slug": form.instance.slug}
|
||||||
|
)
|
||||||
|
return _handle_submission(self.request, form, self.model, success_url)
|
||||||
|
|
||||||
def get_success_url(self) -> str:
|
def get_success_url(self) -> str:
|
||||||
if self.object is None:
|
if self.object is None:
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import pghistory
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.core.handlers.wsgi import WSGIRequest
|
|
||||||
|
|
||||||
class RequestContextProvider(pghistory.context):
|
|
||||||
"""Custom context provider for pghistory that extracts information from the request."""
|
|
||||||
def __call__(self, request: WSGIRequest) -> dict:
|
|
||||||
return {
|
|
||||||
'user': str(request.user) if request.user and not isinstance(request.user, AnonymousUser) else None,
|
|
||||||
'ip': request.META.get('REMOTE_ADDR'),
|
|
||||||
'user_agent': request.META.get('HTTP_USER_AGENT'),
|
|
||||||
'session_key': request.session.session_key if hasattr(request, 'session') else None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Initialize the context provider
|
|
||||||
request_context = RequestContextProvider()
|
|
||||||
|
|
||||||
class PgHistoryContextMiddleware:
|
|
||||||
"""
|
|
||||||
Middleware that ensures request object is available to pghistory context.
|
|
||||||
"""
|
|
||||||
def __init__(self, get_response):
|
|
||||||
self.get_response = get_response
|
|
||||||
|
|
||||||
def __call__(self, request):
|
|
||||||
response = self.get_response(request)
|
|
||||||
return response
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from django.db import models
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from history_tracking.models import TrackedModel
|
|
||||||
|
|
||||||
class SlugHistory(models.Model):
|
class SlugHistory(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -27,7 +26,7 @@ class SlugHistory(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Old slug '{self.old_slug}' for {self.content_object}"
|
return f"Old slug '{self.old_slug}' for {self.content_object}"
|
||||||
|
|
||||||
class SluggedModel(TrackedModel):
|
class SluggedModel(models.Model):
|
||||||
"""
|
"""
|
||||||
Abstract base model that provides slug functionality with history tracking.
|
Abstract base model that provides slug functionality with history tracking.
|
||||||
"""
|
"""
|
||||||
@@ -77,18 +76,7 @@ class SluggedModel(TrackedModel):
|
|||||||
# Try to get by current slug first
|
# Try to get by current slug first
|
||||||
return cls.objects.get(slug=slug), False
|
return cls.objects.get(slug=slug), False
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
# Check pghistory first
|
# Try to find in slug history
|
||||||
history_model = cls.get_history_model()
|
|
||||||
history_entry = (
|
|
||||||
history_model.objects.filter(slug=slug)
|
|
||||||
.order_by('-pgh_created_at')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if history_entry:
|
|
||||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
|
||||||
|
|
||||||
# Try to find in manual slug history as fallback
|
|
||||||
history = SlugHistory.objects.filter(
|
history = SlugHistory.objects.filter(
|
||||||
content_type=ContentType.objects.get_for_model(cls),
|
content_type=ContentType.objects.get_for_model(cls),
|
||||||
old_slug=slug
|
old_slug=slug
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.text import slugify
|
from simple_history.admin import SimpleHistoryAdmin
|
||||||
from .models import Designer
|
from .models import Designer
|
||||||
|
|
||||||
@admin.register(Designer)
|
@admin.register(Designer)
|
||||||
class DesignerAdmin(admin.ModelAdmin):
|
class DesignerAdmin(SimpleHistoryAdmin):
|
||||||
list_display = ('name', 'headquarters', 'founded_date', 'website')
|
list_display = ('name', 'headquarters', 'founded_date', 'website')
|
||||||
search_fields = ('name', 'headquarters')
|
search_fields = ('name', 'headquarters')
|
||||||
|
list_filter = ('founded_date',)
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
readonly_fields = ('created_at', 'updated_at')
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
return super().get_queryset(request).select_related()
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import pgtrigger.compiler
|
import simple_history.models
|
||||||
import pgtrigger.migrations
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -11,14 +11,22 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("pghistory", "0006_delete_aggregateevent"),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Designer",
|
name="Designer",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(max_length=255, unique=True)),
|
("slug", models.SlugField(max_length=255, unique=True)),
|
||||||
("description", models.TextField(blank=True)),
|
("description", models.TextField(blank=True)),
|
||||||
@@ -33,73 +41,48 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="DesignerEvent",
|
name="HistoricalDesigner",
|
||||||
fields=[
|
fields=[
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
(
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
"id",
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
models.BigIntegerField(
|
||||||
("id", models.BigIntegerField()),
|
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
("slug", models.SlugField(max_length=255)),
|
||||||
("description", models.TextField(blank=True)),
|
("description", models.TextField(blank=True)),
|
||||||
("website", models.URLField(blank=True)),
|
("website", models.URLField(blank=True)),
|
||||||
("founded_date", models.DateField(blank=True, null=True)),
|
("founded_date", models.DateField(blank=True, null=True)),
|
||||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
("headquarters", models.CharField(blank=True, max_length=255)),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(blank=True, editable=False)),
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
("updated_at", models.DateTimeField(blank=True, editable=False)),
|
||||||
|
("history_id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("history_date", models.DateTimeField(db_index=True)),
|
||||||
|
("history_change_reason", models.CharField(max_length=100, null=True)),
|
||||||
|
(
|
||||||
|
"history_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
|
||||||
|
max_length=1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"history_user",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"abstract": False,
|
"verbose_name": "historical designer",
|
||||||
|
"verbose_name_plural": "historical designers",
|
||||||
|
"ordering": ("-history_date", "-history_id"),
|
||||||
|
"get_latest_by": ("history_date", "history_id"),
|
||||||
},
|
},
|
||||||
),
|
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="designer",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_9be65",
|
|
||||||
table="designers_designer",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="designer",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_b5f91",
|
|
||||||
table="designers_designer",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="designerevent",
|
|
||||||
name="pgh_context",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="designerevent",
|
|
||||||
name="pgh_obj",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="designers.designer",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from history_tracking.models import TrackedModel
|
from simple_history.models import HistoricalRecords
|
||||||
import pghistory
|
|
||||||
|
|
||||||
@pghistory.track()
|
class Designer(models.Model):
|
||||||
class Designer(TrackedModel):
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
@@ -13,6 +11,7 @@ class Designer(TrackedModel):
|
|||||||
headquarters = models.CharField(max_length=255, blank=True)
|
headquarters = models.CharField(max_length=255, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
history = HistoricalRecords()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
@@ -31,13 +30,8 @@ class Designer(TrackedModel):
|
|||||||
try:
|
try:
|
||||||
return cls.objects.get(slug=slug), False
|
return cls.objects.get(slug=slug), False
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
# Check historical slugs using pghistory
|
# Check historical slugs
|
||||||
history_model = cls.get_history_model()
|
history = cls.history.filter(slug=slug).order_by('-history_date').first()
|
||||||
history = (
|
|
||||||
history_model.objects.filter(slug=slug)
|
|
||||||
.order_by('-pgh_created_at')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if history:
|
if history:
|
||||||
return cls.objects.get(id=history.pgh_obj_id), True
|
return cls.objects.get(id=history.id), True
|
||||||
raise cls.DoesNotExist("No designer found with this slug")
|
raise cls.DoesNotExist("No designer found with this slug")
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class Command(BaseCommand):
|
|||||||
# If no recipient specified, use the from_email address for testing
|
# If no recipient specified, use the from_email address for testing
|
||||||
to_email = options['to'] or 'test@thrillwiki.com'
|
to_email = options['to'] or 'test@thrillwiki.com'
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS('Using configuration:'))
|
self.stdout.write(self.style.SUCCESS(f'Using configuration:'))
|
||||||
self.stdout.write(f' From: {from_email}')
|
self.stdout.write(f' From: {from_email}')
|
||||||
self.stdout.write(f' To: {to_email}')
|
self.stdout.write(f' To: {to_email}')
|
||||||
self.stdout.write(f' API Key: {"*" * len(api_key)}')
|
self.stdout.write(f' API Key: {"*" * len(api_key)}')
|
||||||
@@ -146,8 +146,8 @@ class Command(BaseCommand):
|
|||||||
},
|
},
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
}
|
||||||
timeout=60)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
self.stdout.write(self.style.SUCCESS('✓ API endpoint test successful'))
|
self.stdout.write(self.style.SUCCESS('✓ API endpoint test successful'))
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import pgtrigger.compiler
|
|
||||||
import pgtrigger.migrations
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -11,7 +9,6 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("pghistory", "0006_delete_aggregateevent"),
|
|
||||||
("sites", "0002_alter_domain_unique"),
|
("sites", "0002_alter_domain_unique"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -19,7 +16,15 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="EmailConfiguration",
|
name="EmailConfiguration",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
("api_key", models.CharField(max_length=255)),
|
("api_key", models.CharField(max_length=255)),
|
||||||
("from_email", models.EmailField(max_length=254)),
|
("from_email", models.EmailField(max_length=254)),
|
||||||
(
|
(
|
||||||
@@ -44,86 +49,4 @@ class Migration(migrations.Migration):
|
|||||||
"verbose_name_plural": "Email Configurations",
|
"verbose_name_plural": "Email Configurations",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name="EmailConfigurationEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
("api_key", models.CharField(max_length=255)),
|
|
||||||
("from_email", models.EmailField(max_length=254)),
|
|
||||||
(
|
|
||||||
"from_name",
|
|
||||||
models.CharField(
|
|
||||||
help_text="The name that will appear in the From field of emails",
|
|
||||||
max_length=255,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("reply_to", models.EmailField(max_length=254)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
(
|
|
||||||
"pgh_context",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_obj",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="email_service.emailconfiguration",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"site",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to="sites.site",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="emailconfiguration",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_08c59",
|
|
||||||
table="email_service_emailconfiguration",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="emailconfiguration",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_992a4",
|
|
||||||
table="email_service_emailconfiguration",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from history_tracking.models import TrackedModel
|
|
||||||
import pghistory
|
|
||||||
|
|
||||||
@pghistory.track()
|
class EmailConfiguration(models.Model):
|
||||||
class EmailConfiguration(TrackedModel):
|
|
||||||
api_key = models.CharField(max_length=255)
|
api_key = models.CharField(max_length=255)
|
||||||
from_email = models.EmailField()
|
from_email = models.EmailField()
|
||||||
from_name = models.CharField(max_length=255, help_text="The name that will appear in the From field of emails")
|
from_name = models.CharField(max_length=255, help_text="The name that will appear in the From field of emails")
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class EmailService:
|
|||||||
f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails",
|
f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails",
|
||||||
json=data,
|
json=data,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=60)
|
)
|
||||||
|
|
||||||
# Debug output
|
# Debug output
|
||||||
print(f"Response Status: {response.status_code}")
|
print(f"Response Status: {response.status_code}")
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
const locators = {};
|
|
||||||
|
|
||||||
module.exports = { locators };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class HistoryConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'history'
|
|
||||||
verbose_name = 'History Tracking'
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
"""Initialize app and signal handlers"""
|
|
||||||
from django.dispatch import Signal
|
|
||||||
# Create a signal for history updates
|
|
||||||
self.history_updated = Signal()
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<div id="history-timeline"
|
|
||||||
hx-get="{% url 'history:timeline' content_type_id=content_type.id object_id=object.id %}"
|
|
||||||
hx-trigger="every 30s, historyUpdate from:body">
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% for event in events %}
|
|
||||||
<div class="component-wrapper bg-white p-4 shadow-sm">
|
|
||||||
<div class="component-header flex items-center gap-2 mb-2">
|
|
||||||
<span class="text-sm font-medium">{{ event.pgh_label|title }}</span>
|
|
||||||
<time class="text-xs text-gray-500">{{ event.pgh_created_at|date:"M j, Y H:i" }}</time>
|
|
||||||
</div>
|
|
||||||
<div class="component-content text-sm">
|
|
||||||
{% if event.pgh_context.metadata.user %}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ event.pgh_context.metadata.user }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if event.pgh_data %}
|
|
||||||
<div class="mt-2 text-gray-600">
|
|
||||||
<pre class="text-xs">{{ event.pgh_data|pprint }}</pre>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from django import template
|
|
||||||
import json
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
@register.filter
|
|
||||||
def pprint(value):
|
|
||||||
"""Pretty print JSON data"""
|
|
||||||
if isinstance(value, str):
|
|
||||||
try:
|
|
||||||
value = json.loads(value)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return value
|
|
||||||
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
return json.dumps(value, indent=2)
|
|
||||||
return str(value)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .views import HistoryTimelineView
|
|
||||||
|
|
||||||
app_name = 'history'
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('timeline/<int:content_type_id>/<int:object_id>/',
|
|
||||||
HistoryTimelineView.as_view(),
|
|
||||||
name='timeline'),
|
|
||||||
]
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from django.views import View
|
|
||||||
from django.shortcuts import render
|
|
||||||
from django.http import JsonResponse
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
import pghistory
|
|
||||||
|
|
||||||
def serialize_event(event):
|
|
||||||
"""Serialize a history event for JSON response"""
|
|
||||||
return {
|
|
||||||
'label': event.pgh_label,
|
|
||||||
'created_at': event.pgh_created_at.isoformat(),
|
|
||||||
'context': event.pgh_context,
|
|
||||||
'data': event.pgh_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
class HistoryTimelineView(View):
|
|
||||||
"""View for displaying object history timeline"""
|
|
||||||
|
|
||||||
def get(self, request, content_type_id, object_id):
|
|
||||||
# Get content type and object
|
|
||||||
content_type = ContentType.objects.get_for_id(content_type_id)
|
|
||||||
obj = content_type.get_object_for_this_type(id=object_id)
|
|
||||||
|
|
||||||
# Get history events
|
|
||||||
events = pghistory.models.Event.objects.filter(
|
|
||||||
pgh_obj_model=content_type.model_class(),
|
|
||||||
pgh_obj_id=object_id
|
|
||||||
).order_by('-pgh_created_at')[:25]
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'events': events,
|
|
||||||
'content_type': content_type,
|
|
||||||
'object': obj,
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.htmx:
|
|
||||||
return render(request, "history/partials/history_timeline.html", context)
|
|
||||||
|
|
||||||
return JsonResponse({
|
|
||||||
'history': [serialize_event(e) for e in events]
|
|
||||||
})
|
|
||||||
@@ -7,9 +7,20 @@ class HistoryTrackingConfig(AppConfig):
|
|||||||
name = "history_tracking"
|
name = "history_tracking"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""
|
from django.apps import apps
|
||||||
No initialization needed for pghistory tracking.
|
from .mixins import HistoricalChangeMixin
|
||||||
History tracking is handled by the @pghistory.track() decorator
|
|
||||||
and triggers installed in migrations.
|
# Get the Park model
|
||||||
"""
|
try:
|
||||||
|
Park = apps.get_model('parks', 'Park')
|
||||||
|
ParkArea = apps.get_model('parks', 'ParkArea')
|
||||||
|
|
||||||
|
# Apply mixin to historical models
|
||||||
|
if HistoricalChangeMixin not in Park.history.model.__bases__:
|
||||||
|
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
|
||||||
|
|
||||||
|
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
|
||||||
|
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
|
||||||
|
except LookupError:
|
||||||
|
# Models might not be loaded yet
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
from django.conf import settings
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='HistoricalSlug',
|
name="HistoricalSlug",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('object_id', models.PositiveIntegerField()),
|
"id",
|
||||||
('slug', models.SlugField(max_length=255)),
|
models.BigAutoField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
|
auto_created=True,
|
||||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
primary_key=True,
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='historical_slugs', to=settings.AUTH_USER_MODEL)),
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("object_id", models.PositiveIntegerField()),
|
||||||
|
("slug", models.SlugField(max_length=255)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'unique_together': {('content_type', 'slug')},
|
"indexes": [
|
||||||
'indexes': [
|
models.Index(
|
||||||
models.Index(fields=['content_type', 'object_id'], name='history_tra_content_1234ab_idx'),
|
fields=["content_type", "object_id"],
|
||||||
models.Index(fields=['slug'], name='history_tra_slug_1234ab_idx'),
|
name="history_tra_content_63013c_idx",
|
||||||
|
),
|
||||||
|
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
|
||||||
],
|
],
|
||||||
|
"unique_together": {("content_type", "slug")},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
74
history_tracking/mixins.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# history_tracking/mixins.py
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class HistoricalChangeMixin(models.Model):
|
||||||
|
"""Mixin for historical models to track changes"""
|
||||||
|
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
|
||||||
|
history_date = models.DateTimeField()
|
||||||
|
history_id = models.AutoField(primary_key=True)
|
||||||
|
history_type = models.CharField(max_length=1)
|
||||||
|
history_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='+'
|
||||||
|
)
|
||||||
|
history_change_reason = models.CharField(max_length=100, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
ordering = ['-history_date', '-history_id']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prev_record(self):
|
||||||
|
"""Get the previous record for this instance"""
|
||||||
|
try:
|
||||||
|
return self.__class__.objects.filter(
|
||||||
|
history_date__lt=self.history_date,
|
||||||
|
id=self.id
|
||||||
|
).order_by('-history_date').first()
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def diff_against_previous(self):
|
||||||
|
prev_record = self.prev_record
|
||||||
|
if not prev_record:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
changes = {}
|
||||||
|
for field in self.__dict__:
|
||||||
|
if field not in [
|
||||||
|
"history_date",
|
||||||
|
"history_id",
|
||||||
|
"history_type",
|
||||||
|
"history_user_id",
|
||||||
|
"history_change_reason",
|
||||||
|
"history_type",
|
||||||
|
"id",
|
||||||
|
"_state",
|
||||||
|
"_history_user_cache"
|
||||||
|
] and not field.startswith("_"):
|
||||||
|
try:
|
||||||
|
old_value = getattr(prev_record, field)
|
||||||
|
new_value = getattr(self, field)
|
||||||
|
if old_value != new_value:
|
||||||
|
changes[field] = {"old": str(old_value), "new": str(new_value)}
|
||||||
|
except AttributeError:
|
||||||
|
continue
|
||||||
|
return changes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def history_user_display(self):
|
||||||
|
"""Get a display name for the history user"""
|
||||||
|
if hasattr(self, 'history_user') and self.history_user:
|
||||||
|
return str(self.history_user)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_instance(self):
|
||||||
|
"""Get the model instance this history record represents"""
|
||||||
|
try:
|
||||||
|
return self.__class__.objects.get(id=self.id)
|
||||||
|
except self.__class__.DoesNotExist:
|
||||||
|
return None
|
||||||
@@ -1,75 +1,34 @@
|
|||||||
|
# history_tracking/models.py
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.conf import settings
|
from simple_history.models import HistoricalRecords
|
||||||
from typing import Any, Dict, Optional
|
from .mixins import HistoricalChangeMixin
|
||||||
|
from typing import Any, Type, TypeVar, cast
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
class DiffMixin:
|
T = TypeVar('T', bound=models.Model)
|
||||||
"""Mixin to add diffing capabilities to models"""
|
|
||||||
|
|
||||||
def get_prev_record(self) -> Optional[Any]:
|
class HistoricalModel(models.Model):
|
||||||
"""Get the previous record for this instance"""
|
"""Abstract base class for models with history tracking"""
|
||||||
try:
|
id = models.BigAutoField(primary_key=True)
|
||||||
return type(self).objects.filter(
|
history: HistoricalRecords = HistoricalRecords(
|
||||||
pgh_created_at__lt=self.pgh_created_at,
|
inherit=True,
|
||||||
pgh_obj_id=self.pgh_obj_id
|
bases=(HistoricalChangeMixin,)
|
||||||
).order_by('-pgh_created_at').first()
|
)
|
||||||
except (AttributeError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def diff_against_previous(self) -> Dict:
|
|
||||||
"""Compare this record against the previous one"""
|
|
||||||
prev_record = self.get_prev_record()
|
|
||||||
if not prev_record:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
skip_fields = {
|
|
||||||
'pgh_id', 'pgh_created_at', 'pgh_label',
|
|
||||||
'pgh_obj_id', 'pgh_context_id', '_state',
|
|
||||||
'created_at', 'updated_at'
|
|
||||||
}
|
|
||||||
|
|
||||||
changes = {}
|
|
||||||
for field, value in self.__dict__.items():
|
|
||||||
# Skip internal fields and those we don't want to track
|
|
||||||
if field.startswith('_') or field in skip_fields or field.endswith('_id'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
old_value = getattr(prev_record, field)
|
|
||||||
new_value = value
|
|
||||||
if old_value != new_value:
|
|
||||||
changes[field] = {
|
|
||||||
"old": str(old_value) if old_value is not None else "None",
|
|
||||||
"new": str(new_value) if new_value is not None else "None"
|
|
||||||
}
|
|
||||||
except AttributeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return changes
|
|
||||||
|
|
||||||
class TrackedModel(models.Model):
|
|
||||||
"""Abstract base class for models that need history tracking"""
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def get_history(self) -> QuerySet:
|
@property
|
||||||
"""Get all history records for this instance in chronological order"""
|
def _history_model(self) -> Type[T]:
|
||||||
event_model = self.events.model # pghistory provides this automatically
|
"""Get the history model class"""
|
||||||
if event_model:
|
return cast(Type[T], self.history.model) # type: ignore
|
||||||
return event_model.objects.filter(
|
|
||||||
pgh_obj_id=self.pk
|
|
||||||
).order_by('-pgh_created_at')
|
|
||||||
return self.__class__.objects.none()
|
|
||||||
|
|
||||||
def __str__(self):
|
def get_history(self) -> QuerySet:
|
||||||
model_name = self.__class__.__name__
|
"""Get all history records for this instance"""
|
||||||
fields_str = ", ".join((f"{field.name}={getattr(self, field.name)}" for field in self._meta.fields))
|
model = self._history_model
|
||||||
return f"{model_name}({fields_str})"
|
return model.objects.filter(id=self.pk).order_by('-history_date')
|
||||||
|
|
||||||
class HistoricalSlug(models.Model):
|
class HistoricalSlug(models.Model):
|
||||||
"""Track historical slugs for models"""
|
"""Track historical slugs for models"""
|
||||||
@@ -78,13 +37,6 @@ class HistoricalSlug(models.Model):
|
|||||||
content_object = GenericForeignKey('content_type', 'object_id')
|
content_object = GenericForeignKey('content_type', 'object_id')
|
||||||
slug = models.SlugField(max_length=255)
|
slug = models.SlugField(max_length=255)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name='historical_slugs'
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('content_type', 'slug')
|
unique_together = ('content_type', 'slug')
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
import os
|
|
||||||
|
|
||||||
class LocationConfig(AppConfig):
|
class LocationConfig(AppConfig):
|
||||||
path = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'location'
|
name = 'location'
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.contrib.gis.db.models.fields
|
import django.contrib.gis.db.models.fields
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import pgtrigger.compiler
|
import simple_history.models
|
||||||
import pgtrigger.migrations
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -14,14 +14,140 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
("pghistory", "0006_delete_aggregateevent"),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="HistoricalLocation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigIntegerField(
|
||||||
|
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("object_id", models.PositiveIntegerField()),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Name of the location (e.g. business name, landmark)",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"location_type",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Type of location (e.g. business, landmark, address)",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"latitude",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=6,
|
||||||
|
help_text="Latitude coordinate (legacy field)",
|
||||||
|
max_digits=9,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(-90),
|
||||||
|
django.core.validators.MaxValueValidator(90),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"longitude",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=6,
|
||||||
|
help_text="Longitude coordinate (legacy field)",
|
||||||
|
max_digits=9,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(-180),
|
||||||
|
django.core.validators.MaxValueValidator(180),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"point",
|
||||||
|
django.contrib.gis.db.models.fields.PointField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Geographic coordinates as a Point",
|
||||||
|
null=True,
|
||||||
|
srid=4326,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"street_address",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("city", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
(
|
||||||
|
"state",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="State/Region/Province",
|
||||||
|
max_length=100,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("country", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
|
||||||
|
("created_at", models.DateTimeField(blank=True, editable=False)),
|
||||||
|
("updated_at", models.DateTimeField(blank=True, editable=False)),
|
||||||
|
("history_id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("history_date", models.DateTimeField(db_index=True)),
|
||||||
|
("history_change_reason", models.CharField(max_length=100, null=True)),
|
||||||
|
(
|
||||||
|
"history_type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
|
||||||
|
max_length=1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
db_constraint=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"history_user",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "historical location",
|
||||||
|
"verbose_name_plural": "historical locations",
|
||||||
|
"ordering": ("-history_date", "-history_id"),
|
||||||
|
"get_latest_by": ("history_date", "history_id"),
|
||||||
|
},
|
||||||
|
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Location",
|
name="Location",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
("object_id", models.PositiveIntegerField()),
|
("object_id", models.PositiveIntegerField()),
|
||||||
(
|
(
|
||||||
"name",
|
"name",
|
||||||
@@ -102,163 +228,16 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"ordering": ["name"],
|
"ordering": ["name"],
|
||||||
},
|
"indexes": [
|
||||||
),
|
models.Index(
|
||||||
migrations.CreateModel(
|
|
||||||
name="LocationEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
("object_id", models.PositiveIntegerField()),
|
|
||||||
(
|
|
||||||
"name",
|
|
||||||
models.CharField(
|
|
||||||
help_text="Name of the location (e.g. business name, landmark)",
|
|
||||||
max_length=255,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"location_type",
|
|
||||||
models.CharField(
|
|
||||||
help_text="Type of location (e.g. business, landmark, address)",
|
|
||||||
max_length=50,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"latitude",
|
|
||||||
models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=6,
|
|
||||||
help_text="Latitude coordinate (legacy field)",
|
|
||||||
max_digits=9,
|
|
||||||
null=True,
|
|
||||||
validators=[
|
|
||||||
django.core.validators.MinValueValidator(-90),
|
|
||||||
django.core.validators.MaxValueValidator(90),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"longitude",
|
|
||||||
models.DecimalField(
|
|
||||||
blank=True,
|
|
||||||
decimal_places=6,
|
|
||||||
help_text="Longitude coordinate (legacy field)",
|
|
||||||
max_digits=9,
|
|
||||||
null=True,
|
|
||||||
validators=[
|
|
||||||
django.core.validators.MinValueValidator(-180),
|
|
||||||
django.core.validators.MaxValueValidator(180),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"point",
|
|
||||||
django.contrib.gis.db.models.fields.PointField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Geographic coordinates as a Point",
|
|
||||||
null=True,
|
|
||||||
srid=4326,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"street_address",
|
|
||||||
models.CharField(blank=True, max_length=255, null=True),
|
|
||||||
),
|
|
||||||
("city", models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
(
|
|
||||||
"state",
|
|
||||||
models.CharField(
|
|
||||||
blank=True,
|
|
||||||
help_text="State/Region/Province",
|
|
||||||
max_length=100,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("country", models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
(
|
|
||||||
"content_type",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to="contenttypes.contenttype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_context",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_obj",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="location.location",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="location",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["content_type", "object_id"],
|
fields=["content_type", "object_id"],
|
||||||
name="location_lo_content_9ee1bd_idx",
|
name="location_lo_content_9ee1bd_idx",
|
||||||
),
|
),
|
||||||
),
|
models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
|
||||||
migrations.AddIndex(
|
models.Index(
|
||||||
model_name="location",
|
|
||||||
index=models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="location",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["country"], name="location_lo_country_b75eba_idx"
|
fields=["country"], name="location_lo_country_b75eba_idx"
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
pgtrigger.migrations.AddTrigger(
|
},
|
||||||
model_name="location",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_98cd4",
|
|
||||||
table="location_location",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="location",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_471d2",
|
|
||||||
table="location_location",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ from django.db import models
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from simple_history.models import HistoricalRecords
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point
|
||||||
import pghistory
|
|
||||||
from history_tracking.models import TrackedModel
|
|
||||||
|
|
||||||
@pghistory.track()
|
class Location(models.Model):
|
||||||
class Location(TrackedModel):
|
|
||||||
"""
|
"""
|
||||||
A generic location model that can be associated with any model
|
A generic location model that can be associated with any model
|
||||||
using GenericForeignKey. Stores detailed location information
|
using GenericForeignKey. Stores detailed location information
|
||||||
@@ -65,6 +63,7 @@ class Location(TrackedModel):
|
|||||||
# Metadata
|
# Metadata
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
history = HistoricalRecords()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
indexes = [
|
indexes = [
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.csrf import csrf_protect
|
from django.views.decorators.csrf import csrf_protect
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from location.forms import LocationForm
|
|
||||||
from .models import Location
|
from .models import Location
|
||||||
|
|
||||||
class LocationSearchView(View):
|
class LocationSearchView(View):
|
||||||
@@ -54,8 +52,8 @@ class LocationSearchView(View):
|
|||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://nominatim.openstreetmap.org/search',
|
'https://nominatim.openstreetmap.org/search',
|
||||||
params=params,
|
params=params,
|
||||||
headers={'User-Agent': 'ThrillWiki/1.0'},
|
headers={'User-Agent': 'ThrillWiki/1.0'}
|
||||||
timeout=60)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
results = response.json()
|
results = response.json()
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
@@ -172,8 +170,8 @@ def reverse_geocode(request):
|
|||||||
'format': 'json',
|
'format': 'json',
|
||||||
'addressdetails': 1
|
'addressdetails': 1
|
||||||
},
|
},
|
||||||
headers={'User-Agent': 'ThrillWiki/1.0'},
|
headers={'User-Agent': 'ThrillWiki/1.0'}
|
||||||
timeout=60)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
# Download image
|
# Download image
|
||||||
self.stdout.write(f'Downloading from URL: {photo_url}')
|
self.stdout.write(f'Downloading from URL: {photo_url}')
|
||||||
response = requests.get(photo_url, timeout=60)
|
response = requests.get(photo_url)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Delete any existing photos for this park
|
# Delete any existing photos for this park
|
||||||
Photo.objects.filter(
|
Photo.objects.filter(
|
||||||
@@ -74,7 +74,7 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
# Download image
|
# Download image
|
||||||
self.stdout.write(f'Downloading from URL: {photo_url}')
|
self.stdout.write(f'Downloading from URL: {photo_url}')
|
||||||
response = requests.get(photo_url, timeout=60)
|
response = requests.get(photo_url)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Delete any existing photos for this ride
|
# Delete any existing photos for this ride
|
||||||
Photo.objects.filter(
|
Photo.objects.filter(
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import media.models
|
import media.models
|
||||||
import media.storage
|
import media.storage
|
||||||
import pgtrigger.compiler
|
|
||||||
import pgtrigger.migrations
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -15,7 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
("pghistory", "0006_delete_aggregateevent"),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -23,7 +20,15 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Photo",
|
name="Photo",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"image",
|
"image",
|
||||||
models.ImageField(
|
models.ImageField(
|
||||||
@@ -59,110 +64,12 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"ordering": ["-is_primary", "-created_at"],
|
"ordering": ["-is_primary", "-created_at"],
|
||||||
},
|
"indexes": [
|
||||||
),
|
models.Index(
|
||||||
migrations.CreateModel(
|
|
||||||
name="PhotoEvent",
|
|
||||||
fields=[
|
|
||||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
|
||||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
|
||||||
("id", models.BigIntegerField()),
|
|
||||||
(
|
|
||||||
"image",
|
|
||||||
models.ImageField(
|
|
||||||
max_length=255,
|
|
||||||
storage=media.storage.MediaStorage(),
|
|
||||||
upload_to=media.models.photo_upload_path,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("caption", models.CharField(blank=True, max_length=255)),
|
|
||||||
("alt_text", models.CharField(blank=True, max_length=255)),
|
|
||||||
("is_primary", models.BooleanField(default=False)),
|
|
||||||
("is_approved", models.BooleanField(default=False)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
("date_taken", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("object_id", models.PositiveIntegerField()),
|
|
||||||
(
|
|
||||||
"content_type",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to="contenttypes.contenttype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_context",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
to="pghistory.context",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"pgh_obj",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="events",
|
|
||||||
to="media.photo",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"uploaded_by",
|
|
||||||
models.ForeignKey(
|
|
||||||
db_constraint=False,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
|
||||||
related_name="+",
|
|
||||||
related_query_name="+",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"abstract": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="photo",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["content_type", "object_id"],
|
fields=["content_type", "object_id"],
|
||||||
name="media_photo_content_0187f5_idx",
|
name="media_photo_content_0187f5_idx",
|
||||||
),
|
)
|
||||||
),
|
],
|
||||||
pgtrigger.migrations.AddTrigger(
|
},
|
||||||
model_name="photo",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="insert_insert",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="INSERT",
|
|
||||||
pgid="pgtrigger_insert_insert_e1ca0",
|
|
||||||
table="media_photo",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
pgtrigger.migrations.AddTrigger(
|
|
||||||
model_name="photo",
|
|
||||||
trigger=pgtrigger.compiler.Trigger(
|
|
||||||
name="update_update",
|
|
||||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
|
||||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
|
||||||
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
|
||||||
hash="[AWS-SECRET-REMOVED]",
|
|
||||||
operation="UPDATE",
|
|
||||||
pgid="pgtrigger_update_update_6ff7d",
|
|
||||||
table="media_photo",
|
|
||||||
when="AFTER",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ from datetime import datetime
|
|||||||
from .storage import MediaStorage
|
from .storage import MediaStorage
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from history_tracking.models import TrackedModel
|
|
||||||
import pghistory
|
|
||||||
|
|
||||||
def photo_upload_path(instance: models.Model, filename: str) -> str:
|
def photo_upload_path(instance: models.Model, filename: str) -> str:
|
||||||
"""Generate upload path for photos using normalized filenames"""
|
"""Generate upload path for photos using normalized filenames"""
|
||||||
@@ -40,8 +38,7 @@ def photo_upload_path(instance: models.Model, filename: str) -> str:
|
|||||||
# For park photos, store directly in park directory
|
# For park photos, store directly in park directory
|
||||||
return f"park/{identifier}/{base_filename}"
|
return f"park/{identifier}/{base_filename}"
|
||||||
|
|
||||||
@pghistory.track()
|
class Photo(models.Model):
|
||||||
class Photo(TrackedModel):
|
|
||||||
"""Generic photo model that can be attached to any model"""
|
"""Generic photo model that can be attached to any model"""
|
||||||
image = models.ImageField(
|
image = models.ImageField(
|
||||||
upload_to=photo_upload_path, # type: ignore[arg-type]
|
upload_to=photo_upload_path, # type: ignore[arg-type]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
@@ -8,7 +8,7 @@ from django.test.utils import override_settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import piexif # type: ignore
|
import piexif
|
||||||
import io
|
import io
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
## 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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
## 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)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||