Compare commits

..

1 Commits

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

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

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ class Command(BaseCommand):
self.stdout.write(f'- {site.domain} ({site.name})')
# 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(callback_url)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Designer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('website', models.URLField(blank=True)),
('description', models.TextField(blank=True)),
('total_rides', models.IntegerField(default=0)),
('total_roller_coasters', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
]

View File

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

View File

@@ -2,11 +2,11 @@ from django.db import models
from django.utils.text import slugify
from django.urls import reverse
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
import pghistory
from history_tracking.models import TrackedModel, HistoricalSlug
@pghistory.track()
class Company(TrackedModel):
if TYPE_CHECKING:
from history_tracking.models import HistoricalSlug
class Company(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
@@ -37,18 +37,8 @@ class Company(TrackedModel):
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Check manual slug history as fallback
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='company',
@@ -58,8 +48,7 @@ class Company(TrackedModel):
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()
@pghistory.track()
class Manufacturer(TrackedModel):
class Manufacturer(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
@@ -89,18 +78,8 @@ class Manufacturer(TrackedModel):
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Check manual slug history as fallback
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='manufacturer',
@@ -109,3 +88,43 @@ class Manufacturer(TrackedModel):
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()
class Designer(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
description = models.TextField(blank=True)
total_rides = models.IntegerField(default=0)
total_roller_coasters = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects: ClassVar[models.Manager['Designer']]
class Meta:
ordering = ['name']
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
"""Get designer by slug, checking historical slugs if needed"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='designer',
slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
from history_tracking.models import TrackedModel
class SlugHistory(models.Model):
"""
@@ -27,7 +26,7 @@ class SlugHistory(models.Model):
def __str__(self):
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.
"""
@@ -77,18 +76,7 @@ class SluggedModel(TrackedModel):
# Try to get by current slug first
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Try to find in manual slug history as fallback
# Try to find in slug history
history = SlugHistory.objects.filter(
content_type=ContentType.objects.get_for_model(cls),
old_slug=slug

View File

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

View File

@@ -1,8 +1,8 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
import simple_history.models
from django.conf import settings
from django.db import migrations, models
@@ -11,14 +11,22 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Designer",
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)),
("slug", models.SlugField(max_length=255, unique=True)),
("description", models.TextField(blank=True)),
@@ -33,73 +41,48 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name="DesignerEvent",
name="HistoricalDesigner",
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()),
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("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)),
("website", models.URLField(blank=True)),
("founded_date", models.DateField(blank=True, null=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=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,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
"verbose_name": "historical designer",
"verbose_name_plural": "historical designers",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
),
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",
),
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
# history_tracking/mixins.py
from django.db import models
from django.conf import settings
class HistoricalChangeMixin(models.Model):
"""Mixin for historical models to track changes"""
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
history_date = models.DateTimeField()
history_id = models.AutoField(primary_key=True)
history_type = models.CharField(max_length=1)
history_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='+'
)
history_change_reason = models.CharField(max_length=100, null=True)
class Meta:
abstract = True
ordering = ['-history_date', '-history_id']
@property
def prev_record(self):
"""Get the previous record for this instance"""
try:
return self.__class__.objects.filter(
history_date__lt=self.history_date,
id=self.id
).order_by('-history_date').first()
except (AttributeError, TypeError):
return None
@property
def diff_against_previous(self):
prev_record = self.prev_record
if not prev_record:
return {}
changes = {}
for field in self.__dict__:
if field not in [
"history_date",
"history_id",
"history_type",
"history_user_id",
"history_change_reason",
"history_type",
"id",
"_state",
"_history_user_cache"
] and not field.startswith("_"):
try:
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != new_value:
changes[field] = {"old": str(old_value), "new": str(new_value)}
except AttributeError:
continue
return changes
@property
def history_user_display(self):
"""Get a display name for the history user"""
if hasattr(self, 'history_user') and self.history_user:
return str(self.history_user)
return None
def get_instance(self):
"""Get the model instance this history record represents"""
try:
return self.__class__.objects.get(id=self.id)
except self.__class__.DoesNotExist:
return None

View File

@@ -1,70 +1,34 @@
# history_tracking/models.py
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.conf import settings
from typing import Any, Dict, Optional
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
from typing import Any, Type, TypeVar, cast
from django.db.models import QuerySet
class DiffMixin:
"""Mixin to add diffing capabilities to models"""
def get_prev_record(self) -> Optional[Any]:
"""Get the previous record for this instance"""
try:
return type(self).objects.filter(
pgh_created_at__lt=self.pgh_created_at,
pgh_obj_id=self.pgh_obj_id
).order_by('-pgh_created_at').first()
except (AttributeError, TypeError):
return None
T = TypeVar('T', bound=models.Model)
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 HistoricalModel(models.Model):
"""Abstract base class for models with history tracking"""
id = models.BigAutoField(primary_key=True)
history: HistoricalRecords = HistoricalRecords(
inherit=True,
bases=(HistoricalChangeMixin,)
)
class Meta:
abstract = True
@property
def _history_model(self) -> Type[T]:
"""Get the history model class"""
return cast(Type[T], self.history.model) # type: ignore
def get_history(self) -> QuerySet:
"""Get all history records for this instance in chronological order"""
event_model = self.events.model # pghistory provides this automatically
if event_model:
return event_model.objects.filter(
pgh_obj_id=self.pk
).order_by('-pgh_created_at')
return self.__class__.objects.none()
"""Get all history records for this instance"""
model = self._history_model
return model.objects.filter(id=self.pk).order_by('-history_date')
class HistoricalSlug(models.Model):
"""Track historical slugs for models"""
@@ -73,13 +37,6 @@ class HistoricalSlug(models.Model):
content_object = GenericForeignKey('content_type', 'object_id')
slug = models.SlugField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='historical_slugs'
)
class Meta:
unique_together = ('content_type', 'slug')

View File

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

View File

@@ -1,10 +1,10 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.contrib.gis.db.models.fields
import django.core.validators
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
import simple_history.models
from django.conf import settings
from django.db import migrations, models
@@ -14,14 +14,140 @@ class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="HistoricalLocation",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"content_type",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="contenttypes.contenttype",
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "historical location",
"verbose_name_plural": "historical locations",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="Location",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
(
"name",
@@ -102,163 +228,16 @@ class Migration(migrations.Migration):
],
options={
"ordering": ["name"],
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="location_lo_content_9ee1bd_idx",
),
models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
models.Index(
fields=["country"], name="location_lo_country_b75eba_idx"
),
],
},
),
migrations.CreateModel(
name="LocationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="location.location",
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["content_type", "object_id"],
name="location_lo_content_9ee1bd_idx",
),
),
migrations.AddIndex(
model_name="location",
index=models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["country"], name="location_lo_country_b75eba_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_98cd4",
table="location_location",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_471d2",
table="location_location",
when="AFTER",
),
),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 B

View File

@@ -8,7 +8,7 @@ from django.test.utils import override_settings
from django.db import models
from datetime import datetime
from PIL import Image
import piexif # type: ignore
import piexif
import io
import shutil
import tempfile

View File

@@ -1,74 +0,0 @@
# Active Development Context
## Recently Completed
### Park Search Implementation (2024-02-22)
1. Autocomplete Base:
- Created BaseAutocomplete in core/forms.py
- Configured project-wide auth requirement
- Added test coverage for base functionality
2. Park Search:
- Implemented ParkAutocomplete class
- Created ParkSearchForm with autocomplete widget
- Updated views and templates for integration
- Added comprehensive test suite
3. Documentation:
- Updated memory-bank/features/parks/search.md
- Added test documentation
- Created user interface guidelines
## Active Tasks
1. Testing:
- [ ] Run the test suite with `uv run pytest parks/tests/`
- [ ] Monitor test coverage with pytest-cov
- [ ] Verify HTMX interactions work as expected
2. Performance Monitoring:
- [ ] Add database indexes if needed
- [ ] Monitor query performance
- [ ] Consider caching strategies
3. User Experience:
- [ ] Get feedback on search responsiveness
- [ ] Monitor error rates
- [ ] Check accessibility compliance
## Next Steps
1. Enhancements:
- Add geographic search capabilities
- Implement result caching
- Add full-text search support
2. Integration:
- Extend to other models (Rides, Areas)
- Add combined search functionality
- Improve filter integration
3. Testing:
- Add Playwright e2e tests
- Implement performance benchmarks
- Add accessibility tests
## Technical Debt
None currently identified for the search implementation.
## Dependencies
- django-htmx-autocomplete
- pytest-django
- pytest-cov
## Notes
The implementation follows these principles:
- Authentication-first approach
- Performance optimization
- Accessibility compliance
- Test coverage
- Clean documentation

View File

@@ -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

View File

@@ -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

View File

@@ -1,254 +0,0 @@
# Laravel Migration Analysis
## Executive Summary
After thorough analysis of the ThrillWiki Django codebase, this document presents a comprehensive evaluation of migrating to Laravel. The analysis considers technical compatibility, implementation impact, and business implications.
### Quick Overview
**Current Stack:**
- Framework: Django (MVT Architecture)
- Frontend: HTMX + AlpineJS + Tailwind CSS
- Database: PostgreSQL with Django ORM
- Authentication: Django Built-in Auth
**Recommendation:** ⛔️ DO NOT PROCEED with Laravel migration
The analysis reveals that the costs, risks, and disruption of migration outweigh potential benefits, particularly given the project's mature Django codebase and specialized features.
## Technical Analysis
### Core Functionality Compatibility
#### Data Model Migration Complexity: HIGH
- Complex Django models with inheritance (TrackedModel)
- Custom user model with role-based permissions
- Extensive use of Django-specific model features
- Migration challenges:
* Different ORM paradigms
* Custom model behaviors
* Signal system reimplementation
* Complex queries and annotations
#### Authentication System: HIGH
- Currently leverages Django's auth framework extensively
- Custom adapters for social authentication
- Role-based permission system
- Migration challenges:
* Laravel's auth system differs fundamentally
* Custom middleware rewrites needed
* Session handling differences
* Social auth integration rework
#### Template Engine: MEDIUM
- Heavy use of Django template inheritance
- HTMX integration for dynamic updates
- Migration challenges:
* Blade syntax differences
* Different template inheritance patterns
* HTMX integration patterns
* Custom template tags rewrite
#### ORM and Database Layer: VERY HIGH
- Extensive use of Django ORM features
- Complex model relationships
- Custom model managers
- Migration challenges:
* Different query builder syntax
* Relationship definition differences
* Transaction handling variations
* Custom field type conversions
### Architecture Impact
#### Routing and Middleware: HIGH
- Complex URL patterns with nested resources
- Custom middleware for analytics and tracking
- Migration challenges:
* Different routing paradigms
* Middleware architecture differences
* Request/Response cycle variations
#### File Structure Changes: MEDIUM
- Current Django apps need restructuring
- Different convention requirements
- Migration challenges:
* Resource organization
* Namespace handling
* Service provider implementation
#### API and Service Layer: HIGH
- Custom API implementation
- Complex service layer integration
- Migration challenges:
* Different API architecture
* Service container differences
* Dependency injection patterns
## Implementation Impact
### Development Timeline
Estimated timeline: 4-6 months minimum
- Phase 1 (Data Layer): 6-8 weeks
- Phase 2 (Business Logic): 8-10 weeks
- Phase 3 (Frontend Integration): 4-6 weeks
- Phase 4 (Testing & Deployment): 4-6 weeks
### Resource Requirements
- 2-3 Senior Laravel Developers
- 1 DevOps Engineer
- 1 QA Engineer
- Project Manager
### Testing Strategy Updates
- Complete test suite rewrite needed
- New testing frameworks required
- Integration test complexity
- Performance testing rework
### Deployment Modifications
- CI/CD pipeline updates
- Environment configuration changes
- Server requirement updates
- Monitoring system adjustments
## Business Impact
### Cost Analysis
1. Direct Costs:
- Development Resources: ~$150,000-200,000
- Training: ~$20,000
- Infrastructure Updates: ~$10,000
- Total: ~$180,000-230,000
2. Indirect Costs:
- Productivity loss during transition
- Potential downtime
- Bug risk increase
- Learning curve impact
### Risk Assessment
#### Technical Risks (HIGH)
- Data integrity during migration
- Performance regressions
- Unknown edge cases
- Integration failures
#### Business Risks (HIGH)
- Service disruption
- Feature parity gaps
- User experience inconsistency
- Timeline uncertainty
#### Mitigation Strategies
- Phased migration approach
- Comprehensive testing
- Rollback procedures
- User communication plan
## Detailed Technical Challenges
### Critical Areas
1. History Tracking System
- Custom implementation in Django
- Complex diff tracking
- Temporal data management
2. Authentication System
- Role-based access control
- Social authentication integration
- Custom user profiles
3. Geographic Features
- Location services
- Coordinate normalization
- Geographic queries
4. Media Management
- Custom storage backends
- Image processing
- Upload handling
## Conclusion
### Key Findings
1. High Technical Debt: Migration would require substantial rewrite
2. Complex Domain Logic: Specialized features need careful translation
3. Resource Intensive: Significant time and budget required
4. High Risk: Critical business functions affected
### Recommendation
**Do Not Proceed with Migration**
Rationale:
1. Current Django implementation is stable and mature
2. Migration costs outweigh potential benefits
3. High risk to business continuity
4. Significant resource requirement
### Alternative Recommendations
1. **Modernize Current Stack**
- Update Django version
- Enhance current architecture
- Improve performance in place
2. **Gradual Enhancement**
- Add Laravel microservices if needed
- Keep core Django system
- Hybrid approach for new features
3. **Focus on Business Value**
- Invest in feature development
- Improve user experience
- Enhance current system
## Success Metrics (If Migration Proceeded)
1. Technical Metrics
- Performance parity or improvement
- Code quality metrics
- Test coverage
- Deployment success rate
2. Business Metrics
- User satisfaction
- System availability
- Feature parity
- Development velocity
## Timeline and Resource Allocation
### Phase 1: Planning and Setup (4-6 weeks)
- Architecture design
- Environment setup
- Team training
### Phase 2: Core Migration (12-16 weeks)
- Database migration
- Authentication system
- Core business logic
### Phase 3: Frontend Integration (8-10 weeks)
- Template conversion
- HTMX integration
- UI testing
### Phase 4: Testing and Deployment (6-8 weeks)
- System testing
- Performance optimization
- Production deployment
### Total Timeline: 30-40 weeks
## Final Verdict
Given the extensive analysis, the recommendation is to **maintain and enhance the current Django implementation** rather than pursuing a Laravel migration. The current system is stable, well-architected, and effectively serves business needs. The high costs, risks, and potential disruption of migration outweigh any potential benefits that Laravel might offer.
Focus should instead be directed toward:
1. Optimizing current Django implementation
2. Enhancing feature set and user experience
3. Updating dependencies and security
4. Improving development workflows

View File

@@ -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)

View File

@@ -1,71 +0,0 @@
# Park Search Implementation Improvements
## Context
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
## Decision
Implemented a unified search pattern that:
1. Uses only built-in HTMX and Alpine.js features
2. Matches location search pattern
3. Removes any custom JavaScript files
4. Maintains consistency across the application
### Benefits
1. **Simplified Architecture:**
- No custom JavaScript files needed
- Direct template-based implementation
- Reduced maintenance burden
- Smaller codebase
2. **Framework Alignment:**
- Uses HTMX for AJAX requests
- Uses Alpine.js for state management
- All functionality in templates
- Follows project patterns
3. **Better Maintainability:**
- Single source of truth in templates
- Reduced complexity
- Easier to understand
- Consistent with other features
## Implementation Details
### Template Features
1. HTMX Integration:
- Debounced search requests (300ms)
- Loading indicators
- JSON response handling
2. Alpine.js Usage:
- State management in template
- Event handling
- UI updates
- Keyboard interactions
### Backend Changes
1. JSON API:
- Consistent response format
- Type validation
- Limited results (8 items)
- Performance optimization
2. View Updates:
- Search filtering
- Result formatting
- Error handling
- State preservation
## Benefits
1. Better adherence to project standards
2. Simplified codebase
3. Reduced technical debt
4. Easier maintenance
5. Consistent user experience
## Testing
1. API response format
2. Empty search handling
3. Field validation
4. UI interactions
5. State management

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,24 +0,0 @@
# Search Form Fix
## Issue
Search results were being duplicated because selecting a suggestion triggered both:
1. The suggestions form submission (to /suggest_parks/)
2. The filter form submission (to /park_list/)
## Root Cause
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
## Solution
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
## Implementation
1. Modified the `@search-selected` handler to:
- Set the search query in filter form
- Submit filter form to update results
- Hide suggestions dropdown
2. Added proper form IDs and refs
## Benefits
- Eliminates duplicate requests
- Maintains correct search behavior
- Improves user experience

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