Compare commits
1 Commits
pixeebot/d
...
cbe1dd726f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe1dd726f |
55
.clinerules
@@ -1,55 +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.
|
||||
|
||||
## Entity Relationship Rules
|
||||
IMPORTANT: Follow these entity relationship patterns consistently:
|
||||
|
||||
# Park Relationships
|
||||
- Parks MUST have an Operator (required relationship)
|
||||
- Parks MAY have a PropertyOwner (optional, usually same as Operator)
|
||||
- Parks CANNOT directly reference Company entities
|
||||
|
||||
# Ride Relationships
|
||||
- Rides MUST belong to a Park (required relationship)
|
||||
- Rides MAY have a Manufacturer (optional relationship)
|
||||
- Rides MAY have a Designer (optional relationship)
|
||||
- Rides CANNOT directly reference Company entities
|
||||
|
||||
# Entity Definitions
|
||||
- Operators: Companies that operate theme parks (replaces Company.owner)
|
||||
- PropertyOwners: Companies that own park property (new concept, optional)
|
||||
- Manufacturers: Companies that manufacture rides (replaces Company for rides)
|
||||
- Designers: Companies/individuals that design rides (existing concept)
|
||||
|
||||
# Relationship Constraints
|
||||
- Operator and PropertyOwner are usually the same entity but CAN be different
|
||||
- Manufacturers and Designers are distinct concepts and should not be conflated
|
||||
- All entity relationships should use proper foreign keys with appropriate null/blank settings
|
||||
27
.github/workflows/django.yml
vendored
@@ -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
|
||||
|
||||
34
.github/workflows/review.yml
vendored
@@ -1,34 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
# Run on new/updated PRs
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
# Allow manual triggers for existing PRs
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull Request Number'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
runs-on: ubuntu-latest
|
||||
environment: development_environment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Review
|
||||
uses: pacnpal/claude-code-review@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
370
README.md
@@ -1,370 +0,0 @@
|
||||
# ThrillWiki Development Environment Setup
|
||||
|
||||
ThrillWiki is a modern Django web application for theme park and roller coaster enthusiasts, featuring a sophisticated dark theme design with purple-to-blue gradients, HTMX interactivity, and comprehensive park/ride information management.
|
||||
|
||||
## 🏗️ Technology Stack
|
||||
|
||||
- **Backend**: Django 5.0+ with GeoDjango (PostGIS)
|
||||
- **Frontend**: HTMX + Alpine.js + Tailwind CSS
|
||||
- **Database**: PostgreSQL with PostGIS extension
|
||||
- **Package Management**: UV (Python package manager)
|
||||
- **Authentication**: Django Allauth with Google/Discord OAuth
|
||||
- **Styling**: Tailwind CSS with custom dark theme
|
||||
- **History Tracking**: django-pghistory for audit trails
|
||||
- **Testing**: Pytest + Playwright for E2E testing
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
1. **Python 3.11+**
|
||||
```bash
|
||||
python --version # Should be 3.11 or higher
|
||||
```
|
||||
|
||||
2. **UV Package Manager**
|
||||
```bash
|
||||
# Install UV if not already installed
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# or
|
||||
pip install uv
|
||||
```
|
||||
|
||||
3. **PostgreSQL with PostGIS**
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install postgresql postgis
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install postgresql postgresql-contrib postgis
|
||||
|
||||
# Start PostgreSQL service
|
||||
brew services start postgresql # macOS
|
||||
sudo systemctl start postgresql # Linux
|
||||
```
|
||||
|
||||
4. **GDAL/GEOS Libraries** (for GeoDjango)
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install gdal geos
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install gdal-bin libgdal-dev libgeos-dev
|
||||
```
|
||||
|
||||
5. **Node.js** (for Tailwind CSS)
|
||||
```bash
|
||||
# Install Node.js 18+ for Tailwind CSS compilation
|
||||
node --version # Should be 18 or higher
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Clone and Setup Project
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd thrillwiki_django_no_react
|
||||
|
||||
# Install Python dependencies using UV
|
||||
uv sync
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
```bash
|
||||
# Create PostgreSQL database and user
|
||||
createdb thrillwiki
|
||||
createuser wiki
|
||||
|
||||
# Connect to PostgreSQL and setup
|
||||
psql postgres
|
||||
```
|
||||
|
||||
In the PostgreSQL shell:
|
||||
```sql
|
||||
-- Set password for wiki user
|
||||
ALTER USER wiki WITH PASSWORD 'thrillwiki';
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL PRIVILEGES ON DATABASE thrillwiki TO wiki;
|
||||
|
||||
-- Enable PostGIS extension
|
||||
\c thrillwiki
|
||||
CREATE EXTENSION postgis;
|
||||
\q
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
The project uses these database settings (configured in [`thrillwiki/settings.py`](thrillwiki/settings.py)):
|
||||
```python
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.contrib.gis.db.backends.postgis",
|
||||
"NAME": "thrillwiki",
|
||||
"USER": "wiki",
|
||||
"PASSWORD": "thrillwiki",
|
||||
"HOST": "192.168.86.3", # Update to your PostgreSQL host
|
||||
"PORT": "5432",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Update the `HOST` setting in [`thrillwiki/settings.py`](thrillwiki/settings.py) to match your PostgreSQL server location:
|
||||
- Use `"localhost"` or `"127.0.0.1"` for local development
|
||||
- Current setting is `"192.168.86.3"` - update this to your PostgreSQL server IP
|
||||
- For local development, change to `"localhost"` in settings.py
|
||||
|
||||
### 4. Database Migration
|
||||
|
||||
```bash
|
||||
# Run database migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Create a superuser account
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
**Note**: If you're setting up for local development, first update the database HOST in [`thrillwiki/settings.py`](thrillwiki/settings.py) from `"192.168.86.3"` to `"localhost"` before running migrations.
|
||||
|
||||
### 5. Start Development Server
|
||||
|
||||
**CRITICAL**: Always use this exact command sequence for 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
|
||||
```
|
||||
|
||||
This command:
|
||||
- Kills any existing processes on port 8000
|
||||
- Cleans Python cache files
|
||||
- Starts Tailwind CSS compilation
|
||||
- Runs the Django development server
|
||||
|
||||
The application will be available at: http://localhost:8000
|
||||
|
||||
## 🛠️ Development Workflow
|
||||
|
||||
### Package Management
|
||||
|
||||
**ALWAYS use UV for package management**:
|
||||
|
||||
```bash
|
||||
# Add new Python packages
|
||||
uv add <package-name>
|
||||
|
||||
# Add development dependencies
|
||||
uv add --dev <package-name>
|
||||
|
||||
# Never use pip install - always use UV
|
||||
```
|
||||
|
||||
### Django Management Commands
|
||||
|
||||
**ALWAYS use UV for Django commands**:
|
||||
|
||||
```bash
|
||||
# Correct way to run Django commands
|
||||
uv run manage.py <command>
|
||||
|
||||
# Examples:
|
||||
uv run manage.py makemigrations
|
||||
uv run manage.py migrate
|
||||
uv run manage.py shell
|
||||
uv run manage.py createsuperuser
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# NEVER use these patterns:
|
||||
# python manage.py <command> ❌ Wrong
|
||||
# uv run python manage.py <command> ❌ Wrong
|
||||
```
|
||||
|
||||
### CSS Development
|
||||
|
||||
The project uses Tailwind CSS with a custom dark theme. CSS files are located in:
|
||||
- Source: [`static/css/src/input.css`](static/css/src/input.css)
|
||||
- Compiled: [`static/css/`](static/css/) (auto-generated)
|
||||
|
||||
Tailwind automatically compiles when using the `tailwind runserver` command.
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
thrillwiki_django_no_react/
|
||||
├── accounts/ # User account management
|
||||
├── analytics/ # Analytics and tracking
|
||||
├── companies/ # Theme park companies
|
||||
├── core/ # Core application logic
|
||||
├── designers/ # Ride designers
|
||||
├── history/ # History timeline features
|
||||
├── location/ # Geographic location handling
|
||||
├── media/ # Media file management
|
||||
├── moderation/ # Content moderation
|
||||
├── parks/ # Theme park management
|
||||
├── reviews/ # User reviews
|
||||
├── rides/ # Roller coaster/ride management
|
||||
├── search/ # Search functionality
|
||||
├── static/ # Static assets (CSS, JS, images)
|
||||
├── templates/ # Django templates
|
||||
├── thrillwiki/ # Main Django project settings
|
||||
├── memory-bank/ # Development documentation
|
||||
└── .clinerules # Project development rules
|
||||
```
|
||||
|
||||
## 🔧 Key Features
|
||||
|
||||
### Authentication System
|
||||
- Django Allauth integration
|
||||
- Google OAuth authentication
|
||||
- Discord OAuth authentication
|
||||
- Custom user profiles with avatars
|
||||
|
||||
### Geographic Features
|
||||
- PostGIS integration for location data
|
||||
- Interactive park maps
|
||||
- Location-based search and filtering
|
||||
|
||||
### Content Management
|
||||
- Park and ride information management
|
||||
- Photo galleries with upload capabilities
|
||||
- User-generated reviews and ratings
|
||||
- Content moderation system
|
||||
|
||||
### Modern Frontend
|
||||
- HTMX for dynamic interactions
|
||||
- Alpine.js for client-side behavior
|
||||
- Tailwind CSS with custom dark theme
|
||||
- Responsive design (mobile-first)
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run Python tests
|
||||
uv run pytest
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run -m pytest
|
||||
uv run coverage report
|
||||
|
||||
# Run E2E tests with Playwright
|
||||
uv run pytest tests/e2e/
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
- Unit tests: Located within each app's `tests/` directory
|
||||
- E2E tests: [`tests/e2e/`](tests/e2e/)
|
||||
- Test fixtures: [`tests/fixtures/`](tests/fixtures/)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Memory Bank System
|
||||
The project uses a comprehensive documentation system in [`memory-bank/`](memory-bank/):
|
||||
|
||||
- [`memory-bank/activeContext.md`](memory-bank/activeContext.md) - Current development context
|
||||
- [`memory-bank/documentation/design-system.md`](memory-bank/documentation/design-system.md) - Design system documentation
|
||||
- [`memory-bank/features/`](memory-bank/features/) - Feature-specific documentation
|
||||
- [`memory-bank/testing/`](memory-bank/testing/) - Testing documentation and results
|
||||
|
||||
### Key Documentation Files
|
||||
- [Design System](memory-bank/documentation/design-system.md) - UI/UX guidelines and patterns
|
||||
- [Authentication System](memory-bank/features/auth/) - OAuth and user management
|
||||
- [Layout Optimization](memory-bank/projects/) - Responsive design implementations
|
||||
|
||||
## 🚨 Important Development Rules
|
||||
|
||||
### Critical Commands
|
||||
1. **Server Startup**: Always use the full command sequence:
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
2. **Package Management**: Only use UV:
|
||||
```bash
|
||||
uv add <package> # ✅ Correct
|
||||
pip install <package> # ❌ Wrong
|
||||
```
|
||||
|
||||
3. **Django Commands**: Always prefix with `uv run`:
|
||||
```bash
|
||||
uv run manage.py <command> # ✅ Correct
|
||||
python manage.py <command> # ❌ Wrong
|
||||
```
|
||||
|
||||
### Database Configuration
|
||||
- Ensure PostgreSQL is running before starting development
|
||||
- PostGIS extension must be enabled
|
||||
- Update database host settings for your environment
|
||||
|
||||
### GeoDjango Requirements
|
||||
- GDAL and GEOS libraries must be properly installed
|
||||
- Library paths are configured in [`thrillwiki/settings.py`](thrillwiki/settings.py) for macOS Homebrew
|
||||
- Current paths: `/opt/homebrew/lib/libgdal.dylib` and `/opt/homebrew/lib/libgeos_c.dylib`
|
||||
- May need adjustment based on your system's library locations (Linux users will need different paths)
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **PostGIS Extension Error**
|
||||
```bash
|
||||
# Connect to database and enable PostGIS
|
||||
psql thrillwiki
|
||||
CREATE EXTENSION postgis;
|
||||
```
|
||||
|
||||
2. **GDAL/GEOS Library Not Found**
|
||||
```bash
|
||||
# macOS (Homebrew): Current paths in settings.py
|
||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
||||
|
||||
# Linux: Update paths in settings.py to something like:
|
||||
# GDAL_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgdal.so"
|
||||
# GEOS_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgeos_c.so"
|
||||
|
||||
# Find your library locations
|
||||
find /usr -name "libgdal*" 2>/dev/null
|
||||
find /usr -name "libgeos*" 2>/dev/null
|
||||
find /opt -name "libgdal*" 2>/dev/null
|
||||
find /opt -name "libgeos*" 2>/dev/null
|
||||
```
|
||||
|
||||
3. **Port 8000 Already in Use**
|
||||
```bash
|
||||
# Kill existing processes
|
||||
lsof -ti :8000 | xargs kill -9
|
||||
```
|
||||
|
||||
4. **Tailwind CSS Not Compiling**
|
||||
```bash
|
||||
# Ensure Node.js is installed and use the full server command
|
||||
node --version
|
||||
uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. Check the [`memory-bank/`](memory-bank/) documentation for detailed feature information
|
||||
2. Review [`memory-bank/testing/`](memory-bank/testing/) for known issues and solutions
|
||||
3. Ensure all prerequisites are properly installed
|
||||
4. Verify database connection and PostGIS extension
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
After successful setup:
|
||||
|
||||
1. **Explore the Admin Interface**: http://localhost:8000/admin/
|
||||
2. **Browse the Application**: http://localhost:8000/
|
||||
3. **Review Documentation**: Check [`memory-bank/`](memory-bank/) for detailed feature docs
|
||||
4. **Run Tests**: Ensure everything works with `uv run pytest`
|
||||
5. **Start Development**: Follow the development workflow guidelines above
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding!** 🎢✨
|
||||
|
||||
For detailed feature documentation and development context, see the [`memory-bank/`](memory-bank/) directory.
|
||||
@@ -1,67 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from reviews.models import Review
|
||||
from parks.models import Park
|
||||
from rides.models import Ride
|
||||
from media.models import Photo
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Cleans up test users and data created during e2e testing"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Delete test users
|
||||
test_users = User.objects.filter(username__in=["testuser", "moderator"])
|
||||
count = test_users.count()
|
||||
test_users.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||
|
||||
# Delete test reviews
|
||||
reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
|
||||
count = reviews.count()
|
||||
reviews.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||
|
||||
# Delete test photos
|
||||
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
|
||||
count = photos.count()
|
||||
photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
||||
|
||||
# Delete test parks
|
||||
parks = Park.objects.filter(name__startswith="Test Park")
|
||||
count = parks.count()
|
||||
parks.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test parks"))
|
||||
|
||||
# Delete test rides
|
||||
rides = Ride.objects.filter(name__startswith="Test Ride")
|
||||
count = rides.count()
|
||||
rides.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
||||
|
||||
# Clean up test files
|
||||
import os
|
||||
import glob
|
||||
|
||||
# Clean up test uploads
|
||||
media_patterns = [
|
||||
"media/uploads/test_*",
|
||||
"media/avatars/test_*",
|
||||
"media/park/test_*",
|
||||
"media/rides/test_*",
|
||||
]
|
||||
|
||||
for pattern in media_patterns:
|
||||
files = glob.glob(pattern)
|
||||
for f in files:
|
||||
try:
|
||||
os.remove(f)
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))
|
||||
@@ -1,56 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Creates test users for e2e testing"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Create regular test user
|
||||
if not User.objects.filter(username="testuser").exists():
|
||||
user = User.objects.create_user(
|
||||
username="testuser",
|
||||
email="testuser@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Test user already exists"))
|
||||
|
||||
# Create moderator user
|
||||
if not User.objects.filter(username="moderator").exists():
|
||||
moderator = User.objects.create_user(
|
||||
username="moderator",
|
||||
email="moderator@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
|
||||
# Create moderator group if it doesn't exist
|
||||
moderator_group, created = Group.objects.get_or_create(name="Moderators")
|
||||
|
||||
# Add relevant permissions
|
||||
permissions = Permission.objects.filter(
|
||||
codename__in=[
|
||||
"change_review",
|
||||
"delete_review",
|
||||
"change_park",
|
||||
"change_ride",
|
||||
"moderate_photos",
|
||||
"moderate_comments",
|
||||
]
|
||||
)
|
||||
moderator_group.permissions.add(*permissions)
|
||||
|
||||
# Add user to moderator group
|
||||
moderator.groups.add(moderator_group)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Test users setup complete"))
|
||||
@@ -22,7 +22,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f'- {site.domain} ({site.name})')
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
# Frontend Architecture Documentation
|
||||
Last Updated: 2024-02-21
|
||||
|
||||
## Core Technologies
|
||||
|
||||
### 1. HTMX
|
||||
- Used for dynamic updates and server interactions
|
||||
- Enables partial page updates without full reloads
|
||||
- Integrated with Django backend for seamless data exchange
|
||||
- Used for form submissions and dynamic content loading
|
||||
|
||||
### 2. AlpineJS
|
||||
- Handles client-side interactivity and state management
|
||||
- Used for dropdowns, modals, and other interactive components
|
||||
- Provides reactive data binding and event handling
|
||||
- Key features used:
|
||||
- x-data for component state
|
||||
- x-show/x-if for conditional rendering
|
||||
- x-model for two-way data binding
|
||||
- x-on for event handling
|
||||
|
||||
### 3. Tailwind CSS
|
||||
- Utility-first CSS framework for styling
|
||||
- Custom configuration in tailwind.config.js
|
||||
- Responsive design utilities
|
||||
- Dark mode support with class-based implementation
|
||||
- Custom color scheme with primary/secondary colors
|
||||
|
||||
## Styling System
|
||||
|
||||
### 1. Base Styles
|
||||
- Font: Poppins (400, 500, 600, 700 weights)
|
||||
- Color Scheme:
|
||||
- Primary: Indigo (#4F46E5)
|
||||
- Secondary: Rose (#E11D48)
|
||||
- Gradients for interactive elements
|
||||
- Dark mode compatible color palette
|
||||
|
||||
### 2. Component Classes
|
||||
- Button Variants:
|
||||
- .btn-primary: Gradient background with hover effects
|
||||
- .btn-secondary: Light/dark mode aware styling
|
||||
- Social login buttons with brand colors
|
||||
- Form Elements:
|
||||
- .form-input: Styled input fields
|
||||
- .form-label: Consistent label styling
|
||||
- .form-error: Error message styling
|
||||
- Cards:
|
||||
- .card: Base card styling with shadows
|
||||
- .auth-card: Special styling for authentication forms
|
||||
- Status Badges:
|
||||
- .status-operating: Green success state
|
||||
- .status-closed: Red error state
|
||||
- .status-construction: Yellow warning state
|
||||
|
||||
### 3. Layout Components
|
||||
- Responsive container system
|
||||
- Grid system using Tailwind's grid utilities
|
||||
- Flexbox-based navigation and content layouts
|
||||
- Mobile-first responsive design
|
||||
|
||||
## Interactive Components
|
||||
|
||||
### 1. Navigation
|
||||
- Responsive header with mobile menu
|
||||
- User dropdown menu with authentication states
|
||||
- Theme toggle (light/dark mode)
|
||||
- Mobile-optimized navigation drawer
|
||||
|
||||
### 2. Forms
|
||||
- Location autocomplete system
|
||||
- Form validation with error states
|
||||
- CSRF protection integration
|
||||
- File upload handling
|
||||
|
||||
### 3. Alerts System
|
||||
- Timed auto-dismissing alerts
|
||||
- Slide animations for entry/exit
|
||||
- Context-aware styling (success, error, info, warning)
|
||||
- Accessible notifications
|
||||
|
||||
### 4. Modal System
|
||||
- HTMX-powered dynamic content loading
|
||||
- Alpine.js state management
|
||||
- Backdrop blur effects
|
||||
- Keyboard navigation support
|
||||
|
||||
## JavaScript Architecture
|
||||
|
||||
### 1. Core Functionality
|
||||
- Theme management with local storage persistence
|
||||
- HTMX configuration and setup
|
||||
- Alpine.js component initialization
|
||||
- Event delegation and handling
|
||||
|
||||
### 2. Location Autocomplete
|
||||
- Progressive enhancement for location fields
|
||||
- Country/Region/City hierarchical selection
|
||||
- Dynamic filtering based on parent selections
|
||||
- AJAX-powered suggestions
|
||||
|
||||
### 3. Form Handling
|
||||
- Client-side validation
|
||||
- File upload preview
|
||||
- Dynamic form updates
|
||||
- Error state management
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Asset Loading
|
||||
- Deferred script loading
|
||||
- Preloaded critical assets
|
||||
- Minified production assets
|
||||
- Cached static resources
|
||||
|
||||
### 2. Rendering
|
||||
- Progressive enhancement
|
||||
- Partial page updates
|
||||
- Lazy loading of images
|
||||
- Optimized animation performance
|
||||
|
||||
### 3. State Management
|
||||
- Efficient DOM updates
|
||||
- Debounced search inputs
|
||||
- Throttled scroll handlers
|
||||
- Memory leak prevention
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### 1. Semantic HTML
|
||||
- Proper heading hierarchy
|
||||
- ARIA labels and roles
|
||||
- Semantic landmark regions
|
||||
- Meaningful alt text
|
||||
|
||||
### 2. Keyboard Navigation
|
||||
- Focus management
|
||||
- Skip links
|
||||
- Keyboard shortcuts
|
||||
- Focus trapping in modals
|
||||
|
||||
### 3. Screen Readers
|
||||
- ARIA live regions for alerts
|
||||
- Status role for notifications
|
||||
- Description text for icons
|
||||
- Form label associations
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. CSS Organization
|
||||
- Utility-first approach
|
||||
- Component-specific styles
|
||||
- Shared design tokens
|
||||
- Dark mode variants
|
||||
|
||||
### 2. JavaScript Patterns
|
||||
- Event delegation
|
||||
- Component encapsulation
|
||||
- State management
|
||||
- Error handling
|
||||
|
||||
### 3. Testing Considerations
|
||||
- Browser compatibility
|
||||
- Responsive design testing
|
||||
- Accessibility testing
|
||||
- Performance monitoring
|
||||
|
||||
## Browser Support
|
||||
|
||||
### 1. Supported Browsers
|
||||
- Chrome (latest 2 versions)
|
||||
- Firefox (latest 2 versions)
|
||||
- Safari (latest 2 versions)
|
||||
- Edge (latest version)
|
||||
|
||||
### 2. Fallbacks
|
||||
- Graceful degradation
|
||||
- No-script support
|
||||
- Legacy browser handling
|
||||
- Progressive enhancement
|
||||
|
||||
## Security Measures
|
||||
|
||||
### 1. CSRF Protection
|
||||
- Token validation
|
||||
- Secure form submission
|
||||
- Protected AJAX requests
|
||||
- Session handling
|
||||
|
||||
### 2. XSS Prevention
|
||||
- Content sanitization
|
||||
- Escaped output
|
||||
- Secure cookie handling
|
||||
- Input validation
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### 1. Potential Improvements
|
||||
- Component library development
|
||||
- Enhanced type checking
|
||||
- Performance monitoring
|
||||
- Automated testing
|
||||
|
||||
### 2. Maintenance
|
||||
- Regular dependency updates
|
||||
- Browser compatibility checks
|
||||
- Performance optimization
|
||||
- Security audits
|
||||
@@ -20,22 +20,22 @@
|
||||
|
||||
### Frontend Technologies
|
||||
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
|
||||
|
||||
17
companies/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from simple_history.admin import SimpleHistoryAdmin
|
||||
from .models import Company, Manufacturer
|
||||
|
||||
@admin.register(Company)
|
||||
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(SimpleHistoryAdmin):
|
||||
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
|
||||
search_fields = ('name', 'headquarters', 'description')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
9
companies/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class CompaniesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'companies'
|
||||
verbose_name = 'Companies'
|
||||
|
||||
def ready(self):
|
||||
import companies.signals # noqa
|
||||
46
companies/forms.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django import forms
|
||||
from .models import Company, Manufacturer
|
||||
|
||||
class CompanyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = ['name', 'headquarters', 'website', 'description']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
}),
|
||||
'headquarters': forms.TextInput(attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'placeholder': 'e.g., Orlando, Florida, United States'
|
||||
}),
|
||||
'website': forms.URLInput(attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'placeholder': 'https://example.com'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'rows': 4,
|
||||
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
}),
|
||||
}
|
||||
|
||||
class ManufacturerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = ['name', 'headquarters', 'website', 'description']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
}),
|
||||
'headquarters': forms.TextInput(attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'placeholder': 'e.g., Altoona, Pennsylvania, United States'
|
||||
}),
|
||||
'website': forms.URLInput(attrs={
|
||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
'placeholder': 'https://example.com'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'rows': 4,
|
||||
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
}),
|
||||
}
|
||||
66
companies/migrations/0001_initial.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
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)),
|
||||
("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={
|
||||
"verbose_name_plural": "companies",
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Manufacturer",
|
||||
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)),
|
||||
("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={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
28
companies/migrations/0002_add_designer_model.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('companies', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Designer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(max_length=255, unique=True)),
|
||||
('website', models.URLField(blank=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('total_rides', models.IntegerField(default=0)),
|
||||
('total_roller_coasters', models.IntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
130
companies/models.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.urls import reverse
|
||||
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
||||
|
||||
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)
|
||||
headquarters = models.CharField(max_length=255, blank=True)
|
||||
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)
|
||||
|
||||
objects: ClassVar[models.Manager['Company']]
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = 'companies'
|
||||
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['Company', bool]:
|
||||
"""Get company 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='company',
|
||||
slug=slug
|
||||
)
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
|
||||
class Manufacturer(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
headquarters = models.CharField(max_length=255, 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['Manufacturer']]
|
||||
|
||||
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['Manufacturer', bool]:
|
||||
"""Get manufacturer 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='manufacturer',
|
||||
slug=slug
|
||||
)
|
||||
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()
|
||||
55
companies/signals.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.db.utils import ProgrammingError
|
||||
from parks.models import Park
|
||||
from rides.models import Ride
|
||||
from .models import Company, Manufacturer
|
||||
|
||||
@receiver([post_save, post_delete], sender=Park)
|
||||
def update_company_stats(sender, instance, **kwargs):
|
||||
"""Update company statistics when a park is added, modified, or deleted."""
|
||||
if instance.owner:
|
||||
try:
|
||||
# Update total parks
|
||||
total_parks = Park.objects.filter(owner=instance.owner).count()
|
||||
total_rides = Ride.objects.filter(park__owner=instance.owner).count()
|
||||
|
||||
Company.objects.filter(id=instance.owner.id).update(
|
||||
total_parks=total_parks,
|
||||
total_rides=total_rides
|
||||
)
|
||||
except ProgrammingError:
|
||||
# If rides table doesn't exist yet, just update parks count
|
||||
total_parks = Park.objects.filter(owner=instance.owner).count()
|
||||
Company.objects.filter(id=instance.owner.id).update(
|
||||
total_parks=total_parks
|
||||
)
|
||||
|
||||
@receiver([post_save, post_delete], sender=Ride)
|
||||
def update_manufacturer_stats(sender, instance, **kwargs):
|
||||
"""Update manufacturer statistics when a ride is added, modified, or deleted."""
|
||||
if instance.manufacturer:
|
||||
try:
|
||||
# Update total rides and roller coasters
|
||||
total_rides = Ride.objects.filter(manufacturer=instance.manufacturer).count()
|
||||
total_roller_coasters = Ride.objects.filter(
|
||||
manufacturer=instance.manufacturer,
|
||||
category='RC'
|
||||
).count()
|
||||
|
||||
Manufacturer.objects.filter(id=instance.manufacturer.id).update(
|
||||
total_rides=total_rides,
|
||||
total_roller_coasters=total_roller_coasters
|
||||
)
|
||||
except ProgrammingError:
|
||||
pass # Skip if rides table doesn't exist yet
|
||||
|
||||
@receiver(post_save, sender=Ride)
|
||||
def update_company_ride_stats(sender, instance, **kwargs):
|
||||
"""Update company ride statistics when a ride is added or modified."""
|
||||
if instance.park and instance.park.owner:
|
||||
try:
|
||||
total_rides = Ride.objects.filter(park__owner=instance.park.owner).count()
|
||||
Company.objects.filter(id=instance.park.owner.id).update(total_rides=total_rides)
|
||||
except ProgrammingError:
|
||||
pass # Skip if rides table doesn't exist yet
|
||||
429
companies/tests.py
Normal file
@@ -0,0 +1,429 @@
|
||||
from django.test import TestCase, Client
|
||||
from django.urls import reverse
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.http import HttpResponse
|
||||
from typing import cast, Tuple, Optional
|
||||
from .models import Company, Manufacturer
|
||||
from location.models import Location
|
||||
from moderation.models import EditSubmission, PhotoSubmission
|
||||
from media.models import Photo
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class CompanyModelTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.company = Company.objects.create(
|
||||
name='Test Company',
|
||||
website='http://example.com',
|
||||
headquarters='Test HQ',
|
||||
description='Test Description',
|
||||
total_parks=5,
|
||||
total_rides=100
|
||||
)
|
||||
|
||||
self.location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk,
|
||||
name='Test Company HQ',
|
||||
location_type='business',
|
||||
street_address='123 Company St',
|
||||
city='Company City',
|
||||
state='CS',
|
||||
country='Test Country',
|
||||
postal_code='12345',
|
||||
point=Point(-118.2437, 34.0522)
|
||||
)
|
||||
|
||||
def test_company_creation(self) -> None:
|
||||
"""Test company instance creation and field values"""
|
||||
self.assertEqual(self.company.name, 'Test Company')
|
||||
self.assertEqual(self.company.website, 'http://example.com')
|
||||
self.assertEqual(self.company.headquarters, 'Test HQ')
|
||||
self.assertEqual(self.company.description, 'Test Description')
|
||||
self.assertEqual(self.company.total_parks, 5)
|
||||
self.assertEqual(self.company.total_rides, 100)
|
||||
self.assertTrue(self.company.slug)
|
||||
|
||||
def test_company_str_representation(self) -> None:
|
||||
"""Test string representation of company"""
|
||||
self.assertEqual(str(self.company), 'Test Company')
|
||||
|
||||
def test_company_get_by_slug(self) -> None:
|
||||
"""Test get_by_slug class method"""
|
||||
company, is_historical = Company.get_by_slug(self.company.slug)
|
||||
self.assertEqual(company, self.company)
|
||||
self.assertFalse(is_historical)
|
||||
|
||||
def test_company_get_by_invalid_slug(self) -> None:
|
||||
"""Test get_by_slug with invalid slug"""
|
||||
with self.assertRaises(Company.DoesNotExist):
|
||||
Company.get_by_slug('invalid-slug')
|
||||
|
||||
def test_company_stats(self) -> None:
|
||||
"""Test company statistics fields"""
|
||||
self.company.total_parks = 10
|
||||
self.company.total_rides = 200
|
||||
self.company.save()
|
||||
|
||||
company = Company.objects.get(pk=self.company.pk)
|
||||
self.assertEqual(company.total_parks, 10)
|
||||
self.assertEqual(company.total_rides, 200)
|
||||
|
||||
class ManufacturerModelTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.manufacturer = Manufacturer.objects.create(
|
||||
name='Test Manufacturer',
|
||||
website='http://example.com',
|
||||
headquarters='Test HQ',
|
||||
description='Test Description',
|
||||
total_rides=50,
|
||||
total_roller_coasters=20
|
||||
)
|
||||
|
||||
self.location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Manufacturer),
|
||||
object_id=self.manufacturer.pk,
|
||||
name='Test Manufacturer HQ',
|
||||
location_type='business',
|
||||
street_address='123 Manufacturer St',
|
||||
city='Manufacturer City',
|
||||
state='MS',
|
||||
country='Test Country',
|
||||
postal_code='12345',
|
||||
point=Point(-118.2437, 34.0522)
|
||||
)
|
||||
|
||||
def test_manufacturer_creation(self) -> None:
|
||||
"""Test manufacturer instance creation and field values"""
|
||||
self.assertEqual(self.manufacturer.name, 'Test Manufacturer')
|
||||
self.assertEqual(self.manufacturer.website, 'http://example.com')
|
||||
self.assertEqual(self.manufacturer.headquarters, 'Test HQ')
|
||||
self.assertEqual(self.manufacturer.description, 'Test Description')
|
||||
self.assertEqual(self.manufacturer.total_rides, 50)
|
||||
self.assertEqual(self.manufacturer.total_roller_coasters, 20)
|
||||
self.assertTrue(self.manufacturer.slug)
|
||||
|
||||
def test_manufacturer_str_representation(self) -> None:
|
||||
"""Test string representation of manufacturer"""
|
||||
self.assertEqual(str(self.manufacturer), 'Test Manufacturer')
|
||||
|
||||
def test_manufacturer_get_by_slug(self) -> None:
|
||||
"""Test get_by_slug class method"""
|
||||
manufacturer, is_historical = Manufacturer.get_by_slug(self.manufacturer.slug)
|
||||
self.assertEqual(manufacturer, self.manufacturer)
|
||||
self.assertFalse(is_historical)
|
||||
|
||||
def test_manufacturer_get_by_invalid_slug(self) -> None:
|
||||
"""Test get_by_slug with invalid slug"""
|
||||
with self.assertRaises(Manufacturer.DoesNotExist):
|
||||
Manufacturer.get_by_slug('invalid-slug')
|
||||
|
||||
def test_manufacturer_stats(self) -> None:
|
||||
"""Test manufacturer statistics fields"""
|
||||
self.manufacturer.total_rides = 100
|
||||
self.manufacturer.total_roller_coasters = 40
|
||||
self.manufacturer.save()
|
||||
|
||||
manufacturer = Manufacturer.objects.get(pk=self.manufacturer.pk)
|
||||
self.assertEqual(manufacturer.total_rides, 100)
|
||||
self.assertEqual(manufacturer.total_roller_coasters, 40)
|
||||
|
||||
class CompanyViewTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = Client()
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password='testpass123'
|
||||
)
|
||||
self.moderator = User.objects.create_user(
|
||||
username='moderator',
|
||||
email='moderator@example.com',
|
||||
password='modpass123',
|
||||
role='MODERATOR'
|
||||
)
|
||||
self.company = Company.objects.create(
|
||||
name='Test Company',
|
||||
website='http://example.com',
|
||||
headquarters='Test HQ',
|
||||
description='Test Description'
|
||||
)
|
||||
|
||||
self.location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk,
|
||||
name='Test Company HQ',
|
||||
location_type='business',
|
||||
street_address='123 Company St',
|
||||
city='Company City',
|
||||
state='CS',
|
||||
country='Test Country',
|
||||
postal_code='12345',
|
||||
point=Point(-118.2437, 34.0522)
|
||||
)
|
||||
|
||||
def test_company_list_view(self) -> None:
|
||||
"""Test company list view"""
|
||||
response = self.client.get(reverse('companies:company_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.company.name)
|
||||
|
||||
def test_company_list_view_with_search(self) -> None:
|
||||
"""Test company list view with search"""
|
||||
response = self.client.get(reverse('companies:company_list') + '?search=Test')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.company.name)
|
||||
|
||||
response = self.client.get(reverse('companies:company_list') + '?search=NonExistent')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, self.company.name)
|
||||
|
||||
def test_company_list_view_with_country_filter(self) -> None:
|
||||
"""Test company list view with country filter"""
|
||||
response = self.client.get(reverse('companies:company_list') + '?country=Test Country')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.company.name)
|
||||
|
||||
response = self.client.get(reverse('companies:company_list') + '?country=NonExistent')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, self.company.name)
|
||||
|
||||
def test_company_detail_view(self) -> None:
|
||||
"""Test company detail view"""
|
||||
response = self.client.get(
|
||||
reverse('companies:company_detail', kwargs={'slug': self.company.slug})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.company.name)
|
||||
self.assertContains(response, self.company.website)
|
||||
self.assertContains(response, self.company.headquarters)
|
||||
|
||||
def test_company_detail_view_invalid_slug(self) -> None:
|
||||
"""Test company detail view with invalid slug"""
|
||||
response = self.client.get(
|
||||
reverse('companies:company_detail', kwargs={'slug': 'invalid-slug'})
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_company_create_view_unauthenticated(self) -> None:
|
||||
"""Test company create view when not logged in"""
|
||||
response = self.client.get(reverse('companies:company_create'))
|
||||
self.assertEqual(response.status_code, 302) # Redirects to login
|
||||
|
||||
def test_company_create_view_authenticated(self) -> None:
|
||||
"""Test company create view when logged in"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
response = self.client.get(reverse('companies:company_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_company_create_submission_regular_user(self) -> None:
|
||||
"""Test creating a company submission as regular user"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
data = {
|
||||
'name': 'New Company',
|
||||
'website': 'http://newcompany.com',
|
||||
'headquarters': 'New HQ',
|
||||
'description': 'New Description',
|
||||
'reason': 'Adding new company',
|
||||
'source': 'Company website'
|
||||
}
|
||||
response = self.client.post(reverse('companies:company_create'), data)
|
||||
self.assertEqual(response.status_code, 302) # Redirects after submission
|
||||
self.assertTrue(EditSubmission.objects.filter(
|
||||
submission_type='CREATE',
|
||||
changes__name='New Company',
|
||||
status='NEW'
|
||||
).exists())
|
||||
|
||||
def test_company_create_submission_moderator(self) -> None:
|
||||
"""Test creating a company submission as moderator"""
|
||||
self.client.login(username='moderator', password='modpass123')
|
||||
data = {
|
||||
'name': 'New Company',
|
||||
'website': 'http://newcompany.com',
|
||||
'headquarters': 'New HQ',
|
||||
'description': 'New Description',
|
||||
'reason': 'Adding new company',
|
||||
'source': 'Company website'
|
||||
}
|
||||
response = self.client.post(reverse('companies:company_create'), data)
|
||||
self.assertEqual(response.status_code, 302) # Redirects after submission
|
||||
submission = EditSubmission.objects.get(
|
||||
submission_type='CREATE',
|
||||
changes__name='New Company'
|
||||
)
|
||||
self.assertEqual(submission.status, 'APPROVED')
|
||||
self.assertEqual(submission.handled_by, self.moderator)
|
||||
|
||||
def test_company_photo_submission(self) -> None:
|
||||
"""Test photo submission for company"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
image_content = b'GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
|
||||
image = SimpleUploadedFile('test.gif', image_content, content_type='image/gif')
|
||||
data = {
|
||||
'photo': image,
|
||||
'caption': 'Test Photo',
|
||||
'date_taken': '2024-01-01'
|
||||
}
|
||||
response = cast(HttpResponse, self.client.post(
|
||||
reverse('companies:company_detail', kwargs={'slug': self.company.slug}),
|
||||
data,
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest' # Simulate AJAX request
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(PhotoSubmission.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk
|
||||
).exists())
|
||||
|
||||
class ManufacturerViewTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = Client()
|
||||
self.user = User.objects.create_user(
|
||||
username='testuser',
|
||||
email='test@example.com',
|
||||
password='testpass123'
|
||||
)
|
||||
self.moderator = User.objects.create_user(
|
||||
username='moderator',
|
||||
email='moderator@example.com',
|
||||
password='modpass123',
|
||||
role='MODERATOR'
|
||||
)
|
||||
self.manufacturer = Manufacturer.objects.create(
|
||||
name='Test Manufacturer',
|
||||
website='http://example.com',
|
||||
headquarters='Test HQ',
|
||||
description='Test Description'
|
||||
)
|
||||
|
||||
self.location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Manufacturer),
|
||||
object_id=self.manufacturer.pk,
|
||||
name='Test Manufacturer HQ',
|
||||
location_type='business',
|
||||
street_address='123 Manufacturer St',
|
||||
city='Manufacturer City',
|
||||
state='MS',
|
||||
country='Test Country',
|
||||
postal_code='12345',
|
||||
point=Point(-118.2437, 34.0522)
|
||||
)
|
||||
|
||||
def test_manufacturer_list_view(self) -> None:
|
||||
"""Test manufacturer list view"""
|
||||
response = self.client.get(reverse('companies:manufacturer_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.manufacturer.name)
|
||||
|
||||
def test_manufacturer_list_view_with_search(self) -> None:
|
||||
"""Test manufacturer list view with search"""
|
||||
response = self.client.get(reverse('companies:manufacturer_list') + '?search=Test')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.manufacturer.name)
|
||||
|
||||
response = self.client.get(reverse('companies:manufacturer_list') + '?search=NonExistent')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, self.manufacturer.name)
|
||||
|
||||
def test_manufacturer_list_view_with_country_filter(self) -> None:
|
||||
"""Test manufacturer list view with country filter"""
|
||||
response = self.client.get(reverse('companies:manufacturer_list') + '?country=Test Country')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.manufacturer.name)
|
||||
|
||||
response = self.client.get(reverse('companies:manufacturer_list') + '?country=NonExistent')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, self.manufacturer.name)
|
||||
|
||||
def test_manufacturer_detail_view(self) -> None:
|
||||
"""Test manufacturer detail view"""
|
||||
response = self.client.get(
|
||||
reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.manufacturer.name)
|
||||
self.assertContains(response, self.manufacturer.website)
|
||||
self.assertContains(response, self.manufacturer.headquarters)
|
||||
|
||||
def test_manufacturer_detail_view_invalid_slug(self) -> None:
|
||||
"""Test manufacturer detail view with invalid slug"""
|
||||
response = self.client.get(
|
||||
reverse('companies:manufacturer_detail', kwargs={'slug': 'invalid-slug'})
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_manufacturer_create_view_unauthenticated(self) -> None:
|
||||
"""Test manufacturer create view when not logged in"""
|
||||
response = self.client.get(reverse('companies:manufacturer_create'))
|
||||
self.assertEqual(response.status_code, 302) # Redirects to login
|
||||
|
||||
def test_manufacturer_create_view_authenticated(self) -> None:
|
||||
"""Test manufacturer create view when logged in"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
response = self.client.get(reverse('companies:manufacturer_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_manufacturer_create_submission_regular_user(self) -> None:
|
||||
"""Test creating a manufacturer submission as regular user"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
data = {
|
||||
'name': 'New Manufacturer',
|
||||
'website': 'http://newmanufacturer.com',
|
||||
'headquarters': 'New HQ',
|
||||
'description': 'New Description',
|
||||
'reason': 'Adding new manufacturer',
|
||||
'source': 'Manufacturer website'
|
||||
}
|
||||
response = self.client.post(reverse('companies:manufacturer_create'), data)
|
||||
self.assertEqual(response.status_code, 302) # Redirects after submission
|
||||
self.assertTrue(EditSubmission.objects.filter(
|
||||
submission_type='CREATE',
|
||||
changes__name='New Manufacturer',
|
||||
status='NEW'
|
||||
).exists())
|
||||
|
||||
def test_manufacturer_create_submission_moderator(self) -> None:
|
||||
"""Test creating a manufacturer submission as moderator"""
|
||||
self.client.login(username='moderator', password='modpass123')
|
||||
data = {
|
||||
'name': 'New Manufacturer',
|
||||
'website': 'http://newmanufacturer.com',
|
||||
'headquarters': 'New HQ',
|
||||
'description': 'New Description',
|
||||
'reason': 'Adding new manufacturer',
|
||||
'source': 'Manufacturer website'
|
||||
}
|
||||
response = self.client.post(reverse('companies:manufacturer_create'), data)
|
||||
self.assertEqual(response.status_code, 302) # Redirects after submission
|
||||
submission = EditSubmission.objects.get(
|
||||
submission_type='CREATE',
|
||||
changes__name='New Manufacturer'
|
||||
)
|
||||
self.assertEqual(submission.status, 'APPROVED')
|
||||
self.assertEqual(submission.handled_by, self.moderator)
|
||||
|
||||
def test_manufacturer_photo_submission(self) -> None:
|
||||
"""Test photo submission for manufacturer"""
|
||||
self.client.login(username='testuser', password='testpass123')
|
||||
image_content = b'GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
|
||||
image = SimpleUploadedFile('test.gif', image_content, content_type='image/gif')
|
||||
data = {
|
||||
'photo': image,
|
||||
'caption': 'Test Photo',
|
||||
'date_taken': '2024-01-01'
|
||||
}
|
||||
response = cast(HttpResponse, self.client.post(
|
||||
reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug}),
|
||||
data,
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest' # Simulate AJAX request
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(PhotoSubmission.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(Manufacturer),
|
||||
object_id=self.manufacturer.pk
|
||||
).exists())
|
||||
22
companies/urls.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'companies'
|
||||
|
||||
urlpatterns = [
|
||||
# List views first
|
||||
path('', views.CompanyListView.as_view(), name='company_list'),
|
||||
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
||||
|
||||
# Create views
|
||||
path('create/', views.CompanyCreateView.as_view(), name='company_create'),
|
||||
path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'),
|
||||
|
||||
# Update views
|
||||
path('<slug:slug>/edit/', views.CompanyUpdateView.as_view(), name='company_edit'),
|
||||
path('manufacturers/<slug:slug>/edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'),
|
||||
|
||||
# Detail views last (to avoid conflicts with other URL patterns)
|
||||
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
|
||||
path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'),
|
||||
]
|
||||
365
companies/views.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from typing import Any, Optional, Tuple, Type, cast, Union, Dict, Callable
|
||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect, Http404, JsonResponse, HttpResponse
|
||||
from django.db.models import Count, Sum, Q, QuerySet, Model
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import Company, Manufacturer
|
||||
from .forms import CompanyForm, ManufacturerForm
|
||||
from rides.models import Ride
|
||||
from parks.models import Park
|
||||
from location.models import Location
|
||||
from core.views import SlugRedirectMixin
|
||||
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||
from moderation.models import EditSubmission
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
ModelType = Union[Type[Company], Type[Manufacturer]]
|
||||
|
||||
def get_company_parks(company: Company) -> QuerySet[Park]:
|
||||
"""Get parks owned by a company with related data."""
|
||||
return Park.objects.filter(
|
||||
owner=company
|
||||
).select_related('owner')
|
||||
|
||||
def get_company_ride_count(parks: QuerySet[Park]) -> int:
|
||||
"""Get total number of rides across all parks."""
|
||||
return Ride.objects.filter(park__in=parks).count()
|
||||
|
||||
def get_manufacturer_rides(manufacturer: Manufacturer) -> QuerySet[Ride]:
|
||||
"""Get rides made by a manufacturer with related data."""
|
||||
return Ride.objects.filter(
|
||||
manufacturer=manufacturer
|
||||
).select_related('park', 'coaster_stats')
|
||||
|
||||
def get_manufacturer_stats(rides: QuerySet[Ride]) -> Dict[str, int]:
|
||||
"""Get statistics for manufacturer rides."""
|
||||
return {
|
||||
'coaster_count': rides.filter(category='ROLLER_COASTER').count(),
|
||||
'parks_count': rides.values('park').distinct().count()
|
||||
}
|
||||
|
||||
def handle_submission_post(
|
||||
request: Any,
|
||||
handle_photo_submission: Callable[[Any], HttpResponse],
|
||||
super_post: Callable[..., HttpResponse],
|
||||
*args: Any,
|
||||
**kwargs: Any
|
||||
) -> HttpResponse:
|
||||
"""Handle POST requests for photos and edits."""
|
||||
if request.FILES:
|
||||
# Handle photo submission
|
||||
return handle_photo_submission(request)
|
||||
# Handle edit submission
|
||||
return super_post(request, *args, **kwargs)
|
||||
|
||||
# List Views
|
||||
class CompanyListView(ListView):
|
||||
model: Type[Company] = Company
|
||||
template_name = "companies/company_list.html"
|
||||
context_object_name = "companies"
|
||||
paginate_by = 12
|
||||
|
||||
def get_queryset(self) -> QuerySet[Company]:
|
||||
queryset = self.model.objects.all()
|
||||
|
||||
if country := self.request.GET.get("country"):
|
||||
# Get companies that have locations in the specified country
|
||||
company_ids = Location.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
country__iexact=country,
|
||||
).values_list("object_id", flat=True)
|
||||
queryset = queryset.filter(pk__in=company_ids)
|
||||
|
||||
if search := self.request.GET.get("search"):
|
||||
queryset = queryset.filter(name__icontains=search)
|
||||
|
||||
return queryset.order_by("name")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Add filter values to context
|
||||
context["country"] = self.request.GET.get("country", "")
|
||||
context["search"] = self.request.GET.get("search", "")
|
||||
return context
|
||||
|
||||
|
||||
class ManufacturerListView(ListView):
|
||||
model: Type[Manufacturer] = Manufacturer
|
||||
template_name = "companies/manufacturer_list.html"
|
||||
context_object_name = "manufacturers"
|
||||
paginate_by = 12
|
||||
|
||||
def get_queryset(self) -> QuerySet[Manufacturer]:
|
||||
queryset = self.model.objects.all()
|
||||
|
||||
if country := self.request.GET.get("country"):
|
||||
# Get manufacturers that have locations in the specified country
|
||||
manufacturer_ids = Location.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(Manufacturer),
|
||||
country__iexact=country,
|
||||
).values_list("object_id", flat=True)
|
||||
queryset = queryset.filter(pk__in=manufacturer_ids)
|
||||
|
||||
if search := self.request.GET.get("search"):
|
||||
queryset = queryset.filter(name__icontains=search)
|
||||
|
||||
return queryset.order_by("name")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Add stats for filtering
|
||||
context["total_manufacturers"] = self.model.objects.count()
|
||||
context["total_rides"] = Ride.objects.filter(manufacturer__isnull=False).count()
|
||||
context["total_roller_coasters"] = Ride.objects.filter(
|
||||
manufacturer__isnull=False, category="ROLLER_COASTER"
|
||||
).count()
|
||||
# Add filter values to context
|
||||
context["country"] = self.request.GET.get("country", "")
|
||||
context["search"] = self.request.GET.get("search", "")
|
||||
return context
|
||||
|
||||
|
||||
# Detail Views
|
||||
class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
|
||||
model: Type[Company] = Company
|
||||
template_name = 'companies/company_detail.html'
|
||||
context_object_name = 'company'
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[Company]] = None) -> Company:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
try:
|
||||
# Try to get by current or historical slug
|
||||
model = cast(Type[Company], self.model)
|
||||
obj, _ = model.get_by_slug(slug)
|
||||
return obj
|
||||
except model.DoesNotExist as e:
|
||||
raise Http404(f"No {model._meta.verbose_name} found matching the query") from e
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
company = cast(Company, self.object)
|
||||
|
||||
parks = get_company_parks(company)
|
||||
context['parks'] = parks
|
||||
context['total_rides'] = get_company_ride_count(parks)
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return 'companies:company_detail'
|
||||
|
||||
def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
"""Handle POST requests for photos and edits."""
|
||||
return handle_submission_post(
|
||||
request,
|
||||
self.handle_photo_submission,
|
||||
super().post,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
|
||||
model: Type[Manufacturer] = Manufacturer
|
||||
template_name = 'companies/manufacturer_detail.html'
|
||||
context_object_name = 'manufacturer'
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[Manufacturer]] = None) -> Manufacturer:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
try:
|
||||
# Try to get by current or historical slug
|
||||
model = cast(Type[Manufacturer], self.model)
|
||||
obj, _ = model.get_by_slug(slug)
|
||||
return obj
|
||||
except model.DoesNotExist as e:
|
||||
raise Http404(f"No {model._meta.verbose_name} found matching the query") from e
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
manufacturer = cast(Manufacturer, self.object)
|
||||
|
||||
rides = get_manufacturer_rides(manufacturer)
|
||||
context['rides'] = rides
|
||||
context.update(get_manufacturer_stats(rides))
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return 'companies:manufacturer_detail'
|
||||
|
||||
def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
"""Handle POST requests for photos and edits."""
|
||||
return handle_submission_post(
|
||||
request,
|
||||
self.handle_photo_submission,
|
||||
super().post,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def _handle_submission(
|
||||
request: Any, form: Any, model: ModelType, success_url: str
|
||||
) -> HttpResponseRedirect:
|
||||
"""Helper method to handle form submissions"""
|
||||
cleaned_data = form.cleaned_data.copy()
|
||||
submission = EditSubmission.objects.create(
|
||||
user=request.user,
|
||||
content_type=ContentType.objects.get_for_model(model),
|
||||
submission_type="CREATE",
|
||||
changes=cleaned_data,
|
||||
reason=request.POST.get("reason", ""),
|
||||
source=request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
# Get user role safely
|
||||
user_role = getattr(request.user, "role", None)
|
||||
|
||||
# If user is moderator or above, auto-approve
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
obj = form.save()
|
||||
submission.object_id = obj.pk
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = request.user
|
||||
submission.save()
|
||||
messages.success(request, f'Successfully created {getattr(obj, "name", "")}')
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
messages.success(request, "Your submission has been sent for review")
|
||||
return HttpResponseRedirect(reverse(f"companies:{model.__name__.lower()}_list"))
|
||||
|
||||
|
||||
# Create Views
|
||||
class CompanyCreateView(LoginRequiredMixin, CreateView):
|
||||
model: Type[Company] = Company
|
||||
form_class = CompanyForm
|
||||
template_name = "companies/company_form.html"
|
||||
object: Optional[Company]
|
||||
|
||||
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
||||
success_url = reverse(
|
||||
"companies:company_detail", kwargs={"slug": form.instance.slug}
|
||||
)
|
||||
return _handle_submission(self.request, form, self.model, success_url)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
if self.object is None:
|
||||
return reverse("companies:company_list")
|
||||
return reverse("companies:company_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ManufacturerCreateView(LoginRequiredMixin, CreateView):
|
||||
model: Type[Manufacturer] = Manufacturer
|
||||
form_class = ManufacturerForm
|
||||
template_name = "companies/manufacturer_form.html"
|
||||
object: Optional[Manufacturer]
|
||||
|
||||
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
||||
success_url = reverse(
|
||||
"companies:manufacturer_detail", kwargs={"slug": form.instance.slug}
|
||||
)
|
||||
return _handle_submission(self.request, form, self.model, success_url)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
if self.object is None:
|
||||
return reverse("companies:manufacturer_list")
|
||||
return reverse(
|
||||
"companies:manufacturer_detail", kwargs={"slug": self.object.slug}
|
||||
)
|
||||
|
||||
|
||||
def _handle_update(
|
||||
request: Any, form: Any, obj: Union[Company, Manufacturer], model: ModelType
|
||||
) -> HttpResponseRedirect:
|
||||
"""Helper method to handle update submissions"""
|
||||
cleaned_data = form.cleaned_data.copy()
|
||||
submission = EditSubmission.objects.create(
|
||||
user=request.user,
|
||||
content_type=ContentType.objects.get_for_model(model),
|
||||
object_id=obj.pk,
|
||||
submission_type="EDIT",
|
||||
changes=cleaned_data,
|
||||
reason=request.POST.get("reason", ""),
|
||||
source=request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
# Get user role safely
|
||||
user_role = getattr(request.user, "role", None)
|
||||
|
||||
# If user is moderator or above, auto-approve
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
obj = form.save()
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = request.user
|
||||
submission.save()
|
||||
messages.success(request, f'Successfully updated {getattr(obj, "name", "")}')
|
||||
return HttpResponseRedirect(
|
||||
reverse(
|
||||
f"companies:{model.__name__.lower()}_detail",
|
||||
kwargs={"slug": getattr(obj, "slug", "")},
|
||||
)
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request, f'Your changes to {getattr(obj, "name", "")} have been sent for review'
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(
|
||||
f"companies:{model.__name__.lower()}_detail",
|
||||
kwargs={"slug": getattr(obj, "slug", "")},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Update Views
|
||||
class CompanyUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model: Type[Company] = Company
|
||||
form_class = CompanyForm
|
||||
template_name = "companies/company_form.html"
|
||||
object: Optional[Company]
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["is_edit"] = True
|
||||
return context
|
||||
|
||||
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
|
||||
if self.object is None:
|
||||
return HttpResponseRedirect(reverse("companies:company_list"))
|
||||
return _handle_update(self.request, form, self.object, self.model)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
if self.object is None:
|
||||
return reverse("companies:company_list")
|
||||
return reverse("companies:company_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ManufacturerUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model: Type[Manufacturer] = Manufacturer
|
||||
form_class = ManufacturerForm
|
||||
template_name = "companies/manufacturer_form.html"
|
||||
object: Optional[Manufacturer]
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["is_edit"] = True
|
||||
return context
|
||||
|
||||
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
|
||||
if self.object is None:
|
||||
return HttpResponseRedirect(reverse("companies:manufacturer_list"))
|
||||
return _handle_update(self.request, form, self.object, self.model)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
if self.object is None:
|
||||
return reverse("companies:manufacturer_list")
|
||||
return reverse(
|
||||
"companies:manufacturer_detail", kwargs={"slug": self.object.slug}
|
||||
)
|
||||
@@ -1,435 +0,0 @@
|
||||
# ThrillWiki Django Project - Complete Technical Review
|
||||
**Date:** January 5, 2025
|
||||
**Reviewer:** Roo (Architect Mode)
|
||||
**Review Type:** Exhaustive Code Analysis
|
||||
**Status:** COMPLETED - Comprehensive analysis of entire codebase
|
||||
|
||||
> **CRITICAL MEMORY BANK DOCUMENT** - This exhaustive review represents the most comprehensive analysis of the ThrillWiki project to date. All future architectural decisions should reference this document.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
ThrillWiki is a comprehensive Django-based theme park and ride database application with advanced features including user authentication, content moderation, media management, location services, analytics, and history tracking. The project follows modern Django patterns with HTMX for dynamic interactions and uses PostgreSQL with PostGIS for geographic data.
|
||||
|
||||
## Technical Stack Analysis
|
||||
|
||||
### Core Framework & Dependencies
|
||||
- **Django 5.0+** - Modern Django framework
|
||||
- **Python 3.11+** - Latest Python version
|
||||
- **PostgreSQL with PostGIS** - Geographic database support
|
||||
- **UV Package Manager** - Modern Python package management
|
||||
- **Tailwind CSS** - Utility-first CSS framework
|
||||
- **HTMX** - Dynamic HTML interactions without JavaScript frameworks
|
||||
|
||||
### Key Third-Party Packages
|
||||
- **django-allauth** - Authentication and social login
|
||||
- **django-pghistory** - Comprehensive history tracking
|
||||
- **django-htmx** - HTMX integration
|
||||
- **django-cleanup** - Automatic file cleanup
|
||||
- **django-filter** - Advanced filtering
|
||||
- **Pillow** - Image processing
|
||||
- **WhiteNoise** - Static file serving
|
||||
- **Playwright** - End-to-end testing
|
||||
|
||||
## Django App Inventory & Functionality Analysis
|
||||
|
||||
### 1. Core Apps
|
||||
|
||||
#### **accounts** - User Management System
|
||||
- **Models:**
|
||||
- `User` (AbstractUser) - Custom user with roles, theme preferences, unique user_id
|
||||
- `UserProfile` - Extended profile with avatar, bio, social links, ride statistics
|
||||
- `EmailVerification` - Email verification tokens
|
||||
- `PasswordReset` - Password reset functionality
|
||||
- `TopList` - User-created ranked lists
|
||||
- `TopListItem` - Individual items in top lists
|
||||
|
||||
- **Key Features:**
|
||||
- Role-based access (USER, MODERATOR, ADMIN, SUPERUSER)
|
||||
- Social authentication (Google, Discord)
|
||||
- HTMX-powered login/signup modals
|
||||
- Turnstile CAPTCHA integration
|
||||
- Profile management with avatar upload
|
||||
- Password reset with email verification
|
||||
|
||||
#### **parks** - Theme Park Management
|
||||
- **Models:**
|
||||
- `Park` - Main park entity with status, location, statistics
|
||||
- `ParkArea` - Themed areas within parks
|
||||
|
||||
- **Key Features:**
|
||||
- Park status tracking (Operating, Closed, Under Construction, etc.)
|
||||
- Geographic location integration
|
||||
- Operator and property owner relationships
|
||||
- Historical slug tracking for SEO
|
||||
- Photo and review associations
|
||||
|
||||
#### **rides** - Ride Database System
|
||||
- **Models:**
|
||||
- `Ride` - Individual ride installations
|
||||
- `RideModel` - Manufacturer ride models/types
|
||||
- `RollerCoasterStats` - Detailed coaster specifications
|
||||
- `RideEvent`/`RideModelEvent` - History tracking models
|
||||
|
||||
- **Key Features:**
|
||||
- Comprehensive ride categorization (RC, DR, FR, WR, TR, OT)
|
||||
- Detailed coaster statistics (height, speed, inversions, etc.)
|
||||
- Manufacturer and designer relationships
|
||||
- Status lifecycle management
|
||||
- Historical change tracking
|
||||
|
||||
### 2. Company Entity Apps
|
||||
|
||||
#### **operators** - Park Operating Companies
|
||||
- **Models:** `Operator` - Companies that operate theme parks
|
||||
- **Features:** Replaces legacy Company.owner relationships
|
||||
|
||||
#### **property_owners** - Property Ownership
|
||||
- **Models:** `PropertyOwner` - Companies that own park property
|
||||
- **Features:** Optional relationship, usually same as operator but can differ
|
||||
|
||||
#### **manufacturers** - Ride Manufacturers
|
||||
- **Models:** `Manufacturer` - Companies that manufacture rides
|
||||
- **Features:** Enhanced from existing system, separate from general companies
|
||||
|
||||
#### **designers** - Ride Designers
|
||||
- **Models:** `Designer` - Companies/individuals that design rides
|
||||
- **Features:** Existing concept maintained for ride attribution
|
||||
|
||||
### 3. Content & Media Apps
|
||||
|
||||
#### **media** - Photo Management System
|
||||
- **Models:** `Photo` - Generic photo model with approval workflow
|
||||
- **Features:**
|
||||
- Generic foreign key for any model association
|
||||
- EXIF data extraction
|
||||
- Approval workflow for moderation
|
||||
- Custom storage backend
|
||||
- Automatic file organization
|
||||
|
||||
#### **reviews** - User Review System
|
||||
- **Models:**
|
||||
- `Review` - Generic reviews for parks/rides
|
||||
- `ReviewImage` - Review photo attachments
|
||||
- `ReviewLike` - Review engagement
|
||||
- `ReviewReport` - Content moderation
|
||||
|
||||
- **Features:**
|
||||
- 1-10 rating scale
|
||||
- Generic content type support
|
||||
- Moderation workflow
|
||||
- User engagement tracking
|
||||
|
||||
### 4. Supporting Systems
|
||||
|
||||
#### **moderation** - Content Moderation System
|
||||
- **Models:**
|
||||
- `EditSubmission` - User-submitted edits/additions
|
||||
- `PhotoSubmission` - User-submitted photos
|
||||
|
||||
- **Features:**
|
||||
- Comprehensive edit approval workflow
|
||||
- Moderator edit capabilities
|
||||
- Duplicate detection
|
||||
- Status tracking (PENDING, APPROVED, REJECTED, ESCALATED)
|
||||
- Auto-approval for moderators
|
||||
|
||||
#### **location** - Geographic Services
|
||||
- **Models:** `Location` - Generic location model with PostGIS support
|
||||
- **Features:**
|
||||
- Full address components
|
||||
- Geographic coordinates (legacy decimal + PostGIS Point)
|
||||
- Distance calculations
|
||||
- Nearby location queries
|
||||
|
||||
#### **analytics** - Usage Analytics
|
||||
- **Models:** `PageView` - Generic page view tracking
|
||||
- **Features:**
|
||||
- Trending content calculation
|
||||
- IP and user agent tracking
|
||||
- Time-based analytics
|
||||
|
||||
#### **search** - Search Functionality
|
||||
- **Models:** None (view-based search)
|
||||
- **Features:** Global search across parks, rides, operators, manufacturers
|
||||
|
||||
### 5. Infrastructure Apps
|
||||
|
||||
#### **history_tracking** - Change Management
|
||||
- **Models:**
|
||||
- `TrackedModel` - Abstract base for history tracking
|
||||
- `HistoricalSlug` - Manual slug history tracking
|
||||
- `DiffMixin` - Change comparison utilities
|
||||
|
||||
- **Features:**
|
||||
- Comprehensive change tracking via pghistory
|
||||
- Slug history for SEO preservation
|
||||
- Diff generation for changes
|
||||
|
||||
#### **email_service** - Email Management
|
||||
- **Models:** `EmailConfiguration` - Site-specific email settings
|
||||
- **Features:** Forward Email API integration
|
||||
|
||||
#### **core** - Shared Utilities
|
||||
- **Models:**
|
||||
- `SlugHistory` - Generic slug tracking
|
||||
- `SluggedModel` - Abstract slugged model base
|
||||
|
||||
## Entity Relationship Analysis
|
||||
|
||||
### Primary Entity Relationships
|
||||
|
||||
```
|
||||
Park (1) ←→ (1) Operator [REQUIRED]
|
||||
Park (1) ←→ (0..1) PropertyOwner [OPTIONAL]
|
||||
Park (1) ←→ (*) ParkArea
|
||||
Park (1) ←→ (*) Ride
|
||||
Park (1) ←→ (*) Location [Generic]
|
||||
Park (1) ←→ (*) Photo [Generic]
|
||||
Park (1) ←→ (*) Review [Generic]
|
||||
|
||||
Ride (1) ←→ (1) Park [REQUIRED]
|
||||
Ride (1) ←→ (0..1) ParkArea [OPTIONAL]
|
||||
Ride (1) ←→ (0..1) Manufacturer [OPTIONAL]
|
||||
Ride (1) ←→ (0..1) Designer [OPTIONAL]
|
||||
Ride (1) ←→ (0..1) RideModel [OPTIONAL]
|
||||
Ride (1) ←→ (0..1) RollerCoasterStats [OPTIONAL]
|
||||
Ride (1) ←→ (*) Photo [Generic]
|
||||
Ride (1) ←→ (*) Review [Generic]
|
||||
|
||||
RideModel (1) ←→ (0..1) Manufacturer
|
||||
RideModel (1) ←→ (*) Ride
|
||||
|
||||
User (1) ←→ (1) UserProfile
|
||||
User (1) ←→ (*) Review
|
||||
User (1) ←→ (*) TopList
|
||||
User (1) ←→ (*) EditSubmission
|
||||
User (1) ←→ (*) PhotoSubmission
|
||||
```
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
1. **Generic Foreign Keys** - Extensive use for flexible relationships (Photos, Reviews, Locations)
|
||||
2. **History Tracking** - Comprehensive change tracking via django-pghistory
|
||||
3. **Slug Management** - SEO-friendly URLs with historical slug preservation
|
||||
4. **Moderation Workflow** - User-generated content approval system
|
||||
5. **Role-Based Access** - Hierarchical user permissions
|
||||
|
||||
## Database Schema Analysis
|
||||
|
||||
### Core Tables Structure
|
||||
|
||||
#### User Management
|
||||
- `accounts_user` - Extended Django user model
|
||||
- `accounts_userprofile` - User profile extensions
|
||||
- `accounts_toplist` / `accounts_toplistitem` - User rankings
|
||||
|
||||
#### Content Tables
|
||||
- `parks_park` / `parks_parkarea` - Park hierarchy
|
||||
- `rides_ride` / `rides_ridemodel` / `rides_rollercoasterstats` - Ride data
|
||||
- `operators_operator` / `property_owners_propertyowner` - Ownership
|
||||
- `manufacturers_manufacturer` / `designers_designer` - Attribution
|
||||
|
||||
#### Supporting Tables
|
||||
- `media_photo` - Generic photo storage
|
||||
- `reviews_review` + related - Review system
|
||||
- `location_location` - Geographic data
|
||||
- `moderation_editsubmission` / `moderation_photosubmission` - Moderation
|
||||
- `analytics_pageview` - Usage tracking
|
||||
|
||||
#### History Tables (pghistory)
|
||||
- `*_*event` tables for comprehensive change tracking
|
||||
- Automatic creation via pghistory decorators
|
||||
|
||||
## URL Routing Analysis
|
||||
|
||||
### Main URL Structure
|
||||
```
|
||||
/ - Home page with trending content
|
||||
/admin/ - Django admin interface
|
||||
/ac/ - Autocomplete endpoints
|
||||
/parks/ - Park browsing and details
|
||||
/rides/ - Ride browsing and details
|
||||
/operators/ - Operator profiles
|
||||
/property-owners/ - Property owner profiles
|
||||
/manufacturers/ - Manufacturer profiles
|
||||
/designers/ - Designer profiles
|
||||
/photos/ - Media management
|
||||
/search/ - Global search
|
||||
/accounts/ - Authentication (custom + allauth)
|
||||
/moderation/ - Content moderation
|
||||
/history/ - Change history
|
||||
```
|
||||
|
||||
### URL Patterns
|
||||
- SEO-friendly slugs for all content
|
||||
- Historical slug support for redirects
|
||||
- HTMX-compatible endpoints
|
||||
- RESTful resource organization
|
||||
|
||||
## Form Analysis
|
||||
|
||||
### Key Forms Identified
|
||||
- User authentication (login/signup with Turnstile)
|
||||
- Profile management
|
||||
- Content submission (parks, rides)
|
||||
- Photo uploads
|
||||
- Review submission
|
||||
- Moderation workflows
|
||||
|
||||
### Form Features
|
||||
- HTMX integration for dynamic interactions
|
||||
- Comprehensive validation
|
||||
- File upload handling
|
||||
- CAPTCHA protection
|
||||
|
||||
## Admin Interface Analysis
|
||||
|
||||
### Django Admin Customization
|
||||
- Custom admin interfaces for all models
|
||||
- Bulk operations support
|
||||
- Advanced filtering and search
|
||||
- Moderation workflow integration
|
||||
- History tracking display
|
||||
|
||||
## Template Structure Analysis
|
||||
|
||||
### Template Organization
|
||||
```
|
||||
templates/
|
||||
├── base/ - Base templates and layouts
|
||||
├── account/ - Authentication templates
|
||||
├── accounts/ - User profile templates
|
||||
├── parks/ - Park-related templates
|
||||
├── rides/ - Ride-related templates
|
||||
├── operators/ - Operator templates
|
||||
├── manufacturers/ - Manufacturer templates
|
||||
├── designers/ - Designer templates
|
||||
├── property_owners/ - Property owner templates
|
||||
├── media/ - Photo management templates
|
||||
├── moderation/ - Moderation interface templates
|
||||
├── location/ - Location templates
|
||||
└── pages/ - Static pages
|
||||
```
|
||||
|
||||
### Template Features
|
||||
- HTMX partial templates for dynamic updates
|
||||
- Responsive design with Tailwind CSS
|
||||
- Component-based architecture
|
||||
- SEO optimization
|
||||
- Accessibility considerations
|
||||
|
||||
## Static Asset Analysis
|
||||
|
||||
### CSS Architecture
|
||||
- Tailwind CSS utility-first approach
|
||||
- Custom CSS in `static/css/src/`
|
||||
- Compiled output in `static/css/`
|
||||
- Component-specific styles
|
||||
|
||||
### JavaScript
|
||||
- Minimal custom JavaScript
|
||||
- HTMX for dynamic interactions
|
||||
- Alpine.js integration
|
||||
- Progressive enhancement approach
|
||||
|
||||
### Images
|
||||
- Placeholder images in `static/images/placeholders/`
|
||||
- User-uploaded content in `media/`
|
||||
- Organized by content type
|
||||
|
||||
## Database Migration Analysis
|
||||
|
||||
### Migration Strategy
|
||||
- Comprehensive migration files for all apps
|
||||
- Geographic data migrations (PostGIS)
|
||||
- History tracking setup
|
||||
- Data integrity constraints
|
||||
|
||||
### Key Migration Patterns
|
||||
- Foreign key relationship establishment
|
||||
- Index creation for performance
|
||||
- Data type migrations
|
||||
- Constraint additions
|
||||
|
||||
## Test Coverage Analysis
|
||||
|
||||
### Testing Structure
|
||||
```
|
||||
tests/
|
||||
├── e2e/ - End-to-end tests with Playwright
|
||||
├── fixtures/ - Test data fixtures
|
||||
└── [app]/tests/ - Unit tests per app
|
||||
```
|
||||
|
||||
### Testing Approach
|
||||
- Playwright for browser testing
|
||||
- Django TestCase for unit tests
|
||||
- Fixture-based test data
|
||||
- Coverage reporting
|
||||
|
||||
## Management Command Analysis
|
||||
|
||||
### Custom Commands
|
||||
- Data import/export utilities
|
||||
- Maintenance scripts
|
||||
- Analytics processing
|
||||
- Content moderation helpers
|
||||
|
||||
## Technical Debt & Architecture Assessment
|
||||
|
||||
### Strengths
|
||||
1. **Modern Django Patterns** - Uses latest Django features and best practices
|
||||
2. **Comprehensive History Tracking** - Full audit trail via pghistory
|
||||
3. **Flexible Content System** - Generic foreign keys for extensibility
|
||||
4. **Geographic Support** - PostGIS integration for location features
|
||||
5. **Moderation Workflow** - Robust user-generated content management
|
||||
6. **Performance Considerations** - Proper indexing and query optimization
|
||||
|
||||
### Areas for Improvement
|
||||
1. **API Layer** - No REST API for mobile/external access
|
||||
2. **Caching Strategy** - Limited caching implementation
|
||||
3. **Search Optimization** - Basic search, could benefit from Elasticsearch
|
||||
4. **Image Optimization** - No automatic image resizing/optimization
|
||||
5. **Internationalization** - No i18n support currently
|
||||
|
||||
### Security Analysis
|
||||
1. **Authentication** - Robust with social login and 2FA options
|
||||
2. **Authorization** - Role-based access control
|
||||
3. **Input Validation** - Comprehensive form validation
|
||||
4. **CSRF Protection** - Django built-in protection
|
||||
5. **SQL Injection** - ORM usage prevents issues
|
||||
6. **File Upload Security** - Proper validation and storage
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Database Optimization
|
||||
- Proper indexing on frequently queried fields
|
||||
- Select/prefetch related for query optimization
|
||||
- Generic foreign key indexing
|
||||
|
||||
### Caching Strategy
|
||||
- Basic cache implementation
|
||||
- Trending content caching
|
||||
- Static file optimization with WhiteNoise
|
||||
|
||||
### Media Handling
|
||||
- Custom storage backend
|
||||
- Organized file structure
|
||||
- EXIF data extraction
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Production Considerations
|
||||
- PostgreSQL with PostGIS extensions
|
||||
- Static file serving via WhiteNoise
|
||||
- Media file storage (local/cloud)
|
||||
- Email service integration
|
||||
- Geographic library dependencies (GDAL, GEOS)
|
||||
|
||||
## Conclusion
|
||||
|
||||
ThrillWiki represents a well-architected Django application with modern patterns and comprehensive functionality. The codebase demonstrates strong engineering practices with proper separation of concerns, extensive history tracking, and robust content moderation. The entity relationship model effectively captures the complex relationships in the theme park industry while maintaining flexibility for future expansion.
|
||||
|
||||
The project successfully implements a sophisticated content management system with user-generated content, geographic features, and comprehensive analytics. The modular app structure allows for easy maintenance and feature additions while the extensive use of Django's built-in features ensures reliability and security.
|
||||
|
||||
**Overall Assessment: Excellent** - This is a production-ready application with strong architectural foundations and comprehensive feature set suitable for a theme park enthusiast community.
|
||||
@@ -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"))
|
||||
@@ -1,27 +0,0 @@
|
||||
import pghistory
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
|
||||
class RequestContextProvider(pghistory.context):
|
||||
"""Custom context provider for pghistory that extracts information from the request."""
|
||||
def __call__(self, request: WSGIRequest) -> dict:
|
||||
return {
|
||||
'user': str(request.user) if request.user and not isinstance(request.user, AnonymousUser) else None,
|
||||
'ip': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT'),
|
||||
'session_key': request.session.session_key if hasattr(request, 'session') else None
|
||||
}
|
||||
|
||||
# Initialize the context provider
|
||||
request_context = RequestContextProvider()
|
||||
|
||||
class PgHistoryContextMiddleware:
|
||||
"""
|
||||
Middleware that ensures request object is available to pghistory context.
|
||||
"""
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||
# Generated by Django 5.1.3 on 2024-11-12 18:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -2,7 +2,6 @@ from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
# Fresh Project Status - January 5, 2025
|
||||
|
||||
**Analysis Date:** January 5, 2025
|
||||
**Analysis Method:** Direct observation of current project state only
|
||||
**Analyst:** Roo (Fresh perspective, no prior documentation consulted)
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Project Identity
|
||||
- **Name:** ThrillWiki Django (No React)
|
||||
- **Type:** Django web application for theme park and ride information
|
||||
- **Location:** `/Volumes/macminissd/Projects/thrillwiki_django_no_react`
|
||||
|
||||
### Current Running State
|
||||
- **Development Server:** Active on port 8000
|
||||
- **Command Used:** `lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver`
|
||||
- **Package Manager:** UV (Ultraviolet Python package manager)
|
||||
- **CSS Framework:** Tailwind CSS integration
|
||||
|
||||
## Technical Stack Observations
|
||||
|
||||
### Backend Framework
|
||||
- **Django:** Python web framework (primary)
|
||||
- **Database:** PostgreSQL (inferred from pghistory usage)
|
||||
- **History Tracking:** pghistory library for model change tracking
|
||||
- **Package Management:** UV instead of pip/poetry
|
||||
|
||||
### Frontend Approach
|
||||
- **No React:** Project explicitly excludes React (per directory name)
|
||||
- **Tailwind CSS:** For styling
|
||||
- **HTMX/Alpine.js:** Likely used for interactivity (inferred from Django-focused approach)
|
||||
|
||||
### Key Libraries Observed
|
||||
- `pghistory`: PostgreSQL-based model history tracking
|
||||
- `django-contenttypes`: Generic foreign keys
|
||||
- Custom history tracking system with `TrackedModel` base class
|
||||
|
||||
## Current Entity Architecture
|
||||
|
||||
### Core Business Entities
|
||||
|
||||
#### 1. Operators (`operators/`)
|
||||
- **Purpose:** Companies that operate theme parks
|
||||
- **Key Fields:** name, slug, description, website, founded_year, headquarters
|
||||
- **Relationships:** One-to-many with Parks
|
||||
- **Status:** Fully implemented with history tracking
|
||||
|
||||
#### 2. Property Owners (`property_owners/`)
|
||||
- **Purpose:** Companies that own park property (distinct from operators)
|
||||
- **Key Fields:** name, slug, description, website
|
||||
- **Relationships:** One-to-many with Parks (optional)
|
||||
- **Status:** Newly implemented entity
|
||||
|
||||
#### 3. Manufacturers (`manufacturers/`)
|
||||
- **Purpose:** Companies that manufacture rides
|
||||
- **Key Fields:** name, slug, description, website, founded_year, headquarters
|
||||
- **Relationships:** One-to-many with Rides and RideModels
|
||||
- **Status:** Fully implemented with ride/coaster counting
|
||||
|
||||
#### 4. Parks (`parks/`)
|
||||
- **Purpose:** Theme parks and amusement venues
|
||||
- **Key Relationships:**
|
||||
- Required: Operator (ForeignKey)
|
||||
- Optional: PropertyOwner (ForeignKey)
|
||||
- Contains: Rides, ParkAreas
|
||||
- **Features:** Location integration, status tracking, photo support
|
||||
- **Status:** Core entity with complex relationship structure
|
||||
|
||||
#### 5. Rides (`rides/`)
|
||||
- **Purpose:** Individual ride installations at parks
|
||||
- **Key Relationships:**
|
||||
- Required: Park (ForeignKey)
|
||||
- Optional: Manufacturer, Designer, RideModel, ParkArea
|
||||
- **Features:** Detailed statistics, roller coaster specific data
|
||||
- **Status:** Comprehensive implementation with specialized coaster stats
|
||||
|
||||
### Supporting Entities
|
||||
|
||||
#### 6. Designers (`designers/`)
|
||||
- **Purpose:** Companies/individuals that design rides
|
||||
- **Status:** Referenced but not directly observed in open files
|
||||
|
||||
#### 7. RideModel (`rides/models.py`)
|
||||
- **Purpose:** Specific ride types/models (e.g., "B&M Dive Coaster")
|
||||
- **Relationships:** Manufacturer, multiple Rides
|
||||
- **Status:** Implemented as part of rides app
|
||||
|
||||
#### 8. Location System
|
||||
- **Implementation:** Generic foreign key system
|
||||
- **Purpose:** Geographic data for parks
|
||||
- **Status:** Integrated with parks
|
||||
|
||||
## Current Work Context (Based on Open Files)
|
||||
|
||||
### Active Development Areas
|
||||
1. **Entity Relationship Migration:** Heavy focus on company-related entities
|
||||
2. **Admin Interface:** Multiple admin.py files open suggesting admin customization
|
||||
3. **Form Development:** Parks and rides forms being worked on
|
||||
4. **Template Development:** Park detail and search result templates
|
||||
5. **URL Configuration:** Operators URL patterns being developed
|
||||
|
||||
### File Structure Observations
|
||||
|
||||
#### Django Apps Structure
|
||||
- `accounts/` - User management
|
||||
- `analytics/` - Usage tracking
|
||||
- `core/` - Core functionality
|
||||
- `designers/` - Ride designers
|
||||
- `email_service/` - Email handling
|
||||
- `history/` - History display
|
||||
- `history_tracking/` - Custom history system
|
||||
- `location/` - Geographic data
|
||||
- `manufacturers/` - Ride manufacturers
|
||||
- `media/` - File/photo management
|
||||
- `moderation/` - Content moderation
|
||||
- `operators/` - Park operators
|
||||
- `parks/` - Theme parks
|
||||
- `property_owners/` - Property ownership
|
||||
- `reviews/` - User reviews
|
||||
- `rides/` - Ride information
|
||||
- `search/` - Search functionality
|
||||
|
||||
#### Static Assets
|
||||
- Organized media files by park and ride
|
||||
- Placeholder images system
|
||||
- Tailwind CSS integration
|
||||
|
||||
#### Testing Infrastructure
|
||||
- `tests/` directory with e2e and fixtures
|
||||
- Comprehensive test structure
|
||||
|
||||
## Data Model Patterns Observed
|
||||
|
||||
### History Tracking System
|
||||
- **Base Class:** `TrackedModel` for all major entities
|
||||
- **pghistory Integration:** Automatic change tracking
|
||||
- **Custom Events:** Specialized event models for complex entities
|
||||
- **Slug History:** Historical slug tracking for URL persistence
|
||||
|
||||
### Slug Management
|
||||
- **Auto-generation:** From name fields using Django's slugify
|
||||
- **Historical Tracking:** Old slugs preserved for URL redirects
|
||||
- **Uniqueness:** Enforced at database level
|
||||
|
||||
### Relationship Patterns
|
||||
- **Required Relationships:** Park→Operator, Ride→Park
|
||||
- **Optional Relationships:** Park→PropertyOwner, Ride→Manufacturer
|
||||
- **Generic Relations:** Photos, Reviews, Location data
|
||||
- **Separation of Concerns:** Distinct entities for different business roles
|
||||
|
||||
## Current Development State
|
||||
|
||||
### Implementation Status
|
||||
- **Models:** Fully implemented for core entities
|
||||
- **Admin:** In active development
|
||||
- **Forms:** Being developed for parks and rides
|
||||
- **Templates:** Basic structure in place
|
||||
- **URLs:** Routing being configured
|
||||
|
||||
### Technical Debt Observations
|
||||
- Complex history tracking system suggests ongoing migration
|
||||
- Multiple similar entity types (operators, property_owners, manufacturers) indicate recent refactoring
|
||||
- Extensive use of nullable foreign keys suggests data migration challenges
|
||||
|
||||
### Development Workflow
|
||||
- **UV Package Manager:** Modern Python dependency management
|
||||
- **Tailwind Integration:** CSS framework properly integrated
|
||||
- **Development Server:** Sophisticated startup script with cleanup
|
||||
- **Database:** PostgreSQL with advanced history tracking
|
||||
|
||||
## Next Steps Inference (Based on Current State)
|
||||
|
||||
### Immediate Priorities
|
||||
1. Complete admin interface development
|
||||
2. Finalize form implementations
|
||||
3. Template development for entity detail pages
|
||||
4. URL pattern completion
|
||||
|
||||
### Technical Priorities
|
||||
1. Data migration completion (company→specific entity types)
|
||||
2. History tracking system optimization
|
||||
3. Search functionality enhancement
|
||||
4. Media management system completion
|
||||
|
||||
## Architecture Quality Assessment
|
||||
|
||||
### Strengths
|
||||
- **Separation of Concerns:** Clear entity boundaries
|
||||
- **History Tracking:** Comprehensive change auditing
|
||||
- **Flexibility:** Generic relations for extensibility
|
||||
- **Modern Tooling:** UV, Tailwind, pghistory
|
||||
|
||||
### Areas for Attention
|
||||
- **Complexity:** Multiple similar entities may confuse users
|
||||
- **Migration State:** Appears to be mid-migration from simpler structure
|
||||
- **Performance:** History tracking overhead needs monitoring
|
||||
|
||||
---
|
||||
|
||||
**Note:** This analysis is based solely on direct observation of the current project state without consulting any existing documentation or memory bank files.
|
||||
@@ -1,3 +0,0 @@
|
||||
const locators = {};
|
||||
|
||||
module.exports = { locators };
|
||||
@@ -1,12 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class HistoryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'history'
|
||||
verbose_name = 'History Tracking'
|
||||
|
||||
def ready(self):
|
||||
"""Initialize app and signal handlers"""
|
||||
from django.dispatch import Signal
|
||||
# Create a signal for history updates
|
||||
self.history_updated = Signal()
|
||||
@@ -1,29 +0,0 @@
|
||||
<div id="history-timeline"
|
||||
hx-get="{% url 'history:timeline' content_type_id=content_type.id object_id=object.id %}"
|
||||
hx-trigger="every 30s, historyUpdate from:body">
|
||||
<div class="space-y-4">
|
||||
{% for event in events %}
|
||||
<div class="component-wrapper bg-white p-4 shadow-sm">
|
||||
<div class="component-header flex items-center gap-2 mb-2">
|
||||
<span class="text-sm font-medium">{{ event.pgh_label|title }}</span>
|
||||
<time class="text-xs text-gray-500">{{ event.pgh_created_at|date:"M j, Y H:i" }}</time>
|
||||
</div>
|
||||
<div class="component-content text-sm">
|
||||
{% if event.pgh_context.metadata.user %}
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>{{ event.pgh_context.metadata.user }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.pgh_data %}
|
||||
<div class="mt-2 text-gray-600">
|
||||
<pre class="text-xs">{{ event.pgh_data|pprint }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
from django import template
|
||||
import json
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def pprint(value):
|
||||
"""Pretty print JSON data"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, indent=2)
|
||||
return str(value)
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.urls import path
|
||||
from .views import HistoryTimelineView
|
||||
|
||||
app_name = 'history'
|
||||
|
||||
urlpatterns = [
|
||||
path('timeline/<int:content_type_id>/<int:object_id>/',
|
||||
HistoryTimelineView.as_view(),
|
||||
name='timeline'),
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
from django.views import View
|
||||
from django.shortcuts import render
|
||||
from django.http import JsonResponse
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
import pghistory
|
||||
|
||||
def serialize_event(event):
|
||||
"""Serialize a history event for JSON response"""
|
||||
return {
|
||||
'label': event.pgh_label,
|
||||
'created_at': event.pgh_created_at.isoformat(),
|
||||
'context': event.pgh_context,
|
||||
'data': event.pgh_data,
|
||||
}
|
||||
|
||||
class HistoryTimelineView(View):
|
||||
"""View for displaying object history timeline"""
|
||||
|
||||
def get(self, request, content_type_id, object_id):
|
||||
# Get content type and object
|
||||
content_type = ContentType.objects.get_for_id(content_type_id)
|
||||
obj = content_type.get_object_for_this_type(id=object_id)
|
||||
|
||||
# Get history events
|
||||
events = pghistory.models.Event.objects.filter(
|
||||
pgh_obj_model=content_type.model_class(),
|
||||
pgh_obj_id=object_id
|
||||
).order_by('-pgh_created_at')[:25]
|
||||
|
||||
context = {
|
||||
'events': events,
|
||||
'content_type': content_type,
|
||||
'object': obj,
|
||||
}
|
||||
|
||||
if request.htmx:
|
||||
return render(request, "history/partials/history_timeline.html", context)
|
||||
|
||||
return JsonResponse({
|
||||
'history': [serialize_event(e) for e in events]
|
||||
})
|
||||
@@ -7,9 +7,20 @@ class HistoryTrackingConfig(AppConfig):
|
||||
name = "history_tracking"
|
||||
|
||||
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
|
||||
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
74
history_tracking/mixins.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# history_tracking/mixins.py
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
class HistoricalChangeMixin(models.Model):
|
||||
"""Mixin for historical models to track changes"""
|
||||
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
|
||||
history_date = models.DateTimeField()
|
||||
history_id = models.AutoField(primary_key=True)
|
||||
history_type = models.CharField(max_length=1)
|
||||
history_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+'
|
||||
)
|
||||
history_change_reason = models.CharField(max_length=100, null=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ['-history_date', '-history_id']
|
||||
|
||||
@property
|
||||
def prev_record(self):
|
||||
"""Get the previous record for this instance"""
|
||||
try:
|
||||
return self.__class__.objects.filter(
|
||||
history_date__lt=self.history_date,
|
||||
id=self.id
|
||||
).order_by('-history_date').first()
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@property
|
||||
def diff_against_previous(self):
|
||||
prev_record = self.prev_record
|
||||
if not prev_record:
|
||||
return {}
|
||||
|
||||
changes = {}
|
||||
for field in self.__dict__:
|
||||
if field not in [
|
||||
"history_date",
|
||||
"history_id",
|
||||
"history_type",
|
||||
"history_user_id",
|
||||
"history_change_reason",
|
||||
"history_type",
|
||||
"id",
|
||||
"_state",
|
||||
"_history_user_cache"
|
||||
] and not field.startswith("_"):
|
||||
try:
|
||||
old_value = getattr(prev_record, field)
|
||||
new_value = getattr(self, field)
|
||||
if old_value != new_value:
|
||||
changes[field] = {"old": str(old_value), "new": str(new_value)}
|
||||
except AttributeError:
|
||||
continue
|
||||
return changes
|
||||
|
||||
@property
|
||||
def history_user_display(self):
|
||||
"""Get a display name for the history user"""
|
||||
if hasattr(self, 'history_user') and self.history_user:
|
||||
return str(self.history_user)
|
||||
return None
|
||||
|
||||
def get_instance(self):
|
||||
"""Get the model instance this history record represents"""
|
||||
try:
|
||||
return self.__class__.objects.get(id=self.id)
|
||||
except self.__class__.DoesNotExist:
|
||||
return None
|
||||
@@ -1,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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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 = [
|
||||
|
||||
@@ -4,32 +4,32 @@ from django.core.exceptions import ValidationError
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import D
|
||||
from .models import Location
|
||||
from operators.models import Operator
|
||||
from companies.models import Company
|
||||
from parks.models import Park
|
||||
|
||||
class LocationModelTests(TestCase):
|
||||
def setUp(self):
|
||||
# Create test company
|
||||
self.operator = Operator.objects.create(
|
||||
name='Test Operator',
|
||||
self.company = Company.objects.create(
|
||||
name='Test Company',
|
||||
website='http://example.com'
|
||||
)
|
||||
|
||||
# Create test park
|
||||
self.park = Park.objects.create(
|
||||
name='Test Park',
|
||||
owner=self.operator,
|
||||
owner=self.company,
|
||||
status='OPERATING'
|
||||
)
|
||||
|
||||
# Create test location for company
|
||||
self.operator_location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Operator),
|
||||
object_id=self.operator.pk,
|
||||
name='Test Operator HQ',
|
||||
self.company_location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk,
|
||||
name='Test Company HQ',
|
||||
location_type='business',
|
||||
street_address='123 Operator St',
|
||||
city='Operator City',
|
||||
street_address='123 Company St',
|
||||
city='Company City',
|
||||
state='CS',
|
||||
country='Test Country',
|
||||
postal_code='12345',
|
||||
@@ -53,14 +53,14 @@ class LocationModelTests(TestCase):
|
||||
def test_location_creation(self):
|
||||
"""Test location instance creation and field values"""
|
||||
# Test company location
|
||||
self.assertEqual(self.operator_location.name, 'Test Operator HQ')
|
||||
self.assertEqual(self.operator_location.location_type, 'business')
|
||||
self.assertEqual(self.operator_location.street_address, '123 Operator St')
|
||||
self.assertEqual(self.operator_location.city, 'Operator City')
|
||||
self.assertEqual(self.operator_location.state, 'CS')
|
||||
self.assertEqual(self.operator_location.country, 'Test Country')
|
||||
self.assertEqual(self.operator_location.postal_code, '12345')
|
||||
self.assertIsNotNone(self.operator_location.point)
|
||||
self.assertEqual(self.company_location.name, 'Test Company HQ')
|
||||
self.assertEqual(self.company_location.location_type, 'business')
|
||||
self.assertEqual(self.company_location.street_address, '123 Company St')
|
||||
self.assertEqual(self.company_location.city, 'Company City')
|
||||
self.assertEqual(self.company_location.state, 'CS')
|
||||
self.assertEqual(self.company_location.country, 'Test Country')
|
||||
self.assertEqual(self.company_location.postal_code, '12345')
|
||||
self.assertIsNotNone(self.company_location.point)
|
||||
|
||||
# Test park location
|
||||
self.assertEqual(self.park_location.name, 'Test Park Location')
|
||||
@@ -74,23 +74,23 @@ class LocationModelTests(TestCase):
|
||||
|
||||
def test_location_str_representation(self):
|
||||
"""Test string representation of location"""
|
||||
expected_company_str = 'Test Operator HQ (Operator City, Test Country)'
|
||||
self.assertEqual(str(self.operator_location), expected_company_str)
|
||||
expected_company_str = 'Test Company HQ (Company City, Test Country)'
|
||||
self.assertEqual(str(self.company_location), expected_company_str)
|
||||
|
||||
expected_park_str = 'Test Park Location (Park City, Test Country)'
|
||||
self.assertEqual(str(self.park_location), expected_park_str)
|
||||
|
||||
def test_get_formatted_address(self):
|
||||
"""Test get_formatted_address method"""
|
||||
expected_address = '123 Operator St, Operator City, CS, 12345, Test Country'
|
||||
self.assertEqual(self.operator_location.get_formatted_address(), expected_address)
|
||||
expected_address = '123 Company St, Company City, CS, 12345, Test Country'
|
||||
self.assertEqual(self.company_location.get_formatted_address(), expected_address)
|
||||
|
||||
def test_point_coordinates(self):
|
||||
"""Test point coordinates"""
|
||||
# Test company location point
|
||||
self.assertIsNotNone(self.operator_location.point)
|
||||
self.assertAlmostEqual(self.operator_location.point.y, 34.0522, places=4) # latitude
|
||||
self.assertAlmostEqual(self.operator_location.point.x, -118.2437, places=4) # longitude
|
||||
self.assertIsNotNone(self.company_location.point)
|
||||
self.assertAlmostEqual(self.company_location.point.y, 34.0522, places=4) # latitude
|
||||
self.assertAlmostEqual(self.company_location.point.x, -118.2437, places=4) # longitude
|
||||
|
||||
# Test park location point
|
||||
self.assertIsNotNone(self.park_location.point)
|
||||
@@ -99,7 +99,7 @@ class LocationModelTests(TestCase):
|
||||
|
||||
def test_coordinates_property(self):
|
||||
"""Test coordinates property"""
|
||||
company_coords = self.operator_location.coordinates
|
||||
company_coords = self.company_location.coordinates
|
||||
self.assertIsNotNone(company_coords)
|
||||
self.assertAlmostEqual(company_coords[0], 34.0522, places=4) # latitude
|
||||
self.assertAlmostEqual(company_coords[1], -118.2437, places=4) # longitude
|
||||
@@ -111,7 +111,7 @@ class LocationModelTests(TestCase):
|
||||
|
||||
def test_distance_calculation(self):
|
||||
"""Test distance_to method"""
|
||||
distance = self.operator_location.distance_to(self.park_location)
|
||||
distance = self.company_location.distance_to(self.park_location)
|
||||
self.assertIsNotNone(distance)
|
||||
self.assertGreater(distance, 0)
|
||||
|
||||
@@ -119,17 +119,17 @@ class LocationModelTests(TestCase):
|
||||
"""Test nearby_locations method"""
|
||||
# Create another location near the company location
|
||||
nearby_location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Operator),
|
||||
object_id=self.operator.pk,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk,
|
||||
name='Nearby Location',
|
||||
location_type='business',
|
||||
street_address='789 Nearby St',
|
||||
city='Operator City',
|
||||
city='Company City',
|
||||
country='Test Country',
|
||||
point=Point(-118.2438, 34.0523) # Very close to company location
|
||||
)
|
||||
|
||||
nearby = self.operator_location.nearby_locations(distance_km=1)
|
||||
nearby = self.company_location.nearby_locations(distance_km=1)
|
||||
self.assertEqual(nearby.count(), 1)
|
||||
self.assertEqual(nearby.first(), nearby_location)
|
||||
|
||||
@@ -137,10 +137,10 @@ class LocationModelTests(TestCase):
|
||||
"""Test generic relations work correctly"""
|
||||
# Test company location relation
|
||||
company_location = Location.objects.get(
|
||||
content_type=ContentType.objects.get_for_model(Operator),
|
||||
object_id=self.operator.pk
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk
|
||||
)
|
||||
self.assertEqual(company_location, self.operator_location)
|
||||
self.assertEqual(company_location, self.company_location)
|
||||
|
||||
# Test park location relation
|
||||
park_location = Location.objects.get(
|
||||
@@ -152,19 +152,19 @@ class LocationModelTests(TestCase):
|
||||
def test_location_updates(self):
|
||||
"""Test location updates"""
|
||||
# Update company location
|
||||
self.operator_location.street_address = 'Updated Address'
|
||||
self.operator_location.city = 'Updated City'
|
||||
self.operator_location.save()
|
||||
self.company_location.street_address = 'Updated Address'
|
||||
self.company_location.city = 'Updated City'
|
||||
self.company_location.save()
|
||||
|
||||
updated_location = Location.objects.get(pk=self.operator_location.pk)
|
||||
updated_location = Location.objects.get(pk=self.company_location.pk)
|
||||
self.assertEqual(updated_location.street_address, 'Updated Address')
|
||||
self.assertEqual(updated_location.city, 'Updated City')
|
||||
|
||||
def test_point_sync_with_lat_lon(self):
|
||||
"""Test point synchronization with latitude/longitude fields"""
|
||||
location = Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Operator),
|
||||
object_id=self.operator.pk,
|
||||
content_type=ContentType.objects.get_for_model(Company),
|
||||
object_id=self.company.pk,
|
||||
name='Test Sync Location',
|
||||
location_type='business',
|
||||
latitude=34.0522,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from .models import Manufacturer
|
||||
|
||||
|
||||
class ManufacturerAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'headquarters', 'founded_year', 'rides_count', 'coasters_count', 'created_at', 'updated_at')
|
||||
list_filter = ('founded_year',)
|
||||
search_fields = ('name', 'description', 'headquarters')
|
||||
readonly_fields = ('created_at', 'updated_at', 'rides_count', 'coasters_count')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
# Register the model with admin
|
||||
admin.site.register(Manufacturer, ManufacturerAdmin)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ManufacturersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'manufacturers'
|
||||
@@ -1,119 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-07-04 14:50
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Manufacturer",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(max_length=255, unique=True)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
||||
("rides_count", models.IntegerField(default=0)),
|
||||
("coasters_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Manufacturer",
|
||||
"verbose_name_plural": "Manufacturers",
|
||||
"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()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("slug", models.SlugField(db_index=False, max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("website", models.URLField(blank=True)),
|
||||
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("headquarters", models.CharField(blank=True, max_length=255)),
|
||||
("rides_count", models.IntegerField(default=0)),
|
||||
("coasters_count", models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="manufacturer",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "manufacturers_manufacturerevent" ("coasters_count", "created_at", "description", "founded_year", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_year", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_e3fce",
|
||||
table="manufacturers_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 "manufacturers_manufacturerevent" ("coasters_count", "created_at", "description", "founded_year", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_year", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_5d619",
|
||||
table="manufacturers_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="manufacturers.manufacturer",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,65 +0,0 @@
|
||||
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 Manufacturer(TrackedModel):
|
||||
"""
|
||||
Companies that manufacture rides (enhanced from existing, separate from companies)
|
||||
"""
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
founded_year = models.PositiveIntegerField(blank=True, null=True)
|
||||
headquarters = models.CharField(max_length=255, blank=True)
|
||||
rides_count = models.IntegerField(default=0)
|
||||
coasters_count = models.IntegerField(default=0)
|
||||
|
||||
objects: ClassVar[models.Manager['Manufacturer']]
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'Manufacturer'
|
||||
verbose_name_plural = 'Manufacturers'
|
||||
|
||||
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)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse('manufacturers:detail', kwargs={'slug': self.slug})
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
|
||||
"""Get manufacturer by slug, checking historical slugs if needed"""
|
||||
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
|
||||
try:
|
||||
historical = HistoricalSlug.objects.get(
|
||||
content_type__model='manufacturer',
|
||||
slug=slug
|
||||
)
|
||||
return cls.objects.get(pk=historical.object_id), True
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "manufacturers"
|
||||
|
||||
urlpatterns = [
|
||||
# Manufacturer list and detail views
|
||||
path("", views.ManufacturerListView.as_view(), name="manufacturer_list"),
|
||||
path("<slug:slug>/", views.ManufacturerDetailView.as_view(), name="manufacturer_detail"),
|
||||
]
|
||||
@@ -1,43 +0,0 @@
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.db.models import QuerySet
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from core.views import SlugRedirectMixin
|
||||
from .models import Manufacturer
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
|
||||
class ManufacturerListView(ListView):
|
||||
model = Manufacturer
|
||||
template_name = "manufacturers/manufacturer_list.html"
|
||||
context_object_name = "manufacturers"
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self) -> QuerySet[Manufacturer]:
|
||||
return Manufacturer.objects.all().order_by('name')
|
||||
|
||||
|
||||
class ManufacturerDetailView(SlugRedirectMixin, DetailView):
|
||||
model = Manufacturer
|
||||
template_name = "manufacturers/manufacturer_detail.html"
|
||||
context_object_name = "manufacturer"
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[Manufacturer]] = None) -> Manufacturer:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
if slug is None:
|
||||
raise ObjectDoesNotExist("No slug provided")
|
||||
manufacturer, _ = Manufacturer.get_by_slug(slug)
|
||||
return manufacturer
|
||||
|
||||
def get_queryset(self) -> QuerySet[Manufacturer]:
|
||||
return Manufacturer.objects.all()
|
||||
|
||||
def get_context_data(self, **kwargs) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
manufacturer = self.get_object()
|
||||
|
||||
# Add related rides to context (using related_name="rides" from Ride model)
|
||||
context['rides'] = manufacturer.rides.all().order_by('name')
|
||||
|
||||
return context
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |
|
Before Width: | Height: | Size: 35 B |