mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 03:27:01 -05:00
Compare commits
25 Commits
pixeebot/d
...
652ea149bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652ea149bd | ||
|
|
66ed4347a9 | ||
|
|
69c07d1381 | ||
|
|
bead0654df | ||
|
|
37a20f83ba | ||
|
|
2304085c32 | ||
|
|
31d83c8889 | ||
|
|
46c6e45eae | ||
|
|
f5db23a791 | ||
|
|
78248aa892 | ||
|
|
641fc1a253 | ||
|
|
ca7555c052 | ||
|
|
74b45aa143 | ||
|
|
d9fc13f350 | ||
|
|
f4f8ec8f9b | ||
|
|
274ba650b3 | ||
|
|
cc990ee003 | ||
|
|
63b9cf1a70 | ||
|
|
c26414ff74 | ||
|
|
17228e9935 | ||
|
|
32736ae660 | ||
|
|
b5bae44cb8 | ||
|
|
da7c7e3381 | ||
|
|
f6c8e0e25c | ||
|
|
16386deee7 |
@@ -4,10 +4,9 @@
|
||||
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
|
||||
```
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; ./scripts/dev_server.sh
|
||||
|
||||
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
|
||||
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior. If server does not start correctly, do not attempt to modify the dev_server.sh script.
|
||||
|
||||
## Package Management
|
||||
IMPORTANT: When a Python package is needed, only use UV to add it:
|
||||
@@ -24,8 +23,8 @@ 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`
|
||||
- Creating superuser: `uv run manage.py createsuperuser` and possible echo commands before for the necessary data input.
|
||||
- Starting shell: `uv run manage.py shell` and possible echo commands before for the necessary data input.
|
||||
|
||||
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
|
||||
|
||||
|
||||
90
.env.example
Normal file
90
.env.example
Normal file
@@ -0,0 +1,90 @@
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# ThrillWiki Environment Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Copy this file to ***REMOVED*** and fill in your actual values
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Core Django Settings
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
SECRET_KEY=your-secret-key-here-generate-a-new-one
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,beta.thrillwiki.com
|
||||
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Database Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# PostgreSQL with PostGIS for production/development
|
||||
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
|
||||
|
||||
# SQLite for quick local development (uncomment to use)
|
||||
# DATABASE_URL=spatialite:///path/to/your/db.sqlite3
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Cache Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Local memory cache for development
|
||||
CACHE_URL=locmem://
|
||||
|
||||
# Redis for production (uncomment and configure for production)
|
||||
# CACHE_URL=redis://localhost:6379/1
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
CACHE_MIDDLEWARE_SECONDS=300
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Email Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
SERVER_EMAIL=django_webmaster@thrillwiki.com
|
||||
|
||||
# ForwardEmail configuration (uncomment to use)
|
||||
# EMAIL_BACKEND=email_service.backends.ForwardEmailBackend
|
||||
# FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||
|
||||
# SMTP configuration (uncomment to use)
|
||||
# EMAIL_URL=smtp://username:password@smtp.example.com:587
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Security Settings
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Cloudflare Turnstile (get keys from Cloudflare dashboard)
|
||||
TURNSTILE_SITE_KEY=your-turnstile-site-key
|
||||
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
|
||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||
|
||||
# Security headers (set to True for production)
|
||||
SECURE_SSL_REDIRECT=False
|
||||
SESSION_COOKIE_SECURE=False
|
||||
CSRF_COOKIE_SECURE=False
|
||||
SECURE_HSTS_SECONDS=31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# GeoDjango Settings (macOS with Homebrew)
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
|
||||
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
|
||||
|
||||
# Linux alternatives (uncomment if on Linux)
|
||||
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
||||
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Optional: Third-party Integrations
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Sentry for error tracking (uncomment to use)
|
||||
# SENTRY_DSN=https://your-sentry-dsn-here
|
||||
|
||||
# Google Analytics (uncomment to use)
|
||||
# GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
|
||||
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Development/Debug Settings
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Set to comma-separated list for debug toolbar
|
||||
# INTERNAL_IPS=127.0.0.1,::1
|
||||
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
29
.flake8
Normal file
29
.flake8
Normal file
@@ -0,0 +1,29 @@
|
||||
[flake8]
|
||||
# Maximum line length (matches Black formatter)
|
||||
max-line-length = 88
|
||||
|
||||
# Exclude common directories that shouldn't be linted
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.venv,
|
||||
venv,
|
||||
env,
|
||||
.env,
|
||||
migrations,
|
||||
node_modules,
|
||||
.tox,
|
||||
.mypy_cache,
|
||||
.pytest_cache,
|
||||
build,
|
||||
dist,
|
||||
*.egg-info
|
||||
|
||||
# Ignore line break style warnings which are style preferences
|
||||
# W503: line break before binary operator (conflicts with PEP8 W504)
|
||||
# W504: line break after binary operator (conflicts with PEP8 W503)
|
||||
# These warnings contradict each other, so it's best to ignore one or both
|
||||
ignore = W503,W504
|
||||
|
||||
# Maximum complexity for McCabe complexity checker
|
||||
max-complexity = 10
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -347,13 +347,19 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Pixi package manager
|
||||
.pixi/
|
||||
|
||||
# Django Tailwind CLI
|
||||
.django_tailwind_cli/
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
@@ -373,3 +379,22 @@ Icon
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
|
||||
# ThrillWiki CI/CD Configuration
|
||||
.thrillwiki-config
|
||||
***REMOVED***.unraid
|
||||
***REMOVED***.webhook
|
||||
.github-token
|
||||
logs/
|
||||
profiles
|
||||
.thrillwiki-github-token
|
||||
.thrillwiki-template-config
|
||||
|
||||
# Environment files with potential secrets
|
||||
scripts/systemd/thrillwiki-automation***REMOVED***
|
||||
scripts/systemd/thrillwiki-deployment***REMOVED***
|
||||
scripts/systemd/****REMOVED***
|
||||
logs/
|
||||
profiles/
|
||||
uv.lock
|
||||
|
||||
277
CI_README.md
Normal file
277
CI_README.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# ThrillWiki CI/CD System
|
||||
|
||||
This repository includes a **complete automated CI/CD system** that creates a Linux VM on Unraid and automatically deploys ThrillWiki when commits are pushed to GitHub.
|
||||
|
||||
## 🚀 Complete Automation (Unraid)
|
||||
|
||||
For **full automation** including VM creation on Unraid:
|
||||
|
||||
```bash
|
||||
./scripts/unraid/setup-complete-automation.sh
|
||||
```
|
||||
|
||||
This single command will:
|
||||
- ✅ Create and configure VM on Unraid
|
||||
- ✅ Install Ubuntu Server with all dependencies
|
||||
- ✅ Deploy ThrillWiki application
|
||||
- ✅ Set up automated CI/CD pipeline
|
||||
- ✅ Configure webhook listener
|
||||
- ✅ Test the entire system
|
||||
|
||||
## Manual Setup (Any Linux VM)
|
||||
|
||||
For manual setup on existing Linux VMs:
|
||||
|
||||
```bash
|
||||
./scripts/setup-vm-ci.sh
|
||||
```
|
||||
|
||||
## System Components
|
||||
|
||||
### 📁 Files Created
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── ci-start.sh # Local development server startup
|
||||
├── webhook-listener.py # GitHub webhook listener
|
||||
├── vm-deploy.sh # VM deployment script
|
||||
├── setup-vm-ci.sh # Manual VM setup script
|
||||
├── unraid/
|
||||
│ ├── vm-manager.py # Unraid VM management
|
||||
│ └── setup-complete-automation.sh # Complete automation
|
||||
└── systemd/
|
||||
├── thrillwiki.service # Django app service
|
||||
└── thrillwiki-webhook.service # Webhook listener service
|
||||
|
||||
docs/
|
||||
├── VM_DEPLOYMENT_SETUP.md # Manual setup documentation
|
||||
└── UNRAID_COMPLETE_AUTOMATION.md # Complete automation guide
|
||||
```
|
||||
|
||||
### 🔄 Deployment Flow
|
||||
|
||||
**Complete Automation:**
|
||||
```
|
||||
GitHub Push → Webhook → Local Listener → SSH → Unraid VM → Deploy & Restart
|
||||
```
|
||||
|
||||
**Manual Setup:**
|
||||
```
|
||||
GitHub Push → Webhook → Local Listener → SSH to VM → Deploy Script → Server Restart
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Complete VM Automation**: Automatically creates VMs on Unraid
|
||||
- **Automatic Deployment**: Deploys on push to main branch
|
||||
- **Health Checks**: Verifies deployment success
|
||||
- **Rollback Support**: Automatic rollback on deployment failure
|
||||
- **Service Management**: Systemd integration for reliable service management
|
||||
- **Database Setup**: Automated PostgreSQL configuration
|
||||
- **Logging**: Comprehensive logging for debugging
|
||||
- **Security**: SSH key authentication and webhook secrets
|
||||
- **One-Command Setup**: Full automation with single script
|
||||
|
||||
## Usage
|
||||
|
||||
### Complete Automation (Recommended)
|
||||
|
||||
For Unraid users, run the complete automation:
|
||||
|
||||
```bash
|
||||
./scripts/unraid/setup-complete-automation.sh
|
||||
```
|
||||
|
||||
After setup, start the webhook listener:
|
||||
```bash
|
||||
./start-webhook.sh
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
Start the local development server:
|
||||
|
||||
```bash
|
||||
./scripts/ci-start.sh
|
||||
```
|
||||
|
||||
### VM Management (Unraid)
|
||||
|
||||
```bash
|
||||
# Check VM status
|
||||
python3 scripts/unraid/vm-manager.py status
|
||||
|
||||
# Start/stop VM
|
||||
python3 scripts/unraid/vm-manager.py start
|
||||
python3 scripts/unraid/vm-manager.py stop
|
||||
|
||||
# Get VM IP
|
||||
python3 scripts/unraid/vm-manager.py ip
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
On the VM:
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
ssh thrillwiki-vm "./scripts/vm-deploy.sh status"
|
||||
|
||||
# Restart service
|
||||
ssh thrillwiki-vm "./scripts/vm-deploy.sh restart"
|
||||
|
||||
# View logs
|
||||
ssh thrillwiki-vm "journalctl -u thrillwiki -f"
|
||||
```
|
||||
|
||||
### Manual VM Deployment
|
||||
|
||||
Deploy to VM manually:
|
||||
|
||||
```bash
|
||||
ssh thrillwiki-vm "cd thrillwiki && ./scripts/vm-deploy.sh"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Automated Configuration
|
||||
|
||||
The complete automation script creates all necessary configuration files:
|
||||
|
||||
- `***REMOVED***.unraid` - Unraid VM configuration
|
||||
- `***REMOVED***.webhook` - Webhook listener configuration
|
||||
- SSH keys and configuration
|
||||
- Service configurations
|
||||
|
||||
### Manual Environment Variables
|
||||
|
||||
For manual setup, create `***REMOVED***.webhook` file:
|
||||
|
||||
```bash
|
||||
WEBHOOK_PORT=9000
|
||||
WEBHOOK_SECRET=your_secret_here
|
||||
VM_HOST=your_vm_ip
|
||||
VM_USER=ubuntu
|
||||
VM_KEY_PATH=/path/to/ssh/key
|
||||
VM_PROJECT_PATH=/home/ubuntu/thrillwiki
|
||||
REPO_URL=https://github.com/username/repo.git
|
||||
DEPLOY_BRANCH=main
|
||||
```
|
||||
|
||||
### GitHub Webhook
|
||||
|
||||
Configure in your GitHub repository:
|
||||
- **URL**: `http://YOUR_PUBLIC_IP:9000/webhook`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Your webhook secret
|
||||
- **Events**: Push events
|
||||
|
||||
## Requirements
|
||||
|
||||
### For Complete Automation
|
||||
- **Local Machine**: Python 3.8+, SSH client
|
||||
- **Unraid Server**: 6.8+ with VM support
|
||||
- **Resources**: 4GB RAM, 50GB disk minimum
|
||||
- **Ubuntu ISO**: Ubuntu Server 22.04 in `/mnt/user/isos/`
|
||||
|
||||
### For Manual Setup
|
||||
- **Local Machine**: Python 3.8+, SSH access to VM, Public IP
|
||||
- **Linux VM**: Ubuntu 20.04+, Python 3.8+, UV package manager, Git, SSH server
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Complete Automation Issues
|
||||
|
||||
1. **VM Creation Fails**
|
||||
```bash
|
||||
# Check Unraid VM support
|
||||
ssh unraid "virsh list --all"
|
||||
|
||||
# Verify Ubuntu ISO exists
|
||||
ssh unraid "ls -la /mnt/user/isos/ubuntu-*.iso"
|
||||
```
|
||||
|
||||
2. **VM Won't Start**
|
||||
```bash
|
||||
# Check VM status
|
||||
python3 scripts/unraid/vm-manager.py status
|
||||
|
||||
# Check Unraid logs
|
||||
ssh unraid "tail -f /var/log/libvirt/qemu/thrillwiki-vm.log"
|
||||
```
|
||||
|
||||
### General Issues
|
||||
|
||||
1. **SSH Connection Failed**
|
||||
```bash
|
||||
# Check SSH key permissions
|
||||
chmod 600 ~/.ssh/thrillwiki_vm
|
||||
|
||||
# Test connection
|
||||
ssh thrillwiki-vm
|
||||
```
|
||||
|
||||
2. **Webhook Not Receiving Events**
|
||||
```bash
|
||||
# Check if port is open
|
||||
sudo ufw allow 9000
|
||||
|
||||
# Verify webhook URL in GitHub
|
||||
curl -X GET http://localhost:9000/health
|
||||
```
|
||||
|
||||
3. **Service Won't Start**
|
||||
```bash
|
||||
# Check service logs
|
||||
ssh thrillwiki-vm "journalctl -u thrillwiki --no-pager"
|
||||
|
||||
# Manual start
|
||||
ssh thrillwiki-vm "cd thrillwiki && ./scripts/ci-start.sh"
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
- **Setup logs**: `logs/unraid-automation.log`
|
||||
- **Local webhook**: `logs/webhook.log`
|
||||
- **VM deployment**: `logs/deploy.log` (on VM)
|
||||
- **Django server**: `logs/django.log` (on VM)
|
||||
- **System logs**: `journalctl -u thrillwiki -f` (on VM)
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Automated SSH key generation and management
|
||||
- Dedicated keys for each connection (VM access, Unraid access)
|
||||
- No password authentication
|
||||
- Systemd security features enabled
|
||||
- Firewall configuration support
|
||||
- Secret management in environment files
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Complete Automation**: [`docs/UNRAID_COMPLETE_AUTOMATION.md`](docs/UNRAID_COMPLETE_AUTOMATION.md)
|
||||
- **Manual Setup**: [`docs/VM_DEPLOYMENT_SETUP.md`](docs/VM_DEPLOYMENT_SETUP.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start Summary
|
||||
|
||||
### For Unraid Users (Complete Automation)
|
||||
```bash
|
||||
# One command to set up everything
|
||||
./scripts/unraid/setup-complete-automation.sh
|
||||
|
||||
# Start webhook listener
|
||||
./start-webhook.sh
|
||||
|
||||
# Push commits to auto-deploy!
|
||||
```
|
||||
|
||||
### For Existing VM Users
|
||||
```bash
|
||||
# Manual setup
|
||||
./scripts/setup-vm-ci.sh
|
||||
|
||||
# Configure webhook and push to deploy
|
||||
```
|
||||
|
||||
**The system will automatically deploy your Django application whenever you push commits to the main branch!** 🚀
|
||||
23
README.md
23
README.md
@@ -183,12 +183,33 @@ uv run manage.py collectstatic
|
||||
|
||||
### CSS Development
|
||||
|
||||
The project uses Tailwind CSS with a custom dark theme. CSS files are located in:
|
||||
The project uses **Tailwind CSS v4** 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.
|
||||
|
||||
#### Tailwind CSS v4 Migration
|
||||
|
||||
This project has been migrated from Tailwind CSS v3 to v4. For complete migration details:
|
||||
|
||||
- **📖 Full Migration Documentation**: [`TAILWIND_V4_MIGRATION.md`](TAILWIND_V4_MIGRATION.md)
|
||||
- **⚡ Quick Reference Guide**: [`TAILWIND_V4_QUICK_REFERENCE.md`](TAILWIND_V4_QUICK_REFERENCE.md)
|
||||
|
||||
**Key v4 Changes**:
|
||||
- New CSS-first approach with `@theme` blocks
|
||||
- Updated utility class names (e.g., `outline-none` → `outline-hidden`)
|
||||
- New opacity syntax (e.g., `bg-blue-500/50` instead of `bg-blue-500 bg-opacity-50`)
|
||||
- Enhanced performance and smaller bundle sizes
|
||||
|
||||
**Custom Theme Variables** (available in CSS):
|
||||
```css
|
||||
var(--color-primary) /* #4f46e5 - Indigo-600 */
|
||||
var(--color-secondary) /* #e11d48 - Rose-600 */
|
||||
var(--color-accent) /* #8b5cf6 - Violet-500 */
|
||||
var(--font-family-sans) /* Poppins, sans-serif */
|
||||
```
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
|
||||
326
TAILWIND_V4_MIGRATION.md
Normal file
326
TAILWIND_V4_MIGRATION.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# Tailwind CSS v3 to v4 Migration Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the complete migration process from Tailwind CSS v3 to v4 for the Django ThrillWiki project. The migration was performed on August 15, 2025, and includes all changes, configurations, and verification steps.
|
||||
|
||||
## Migration Summary
|
||||
|
||||
- **From**: Tailwind CSS v3.x
|
||||
- **To**: Tailwind CSS v4.1.12
|
||||
- **Project**: Django ThrillWiki (Django + Tailwind CSS integration)
|
||||
- **Status**: ✅ Complete and Verified
|
||||
- **Breaking Changes**: None (all styling preserved)
|
||||
|
||||
## Key Changes in Tailwind CSS v4
|
||||
|
||||
### 1. CSS Import Syntax
|
||||
- **v3**: Used `@tailwind` directives
|
||||
- **v4**: Uses single `@import "tailwindcss"` statement
|
||||
|
||||
### 2. Theme Configuration
|
||||
- **v3**: Configuration in `tailwind.config.js`
|
||||
- **v4**: CSS-first approach with `@theme` blocks
|
||||
|
||||
### 3. Deprecated Utilities
|
||||
Multiple utility classes were renamed or deprecated in v4.
|
||||
|
||||
## Migration Steps Performed
|
||||
|
||||
### Step 1: Update Main CSS File
|
||||
|
||||
**File**: `static/css/src/input.css`
|
||||
|
||||
**Before (v3)**:
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom styles... */
|
||||
```
|
||||
|
||||
**After (v4)**:
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #4f46e5;
|
||||
--color-secondary: #e11d48;
|
||||
--color-accent: #8b5cf6;
|
||||
--font-family-sans: Poppins, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom styles... */
|
||||
```
|
||||
|
||||
### Step 2: Theme Variable Migration
|
||||
|
||||
Migrated custom colors and fonts from `tailwind.config.js` to CSS variables in `@theme` block:
|
||||
|
||||
| Variable | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `--color-primary` | `#4f46e5` | Indigo-600 (primary brand color) |
|
||||
| `--color-secondary` | `#e11d48` | Rose-600 (secondary brand color) |
|
||||
| `--color-accent` | `#8b5cf6` | Violet-500 (accent color) |
|
||||
| `--font-family-sans` | `Poppins, sans-serif` | Primary font family |
|
||||
|
||||
### Step 3: Deprecated Utility Updates
|
||||
|
||||
#### Outline Utilities
|
||||
- **Changed**: `outline-none` → `outline-hidden`
|
||||
- **Files affected**: All template files, component CSS
|
||||
|
||||
#### Ring Utilities
|
||||
- **Changed**: `ring` → `ring-3`
|
||||
- **Reason**: Default ring width now requires explicit specification
|
||||
|
||||
#### Shadow Utilities
|
||||
- **Changed**:
|
||||
- `shadow-sm` → `shadow-xs`
|
||||
- `shadow` → `shadow-sm`
|
||||
- **Files affected**: Button components, card components
|
||||
|
||||
#### Opacity Utilities
|
||||
- **Changed**: `bg-opacity-*` format → `color/opacity` format
|
||||
- **Example**: `bg-blue-500 bg-opacity-50` → `bg-blue-500/50`
|
||||
|
||||
#### Flex Utilities
|
||||
- **Changed**: `flex-shrink-0` → `shrink-0`
|
||||
|
||||
#### Important Modifier
|
||||
- **Changed**: `!important` → `!` (shorter syntax)
|
||||
- **Example**: `!outline-none` → `!outline-hidden`
|
||||
|
||||
### Step 4: Template File Updates
|
||||
|
||||
Updated the following template files with new utility classes:
|
||||
|
||||
#### Core Templates
|
||||
- `templates/base.html`
|
||||
- `templates/components/navbar.html`
|
||||
- `templates/components/footer.html`
|
||||
|
||||
#### Page Templates
|
||||
- `templates/parks/park_list.html`
|
||||
- `templates/parks/park_detail.html`
|
||||
- `templates/rides/ride_list.html`
|
||||
- `templates/rides/ride_detail.html`
|
||||
- `templates/companies/company_list.html`
|
||||
- `templates/companies/company_detail.html`
|
||||
|
||||
#### Form Templates
|
||||
- `templates/parks/park_form.html`
|
||||
- `templates/rides/ride_form.html`
|
||||
- `templates/companies/company_form.html`
|
||||
|
||||
#### Component Templates
|
||||
- `templates/components/search_results.html`
|
||||
- `templates/components/pagination.html`
|
||||
|
||||
### Step 5: Component CSS Updates
|
||||
|
||||
Updated custom component classes in `static/css/src/input.css`:
|
||||
|
||||
**Button Components**:
|
||||
```css
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-transparent rounded-full shadow-md text-sm font-medium text-white bg-gradient-to-r from-primary to-secondary hover:from-primary/90 hover:to-secondary/90 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center px-6 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full shadow-md text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-3 focus:ring-offset-2 focus:ring-primary/50 transform hover:scale-105 transition-all;
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Tailwind Config (Preserved for Reference)
|
||||
|
||||
**File**: `tailwind.config.js`
|
||||
|
||||
The original v3 configuration was preserved for reference but is no longer the primary configuration method:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
content: [
|
||||
'./templates/**/*.html',
|
||||
'./static/js/**/*.js',
|
||||
'./*/templates/**/*.html',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#4f46e5',
|
||||
secondary: '#e11d48',
|
||||
accent: '#8b5cf6',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Poppins', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Package.json Updates
|
||||
|
||||
No changes required to `package.json` as the Django-Tailwind package handles version management.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
### 1. Build Process Verification
|
||||
```bash
|
||||
# Clean and rebuild CSS
|
||||
lsof -ti :8000 | xargs kill -9
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
**Result**: ✅ Build successful, no errors
|
||||
|
||||
### 2. CSS Compilation Check
|
||||
```bash
|
||||
# Check compiled CSS size and content
|
||||
ls -la static/css/tailwind.css
|
||||
head -50 static/css/tailwind.css | grep -E "(primary|secondary|accent)"
|
||||
```
|
||||
|
||||
**Result**: ✅ CSS properly compiled with theme variables
|
||||
|
||||
### 3. Server Response Check
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/
|
||||
```
|
||||
|
||||
**Result**: ✅ HTTP 200 - Server responding correctly
|
||||
|
||||
### 4. Visual Verification
|
||||
- ✅ Primary colors (indigo) displaying correctly
|
||||
- ✅ Secondary colors (rose) displaying correctly
|
||||
- ✅ Accent colors (violet) displaying correctly
|
||||
- ✅ Poppins font family loading correctly
|
||||
- ✅ Button styling and interactions working
|
||||
- ✅ Dark mode functionality preserved
|
||||
- ✅ Responsive design intact
|
||||
- ✅ All animations and transitions working
|
||||
|
||||
## Files Modified
|
||||
|
||||
### CSS Files
|
||||
- `static/css/src/input.css` - ✅ Major updates (import syntax, theme variables, component classes)
|
||||
|
||||
### Template Files (Updated utility classes)
|
||||
- `templates/base.html`
|
||||
- `templates/components/navbar.html`
|
||||
- `templates/components/footer.html`
|
||||
- `templates/parks/park_list.html`
|
||||
- `templates/parks/park_detail.html`
|
||||
- `templates/parks/park_form.html`
|
||||
- `templates/rides/ride_list.html`
|
||||
- `templates/rides/ride_detail.html`
|
||||
- `templates/rides/ride_form.html`
|
||||
- `templates/companies/company_list.html`
|
||||
- `templates/companies/company_detail.html`
|
||||
- `templates/companies/company_form.html`
|
||||
- `templates/components/search_results.html`
|
||||
- `templates/components/pagination.html`
|
||||
|
||||
### Configuration Files (Preserved)
|
||||
- `tailwind.config.js` - ✅ Preserved for reference
|
||||
|
||||
## Benefits of v4 Migration
|
||||
|
||||
### Performance Improvements
|
||||
- Smaller CSS bundle size
|
||||
- Faster compilation times
|
||||
- Improved CSS-in-JS performance
|
||||
|
||||
### Developer Experience
|
||||
- CSS-first configuration approach
|
||||
- Better IDE support for theme variables
|
||||
- Simplified import syntax
|
||||
|
||||
### Future Compatibility
|
||||
- Modern CSS features support
|
||||
- Better container queries support
|
||||
- Enhanced dark mode capabilities
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### Issue: "Cannot apply unknown utility class"
|
||||
**Solution**: Check if utility was renamed in v4 migration table above
|
||||
|
||||
#### Issue: Custom colors not working
|
||||
**Solution**: Ensure `@theme` block is properly defined with CSS variables
|
||||
|
||||
#### Issue: Build errors
|
||||
**Solution**: Run clean build process:
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9
|
||||
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||
uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If rollback is needed:
|
||||
|
||||
1. **Restore CSS Import Syntax**:
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
2. **Remove @theme Block**: Delete the `@theme` section from input.css
|
||||
|
||||
3. **Revert Utility Classes**: Use search/replace to revert utility class changes
|
||||
|
||||
4. **Downgrade Tailwind**: Update package to v3.x version
|
||||
|
||||
## Post-Migration Checklist
|
||||
|
||||
- [x] CSS compilation working
|
||||
- [x] Development server running
|
||||
- [x] All pages loading correctly
|
||||
- [x] Colors displaying properly
|
||||
- [x] Fonts loading correctly
|
||||
- [x] Interactive elements working
|
||||
- [x] Dark mode functioning
|
||||
- [x] Responsive design intact
|
||||
- [x] No console errors
|
||||
- [x] Performance acceptable
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### New v4 Features to Explore
|
||||
- Enhanced container queries
|
||||
- Improved dark mode utilities
|
||||
- New color-mix() support
|
||||
- Advanced CSS nesting
|
||||
|
||||
### Maintenance Notes
|
||||
- Monitor for v4 updates and new features
|
||||
- Consider migrating more configuration to CSS variables
|
||||
- Evaluate new utility classes as they're released
|
||||
|
||||
## Contact and Support
|
||||
|
||||
For questions about this migration:
|
||||
- Review this documentation
|
||||
- Check Tailwind CSS v4 official documentation
|
||||
- Consult the preserved `tailwind.config.js` for original settings
|
||||
|
||||
---
|
||||
|
||||
**Migration Completed**: August 15, 2025
|
||||
**Tailwind Version**: v4.1.12
|
||||
**Status**: Production Ready ✅
|
||||
80
TAILWIND_V4_QUICK_REFERENCE.md
Normal file
80
TAILWIND_V4_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Tailwind CSS v4 Quick Reference Guide
|
||||
|
||||
## Common v3 → v4 Utility Migrations
|
||||
|
||||
| v3 Utility | v4 Utility | Notes |
|
||||
|------------|------------|-------|
|
||||
| `outline-none` | `outline-hidden` | Accessibility improvement |
|
||||
| `ring` | `ring-3` | Must specify ring width |
|
||||
| `shadow-sm` | `shadow-xs` | Renamed for consistency |
|
||||
| `shadow` | `shadow-sm` | Renamed for consistency |
|
||||
| `flex-shrink-0` | `shrink-0` | Shortened syntax |
|
||||
| `bg-blue-500 bg-opacity-50` | `bg-blue-500/50` | New opacity syntax |
|
||||
| `text-gray-700 text-opacity-75` | `text-gray-700/75` | New opacity syntax |
|
||||
| `!outline-none` | `!outline-hidden` | Updated important syntax |
|
||||
|
||||
## Theme Variables (Available in CSS)
|
||||
|
||||
```css
|
||||
/* Colors */
|
||||
var(--color-primary) /* #4f46e5 - Indigo-600 */
|
||||
var(--color-secondary) /* #e11d48 - Rose-600 */
|
||||
var(--color-accent) /* #8b5cf6 - Violet-500 */
|
||||
|
||||
/* Fonts */
|
||||
var(--font-family-sans) /* Poppins, sans-serif */
|
||||
```
|
||||
|
||||
## Usage in Templates
|
||||
|
||||
### Before (v3)
|
||||
```html
|
||||
<button class="outline-none ring hover:ring-2 shadow-sm bg-blue-500 bg-opacity-75">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### After (v4)
|
||||
```html
|
||||
<button class="outline-hidden ring-3 hover:ring-2 shadow-xs bg-blue-500/75">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Start Development Server
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
### Force CSS Rebuild
|
||||
```bash
|
||||
uv run manage.py tailwind build
|
||||
```
|
||||
|
||||
## New v4 Features
|
||||
|
||||
- **CSS-first configuration** via `@theme` blocks
|
||||
- **Improved opacity syntax** with `/` operator
|
||||
- **Better color-mix() support**
|
||||
- **Enhanced dark mode utilities**
|
||||
- **Faster compilation**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Unknown utility class error
|
||||
1. Check if utility was renamed (see table above)
|
||||
2. Verify custom theme variables are defined
|
||||
3. Run clean build process
|
||||
|
||||
### Colors not working
|
||||
1. Ensure `@theme` block exists in `static/css/src/input.css`
|
||||
2. Check CSS variable names match usage
|
||||
3. Verify CSS compilation completed
|
||||
|
||||
## Resources
|
||||
|
||||
- [Full Migration Documentation](./TAILWIND_V4_MIGRATION.md)
|
||||
- [Tailwind CSS v4 Official Docs](https://tailwindcss.com/docs)
|
||||
- [Django-Tailwind Package](https://django-tailwind.readthedocs.io/)
|
||||
@@ -6,18 +6,19 @@ from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
return getattr(settings, 'ACCOUNT_ALLOW_SIGNUPS', True)
|
||||
return True
|
||||
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""
|
||||
Constructs the email confirmation (activation) url.
|
||||
"""
|
||||
site = get_current_site(request)
|
||||
get_current_site(request)
|
||||
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
|
||||
|
||||
def send_confirmation_mail(self, request, emailconfirmation, signup):
|
||||
@@ -27,30 +28,31 @@ class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
current_site = get_current_site(request)
|
||||
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
||||
ctx = {
|
||||
'user': emailconfirmation.email_address.user,
|
||||
'activate_url': activate_url,
|
||||
'current_site': current_site,
|
||||
'key': emailconfirmation.key,
|
||||
"user": emailconfirmation.email_address.user,
|
||||
"activate_url": activate_url,
|
||||
"current_site": current_site,
|
||||
"key": emailconfirmation.key,
|
||||
}
|
||||
if signup:
|
||||
email_template = 'account/email/email_confirmation_signup'
|
||||
email_template = "account/email/email_confirmation_signup"
|
||||
else:
|
||||
email_template = 'account/email/email_confirmation'
|
||||
email_template = "account/email/email_confirmation"
|
||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||
|
||||
|
||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def is_open_for_signup(self, request, sociallogin):
|
||||
"""
|
||||
Whether to allow social account sign ups.
|
||||
"""
|
||||
return getattr(settings, 'SOCIALACCOUNT_ALLOW_SIGNUPS', True)
|
||||
return True
|
||||
|
||||
def populate_user(self, request, sociallogin, data):
|
||||
"""
|
||||
Hook that can be used to further populate the user instance.
|
||||
"""
|
||||
user = super().populate_user(request, sociallogin, data)
|
||||
if sociallogin.account.provider == 'discord':
|
||||
if sociallogin.account.provider == "discord":
|
||||
user.discord_id = sociallogin.account.uid
|
||||
return user
|
||||
|
||||
|
||||
@@ -1,78 +1,138 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import User, UserProfile, EmailVerification, TopList, TopListItem
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile'
|
||||
verbose_name_plural = "Profile"
|
||||
fieldsets = (
|
||||
('Personal Info', {
|
||||
'fields': ('display_name', 'avatar', 'pronouns', 'bio')
|
||||
}),
|
||||
('Social Media', {
|
||||
'fields': ('twitter', 'instagram', 'youtube', 'discord')
|
||||
}),
|
||||
('Ride Credits', {
|
||||
'fields': (
|
||||
'coaster_credits',
|
||||
'dark_ride_credits',
|
||||
'flat_ride_credits',
|
||||
'water_ride_credits'
|
||||
)
|
||||
}),
|
||||
(
|
||||
"Personal Info",
|
||||
{"fields": ("display_name", "avatar", "pronouns", "bio")},
|
||||
),
|
||||
(
|
||||
"Social Media",
|
||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
||||
),
|
||||
(
|
||||
"Ride Credits",
|
||||
{
|
||||
"fields": (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TopListItemInline(admin.TabularInline):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
fields = ('content_type', 'object_id', 'rank', 'notes')
|
||||
ordering = ('rank',)
|
||||
fields = ("content_type", "object_id", "rank", "notes")
|
||||
ordering = ("rank",)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = ('username', 'email', 'get_avatar', 'get_status', 'role', 'date_joined', 'last_login', 'get_credits')
|
||||
list_filter = ('is_active', 'is_staff', 'role', 'is_banned', 'groups', 'date_joined')
|
||||
search_fields = ('username', 'email')
|
||||
ordering = ('-date_joined',)
|
||||
actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users']
|
||||
list_display = (
|
||||
"username",
|
||||
"email",
|
||||
"get_avatar",
|
||||
"get_status",
|
||||
"role",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"get_credits",
|
||||
)
|
||||
list_filter = (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"role",
|
||||
"is_banned",
|
||||
"groups",
|
||||
"date_joined",
|
||||
)
|
||||
search_fields = ("username", "email")
|
||||
ordering = ("-date_joined",)
|
||||
actions = [
|
||||
"activate_users",
|
||||
"deactivate_users",
|
||||
"ban_users",
|
||||
"unban_users",
|
||||
]
|
||||
inlines = [UserProfileInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Personal info', {'fields': ('email', 'pending_email')}),
|
||||
('Roles and Permissions', {
|
||||
'fields': ('role', 'groups', 'user_permissions'),
|
||||
'description': 'Role determines group membership. Groups determine permissions.',
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('is_active', 'is_staff', 'is_superuser'),
|
||||
'description': 'These are automatically managed based on role.',
|
||||
}),
|
||||
('Ban Status', {
|
||||
'fields': ('is_banned', 'ban_reason', 'ban_date'),
|
||||
}),
|
||||
('Preferences', {
|
||||
'fields': ('theme_preference',),
|
||||
}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
(None, {"fields": ("username", "password")}),
|
||||
("Personal info", {"fields": ("email", "pending_email")}),
|
||||
(
|
||||
"Roles and Permissions",
|
||||
{
|
||||
"fields": ("role", "groups", "user_permissions"),
|
||||
"description": (
|
||||
"Role determines group membership. Groups determine permissions."
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Status",
|
||||
{
|
||||
"fields": ("is_active", "is_staff", "is_superuser"),
|
||||
"description": "These are automatically managed based on role.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Ban Status",
|
||||
{
|
||||
"fields": ("is_banned", "ban_reason", "ban_date"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Preferences",
|
||||
{
|
||||
"fields": ("theme_preference",),
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('username', 'email', 'password1', 'password2', 'role'),
|
||||
}),
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": (
|
||||
"username",
|
||||
"email",
|
||||
"password1",
|
||||
"password2",
|
||||
"role",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Avatar")
|
||||
def get_avatar(self, obj):
|
||||
if obj.profile.avatar:
|
||||
return format_html('<img src="{}" width="30" height="30" style="border-radius:50%;" />', obj.profile.avatar.url)
|
||||
return format_html('<div style="width:30px; height:30px; border-radius:50%; background-color:#007bff; color:white; display:flex; align-items:center; justify-content:center;">{}</div>', obj.username[0].upper())
|
||||
get_avatar.short_description = 'Avatar'
|
||||
return format_html(
|
||||
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
||||
obj.profile.avatar.url,
|
||||
)
|
||||
return format_html(
|
||||
'<div style="width:30px; height:30px; border-radius:50%; '
|
||||
"background-color:#007bff; color:white; display:flex; "
|
||||
'align-items:center; justify-content:center;">{}</div>',
|
||||
obj.username[0].upper(),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def get_status(self, obj):
|
||||
if obj.is_banned:
|
||||
return format_html('<span style="color: red;">Banned</span>')
|
||||
@@ -83,38 +143,38 @@ class CustomUserAdmin(UserAdmin):
|
||||
if obj.is_staff:
|
||||
return format_html('<span style="color: blue;">Staff</span>')
|
||||
return format_html('<span style="color: green;">Active</span>')
|
||||
get_status.short_description = 'Status'
|
||||
|
||||
@admin.display(description="Ride Credits")
|
||||
def get_credits(self, obj):
|
||||
try:
|
||||
profile = obj.profile
|
||||
return format_html(
|
||||
'RC: {}<br>DR: {}<br>FR: {}<br>WR: {}',
|
||||
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
|
||||
profile.coaster_credits,
|
||||
profile.dark_ride_credits,
|
||||
profile.flat_ride_credits,
|
||||
profile.water_ride_credits
|
||||
profile.water_ride_credits,
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return '-'
|
||||
get_credits.short_description = 'Ride Credits'
|
||||
return "-"
|
||||
|
||||
@admin.action(description="Activate selected users")
|
||||
def activate_users(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
activate_users.short_description = "Activate selected users"
|
||||
|
||||
@admin.action(description="Deactivate selected users")
|
||||
def deactivate_users(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
deactivate_users.short_description = "Deactivate selected users"
|
||||
|
||||
@admin.action(description="Ban selected users")
|
||||
def ban_users(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
||||
ban_users.short_description = "Ban selected users"
|
||||
|
||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
||||
|
||||
@admin.action(description="Unban selected users")
|
||||
def unban_users(self, request, queryset):
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason='')
|
||||
unban_users.short_description = "Unban selected users"
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
creating = not obj.pk
|
||||
@@ -125,83 +185,98 @@ class CustomUserAdmin(UserAdmin):
|
||||
if group:
|
||||
obj.groups.add(group)
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'display_name', 'coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')
|
||||
list_filter = ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')
|
||||
search_fields = ('user__username', 'user__email', 'display_name', 'bio')
|
||||
list_display = (
|
||||
"user",
|
||||
"display_name",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
list_filter = (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
search_fields = ("user__username", "user__email", "display_name", "bio")
|
||||
|
||||
fieldsets = (
|
||||
('User Information', {
|
||||
'fields': ('user', 'display_name', 'avatar', 'pronouns', 'bio')
|
||||
}),
|
||||
('Social Media', {
|
||||
'fields': ('twitter', 'instagram', 'youtube', 'discord')
|
||||
}),
|
||||
('Ride Credits', {
|
||||
'fields': (
|
||||
'coaster_credits',
|
||||
'dark_ride_credits',
|
||||
'flat_ride_credits',
|
||||
'water_ride_credits'
|
||||
)
|
||||
}),
|
||||
(
|
||||
"User Information",
|
||||
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
|
||||
),
|
||||
(
|
||||
"Social Media",
|
||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
||||
),
|
||||
(
|
||||
"Ride Credits",
|
||||
{
|
||||
"fields": (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'created_at', 'last_sent', 'is_expired')
|
||||
list_filter = ('created_at', 'last_sent')
|
||||
search_fields = ('user__username', 'user__email', 'token')
|
||||
readonly_fields = ('created_at', 'last_sent')
|
||||
|
||||
list_display = ("user", "created_at", "last_sent", "is_expired")
|
||||
list_filter = ("created_at", "last_sent")
|
||||
search_fields = ("user__username", "user__email", "token")
|
||||
readonly_fields = ("created_at", "last_sent")
|
||||
|
||||
fieldsets = (
|
||||
('Verification Details', {
|
||||
'fields': ('user', 'token')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('created_at', 'last_sent')
|
||||
}),
|
||||
("Verification Details", {"fields": ("user", "token")}),
|
||||
("Timing", {"fields": ("created_at", "last_sent")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def is_expired(self, obj):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if timezone.now() - obj.last_sent > timedelta(days=1):
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
is_expired.short_description = 'Status'
|
||||
|
||||
|
||||
@admin.register(TopList)
|
||||
class TopListAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'user', 'category', 'created_at', 'updated_at')
|
||||
list_filter = ('category', 'created_at', 'updated_at')
|
||||
search_fields = ('title', 'user__username', 'description')
|
||||
list_display = ("title", "user", "category", "created_at", "updated_at")
|
||||
list_filter = ("category", "created_at", "updated_at")
|
||||
search_fields = ("title", "user__username", "description")
|
||||
inlines = [TopListItemInline]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('user', 'title', 'category', 'description')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("user", "title", "category", "description")},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(TopListItem)
|
||||
class TopListItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('top_list', 'content_type', 'object_id', 'rank')
|
||||
list_filter = ('top_list__category', 'rank')
|
||||
search_fields = ('top_list__title', 'notes')
|
||||
ordering = ('top_list', 'rank')
|
||||
list_display = ("top_list", "content_type", "object_id", "rank")
|
||||
list_filter = ("top_list__category", "rank")
|
||||
search_fields = ("top_list__title", "notes")
|
||||
ordering = ("top_list", "rank")
|
||||
|
||||
fieldsets = (
|
||||
('List Information', {
|
||||
'fields': ('top_list', 'rank')
|
||||
}),
|
||||
('Item Details', {
|
||||
'fields': ('content_type', 'object_id', 'notes')
|
||||
}),
|
||||
("List Information", {"fields": ("top_list", "rank")}),
|
||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
||||
)
|
||||
|
||||
@@ -2,29 +2,45 @@ from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check all social auth related tables'
|
||||
help = "Check all social auth related tables"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check SocialApp
|
||||
self.stdout.write('\nChecking SocialApp table:')
|
||||
self.stdout.write("\nChecking SocialApp table:")
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f'ID: {app.id}, Provider: {app.provider}, Name: {app.name}, Client ID: {app.client_id}')
|
||||
self.stdout.write('Sites:')
|
||||
self.stdout.write(
|
||||
f"ID: {
|
||||
app.pk}, Provider: {
|
||||
app.provider}, Name: {
|
||||
app.name}, Client ID: {
|
||||
app.client_id}"
|
||||
)
|
||||
self.stdout.write("Sites:")
|
||||
for site in app.sites.all():
|
||||
self.stdout.write(f' - {site.domain}')
|
||||
self.stdout.write(f" - {site.domain}")
|
||||
|
||||
# Check SocialAccount
|
||||
self.stdout.write('\nChecking SocialAccount table:')
|
||||
self.stdout.write("\nChecking SocialAccount table:")
|
||||
for account in SocialAccount.objects.all():
|
||||
self.stdout.write(f'ID: {account.id}, Provider: {account.provider}, UID: {account.uid}')
|
||||
self.stdout.write(
|
||||
f"ID: {
|
||||
account.pk}, Provider: {
|
||||
account.provider}, UID: {
|
||||
account.uid}"
|
||||
)
|
||||
|
||||
# Check SocialToken
|
||||
self.stdout.write('\nChecking SocialToken table:')
|
||||
self.stdout.write("\nChecking SocialToken table:")
|
||||
for token in SocialToken.objects.all():
|
||||
self.stdout.write(f'ID: {token.id}, Account: {token.account}, App: {token.app}')
|
||||
self.stdout.write(
|
||||
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
|
||||
)
|
||||
|
||||
# Check Site
|
||||
self.stdout.write('\nChecking Site table:')
|
||||
self.stdout.write("\nChecking Site table:")
|
||||
for site in Site.objects.all():
|
||||
self.stdout.write(f'ID: {site.id}, Domain: {site.domain}, Name: {site.name}')
|
||||
self.stdout.write(
|
||||
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
|
||||
)
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check social app configurations'
|
||||
help = "Check social app configurations"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
social_apps = SocialApp.objects.all()
|
||||
|
||||
|
||||
if not social_apps:
|
||||
self.stdout.write(self.style.ERROR('No social apps found'))
|
||||
self.stdout.write(self.style.ERROR("No social apps found"))
|
||||
return
|
||||
|
||||
|
||||
for app in social_apps:
|
||||
self.stdout.write(self.style.SUCCESS(f'\nProvider: {app.provider}'))
|
||||
self.stdout.write(f'Name: {app.name}')
|
||||
self.stdout.write(f'Client ID: {app.client_id}')
|
||||
self.stdout.write(f'Secret: {app.secret}')
|
||||
self.stdout.write(f'Sites: {", ".join(str(site.domain) for site in app.sites.all())}')
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\nProvider: {
|
||||
app.provider}"
|
||||
)
|
||||
)
|
||||
self.stdout.write(f"Name: {app.name}")
|
||||
self.stdout.write(f"Client ID: {app.client_id}")
|
||||
self.stdout.write(f"Secret: {app.secret}")
|
||||
self.stdout.write(
|
||||
f'Sites: {", ".join(str(site.domain) for site in app.sites.all())}'
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up social auth tables and migrations'
|
||||
help = "Clean up social auth tables and migrations"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
@@ -11,12 +12,17 @@ class Command(BaseCommand):
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp_sites")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialaccount")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialtoken")
|
||||
|
||||
|
||||
# Remove migration records
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' AND name LIKE '%social%'")
|
||||
|
||||
cursor.execute(
|
||||
"DELETE FROM django_migrations WHERE app='accounts' "
|
||||
"AND name LIKE '%social%'"
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully cleaned up social auth configuration'))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully cleaned up social auth configuration")
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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 parks.models import ParkReview, Park
|
||||
from rides.models import Ride
|
||||
from media.models import Photo
|
||||
|
||||
@@ -20,7 +18,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||
|
||||
# Delete test reviews
|
||||
reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
|
||||
reviews = ParkReview.objects.filter(
|
||||
user__username__in=["testuser", "moderator"]
|
||||
)
|
||||
count = reviews.count()
|
||||
reviews.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||
|
||||
@@ -2,47 +2,54 @@ from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Create social apps for authentication'
|
||||
help = "Create social apps for authentication"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'ThrillWiki Development'
|
||||
}
|
||||
"domain": "localhost:8000",
|
||||
"name": "ThrillWiki Development",
|
||||
},
|
||||
)[0]
|
||||
|
||||
# Create Discord app
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider='discord',
|
||||
provider="discord",
|
||||
defaults={
|
||||
'name': 'Discord',
|
||||
'client_id': '1299112802274902047',
|
||||
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
||||
}
|
||||
"name": "Discord",
|
||||
"client_id": "1299112802274902047",
|
||||
"secret": "ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11",
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
discord_app.client_id = '1299112802274902047'
|
||||
discord_app.secret = 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11'
|
||||
discord_app.client_id = "1299112802274902047"
|
||||
discord_app.secret = "ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11"
|
||||
discord_app.save()
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'{"Created" if created else "Updated"} Discord app')
|
||||
|
||||
# Create Google app
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider='google',
|
||||
provider="google",
|
||||
defaults={
|
||||
'name': 'Google',
|
||||
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
||||
'secret': 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue',
|
||||
}
|
||||
"name": "Google",
|
||||
"client_id": (
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
|
||||
"apps.googleusercontent.com"
|
||||
),
|
||||
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
google_app.client_id = '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com'
|
||||
google_app.secret = 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue'
|
||||
google_app.client_id = (
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
|
||||
"apps.googleusercontent.com"
|
||||
)
|
||||
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
|
||||
google_app.save()
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'{"Created" if created else "Updated"} Google app')
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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()
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -11,22 +8,25 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
# Create regular test user
|
||||
if not User.objects.filter(username="testuser").exists():
|
||||
user = User.objects.create_user(
|
||||
user = User.objects.create(
|
||||
username="testuser",
|
||||
email="testuser@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created test user: {user.get_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(
|
||||
moderator = User.objects.create(
|
||||
username="moderator",
|
||||
email="moderator@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
moderator.set_password("modpass123")
|
||||
moderator.save()
|
||||
|
||||
# Create moderator group if it doesn't exist
|
||||
moderator_group, created = Group.objects.get_or_create(name="Moderators")
|
||||
@@ -48,7 +48,9 @@ class Command(BaseCommand):
|
||||
moderator.groups.add(moderator_group)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
|
||||
self.style.SUCCESS(
|
||||
f"Created moderator user: {moderator.get_username()}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix migration history by removing rides.0001_initial'
|
||||
help = "Fix migration history by removing rides.0001_initial"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='rides' AND name='0001_initial';")
|
||||
self.stdout.write(self.style.SUCCESS('Successfully removed rides.0001_initial from migration history'))
|
||||
cursor.execute(
|
||||
"DELETE FROM django_migrations WHERE app='rides' "
|
||||
"AND name='0001_initial';"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Successfully removed rides.0001_initial from migration history"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,33 +3,39 @@ from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix social app configurations'
|
||||
help = "Fix social app configurations"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete all existing social apps
|
||||
SocialApp.objects.all().delete()
|
||||
self.stdout.write('Deleted all existing social apps')
|
||||
|
||||
self.stdout.write("Deleted all existing social apps")
|
||||
|
||||
# Get the default site
|
||||
site = Site.objects.get(id=1)
|
||||
|
||||
|
||||
# Create Google provider
|
||||
google_app = SocialApp.objects.create(
|
||||
provider='google',
|
||||
name='Google',
|
||||
client_id=os.getenv('GOOGLE_CLIENT_ID'),
|
||||
secret=os.getenv('GOOGLE_CLIENT_SECRET'),
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id=os.getenv("GOOGLE_CLIENT_ID"),
|
||||
secret=os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'Created Google app with client_id: {google_app.client_id}')
|
||||
|
||||
self.stdout.write(
|
||||
f"Created Google app with client_id: {
|
||||
google_app.client_id}"
|
||||
)
|
||||
|
||||
# Create Discord provider
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider='discord',
|
||||
name='Discord',
|
||||
client_id=os.getenv('DISCORD_CLIENT_ID'),
|
||||
secret=os.getenv('DISCORD_CLIENT_SECRET'),
|
||||
provider="discord",
|
||||
name="Discord",
|
||||
client_id=os.getenv("DISCORD_CLIENT_ID"),
|
||||
secret=os.getenv("DISCORD_CLIENT_SECRET"),
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'Created Discord app with client_id: {discord_app.client_id}')
|
||||
self.stdout.write(
|
||||
f"Created Discord app with client_id: {discord_app.client_id}"
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.core.management.base import BaseCommand
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
|
||||
def generate_avatar(letter):
|
||||
"""Generate an avatar for a given letter or number"""
|
||||
avatar_size = (100, 100)
|
||||
@@ -10,7 +11,7 @@ def generate_avatar(letter):
|
||||
font_size = 100
|
||||
|
||||
# Create a blank image with background color
|
||||
image = Image.new('RGB', avatar_size, background_color)
|
||||
image = Image.new("RGB", avatar_size, background_color)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Load a font
|
||||
@@ -19,8 +20,14 @@ def generate_avatar(letter):
|
||||
|
||||
# Calculate text size and position using textbbox
|
||||
text_bbox = draw.textbbox((0, 0), letter, font=font)
|
||||
text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
|
||||
text_position = ((avatar_size[0] - text_width) / 2, (avatar_size[1] - text_height) / 2)
|
||||
text_width, text_height = (
|
||||
text_bbox[2] - text_bbox[0],
|
||||
text_bbox[3] - text_bbox[1],
|
||||
)
|
||||
text_position = (
|
||||
(avatar_size[0] - text_width) / 2,
|
||||
(avatar_size[1] - text_height) / 2,
|
||||
)
|
||||
|
||||
# Draw the text on the image
|
||||
draw.text(text_position, letter, font=font, fill=text_color)
|
||||
@@ -34,11 +41,14 @@ def generate_avatar(letter):
|
||||
avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png")
|
||||
image.save(avatar_path)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Generate avatars for letters A-Z and numbers 0-9'
|
||||
help = "Generate avatars for letters A-Z and numbers 0-9"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
|
||||
characters = [chr(i) for i in range(65, 91)] + [
|
||||
str(i) for i in range(10)
|
||||
] # A-Z and 0-9
|
||||
for char in characters:
|
||||
generate_avatar(char)
|
||||
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from accounts.models import UserProfile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Regenerate default avatars for users without an uploaded avatar'
|
||||
help = "Regenerate default avatars for users without an uploaded avatar"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
profiles = UserProfile.objects.filter(avatar='')
|
||||
profiles = UserProfile.objects.filter(avatar="")
|
||||
for profile in profiles:
|
||||
profile.save() # This will trigger the avatar generation logic in the save method
|
||||
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))
|
||||
# This will trigger the avatar generation logic in the save method
|
||||
profile.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Regenerated avatar for {
|
||||
profile.user.username}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,66 +3,87 @@ from django.db import connection
|
||||
from django.contrib.auth.hashers import make_password
|
||||
import uuid
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset database and create admin user'
|
||||
help = "Reset database and create admin user"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Resetting database...')
|
||||
self.stdout.write("Resetting database...")
|
||||
|
||||
# Drop all tables
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
FOR r IN (
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
||||
quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema()) LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||
FOR r IN (
|
||||
SELECT sequencename FROM pg_sequences
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || \
|
||||
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
self.stdout.write('All tables dropped and sequences reset.')
|
||||
self.stdout.write("All tables dropped and sequences reset.")
|
||||
|
||||
# Run migrations
|
||||
from django.core.management import call_command
|
||||
call_command('migrate')
|
||||
|
||||
self.stdout.write('Migrations applied.')
|
||||
call_command("migrate")
|
||||
|
||||
self.stdout.write("Migrations applied.")
|
||||
|
||||
# Create superuser using raw SQL
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Create user
|
||||
user_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_user (
|
||||
username, password, email, is_superuser, is_staff,
|
||||
is_active, date_joined, user_id, first_name,
|
||||
last_name, role, is_banned, ban_reason,
|
||||
username, password, email, is_superuser, is_staff,
|
||||
is_active, date_joined, user_id, first_name,
|
||||
last_name, role, is_banned, ban_reason,
|
||||
theme_preference
|
||||
) VALUES (
|
||||
'admin', %s, 'admin@thrillwiki.com', true, true,
|
||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
||||
'light'
|
||||
) RETURNING id;
|
||||
""", [make_password('admin'), user_id])
|
||||
|
||||
user_db_id = cursor.fetchone()[0]
|
||||
""",
|
||||
[make_password("admin"), user_id],
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
raise Exception("Failed to create user - no ID returned")
|
||||
user_db_id = result[0]
|
||||
|
||||
# Create profile
|
||||
profile_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_userprofile (
|
||||
profile_id, display_name, pronouns, bio,
|
||||
twitter, instagram, youtube, discord,
|
||||
@@ -75,11 +96,18 @@ class Command(BaseCommand):
|
||||
0, 0, 0, 0,
|
||||
%s, ''
|
||||
);
|
||||
""", [profile_id, user_db_id])
|
||||
""",
|
||||
[profile_id, user_db_id],
|
||||
)
|
||||
|
||||
self.stdout.write('Superuser created.')
|
||||
self.stdout.write("Superuser created.")
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error creating superuser: {str(e)}'))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error creating superuser: {
|
||||
str(e)}"
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Database reset complete.'))
|
||||
self.stdout.write(self.style.SUCCESS("Database reset complete."))
|
||||
|
||||
@@ -3,34 +3,37 @@ from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset social apps configuration'
|
||||
help = "Reset social apps configuration"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete all social apps using raw SQL to bypass Django's ORM
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp")
|
||||
|
||||
|
||||
# Get the default site
|
||||
site = Site.objects.get(id=1)
|
||||
|
||||
|
||||
# Create Discord app
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider='discord',
|
||||
name='Discord',
|
||||
client_id='1299112802274902047',
|
||||
secret='ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
||||
provider="discord",
|
||||
name="Discord",
|
||||
client_id="1299112802274902047",
|
||||
secret="ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11",
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'Created Discord app with ID: {discord_app.id}')
|
||||
|
||||
self.stdout.write(f"Created Discord app with ID: {discord_app.pk}")
|
||||
|
||||
# Create Google app
|
||||
google_app = SocialApp.objects.create(
|
||||
provider='google',
|
||||
name='Google',
|
||||
client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
||||
secret='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',
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id=(
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
|
||||
),
|
||||
secret="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",
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'Created Google app with ID: {google_app.id}')
|
||||
self.stdout.write(f"Created Google app with ID: {google_app.pk}")
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset social auth configuration'
|
||||
help = "Reset social auth configuration"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
# Delete all social apps
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp")
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully reset social auth configuration'))
|
||||
cursor.execute(
|
||||
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'"
|
||||
)
|
||||
cursor.execute(
|
||||
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully reset social auth configuration")
|
||||
)
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth.models import Group
|
||||
from accounts.models import User
|
||||
from accounts.signals import create_default_groups
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up default groups and permissions for user roles'
|
||||
help = "Set up default groups and permissions for user roles"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Creating default groups and permissions...')
|
||||
|
||||
self.stdout.write("Creating default groups and permissions...")
|
||||
|
||||
try:
|
||||
# Create default groups with permissions
|
||||
create_default_groups()
|
||||
|
||||
|
||||
# Sync existing users with groups based on their roles
|
||||
users = User.objects.exclude(role=User.Roles.USER)
|
||||
for user in users:
|
||||
group = Group.objects.filter(name=user.role).first()
|
||||
if group:
|
||||
user.groups.add(group)
|
||||
|
||||
|
||||
# Update staff/superuser status based on role
|
||||
if user.role == User.Roles.SUPERUSER:
|
||||
user.is_superuser = True
|
||||
@@ -28,15 +28,22 @@ class Command(BaseCommand):
|
||||
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully set up groups and permissions'))
|
||||
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully set up groups and permissions")
|
||||
)
|
||||
|
||||
# Print summary
|
||||
for group in Group.objects.all():
|
||||
self.stdout.write(f'\nGroup: {group.name}')
|
||||
self.stdout.write('Permissions:')
|
||||
self.stdout.write(f"\nGroup: {group.name}")
|
||||
self.stdout.write("Permissions:")
|
||||
for perm in group.permissions.all():
|
||||
self.stdout.write(f' - {perm.codename}')
|
||||
|
||||
self.stdout.write(f" - {perm.codename}")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Error setting up groups: {
|
||||
str(e)}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up default site'
|
||||
help = "Set up default site"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete any existing sites
|
||||
Site.objects.all().delete()
|
||||
|
||||
|
||||
# Create default site
|
||||
site = Site.objects.create(
|
||||
id=1,
|
||||
domain='localhost:8000',
|
||||
name='ThrillWiki Development'
|
||||
id=1, domain="localhost:8000", name="ThrillWiki Development"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created site: {site.domain}'))
|
||||
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))
|
||||
|
||||
@@ -4,60 +4,123 @@ from allauth.socialaccount.models import SocialApp
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Sets up social authentication apps'
|
||||
help = "Sets up social authentication apps"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Get environment variables
|
||||
google_client_id = os.getenv('GOOGLE_CLIENT_ID')
|
||||
google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
|
||||
discord_client_id = os.getenv('DISCORD_CLIENT_ID')
|
||||
discord_client_secret = os.getenv('DISCORD_CLIENT_SECRET')
|
||||
google_client_id = os.getenv("GOOGLE_CLIENT_ID")
|
||||
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||
discord_client_id = os.getenv("DISCORD_CLIENT_ID")
|
||||
discord_client_secret = os.getenv("DISCORD_CLIENT_SECRET")
|
||||
|
||||
if not all([google_client_id, google_client_secret, discord_client_id, discord_client_secret]):
|
||||
self.stdout.write(self.style.ERROR('Missing required environment variables'))
|
||||
# DEBUG: Log environment variable values
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_id type: {
|
||||
type(google_client_id)}, value: {google_client_id}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_secret type: {
|
||||
type(google_client_secret)}, value: {google_client_secret}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_id type: {
|
||||
type(discord_client_id)}, value: {discord_client_id}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_secret type: {
|
||||
type(discord_client_secret)}, value: {discord_client_secret}"
|
||||
)
|
||||
|
||||
if not all(
|
||||
[
|
||||
google_client_id,
|
||||
google_client_secret,
|
||||
discord_client_id,
|
||||
discord_client_secret,
|
||||
]
|
||||
):
|
||||
self.stdout.write(
|
||||
self.style.ERROR("Missing required environment variables")
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_id is None: {google_client_id is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_secret is None: {
|
||||
google_client_secret is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_id is None: {
|
||||
discord_client_id is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_secret is None: {
|
||||
discord_client_secret is None}"
|
||||
)
|
||||
return
|
||||
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'localhost'
|
||||
}
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
|
||||
)
|
||||
|
||||
# Set up Google
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider='google',
|
||||
provider="google",
|
||||
defaults={
|
||||
'name': 'Google',
|
||||
'client_id': google_client_id,
|
||||
'secret': google_client_secret,
|
||||
}
|
||||
"name": "Google",
|
||||
"client_id": google_client_id,
|
||||
"secret": google_client_secret,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
google_app.client_id = google_client_id
|
||||
google_app.[SECRET-REMOVED]
|
||||
google_app.save()
|
||||
self.stdout.write(
|
||||
f"DEBUG: About to assign google_client_id: {google_client_id} (type: {
|
||||
type(google_client_id)})"
|
||||
)
|
||||
if google_client_id is not None and google_client_secret is not None:
|
||||
google_app.client_id = google_client_id
|
||||
google_app.secret = google_client_secret
|
||||
google_app.save()
|
||||
self.stdout.write("DEBUG: Successfully updated Google app")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Google client_id or secret is None, skipping update."
|
||||
)
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
|
||||
# Set up Discord
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider='discord',
|
||||
provider="discord",
|
||||
defaults={
|
||||
'name': 'Discord',
|
||||
'client_id': discord_client_id,
|
||||
'secret': discord_client_secret,
|
||||
}
|
||||
"name": "Discord",
|
||||
"client_id": discord_client_id,
|
||||
"secret": discord_client_secret,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
discord_app.client_id = discord_client_id
|
||||
discord_app.[SECRET-REMOVED]
|
||||
discord_app.save()
|
||||
self.stdout.write(
|
||||
f"DEBUG: About to assign discord_client_id: {discord_client_id} (type: {
|
||||
type(discord_client_id)})"
|
||||
)
|
||||
if discord_client_id is not None and discord_client_secret is not None:
|
||||
discord_app.client_id = discord_client_id
|
||||
discord_app.secret = discord_client_secret
|
||||
discord_app.save()
|
||||
self.stdout.write("DEBUG: Successfully updated Discord app")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Discord client_id or secret is None, skipping update."
|
||||
)
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully set up social auth apps'))
|
||||
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up social authentication through admin interface'
|
||||
help = "Set up social authentication through admin interface"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'ThrillWiki Development'
|
||||
}
|
||||
"domain": "localhost:8000",
|
||||
"name": "ThrillWiki Development",
|
||||
},
|
||||
)
|
||||
if not _:
|
||||
site.domain = 'localhost:8000'
|
||||
site.name = 'ThrillWiki Development'
|
||||
site.domain = "localhost:8000"
|
||||
site.name = "ThrillWiki Development"
|
||||
site.save()
|
||||
self.stdout.write(f'{"Created" if _ else "Updated"} site: {site.domain}')
|
||||
|
||||
# Create superuser if it doesn't exist
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
||||
self.stdout.write('Created superuser: admin/admin')
|
||||
if not User.objects.filter(username="admin").exists():
|
||||
admin_user = User.objects.create(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
admin_user.set_password("admin")
|
||||
admin_user.save()
|
||||
self.stdout.write("Created superuser: admin/admin")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('''
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"""
|
||||
Social auth setup instructions:
|
||||
|
||||
1. Run the development server:
|
||||
@@ -57,4 +65,6 @@ Social auth setup instructions:
|
||||
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
|
||||
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
|
||||
Sites: Add "localhost:8000"
|
||||
'''))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.test import Client
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test Discord OAuth2 authentication flow'
|
||||
help = "Test Discord OAuth2 authentication flow"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
client = Client(HTTP_HOST='localhost:8000')
|
||||
|
||||
client = Client(HTTP_HOST="localhost:8000")
|
||||
|
||||
# Get Discord app
|
||||
try:
|
||||
discord_app = SocialApp.objects.get(provider='discord')
|
||||
self.stdout.write('Found Discord app configuration:')
|
||||
self.stdout.write(f'Client ID: {discord_app.client_id}')
|
||||
|
||||
discord_app = SocialApp.objects.get(provider="discord")
|
||||
self.stdout.write("Found Discord app configuration:")
|
||||
self.stdout.write(f"Client ID: {discord_app.client_id}")
|
||||
|
||||
# Test login URL
|
||||
login_url = '/accounts/discord/login/'
|
||||
response = client.get(login_url, HTTP_HOST='localhost:8000')
|
||||
self.stdout.write(f'\nTesting login URL: {login_url}')
|
||||
self.stdout.write(f'Status code: {response.status_code}')
|
||||
|
||||
login_url = "/accounts/discord/login/"
|
||||
response = client.get(login_url, HTTP_HOST="localhost:8000")
|
||||
self.stdout.write(f"\nTesting login URL: {login_url}")
|
||||
self.stdout.write(f"Status code: {response.status_code}")
|
||||
|
||||
if response.status_code == 302:
|
||||
redirect_url = response['Location']
|
||||
self.stdout.write(f'Redirects to: {redirect_url}')
|
||||
|
||||
redirect_url = response["Location"]
|
||||
self.stdout.write(f"Redirects to: {redirect_url}")
|
||||
|
||||
# Parse OAuth2 parameters
|
||||
self.stdout.write('\nOAuth2 Parameters:')
|
||||
if 'client_id=' in redirect_url:
|
||||
self.stdout.write('✓ client_id parameter present')
|
||||
if 'redirect_uri=' in redirect_url:
|
||||
self.stdout.write('✓ redirect_uri parameter present')
|
||||
if 'scope=' in redirect_url:
|
||||
self.stdout.write('✓ scope parameter present')
|
||||
if 'response_type=' in redirect_url:
|
||||
self.stdout.write('✓ response_type parameter present')
|
||||
if 'code_challenge=' in redirect_url:
|
||||
self.stdout.write('✓ PKCE enabled (code_challenge present)')
|
||||
|
||||
self.stdout.write("\nOAuth2 Parameters:")
|
||||
if "client_id=" in redirect_url:
|
||||
self.stdout.write("✓ client_id parameter present")
|
||||
if "redirect_uri=" in redirect_url:
|
||||
self.stdout.write("✓ redirect_uri parameter present")
|
||||
if "scope=" in redirect_url:
|
||||
self.stdout.write("✓ scope parameter present")
|
||||
if "response_type=" in redirect_url:
|
||||
self.stdout.write("✓ response_type parameter present")
|
||||
if "code_challenge=" in redirect_url:
|
||||
self.stdout.write("✓ PKCE enabled (code_challenge present)")
|
||||
|
||||
# Show callback URL
|
||||
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
|
||||
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
|
||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||
self.stdout.write(
|
||||
"\nCallback URL to configure in Discord Developer Portal:"
|
||||
)
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
|
||||
# Show frontend login URL
|
||||
frontend_url = 'http://localhost:5173'
|
||||
self.stdout.write('\nFrontend configuration:')
|
||||
self.stdout.write(f'Frontend URL: {frontend_url}')
|
||||
self.stdout.write('Discord login button should use:')
|
||||
self.stdout.write('/accounts/discord/login/?process=login')
|
||||
|
||||
frontend_url = "http://localhost:5173"
|
||||
self.stdout.write("\nFrontend configuration:")
|
||||
self.stdout.write(f"Frontend URL: {frontend_url}")
|
||||
self.stdout.write("Discord login button should use:")
|
||||
self.stdout.write("/accounts/discord/login/?process=login")
|
||||
|
||||
# Show allauth URLs
|
||||
self.stdout.write('\nAllauth URLs:')
|
||||
self.stdout.write('Login URL: /accounts/discord/login/?process=login')
|
||||
self.stdout.write('Callback URL: /accounts/discord/login/callback/')
|
||||
|
||||
self.stdout.write("\nAllauth URLs:")
|
||||
self.stdout.write("Login URL: /accounts/discord/login/?process=login")
|
||||
self.stdout.write("Callback URL: /accounts/discord/login/callback/")
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR('Discord app not found'))
|
||||
self.stdout.write(self.style.ERROR("Discord app not found"))
|
||||
|
||||
@@ -2,19 +2,22 @@ from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Update social apps to be associated with all sites'
|
||||
help = "Update social apps to be associated with all sites"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get all sites
|
||||
sites = Site.objects.all()
|
||||
|
||||
|
||||
# Update each social app
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f'Updating {app.provider} app...')
|
||||
self.stdout.write(f"Updating {app.provider} app...")
|
||||
# Clear existing sites
|
||||
app.sites.clear()
|
||||
# Add all sites
|
||||
for site in sites:
|
||||
app.sites.add(site)
|
||||
self.stdout.write(f'Added sites: {", ".join(site.domain for site in sites)}')
|
||||
self.stdout.write(
|
||||
f'Added sites: {", ".join(site.domain for site in sites)}'
|
||||
)
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Verify Discord OAuth2 settings'
|
||||
help = "Verify Discord OAuth2 settings"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get Discord app
|
||||
try:
|
||||
discord_app = SocialApp.objects.get(provider='discord')
|
||||
self.stdout.write('Found Discord app configuration:')
|
||||
self.stdout.write(f'Client ID: {discord_app.client_id}')
|
||||
self.stdout.write(f'Secret: {discord_app.secret}')
|
||||
|
||||
discord_app = SocialApp.objects.get(provider="discord")
|
||||
self.stdout.write("Found Discord app configuration:")
|
||||
self.stdout.write(f"Client ID: {discord_app.client_id}")
|
||||
self.stdout.write(f"Secret: {discord_app.secret}")
|
||||
|
||||
# Get sites
|
||||
sites = discord_app.sites.all()
|
||||
self.stdout.write('\nAssociated sites:')
|
||||
self.stdout.write("\nAssociated sites:")
|
||||
for site in sites:
|
||||
self.stdout.write(f'- {site.domain} ({site.name})')
|
||||
|
||||
self.stdout.write(f"- {site.domain} ({site.name})")
|
||||
|
||||
# Show callback URL
|
||||
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
|
||||
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
|
||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||
self.stdout.write(
|
||||
"\nCallback URL to configure in Discord Developer Portal:"
|
||||
)
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
|
||||
# Show OAuth2 settings
|
||||
self.stdout.write('\nOAuth2 settings in settings.py:')
|
||||
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get('discord', {})
|
||||
self.stdout.write(f'PKCE Enabled: {discord_settings.get("OAUTH_PKCE_ENABLED", False)}')
|
||||
self.stdout.write("\nOAuth2 settings in settings.py:")
|
||||
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
|
||||
self.stdout.write(
|
||||
f'PKCE Enabled: {
|
||||
discord_settings.get(
|
||||
"OAUTH_PKCE_ENABLED",
|
||||
False)}'
|
||||
)
|
||||
self.stdout.write(f'Scopes: {discord_settings.get("SCOPE", [])}')
|
||||
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR('Discord app not found'))
|
||||
self.stdout.write(self.style.ERROR("Discord app not found"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||
# Generated by Django 5.1.4 on 2025-08-13 21:35
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@@ -33,7 +33,10 @@ class Migration(migrations.Migration):
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"password",
|
||||
models.CharField(max_length=128, verbose_name="password"),
|
||||
),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
@@ -78,7 +81,9 @@ class Migration(migrations.Migration):
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
blank=True,
|
||||
max_length=254,
|
||||
verbose_name="email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -100,7 +105,8 @@ class Migration(migrations.Migration):
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="date joined",
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -232,7 +238,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",
|
||||
@@ -266,7 +280,10 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TopListEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"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()),
|
||||
@@ -324,7 +341,17 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TopListItem",
|
||||
fields=[
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"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)),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("rank", models.PositiveIntegerField()),
|
||||
("notes", models.TextField(blank=True)),
|
||||
@@ -351,10 +378,15 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TopListItemEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"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)),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("rank", models.PositiveIntegerField()),
|
||||
("notes", models.TextField(blank=True)),
|
||||
@@ -431,7 +463,10 @@ class Migration(migrations.Migration):
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
|
||||
(
|
||||
"avatar",
|
||||
models.ImageField(blank=True, upload_to="avatars/"),
|
||||
),
|
||||
("pronouns", models.CharField(blank=True, max_length=50)),
|
||||
("bio", models.TextField(blank=True, max_length=500)),
|
||||
("twitter", models.URLField(blank=True)),
|
||||
@@ -490,7 +525,7 @@ class Migration(migrations.Migration):
|
||||
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;',
|
||||
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",
|
||||
@@ -505,7 +540,7 @@ class Migration(migrations.Migration):
|
||||
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;',
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -2,11 +2,13 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class TurnstileMixin:
|
||||
"""
|
||||
Mixin to handle Cloudflare Turnstile validation.
|
||||
Bypasses validation when DEBUG is True.
|
||||
"""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
"""
|
||||
Validate the Turnstile response token.
|
||||
@@ -14,20 +16,20 @@ class TurnstileMixin:
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
return
|
||||
|
||||
token = request.POST.get('cf-turnstile-response')
|
||||
|
||||
token = request.POST.get("cf-turnstile-response")
|
||||
if not token:
|
||||
raise ValidationError('Please complete the Turnstile challenge.')
|
||||
raise ValidationError("Please complete the Turnstile challenge.")
|
||||
|
||||
# Verify the token with Cloudflare
|
||||
data = {
|
||||
'secret': settings.TURNSTILE_SECRET_KEY,
|
||||
'response': token,
|
||||
'remoteip': request.META.get('REMOTE_ADDR'),
|
||||
"secret": settings.TURNSTILE_SECRET_KEY,
|
||||
"response": token,
|
||||
"remoteip": request.META.get("REMOTE_ADDR"),
|
||||
}
|
||||
|
||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||
result = response.json()
|
||||
|
||||
if not result.get('success'):
|
||||
raise ValidationError('Turnstile validation failed. Please try again.')
|
||||
if not result.get("success"):
|
||||
raise ValidationError("Turnstile validation failed. Please try again.")
|
||||
|
||||
@@ -2,13 +2,12 @@ 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 _
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
from history_tracking.models import TrackedModel
|
||||
import pghistory
|
||||
from core.history 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"""
|
||||
@@ -17,29 +16,33 @@ def generate_random_id(model_class, id_field):
|
||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
# If all 4-digit numbers are taken, try 5 digits
|
||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = 'USER', _('User')
|
||||
MODERATOR = 'MODERATOR', _('Moderator')
|
||||
ADMIN = 'ADMIN', _('Admin')
|
||||
SUPERUSER = 'SUPERUSER', _('Superuser')
|
||||
USER = "USER", _("User")
|
||||
MODERATOR = "MODERATOR", _("Moderator")
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = 'light', _('Light')
|
||||
DARK = 'dark', _('Dark')
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this user that remains constant even if the username changes'
|
||||
help_text=(
|
||||
"Unique identifier for this user that remains constant even if the "
|
||||
"username changes"
|
||||
),
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
@@ -61,50 +64,47 @@ class User(AbstractUser):
|
||||
return self.get_display_name()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('profile', kwargs={'username': self.username})
|
||||
return reverse("profile", kwargs={"username": self.username})
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
profile = getattr(self, 'profile', None)
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, 'user_id')
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this profile that remains constant'
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
)
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile'
|
||||
)
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site"
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
)
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
|
||||
# Social media links
|
||||
twitter = models.URLField(blank=True)
|
||||
instagram = models.URLField(blank=True)
|
||||
youtube = models.URLField(blank=True)
|
||||
discord = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = models.IntegerField(default=0)
|
||||
dark_ride_credits = models.IntegerField(default=0)
|
||||
@@ -112,10 +112,13 @@ class UserProfile(models.Model):
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar(self):
|
||||
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
|
||||
"""
|
||||
Return the avatar URL or serve a pre-generated avatar based on the
|
||||
first letter of the username
|
||||
"""
|
||||
if self.avatar:
|
||||
return self.avatar.url
|
||||
first_letter = self.user.username[0].upper()
|
||||
first_letter = self.user.username.upper()
|
||||
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
|
||||
if os.path.exists(avatar_path):
|
||||
return f"/{avatar_path}"
|
||||
@@ -127,12 +130,13 @@ class UserProfile(models.Model):
|
||||
self.display_name = self.user.username
|
||||
|
||||
if not self.profile_id:
|
||||
self.profile_id = generate_random_id(UserProfile, 'profile_id')
|
||||
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
|
||||
class EmailVerification(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
@@ -146,6 +150,7 @@ class EmailVerification(models.Model):
|
||||
verbose_name = "Email Verification"
|
||||
verbose_name_plural = "Email Verifications"
|
||||
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64)
|
||||
@@ -160,53 +165,55 @@ class PasswordReset(models.Model):
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
@pghistory.track()
|
||||
|
||||
# @pghistory.track()
|
||||
|
||||
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
||||
DARK_RIDE = 'DR', _('Dark Ride')
|
||||
FLAT_RIDE = 'FR', _('Flat Ride')
|
||||
WATER_RIDE = 'WR', _('Water Ride')
|
||||
PARK = 'PK', _('Park')
|
||||
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||
DARK_RIDE = "DR", _("Dark Ride")
|
||||
FLAT_RIDE = "FR", _("Flat Ride")
|
||||
WATER_RIDE = "WR", _("Water Ride")
|
||||
PARK = "PK", _("Park")
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='top_lists' # Added related_name for User model access
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=Categories.choices
|
||||
)
|
||||
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
return (
|
||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
)
|
||||
|
||||
|
||||
# @pghistory.track()
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopListItem(TrackedModel):
|
||||
top_list = models.ForeignKey(
|
||||
TopList,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items'
|
||||
TopList, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
'contenttypes.ContentType',
|
||||
on_delete=models.CASCADE
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['rank']
|
||||
unique_together = [['top_list', 'rank']]
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["rank"]
|
||||
unique_together = [["top_list", "rank"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
|
||||
208
accounts/models_temp.py
Normal file
208
accounts/models_temp.py
Normal file
@@ -0,0 +1,208 @@
|
||||
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 os
|
||||
import secrets
|
||||
from core.history 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))
|
||||
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))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = "USER", _("User")
|
||||
MODERATOR = "MODERATOR", _("Moderator")
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text="Unique identifier for this user that remains constant even if the username changes",
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
max_length=10,
|
||||
choices=Roles.choices,
|
||||
default=Roles.USER,
|
||||
)
|
||||
is_banned = models.BooleanField(default=False)
|
||||
ban_reason = models.TextField(blank=True)
|
||||
ban_date = models.DateTimeField(null=True, blank=True)
|
||||
pending_email = models.EmailField(blank=True, null=True)
|
||||
theme_preference = models.CharField(
|
||||
max_length=5,
|
||||
choices=ThemePreference.choices,
|
||||
default=ThemePreference.LIGHT,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display_name()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("profile", kwargs={"username": self.username})
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
)
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
# Social media links
|
||||
twitter = models.URLField(blank=True)
|
||||
instagram = models.URLField(blank=True)
|
||||
youtube = models.URLField(blank=True)
|
||||
discord = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = models.IntegerField(default=0)
|
||||
dark_ride_credits = models.IntegerField(default=0)
|
||||
flat_ride_credits = models.IntegerField(default=0)
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar(self):
|
||||
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
|
||||
if self.avatar:
|
||||
return self.avatar.url
|
||||
first_letter = self.user.username[0].upper()
|
||||
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
|
||||
if os.path.exists(avatar_path):
|
||||
return f"/{avatar_path}"
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If no display name is set, use the username
|
||||
if not self.display_name:
|
||||
self.display_name = self.user.username
|
||||
|
||||
if not self.profile_id:
|
||||
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
|
||||
class EmailVerification(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_sent = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Email verification for {self.user.username}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Email Verification"
|
||||
verbose_name_plural = "Email Verifications"
|
||||
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"Password reset for {self.user.username}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||
DARK_RIDE = "DR", _("Dark Ride")
|
||||
FLAT_RIDE = "FR", _("Flat Ride")
|
||||
WATER_RIDE = "WR", _("Water Ride")
|
||||
PARK = "PK", _("Park")
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopListItem(TrackedModel):
|
||||
top_list = models.ForeignKey(
|
||||
TopList, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["rank"]
|
||||
unique_together = [["top_list", "rank"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
273
accounts/selectors.py
Normal file
273
accounts/selectors.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Selectors for user and account-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from django.db.models import QuerySet, Q, F, Count
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def user_profile_optimized(*, user_id: int) -> Any:
|
||||
"""
|
||||
Get a user with optimized queries for profile display.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
User instance with prefetched related data
|
||||
|
||||
Raises:
|
||||
User.DoesNotExist: If user doesn't exist
|
||||
"""
|
||||
return (
|
||||
User.objects.prefetch_related(
|
||||
"park_reviews", "ride_reviews", "socialaccount_set"
|
||||
)
|
||||
.annotate(
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.get(id=user_id)
|
||||
)
|
||||
|
||||
|
||||
def active_users_with_stats() -> QuerySet:
|
||||
"""
|
||||
Get active users with review statistics.
|
||||
|
||||
Returns:
|
||||
QuerySet of active users with review counts
|
||||
"""
|
||||
return (
|
||||
User.objects.filter(is_active=True)
|
||||
.annotate(
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.order_by("-total_review_count")
|
||||
)
|
||||
|
||||
|
||||
def users_with_recent_activity(*, days: int = 30) -> QuerySet:
|
||||
"""
|
||||
Get users who have been active in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to look back for activity
|
||||
|
||||
Returns:
|
||||
QuerySet of recently active users
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
return (
|
||||
User.objects.filter(
|
||||
Q(last_login__gte=cutoff_date)
|
||||
| Q(park_reviews__created_at__gte=cutoff_date)
|
||||
| Q(ride_reviews__created_at__gte=cutoff_date)
|
||||
)
|
||||
.annotate(
|
||||
recent_park_reviews=Count(
|
||||
"park_reviews",
|
||||
filter=Q(park_reviews__created_at__gte=cutoff_date),
|
||||
),
|
||||
recent_ride_reviews=Count(
|
||||
"ride_reviews",
|
||||
filter=Q(ride_reviews__created_at__gte=cutoff_date),
|
||||
),
|
||||
recent_total_reviews=F("recent_park_reviews") + F("recent_ride_reviews"),
|
||||
)
|
||||
.order_by("-last_login")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
def top_reviewers(*, limit: int = 10) -> QuerySet:
|
||||
"""
|
||||
Get top users by review count.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of users to return
|
||||
|
||||
Returns:
|
||||
QuerySet of top reviewers
|
||||
"""
|
||||
return (
|
||||
User.objects.filter(is_active=True)
|
||||
.annotate(
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.filter(total_review_count__gt=0)
|
||||
.order_by("-total_review_count")[:limit]
|
||||
)
|
||||
|
||||
|
||||
def moderator_users() -> QuerySet:
|
||||
"""
|
||||
Get users with moderation permissions.
|
||||
|
||||
Returns:
|
||||
QuerySet of users who can moderate content
|
||||
"""
|
||||
return (
|
||||
User.objects.filter(
|
||||
Q(is_staff=True)
|
||||
| Q(groups__name="Moderators")
|
||||
| Q(
|
||||
user_permissions__codename__in=[
|
||||
"change_parkreview",
|
||||
"change_ridereview",
|
||||
]
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
.order_by("username")
|
||||
)
|
||||
|
||||
|
||||
def users_by_registration_date(*, start_date, end_date) -> QuerySet:
|
||||
"""
|
||||
Get users who registered within a date range.
|
||||
|
||||
Args:
|
||||
start_date: Start of date range
|
||||
end_date: End of date range
|
||||
|
||||
Returns:
|
||||
QuerySet of users registered in the date range
|
||||
"""
|
||||
return User.objects.filter(
|
||||
date_joined__date__gte=start_date, date_joined__date__lte=end_date
|
||||
).order_by("-date_joined")
|
||||
|
||||
|
||||
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
||||
"""
|
||||
Get users matching a search query for autocomplete functionality.
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
QuerySet of matching users for autocomplete
|
||||
"""
|
||||
return User.objects.filter(
|
||||
Q(username__icontains=query)
|
||||
| Q(first_name__icontains=query)
|
||||
| Q(last_name__icontains=query),
|
||||
is_active=True,
|
||||
).order_by("username")[:limit]
|
||||
|
||||
|
||||
def users_with_social_accounts() -> QuerySet:
|
||||
"""
|
||||
Get users who have connected social accounts.
|
||||
|
||||
Returns:
|
||||
QuerySet of users with social account connections
|
||||
"""
|
||||
return (
|
||||
User.objects.filter(socialaccount__isnull=False)
|
||||
.prefetch_related("socialaccount_set")
|
||||
.distinct()
|
||||
.order_by("username")
|
||||
)
|
||||
|
||||
|
||||
def user_statistics_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
Get overall user statistics for dashboard/analytics.
|
||||
|
||||
Returns:
|
||||
Dictionary containing user statistics
|
||||
"""
|
||||
total_users = User.objects.count()
|
||||
active_users = User.objects.filter(is_active=True).count()
|
||||
staff_users = User.objects.filter(is_staff=True).count()
|
||||
|
||||
# Users with reviews
|
||||
users_with_reviews = (
|
||||
User.objects.filter(
|
||||
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
|
||||
)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
# Recent registrations (last 30 days)
|
||||
cutoff_date = timezone.now() - timedelta(days=30)
|
||||
recent_registrations = User.objects.filter(date_joined__gte=cutoff_date).count()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"inactive_users": total_users - active_users,
|
||||
"staff_users": staff_users,
|
||||
"users_with_reviews": users_with_reviews,
|
||||
"recent_registrations": recent_registrations,
|
||||
"review_participation_rate": (
|
||||
(users_with_reviews / total_users * 100) if total_users > 0 else 0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def users_needing_email_verification() -> QuerySet:
|
||||
"""
|
||||
Get users who haven't verified their email addresses.
|
||||
|
||||
Returns:
|
||||
QuerySet of users with unverified emails
|
||||
"""
|
||||
return (
|
||||
User.objects.filter(is_active=True, emailaddress__verified=False)
|
||||
.distinct()
|
||||
.order_by("date_joined")
|
||||
)
|
||||
|
||||
|
||||
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
||||
"""
|
||||
Get users who have written at least a minimum number of reviews.
|
||||
|
||||
Args:
|
||||
min_reviews: Minimum number of reviews required
|
||||
|
||||
Returns:
|
||||
QuerySet of users with sufficient review activity
|
||||
"""
|
||||
return (
|
||||
User.objects.annotate(
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.filter(total_review_count__gte=min_reviews)
|
||||
.order_by("-total_review_count")
|
||||
)
|
||||
@@ -5,7 +5,8 @@ from django.db import transaction
|
||||
from django.core.files import File
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
import requests
|
||||
from .models import User, UserProfile, EmailVerification
|
||||
from .models import User, UserProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
@@ -14,21 +15,21 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
# Create profile
|
||||
profile = UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
# If user has a social account with avatar, download it
|
||||
social_account = instance.socialaccount_set.first()
|
||||
if social_account:
|
||||
extra_data = social_account.extra_data
|
||||
avatar_url = None
|
||||
|
||||
if social_account.provider == 'google':
|
||||
avatar_url = extra_data.get('picture')
|
||||
elif social_account.provider == 'discord':
|
||||
avatar = extra_data.get('avatar')
|
||||
discord_id = extra_data.get('id')
|
||||
|
||||
if social_account.provider == "google":
|
||||
avatar_url = extra_data.get("picture")
|
||||
elif social_account.provider == "discord":
|
||||
avatar = extra_data.get("avatar")
|
||||
discord_id = extra_data.get("id")
|
||||
if avatar:
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png'
|
||||
|
||||
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
|
||||
|
||||
if avatar_url:
|
||||
try:
|
||||
response = requests.get(avatar_url, timeout=60)
|
||||
@@ -36,28 +37,34 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
|
||||
|
||||
file_name = f"avatar_{instance.username}.png"
|
||||
profile.avatar.save(
|
||||
file_name,
|
||||
File(img_temp),
|
||||
save=True
|
||||
)
|
||||
profile.avatar.save(file_name, File(img_temp), save=True)
|
||||
except Exception as e:
|
||||
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
|
||||
print(
|
||||
f"Error downloading avatar for user {
|
||||
instance.username}: {
|
||||
str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
"""Ensure UserProfile exists and is saved"""
|
||||
try:
|
||||
if not hasattr(instance, 'profile'):
|
||||
# Try to get existing profile first
|
||||
try:
|
||||
profile = instance.profile
|
||||
profile.save()
|
||||
except UserProfile.DoesNotExist:
|
||||
# Profile doesn't exist, create it
|
||||
UserProfile.objects.create(user=instance)
|
||||
instance.profile.save()
|
||||
except Exception as e:
|
||||
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=User)
|
||||
def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
"""Sync user role with Django groups"""
|
||||
@@ -72,33 +79,49 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
old_group = Group.objects.filter(name=old_instance.role).first()
|
||||
if old_group:
|
||||
instance.groups.remove(old_group)
|
||||
|
||||
|
||||
# Add to new role group
|
||||
if instance.role != User.Roles.USER:
|
||||
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
||||
instance.groups.add(new_group)
|
||||
|
||||
|
||||
# Special handling for superuser role
|
||||
if instance.role == User.Roles.SUPERUSER:
|
||||
instance.is_superuser = True
|
||||
instance.is_staff = True
|
||||
elif old_instance.role == User.Roles.SUPERUSER:
|
||||
# If removing superuser role, remove superuser status
|
||||
# If removing superuser role, remove superuser
|
||||
# status
|
||||
instance.is_superuser = False
|
||||
if instance.role not in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
if instance.role not in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
instance.is_staff = False
|
||||
|
||||
|
||||
# Handle staff status for admin and moderator roles
|
||||
if instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
if instance.role in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
instance.is_staff = True
|
||||
elif old_instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
# If removing admin/moderator role, remove staff status
|
||||
elif old_instance.role in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
# If removing admin/moderator role, remove staff
|
||||
# status
|
||||
if instance.role not in [User.Roles.SUPERUSER]:
|
||||
instance.is_staff = False
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
|
||||
print(
|
||||
f"Error syncing role with groups for user {
|
||||
instance.username}: {
|
||||
str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def create_default_groups():
|
||||
"""
|
||||
@@ -107,33 +130,47 @@ def create_default_groups():
|
||||
"""
|
||||
try:
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
# Create Moderator group
|
||||
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
||||
moderator_permissions = [
|
||||
# Review moderation permissions
|
||||
'change_review', 'delete_review',
|
||||
'change_reviewreport', 'delete_reviewreport',
|
||||
"change_review",
|
||||
"delete_review",
|
||||
"change_reviewreport",
|
||||
"delete_reviewreport",
|
||||
# Edit moderation permissions
|
||||
'change_parkedit', 'delete_parkedit',
|
||||
'change_rideedit', 'delete_rideedit',
|
||||
'change_companyedit', 'delete_companyedit',
|
||||
'change_manufactureredit', 'delete_manufactureredit',
|
||||
"change_parkedit",
|
||||
"delete_parkedit",
|
||||
"change_rideedit",
|
||||
"delete_rideedit",
|
||||
"change_companyedit",
|
||||
"delete_companyedit",
|
||||
"change_manufactureredit",
|
||||
"delete_manufactureredit",
|
||||
]
|
||||
|
||||
|
||||
# Create Admin group
|
||||
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
||||
admin_permissions = moderator_permissions + [
|
||||
# User management permissions
|
||||
'change_user', 'delete_user',
|
||||
"change_user",
|
||||
"delete_user",
|
||||
# Content management permissions
|
||||
'add_park', 'change_park', 'delete_park',
|
||||
'add_ride', 'change_ride', 'delete_ride',
|
||||
'add_company', 'change_company', 'delete_company',
|
||||
'add_manufacturer', 'change_manufacturer', 'delete_manufacturer',
|
||||
"add_park",
|
||||
"change_park",
|
||||
"delete_park",
|
||||
"add_ride",
|
||||
"change_ride",
|
||||
"delete_ride",
|
||||
"add_company",
|
||||
"change_company",
|
||||
"delete_company",
|
||||
"add_manufacturer",
|
||||
"change_manufacturer",
|
||||
"delete_manufacturer",
|
||||
]
|
||||
|
||||
|
||||
# Assign permissions to groups
|
||||
for codename in moderator_permissions:
|
||||
try:
|
||||
@@ -141,7 +178,7 @@ def create_default_groups():
|
||||
moderator_group.permissions.add(perm)
|
||||
except Permission.DoesNotExist:
|
||||
print(f"Permission not found: {codename}")
|
||||
|
||||
|
||||
for codename in admin_permissions:
|
||||
try:
|
||||
perm = Permission.objects.get(codename=codename)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.template.loader import render_to_string
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def turnstile_widget():
|
||||
"""
|
||||
@@ -13,12 +14,10 @@ def turnstile_widget():
|
||||
Usage: {% load turnstile_tags %}{% turnstile_widget %}
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
template_name = 'accounts/turnstile_widget_empty.html'
|
||||
template_name = "accounts/turnstile_widget_empty.html"
|
||||
context = {}
|
||||
else:
|
||||
template_name = 'accounts/turnstile_widget.html'
|
||||
context = {
|
||||
'site_key': settings.TURNSTILE_SITE_KEY
|
||||
}
|
||||
|
||||
template_name = "accounts/turnstile_widget.html"
|
||||
context = {"site_key": settings.TURNSTILE_SITE_KEY}
|
||||
|
||||
return render_to_string(template_name, context)
|
||||
|
||||
@@ -1,3 +1,126 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from unittest.mock import patch, MagicMock
|
||||
from .models import User, UserProfile
|
||||
from .signals import create_default_groups
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
class SignalsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser",
|
||||
email="testuser@example.com",
|
||||
password="password",
|
||||
)
|
||||
|
||||
def test_create_user_profile(self):
|
||||
# Refresh user from database to ensure signals have been processed
|
||||
self.user.refresh_from_db()
|
||||
|
||||
# Check if profile exists in database first
|
||||
profile_exists = UserProfile.objects.filter(user=self.user).exists()
|
||||
self.assertTrue(profile_exists, "UserProfile should be created by signals")
|
||||
|
||||
# Now safely access the profile
|
||||
profile = UserProfile.objects.get(user=self.user)
|
||||
self.assertIsInstance(profile, UserProfile)
|
||||
|
||||
# Test the reverse relationship
|
||||
self.assertTrue(hasattr(self.user, "profile"))
|
||||
# Test that we can access the profile through the user relationship
|
||||
user_profile = getattr(self.user, "profile", None)
|
||||
self.assertEqual(user_profile, profile)
|
||||
|
||||
@patch("accounts.signals.requests.get")
|
||||
def test_create_user_profile_with_social_avatar(self, mock_get):
|
||||
# Mock the response from requests.get
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = b"fake-image-content"
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Create a social account for the user (we'll skip this test since socialaccount_set requires allauth setup)
|
||||
# This test would need proper allauth configuration to work
|
||||
self.skipTest("Requires proper allauth socialaccount setup")
|
||||
|
||||
def test_save_user_profile(self):
|
||||
# Get the profile safely first
|
||||
profile = UserProfile.objects.get(user=self.user)
|
||||
profile.delete()
|
||||
|
||||
# Refresh user to clear cached profile relationship
|
||||
self.user.refresh_from_db()
|
||||
|
||||
# Check that profile no longer exists
|
||||
self.assertFalse(UserProfile.objects.filter(user=self.user).exists())
|
||||
|
||||
# Trigger save to recreate profile via signal
|
||||
self.user.save()
|
||||
|
||||
# Verify profile was recreated
|
||||
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
|
||||
new_profile = UserProfile.objects.get(user=self.user)
|
||||
self.assertIsInstance(new_profile, UserProfile)
|
||||
|
||||
def test_sync_user_role_with_groups(self):
|
||||
self.user.role = User.Roles.MODERATOR
|
||||
self.user.save()
|
||||
self.assertTrue(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
|
||||
self.assertTrue(self.user.is_staff)
|
||||
|
||||
self.user.role = User.Roles.ADMIN
|
||||
self.user.save()
|
||||
self.assertFalse(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
|
||||
self.assertTrue(self.user.groups.filter(name=User.Roles.ADMIN).exists())
|
||||
self.assertTrue(self.user.is_staff)
|
||||
|
||||
self.user.role = User.Roles.SUPERUSER
|
||||
self.user.save()
|
||||
self.assertFalse(self.user.groups.filter(name=User.Roles.ADMIN).exists())
|
||||
self.assertTrue(self.user.groups.filter(name=User.Roles.SUPERUSER).exists())
|
||||
self.assertTrue(self.user.is_superuser)
|
||||
self.assertTrue(self.user.is_staff)
|
||||
|
||||
self.user.role = User.Roles.USER
|
||||
self.user.save()
|
||||
self.assertFalse(self.user.groups.exists())
|
||||
self.assertFalse(self.user.is_superuser)
|
||||
self.assertFalse(self.user.is_staff)
|
||||
|
||||
def test_create_default_groups(self):
|
||||
# Create some permissions for testing
|
||||
content_type = ContentType.objects.get_for_model(User)
|
||||
Permission.objects.create(
|
||||
codename="change_review",
|
||||
name="Can change review",
|
||||
content_type=content_type,
|
||||
)
|
||||
Permission.objects.create(
|
||||
codename="delete_review",
|
||||
name="Can delete review",
|
||||
content_type=content_type,
|
||||
)
|
||||
Permission.objects.create(
|
||||
codename="change_user",
|
||||
name="Can change user",
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
create_default_groups()
|
||||
|
||||
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
||||
self.assertIsNotNone(moderator_group)
|
||||
self.assertTrue(
|
||||
moderator_group.permissions.filter(codename="change_review").exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
moderator_group.permissions.filter(codename="change_user").exists()
|
||||
)
|
||||
|
||||
admin_group = Group.objects.get(name=User.Roles.ADMIN)
|
||||
self.assertIsNotNone(admin_group)
|
||||
self.assertTrue(
|
||||
admin_group.permissions.filter(codename="change_review").exists()
|
||||
)
|
||||
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())
|
||||
|
||||
@@ -3,23 +3,46 @@ from django.contrib.auth import views as auth_views
|
||||
from allauth.account.views import LogoutView
|
||||
from . import views
|
||||
|
||||
app_name = 'accounts'
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
# Override allauth's login and signup views with our Turnstile-enabled versions
|
||||
path('login/', views.CustomLoginView.as_view(), name='account_login'),
|
||||
path('signup/', views.CustomSignupView.as_view(), name='account_signup'),
|
||||
|
||||
# Override allauth's login and signup views with our Turnstile-enabled
|
||||
# versions
|
||||
path("login/", views.CustomLoginView.as_view(), name="account_login"),
|
||||
path("signup/", views.CustomSignupView.as_view(), name="account_signup"),
|
||||
# Authentication views
|
||||
path('logout/', LogoutView.as_view(), name='logout'),
|
||||
path('password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'),
|
||||
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
|
||||
path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
|
||||
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
|
||||
path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
|
||||
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
path(
|
||||
"password_change/",
|
||||
auth_views.PasswordChangeView.as_view(),
|
||||
name="password_change",
|
||||
),
|
||||
path(
|
||||
"password_change/done/",
|
||||
auth_views.PasswordChangeDoneView.as_view(),
|
||||
name="password_change_done",
|
||||
),
|
||||
path(
|
||||
"password_reset/",
|
||||
auth_views.PasswordResetView.as_view(),
|
||||
name="password_reset",
|
||||
),
|
||||
path(
|
||||
"password_reset/done/",
|
||||
auth_views.PasswordResetDoneView.as_view(),
|
||||
name="password_reset_done",
|
||||
),
|
||||
path(
|
||||
"reset/<uidb64>/<token>/",
|
||||
auth_views.PasswordResetConfirmView.as_view(),
|
||||
name="password_reset_confirm",
|
||||
),
|
||||
path(
|
||||
"reset/done/",
|
||||
auth_views.PasswordResetCompleteView.as_view(),
|
||||
name="password_reset_complete",
|
||||
),
|
||||
# Profile views
|
||||
path('profile/', views.user_redirect_view, name='profile_redirect'),
|
||||
path('settings/', views.SettingsView.as_view(), name='settings'),
|
||||
path("profile/", views.user_redirect_view, name="profile_redirect"),
|
||||
path("settings/", views.SettingsView.as_view(), name="settings"),
|
||||
]
|
||||
|
||||
@@ -5,39 +5,38 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.discord.views import DiscordOAuth2Adapter
|
||||
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.db.models import Prefetch, QuerySet
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.sites.requests import RequestSite
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import login
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from accounts.models import User, PasswordReset, TopList, EmailVerification, UserProfile
|
||||
from reviews.models import Review
|
||||
from accounts.models import (
|
||||
User,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
EmailVerification,
|
||||
UserProfile,
|
||||
)
|
||||
from email_service.services import EmailService
|
||||
from parks.models import ParkReview
|
||||
from rides.models import RideReview
|
||||
from allauth.account.views import LoginView, SignupView
|
||||
from .mixins import TurnstileMixin
|
||||
from typing import Dict, Any, Optional, Union, cast, TYPE_CHECKING
|
||||
from django_htmx.http import HttpResponseClientRefresh
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.sites.requests import RequestSite
|
||||
from contextlib import suppress
|
||||
import re
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.sites.requests import RequestSite
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class CustomLoginView(TurnstileMixin, LoginView):
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
@@ -45,28 +44,33 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
||||
except ValidationError as e:
|
||||
form.add_error(None, str(e))
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
response = super().form_valid(form)
|
||||
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
|
||||
return (
|
||||
HttpResponseClientRefresh()
|
||||
if getattr(self.request, "htmx", False)
|
||||
else response
|
||||
)
|
||||
|
||||
def form_invalid(self, form):
|
||||
if getattr(self.request, 'htmx', False):
|
||||
if getattr(self.request, "htmx", False):
|
||||
return render(
|
||||
self.request,
|
||||
'account/partials/login_form.html',
|
||||
self.get_context_data(form=form)
|
||||
"account/partials/login_form.html",
|
||||
self.get_context_data(form=form),
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if getattr(request, 'htmx', False):
|
||||
if getattr(request, "htmx", False):
|
||||
return render(
|
||||
request,
|
||||
'account/partials/login_modal.html',
|
||||
self.get_context_data()
|
||||
"account/partials/login_modal.html",
|
||||
self.get_context_data(),
|
||||
)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CustomSignupView(TurnstileMixin, SignupView):
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
@@ -74,308 +78,349 @@ class CustomSignupView(TurnstileMixin, SignupView):
|
||||
except ValidationError as e:
|
||||
form.add_error(None, str(e))
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
response = super().form_valid(form)
|
||||
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
|
||||
return (
|
||||
HttpResponseClientRefresh()
|
||||
if getattr(self.request, "htmx", False)
|
||||
else response
|
||||
)
|
||||
|
||||
def form_invalid(self, form):
|
||||
if getattr(self.request, 'htmx', False):
|
||||
if getattr(self.request, "htmx", False):
|
||||
return render(
|
||||
self.request,
|
||||
'account/partials/signup_modal.html',
|
||||
self.get_context_data(form=form)
|
||||
"account/partials/signup_modal.html",
|
||||
self.get_context_data(form=form),
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if getattr(request, 'htmx', False):
|
||||
if getattr(request, "htmx", False):
|
||||
return render(
|
||||
request,
|
||||
'account/partials/signup_modal.html',
|
||||
self.get_context_data()
|
||||
"account/partials/signup_modal.html",
|
||||
self.get_context_data(),
|
||||
)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
@login_required
|
||||
def user_redirect_view(request: HttpRequest) -> HttpResponse:
|
||||
user = cast(User, request.user)
|
||||
return redirect('profile', username=user.username)
|
||||
return redirect("profile", username=user.username)
|
||||
|
||||
|
||||
def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
|
||||
if sociallogin := request.session.get('socialaccount_sociallogin'):
|
||||
if sociallogin := request.session.get("socialaccount_sociallogin"):
|
||||
sociallogin.user.email = email
|
||||
sociallogin.save()
|
||||
login(request, sociallogin.user)
|
||||
del request.session['socialaccount_sociallogin']
|
||||
messages.success(request, 'Successfully logged in')
|
||||
return redirect('/')
|
||||
del request.session["socialaccount_sociallogin"]
|
||||
messages.success(request, "Successfully logged in")
|
||||
return redirect("/")
|
||||
|
||||
|
||||
def email_required(request: HttpRequest) -> HttpResponse:
|
||||
if not request.session.get('socialaccount_sociallogin'):
|
||||
messages.error(request, 'No social login in progress')
|
||||
return redirect('/')
|
||||
if not request.session.get("socialaccount_sociallogin"):
|
||||
messages.error(request, "No social login in progress")
|
||||
return redirect("/")
|
||||
|
||||
if request.method == 'POST':
|
||||
if email := request.POST.get('email'):
|
||||
if request.method == "POST":
|
||||
if email := request.POST.get("email"):
|
||||
return handle_social_login(request, email)
|
||||
messages.error(request, 'Email is required')
|
||||
return render(request, 'accounts/email_required.html', {'error': 'Email is required'})
|
||||
messages.error(request, "Email is required")
|
||||
return render(
|
||||
request,
|
||||
"accounts/email_required.html",
|
||||
{"error": "Email is required"},
|
||||
)
|
||||
|
||||
return render(request, "accounts/email_required.html")
|
||||
|
||||
return render(request, 'accounts/email_required.html')
|
||||
|
||||
class ProfileView(DetailView):
|
||||
model = User
|
||||
template_name = 'accounts/profile.html'
|
||||
context_object_name = 'profile_user'
|
||||
slug_field = 'username'
|
||||
slug_url_kwarg = 'username'
|
||||
template_name = "accounts/profile.html"
|
||||
context_object_name = "profile_user"
|
||||
slug_field = "username"
|
||||
slug_url_kwarg = "username"
|
||||
|
||||
def get_queryset(self) -> QuerySet[User]:
|
||||
return User.objects.select_related('profile')
|
||||
return User.objects.select_related("profile")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = cast(User, self.get_object())
|
||||
|
||||
context['recent_reviews'] = self._get_user_reviews(user)
|
||||
context['top_lists'] = self._get_user_top_lists(user)
|
||||
|
||||
|
||||
context["park_reviews"] = self._get_user_park_reviews(user)
|
||||
context["ride_reviews"] = self._get_user_ride_reviews(user)
|
||||
context["top_lists"] = self._get_user_top_lists(user)
|
||||
|
||||
return context
|
||||
|
||||
def _get_user_reviews(self, user: User) -> QuerySet[Review]:
|
||||
return Review.objects.filter(
|
||||
user=user,
|
||||
is_published=True
|
||||
).select_related(
|
||||
'user',
|
||||
'user__profile',
|
||||
'content_type'
|
||||
).prefetch_related(
|
||||
'content_object'
|
||||
).order_by('-created_at')[:5]
|
||||
def _get_user_park_reviews(self, user: User) -> QuerySet[ParkReview]:
|
||||
return (
|
||||
ParkReview.objects.filter(user=user, is_published=True)
|
||||
.select_related("user", "user__profile", "park")
|
||||
.order_by("-created_at")[:5]
|
||||
)
|
||||
|
||||
def _get_user_ride_reviews(self, user: User) -> QuerySet[RideReview]:
|
||||
return (
|
||||
RideReview.objects.filter(user=user, is_published=True)
|
||||
.select_related("user", "user__profile", "ride")
|
||||
.order_by("-created_at")[:5]
|
||||
)
|
||||
|
||||
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
|
||||
return TopList.objects.filter(
|
||||
user=user
|
||||
).select_related(
|
||||
'user',
|
||||
'user__profile'
|
||||
).prefetch_related(
|
||||
'items'
|
||||
).order_by('-created_at')[:5]
|
||||
return (
|
||||
TopList.objects.filter(user=user)
|
||||
.select_related("user", "user__profile")
|
||||
.prefetch_related("items")
|
||||
.order_by("-created_at")[:5]
|
||||
)
|
||||
|
||||
|
||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
template_name = 'accounts/settings.html'
|
||||
template_name = "accounts/settings.html"
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['user'] = self.request.user
|
||||
context["user"] = self.request.user
|
||||
return context
|
||||
|
||||
def _handle_profile_update(self, request: HttpRequest) -> None:
|
||||
user = cast(User, request.user)
|
||||
profile = get_object_or_404(UserProfile, user=user)
|
||||
|
||||
if display_name := request.POST.get('display_name'):
|
||||
|
||||
if display_name := request.POST.get("display_name"):
|
||||
profile.display_name = display_name
|
||||
|
||||
if 'avatar' in request.FILES:
|
||||
avatar_file = cast(UploadedFile, request.FILES['avatar'])
|
||||
|
||||
if "avatar" in request.FILES:
|
||||
avatar_file = cast(UploadedFile, request.FILES["avatar"])
|
||||
profile.avatar.save(avatar_file.name, avatar_file, save=False)
|
||||
profile.save()
|
||||
|
||||
|
||||
user.save()
|
||||
messages.success(request, 'Profile updated successfully')
|
||||
messages.success(request, "Profile updated successfully")
|
||||
|
||||
def _validate_password(self, password: str) -> bool:
|
||||
"""Validate password meets requirements."""
|
||||
return (
|
||||
len(password) >= 8 and
|
||||
bool(re.search(r'[A-Z]', password)) and
|
||||
bool(re.search(r'[a-z]', password)) and
|
||||
bool(re.search(r'[0-9]', password))
|
||||
len(password) >= 8
|
||||
and bool(re.search(r"[A-Z]", password))
|
||||
and bool(re.search(r"[a-z]", password))
|
||||
and bool(re.search(r"[0-9]", password))
|
||||
)
|
||||
|
||||
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
|
||||
def _send_password_change_confirmation(
|
||||
self, request: HttpRequest, user: User
|
||||
) -> None:
|
||||
"""Send password change confirmation email."""
|
||||
site = get_current_site(request)
|
||||
context = {
|
||||
'user': user,
|
||||
'site_name': site.name,
|
||||
"user": user,
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string('accounts/email/password_change_confirmation.html', context)
|
||||
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject='Password Changed Successfully',
|
||||
text='Your password has been changed successfully.',
|
||||
site=site,
|
||||
html=email_html
|
||||
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_change_confirmation.html", context
|
||||
)
|
||||
|
||||
def _handle_password_change(self, request: HttpRequest) -> Optional[HttpResponseRedirect]:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject="Password Changed Successfully",
|
||||
text="Your password has been changed successfully.",
|
||||
site=site,
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
def _handle_password_change(
|
||||
self, request: HttpRequest
|
||||
) -> Optional[HttpResponseRedirect]:
|
||||
user = cast(User, request.user)
|
||||
old_password = request.POST.get('old_password', '')
|
||||
new_password = request.POST.get('new_password', '')
|
||||
confirm_password = request.POST.get('confirm_password', '')
|
||||
|
||||
old_password = request.POST.get("old_password", "")
|
||||
new_password = request.POST.get("new_password", "")
|
||||
confirm_password = request.POST.get("confirm_password", "")
|
||||
|
||||
if not user.check_password(old_password):
|
||||
messages.error(request, 'Current password is incorrect')
|
||||
messages.error(request, "Current password is incorrect")
|
||||
return None
|
||||
|
||||
if new_password != confirm_password:
|
||||
messages.error(request, 'New passwords do not match')
|
||||
messages.error(request, "New passwords do not match")
|
||||
return None
|
||||
|
||||
if not self._validate_password(new_password):
|
||||
messages.error(request, 'Password must be at least 8 characters and contain uppercase, lowercase, and numbers')
|
||||
messages.error(
|
||||
request,
|
||||
"Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
|
||||
)
|
||||
return None
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
|
||||
self._send_password_change_confirmation(request, user)
|
||||
messages.success(request, 'Password changed successfully. Please check your email for confirmation.')
|
||||
return HttpResponseRedirect(reverse('account_login'))
|
||||
messages.success(
|
||||
request,
|
||||
"Password changed successfully. Please check your email for confirmation.",
|
||||
)
|
||||
return HttpResponseRedirect(reverse("account_login"))
|
||||
|
||||
def _handle_email_change(self, request: HttpRequest) -> None:
|
||||
if new_email := request.POST.get('new_email'):
|
||||
if new_email := request.POST.get("new_email"):
|
||||
self._send_email_verification(request, new_email)
|
||||
messages.success(request, 'Verification email sent to your new email address')
|
||||
messages.success(
|
||||
request, "Verification email sent to your new email address"
|
||||
)
|
||||
else:
|
||||
messages.error(request, 'New email is required')
|
||||
messages.error(request, "New email is required")
|
||||
|
||||
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
|
||||
user = cast(User, request.user)
|
||||
token = get_random_string(64)
|
||||
EmailVerification.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={'token': token}
|
||||
)
|
||||
|
||||
EmailVerification.objects.update_or_create(user=user, defaults={"token": token})
|
||||
|
||||
site = cast(Site, get_current_site(request))
|
||||
verification_url = reverse('verify_email', kwargs={'token': token})
|
||||
|
||||
verification_url = reverse("verify_email", kwargs={"token": token})
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'verification_url': verification_url,
|
||||
'site_name': site.name,
|
||||
"user": user,
|
||||
"verification_url": verification_url,
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string('accounts/email/verify_email.html', context)
|
||||
|
||||
email_html = render_to_string("accounts/email/verify_email.html", context)
|
||||
EmailService.send_email(
|
||||
to=new_email,
|
||||
subject='Verify your new email address',
|
||||
text='Click the link to verify your new email address',
|
||||
subject="Verify your new email address",
|
||||
text="Click the link to verify your new email address",
|
||||
site=site,
|
||||
html=email_html
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
|
||||
user.pending_email = new_email
|
||||
user.save()
|
||||
|
||||
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'update_profile':
|
||||
action = request.POST.get("action")
|
||||
|
||||
if action == "update_profile":
|
||||
self._handle_profile_update(request)
|
||||
elif action == 'change_password':
|
||||
elif action == "change_password":
|
||||
if response := self._handle_password_change(request):
|
||||
return response
|
||||
elif action == 'change_email':
|
||||
elif action == "change_email":
|
||||
self._handle_email_change(request)
|
||||
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
|
||||
def create_password_reset_token(user: User) -> str:
|
||||
token = get_random_string(64)
|
||||
PasswordReset.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
'token': token,
|
||||
'expires_at': timezone.now() + timedelta(hours=24)
|
||||
}
|
||||
"token": token,
|
||||
"expires_at": timezone.now() + timedelta(hours=24),
|
||||
},
|
||||
)
|
||||
return token
|
||||
|
||||
def send_password_reset_email(user: User, site: Union[Site, RequestSite], token: str) -> None:
|
||||
reset_url = reverse('password_reset_confirm', kwargs={'token': token})
|
||||
|
||||
def send_password_reset_email(
|
||||
user: User, site: Union[Site, RequestSite], token: str
|
||||
) -> None:
|
||||
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
|
||||
context = {
|
||||
'user': user,
|
||||
'reset_url': reset_url,
|
||||
'site_name': site.name,
|
||||
"user": user,
|
||||
"reset_url": reset_url,
|
||||
"site_name": site.name,
|
||||
}
|
||||
email_html = render_to_string('accounts/email/password_reset.html', context)
|
||||
|
||||
email_html = render_to_string("accounts/email/password_reset.html", context)
|
||||
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject='Reset your password',
|
||||
text='Click the link to reset your password',
|
||||
subject="Reset your password",
|
||||
text="Click the link to reset your password",
|
||||
site=site,
|
||||
html=email_html
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
def request_password_reset(request: HttpRequest) -> HttpResponse:
|
||||
if request.method != 'POST':
|
||||
return render(request, 'accounts/password_reset.html')
|
||||
|
||||
if not (email := request.POST.get('email')):
|
||||
messages.error(request, 'Email is required')
|
||||
return redirect('account_reset_password')
|
||||
|
||||
def request_password_reset(request: HttpRequest) -> HttpResponse:
|
||||
if request.method != "POST":
|
||||
return render(request, "accounts/password_reset.html")
|
||||
|
||||
if not (email := request.POST.get("email")):
|
||||
messages.error(request, "Email is required")
|
||||
return redirect("account_reset_password")
|
||||
|
||||
with suppress(User.DoesNotExist):
|
||||
user = User.objects.get(email=email)
|
||||
token = create_password_reset_token(user)
|
||||
site = get_current_site(request)
|
||||
send_password_reset_email(user, site, token)
|
||||
|
||||
messages.success(request, 'Password reset email sent')
|
||||
return redirect('account_login')
|
||||
|
||||
def handle_password_reset(request: HttpRequest, user: User, new_password: str, reset: PasswordReset, site: Union[Site, RequestSite]) -> None:
|
||||
messages.success(request, "Password reset email sent")
|
||||
return redirect("account_login")
|
||||
|
||||
|
||||
def handle_password_reset(
|
||||
request: HttpRequest,
|
||||
user: User,
|
||||
new_password: str,
|
||||
reset: PasswordReset,
|
||||
site: Union[Site, RequestSite],
|
||||
) -> None:
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
|
||||
reset.used = True
|
||||
reset.save()
|
||||
|
||||
send_password_reset_confirmation(user, site)
|
||||
messages.success(request, 'Password reset successfully')
|
||||
|
||||
def send_password_reset_confirmation(user: User, site: Union[Site, RequestSite]) -> None:
|
||||
send_password_reset_confirmation(user, site)
|
||||
messages.success(request, "Password reset successfully")
|
||||
|
||||
|
||||
def send_password_reset_confirmation(
|
||||
user: User, site: Union[Site, RequestSite]
|
||||
) -> None:
|
||||
context = {
|
||||
'user': user,
|
||||
'site_name': site.name,
|
||||
"user": user,
|
||||
"site_name": site.name,
|
||||
}
|
||||
email_html = render_to_string('accounts/email/password_reset_complete.html', context)
|
||||
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_reset_complete.html", context
|
||||
)
|
||||
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject='Password Reset Complete',
|
||||
text='Your password has been reset successfully.',
|
||||
subject="Password Reset Complete",
|
||||
text="Your password has been reset successfully.",
|
||||
site=site,
|
||||
html=email_html
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
|
||||
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
|
||||
try:
|
||||
reset = PasswordReset.objects.select_related('user').get(
|
||||
token=token,
|
||||
expires_at__gt=timezone.now(),
|
||||
used=False
|
||||
reset = PasswordReset.objects.select_related("user").get(
|
||||
token=token, expires_at__gt=timezone.now(), used=False
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
if new_password := request.POST.get('new_password'):
|
||||
|
||||
if request.method == "POST":
|
||||
if new_password := request.POST.get("new_password"):
|
||||
site = get_current_site(request)
|
||||
handle_password_reset(request, reset.user, new_password, reset, site)
|
||||
return redirect('account_login')
|
||||
|
||||
messages.error(request, 'New password is required')
|
||||
|
||||
return render(request, 'accounts/password_reset_confirm.html', {'token': token})
|
||||
|
||||
return redirect("account_login")
|
||||
|
||||
messages.error(request, "New password is required")
|
||||
|
||||
return render(request, "accounts/password_reset_confirm.html", {"token": token})
|
||||
|
||||
except PasswordReset.DoesNotExist:
|
||||
messages.error(request, 'Invalid or expired reset token')
|
||||
return redirect('account_reset_password')
|
||||
messages.error(request, "Invalid or expired reset token")
|
||||
return redirect("account_reset_password")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = 'analytics.apps.AnalyticsConfig'
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'analytics'
|
||||
@@ -1,39 +0,0 @@
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.views.generic.detail import DetailView
|
||||
from .models import PageView
|
||||
|
||||
class PageViewMiddleware(MiddlewareMixin):
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
# Only track GET requests
|
||||
if request.method != 'GET':
|
||||
return None
|
||||
|
||||
# Get view class if it exists
|
||||
view_class = getattr(view_func, 'view_class', None)
|
||||
if not view_class or not issubclass(view_class, DetailView):
|
||||
return None
|
||||
|
||||
# Get the object if it's a detail view
|
||||
try:
|
||||
view_instance = view_class()
|
||||
view_instance.request = request
|
||||
view_instance.args = view_args
|
||||
view_instance.kwargs = view_kwargs
|
||||
obj = view_instance.get_object()
|
||||
except (AttributeError, Exception):
|
||||
return None
|
||||
|
||||
# Record the page view
|
||||
try:
|
||||
PageView.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(obj.__class__),
|
||||
object_id=obj.pk,
|
||||
ip_address=request.META.get('REMOTE_ADDR', ''),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')[:512]
|
||||
)
|
||||
except Exception:
|
||||
# Fail silently to not interrupt the request
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -1,53 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PageView",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("ip_address", models.GenericIPAddressField()),
|
||||
("user_agent", models.CharField(blank=True, max_length=512)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="page_views",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["timestamp"], name="analytics_p_timesta_835321_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="analytics_p_content_73920a_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
1
backups/config/.github-pat.20250818_210101.backup
Normal file
1
backups/config/.github-pat.20250818_210101.backup
Normal file
@@ -0,0 +1 @@
|
||||
[GITHUB-TOKEN-REMOVED]
|
||||
203
backups/config/thrillwiki-automation.env.20250818_210101.backup
Normal file
203
backups/config/thrillwiki-automation.env.20250818_210101.backup
Normal file
@@ -0,0 +1,203 @@
|
||||
# ThrillWiki Automation Service Environment Configuration
|
||||
# Copy this file to thrillwiki-automation***REMOVED*** and customize for your environment
|
||||
#
|
||||
# Security Note: This file should have restricted permissions (600) as it may contain
|
||||
# sensitive information like GitHub Personal Access Tokens
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# PROJECT CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Base project directory (usually auto-detected)
|
||||
# PROJECT_DIR=/home/ubuntu/thrillwiki
|
||||
|
||||
# Service name for systemd integration
|
||||
# SERVICE_NAME=thrillwiki
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# GITHUB REPOSITORY CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# GitHub repository remote name
|
||||
# GITHUB_REPO=origin
|
||||
|
||||
# Branch to pull from
|
||||
# GITHUB_BRANCH=main
|
||||
|
||||
# GitHub Personal Access Token (PAT) - Required for private repositories
|
||||
# Generate at: https://github.com/settings/tokens
|
||||
# Required permissions: repo (Full control of private repositories)
|
||||
# GITHUB_TOKEN=ghp_your_personal_access_token_here
|
||||
|
||||
# GitHub token file location (alternative to GITHUB_TOKEN)
|
||||
# GITHUB_TOKEN_FILE=/home/ubuntu/thrillwiki/.github-pat
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# AUTOMATION TIMING CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Repository pull interval in seconds (default: 300 = 5 minutes)
|
||||
# PULL_INTERVAL=300
|
||||
|
||||
# Health check interval in seconds (default: 60 = 1 minute)
|
||||
# HEALTH_CHECK_INTERVAL=60
|
||||
|
||||
# Server startup timeout in seconds (default: 120 = 2 minutes)
|
||||
# STARTUP_TIMEOUT=120
|
||||
|
||||
# Restart delay after failure in seconds (default: 10)
|
||||
# RESTART_DELAY=10
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# LOGGING CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Log directory (default: project_dir/logs)
|
||||
# LOG_DIR=/home/ubuntu/thrillwiki/logs
|
||||
|
||||
# Log file path
|
||||
# LOG_[AWS-SECRET-REMOVED]proof-automation.log
|
||||
|
||||
# Maximum log file size in bytes (default: 10485760 = 10MB)
|
||||
# MAX_LOG_SIZE=10485760
|
||||
|
||||
# Lock file location to prevent multiple instances
|
||||
# LOCK_FILE=/tmp/thrillwiki-bulletproof.lock
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# DEVELOPMENT SERVER CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Server host address (default: 0.0.0.0 for all interfaces)
|
||||
# SERVER_HOST=0.0.0.0
|
||||
|
||||
# Server port (default: 8000)
|
||||
# SERVER_PORT=8000
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# DJANGO CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Django settings module
|
||||
# DJANGO_SETTINGS_MODULE=thrillwiki.settings
|
||||
|
||||
# Python path
|
||||
# PYTHONPATH=/home/ubuntu/thrillwiki
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# ADVANCED CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# GitHub authentication script location
|
||||
# GITHUB_AUTH_[AWS-SECRET-REMOVED]ithub-auth.py
|
||||
|
||||
# Enable verbose logging (true/false)
|
||||
# VERBOSE_LOGGING=false
|
||||
|
||||
# Enable debug mode for troubleshooting (true/false)
|
||||
# DEBUG_MODE=false
|
||||
|
||||
# Custom git remote URL (overrides GITHUB_REPO if set)
|
||||
# CUSTOM_GIT_REMOTE=https://github.com/username/repository.git
|
||||
|
||||
# Email notifications for critical failures (requires email configuration)
|
||||
# NOTIFICATION_EMAIL=admin@example.com
|
||||
|
||||
# Maximum consecutive failures before alerting (default: 5)
|
||||
# MAX_CONSECUTIVE_FAILURES=5
|
||||
|
||||
# Enable automatic dependency updates (true/false, default: true)
|
||||
# AUTO_UPDATE_DEPENDENCIES=true
|
||||
|
||||
# Enable automatic migrations on code changes (true/false, default: true)
|
||||
# AUTO_MIGRATE=true
|
||||
|
||||
# Enable automatic static file collection (true/false, default: true)
|
||||
# AUTO_COLLECTSTATIC=true
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# SECURITY CONFIGURATION
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# GitHub authentication method (token|ssh|https)
|
||||
# Default: token (uses GITHUB_TOKEN or GITHUB_TOKEN_FILE)
|
||||
# GITHUB_AUTH_METHOD=token
|
||||
|
||||
# SSH key path for git operations (when using ssh auth method)
|
||||
# SSH_KEY_PATH=/home/ubuntu/.ssh/***REMOVED***
|
||||
|
||||
# Git user configuration for commits
|
||||
# GIT_USER_NAME="ThrillWiki Automation"
|
||||
# GIT_USER_EMAIL="automation@thrillwiki.local"
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# MONITORING AND HEALTH CHECKS
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Health check URL to verify server is running
|
||||
# HEALTH_CHECK_URL=http://localhost:8000/health/
|
||||
|
||||
# Health check timeout in seconds
|
||||
# HEALTH_CHECK_TIMEOUT=30
|
||||
|
||||
# Enable system resource monitoring (true/false)
|
||||
# MONITOR_RESOURCES=true
|
||||
|
||||
# Memory usage threshold for warnings (in MB)
|
||||
# MEMORY_WARNING_THRESHOLD=1024
|
||||
|
||||
# CPU usage threshold for warnings (percentage)
|
||||
# CPU_WARNING_THRESHOLD=80
|
||||
|
||||
# Disk usage threshold for warnings (percentage)
|
||||
# DISK_WARNING_THRESHOLD=90
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# INTEGRATION SETTINGS
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Webhook integration (if using thrillwiki-webhook service)
|
||||
# WEBHOOK_INTEGRATION=true
|
||||
|
||||
# Slack webhook URL for notifications (optional)
|
||||
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url
|
||||
|
||||
# Discord webhook URL for notifications (optional)
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your/webhook/url
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# USAGE EXAMPLES
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# Example 1: Basic setup with GitHub PAT
|
||||
# GITHUB_TOKEN=ghp_your_token_here
|
||||
# PULL_INTERVAL=300
|
||||
# AUTO_MIGRATE=true
|
||||
|
||||
# Example 2: Enhanced monitoring setup
|
||||
# HEALTH_CHECK_INTERVAL=30
|
||||
# MONITOR_RESOURCES=true
|
||||
# NOTIFICATION_EMAIL=admin@thrillwiki.com
|
||||
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook
|
||||
|
||||
# Example 3: Development environment with frequent pulls
|
||||
# PULL_INTERVAL=60
|
||||
# DEBUG_MODE=true
|
||||
# VERBOSE_LOGGING=true
|
||||
# AUTO_UPDATE_DEPENDENCIES=true
|
||||
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
# INSTALLATION NOTES
|
||||
# [AWS-SECRET-REMOVED]====================================
|
||||
|
||||
# 1. Copy this file: cp thrillwiki-automation***REMOVED***.example thrillwiki-automation***REMOVED***
|
||||
# 2. Set secure permissions: chmod 600 thrillwiki-automation***REMOVED***
|
||||
# 3. Customize the settings above for your environment
|
||||
# 4. Enable the service: sudo systemctl enable thrillwiki-automation
|
||||
# 5. Start the service: sudo systemctl start thrillwiki-automation
|
||||
# 6. Check status: sudo systemctl status thrillwiki-automation
|
||||
# 7. View logs: sudo journalctl -u thrillwiki-automation -f
|
||||
|
||||
# For security, ensure only the ubuntu user can read this file:
|
||||
# sudo chown ubuntu:ubuntu thrillwiki-automation***REMOVED***
|
||||
# sudo chmod 600 thrillwiki-automation***REMOVED***
|
||||
1
config/__init__.py
Normal file
1
config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Configuration package for thrillwiki project
|
||||
1
config/django/__init__.py
Normal file
1
config/django/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Django settings package
|
||||
377
config/django/base.py
Normal file
377
config/django/base.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Base Django settings for thrillwiki project.
|
||||
Common settings shared across all environments.
|
||||
"""
|
||||
|
||||
import environ # type: ignore[import]
|
||||
from pathlib import Path
|
||||
|
||||
# Initialize environment variables
|
||||
env = environ.Env(
|
||||
DEBUG=(bool, False),
|
||||
SECRET_KEY=(str, ""),
|
||||
ALLOWED_HOSTS=(list, []),
|
||||
DATABASE_URL=(str, ""),
|
||||
CACHE_URL=(str, "locmem://"),
|
||||
EMAIL_URL=(str, ""),
|
||||
REDIS_URL=(str, ""),
|
||||
)
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Read environment file if it exists
|
||||
environ.Env.read_env(BASE_DIR / ".env")
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env("DEBUG")
|
||||
|
||||
# Allowed hosts
|
||||
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS", default=[]) # type: ignore[arg-type]
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.gis", # GeoDjango
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
"rest_framework", # Django REST Framework
|
||||
"drf_spectacular", # OpenAPI 3.0 documentation
|
||||
"corsheaders", # CORS headers for API
|
||||
"pghistory", # django-pghistory
|
||||
"pgtrigger", # Required by django-pghistory
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.google",
|
||||
"allauth.socialaccount.providers.discord",
|
||||
"django_cleanup",
|
||||
"django_filters",
|
||||
"django_htmx",
|
||||
"whitenoise",
|
||||
"django_tailwind_cli",
|
||||
"autocomplete", # Django HTMX Autocomplete
|
||||
"health_check", # Health checks
|
||||
"health_check.db",
|
||||
"health_check.cache",
|
||||
"health_check.storage",
|
||||
"health_check.contrib.migrations",
|
||||
"health_check.contrib.redis",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"core",
|
||||
"accounts",
|
||||
"parks",
|
||||
"rides",
|
||||
"email_service",
|
||||
"media.apps.MediaConfig",
|
||||
"moderation",
|
||||
"location",
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.cache.UpdateCacheMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware", # CORS middleware for API
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
"django.middleware.cache.FetchFromCacheMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
"core.middleware.PageViewMiddleware", # Add our page view tracking
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "thrillwiki.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"moderation.context_processors.moderation_access",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": (
|
||||
"django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
||||
),
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "America/New_York"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "static/"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Authentication settings
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
|
||||
# django-allauth settings
|
||||
SITE_ID = 1
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
||||
ACCOUNT_LOGIN_METHODS = {"email", "username"}
|
||||
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
# Custom adapters
|
||||
ACCOUNT_ADAPTER = "accounts.adapters.CustomAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "accounts.adapters.CustomSocialAccountAdapter"
|
||||
|
||||
# Social account settings
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"google": {
|
||||
"SCOPE": [
|
||||
"profile",
|
||||
"email",
|
||||
],
|
||||
"AUTH_PARAMS": {"access_type": "online"},
|
||||
},
|
||||
"discord": {
|
||||
"SCOPE": ["identify", "email"],
|
||||
"OAUTH_PKCE_ENABLED": True,
|
||||
},
|
||||
}
|
||||
|
||||
# Additional social account settings
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||
SOCIALACCOUNT_AUTO_SIGNUP = False
|
||||
SOCIALACCOUNT_STORE_TOKENS = True
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
# Autocomplete configuration
|
||||
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
|
||||
|
||||
# Tailwind configuration
|
||||
TAILWIND_CLI_CONFIG_FILE = BASE_DIR / "tailwind.config.js"
|
||||
TAILWIND_CLI_SRC_CSS = BASE_DIR / "static/css/src/input.css"
|
||||
TAILWIND_CLI_DIST_CSS = BASE_DIR / "static/css/tailwind.css"
|
||||
|
||||
# Test runner
|
||||
TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
||||
|
||||
# Road Trip Service Settings
|
||||
ROADTRIP_CACHE_TIMEOUT = 3600 * 24 # 24 hours for geocoding
|
||||
ROADTRIP_ROUTE_CACHE_TIMEOUT = 3600 * 6 # 6 hours for routes
|
||||
ROADTRIP_MAX_REQUESTS_PER_SECOND = 1 # Respect OSM rate limits
|
||||
ROADTRIP_USER_AGENT = "ThrillWiki Road Trip Planner (https://thrillwiki.com)"
|
||||
ROADTRIP_REQUEST_TIMEOUT = 10 # seconds
|
||||
ROADTRIP_MAX_RETRIES = 3
|
||||
ROADTRIP_BACKOFF_FACTOR = 2
|
||||
|
||||
# Django REST Framework Settings
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
|
||||
"DEFAULT_VERSION": "v1",
|
||||
"ALLOWED_VERSIONS": ["v1"],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||
],
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"rest_framework.parsers.FormParser",
|
||||
"rest_framework.parsers.MultiPartParser",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exceptions.custom_exception_handler",
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"TEST_REQUEST_DEFAULT_FORMAT": "json",
|
||||
"NON_FIELD_ERRORS_KEY": "non_field_errors",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
# CORS Settings for API
|
||||
CORS_ALLOWED_ORIGINS = env("CORS_ALLOWED_ORIGINS", default=[]) # type: ignore[arg-type]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = env(
|
||||
"CORS_ALLOW_ALL_ORIGINS", default=False
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
# API-specific settings
|
||||
API_RATE_LIMIT_PER_MINUTE = env.int(
|
||||
"API_RATE_LIMIT_PER_MINUTE", default=60
|
||||
) # type: ignore[arg-type]
|
||||
API_RATE_LIMIT_PER_HOUR = env.int(
|
||||
"API_RATE_LIMIT_PER_HOUR", default=1000
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
# drf-spectacular settings
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "ThrillWiki API",
|
||||
"DESCRIPTION": "Comprehensive theme park and ride information API",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
"TAGS": [
|
||||
{"name": "parks", "description": "Theme park operations"},
|
||||
{"name": "rides", "description": "Ride information and management"},
|
||||
{"name": "locations", "description": "Geographic location services"},
|
||||
{"name": "accounts", "description": "User account management"},
|
||||
{"name": "media", "description": "Media and image management"},
|
||||
{"name": "moderation", "description": "Content moderation"},
|
||||
],
|
||||
"SCHEMA_PATH_PREFIX": "/api/",
|
||||
"DEFAULT_GENERATOR_CLASS": "drf_spectacular.generators.SchemaGenerator",
|
||||
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
|
||||
"SWAGGER_UI_SETTINGS": {
|
||||
"deepLinking": True,
|
||||
"persistAuthorization": True,
|
||||
"displayOperationId": False,
|
||||
"displayRequestDuration": True,
|
||||
},
|
||||
"REDOC_UI_SETTINGS": {
|
||||
"hideDownloadButton": False,
|
||||
"hideHostname": False,
|
||||
"hideLoading": False,
|
||||
"hideSchemaPattern": True,
|
||||
"scrollYOffset": 0,
|
||||
"theme": {"colors": {"primary": {"main": "#1976d2"}}},
|
||||
},
|
||||
}
|
||||
|
||||
# Health Check Configuration
|
||||
HEALTH_CHECK = {
|
||||
"DISK_USAGE_MAX": 90, # Fail if disk usage is over 90%
|
||||
"MEMORY_MIN": 100, # Fail if less than 100MB available memory
|
||||
}
|
||||
|
||||
# Custom health check backends
|
||||
HEALTH_CHECK_BACKENDS = [
|
||||
"health_check.db",
|
||||
"health_check.cache",
|
||||
"health_check.storage",
|
||||
"core.health_checks.custom_checks.CacheHealthCheck",
|
||||
"core.health_checks.custom_checks.DatabasePerformanceCheck",
|
||||
"core.health_checks.custom_checks.ApplicationHealthCheck",
|
||||
"core.health_checks.custom_checks.ExternalServiceHealthCheck",
|
||||
"core.health_checks.custom_checks.DiskSpaceHealthCheck",
|
||||
]
|
||||
|
||||
# Enhanced Cache Configuration
|
||||
DJANGO_REDIS_CACHE_BACKEND = "django_redis.cache.RedisCache"
|
||||
DJANGO_REDIS_CLIENT_CLASS = "django_redis.client.DefaultClient"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
|
||||
# type: ignore[arg-type]
|
||||
# pyright: ignore[reportArgumentType]
|
||||
# pyright: ignore[reportArgumentType]
|
||||
# type: ignore
|
||||
"LOCATION": env("REDIS_URL", default="redis://127.0.0.1:6379/1"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
|
||||
"PARSER_CLASS": "redis.connection.HiredisParser",
|
||||
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
|
||||
"CONNECTION_POOL_CLASS_KWARGS": {
|
||||
"max_connections": 50,
|
||||
"timeout": 20,
|
||||
},
|
||||
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
|
||||
"IGNORE_EXCEPTIONS": True,
|
||||
},
|
||||
"KEY_PREFIX": "thrillwiki",
|
||||
"VERSION": 1,
|
||||
},
|
||||
"sessions": {
|
||||
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
|
||||
# type: ignore[arg-type]
|
||||
# type: ignore
|
||||
"LOCATION": env("REDIS_URL", default="redis://127.0.0.1:6379/2"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
|
||||
},
|
||||
},
|
||||
"api": {
|
||||
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
|
||||
# type: ignore[arg-type]
|
||||
"LOCATION": env("REDIS_URL", default="redis://127.0.0.1:6379/3"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Use Redis for sessions
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "sessions"
|
||||
SESSION_COOKIE_AGE = 86400 # 24 hours
|
||||
|
||||
# Cache middleware settings
|
||||
CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
||||
189
config/django/local.py
Normal file
189
config/django/local.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Local development settings for thrillwiki project.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from .base import *
|
||||
from ..settings import database
|
||||
|
||||
# Import the module and use its members, e.g., email.EMAIL_HOST
|
||||
|
||||
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
|
||||
|
||||
# Import database configuration
|
||||
DATABASES = database.DATABASES
|
||||
|
||||
# Development-specific settings
|
||||
DEBUG = True
|
||||
|
||||
# For local development, allow all hosts
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
# CSRF trusted origins for local development
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost:8000",
|
||||
"http://127.0.0.1:8000",
|
||||
"https://beta.thrillwiki.com",
|
||||
]
|
||||
|
||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
||||
|
||||
# Local cache configuration
|
||||
LOC_MEM_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": LOC_MEM_CACHE_BACKEND,
|
||||
"LOCATION": "unique-snowflake",
|
||||
"TIMEOUT": 300, # 5 minutes
|
||||
"OPTIONS": {"MAX_ENTRIES": 1000},
|
||||
},
|
||||
"sessions": {
|
||||
"BACKEND": LOC_MEM_CACHE_BACKEND,
|
||||
"LOCATION": "sessions-cache",
|
||||
"TIMEOUT": 86400, # 24 hours (same as SESSION_COOKIE_AGE)
|
||||
"OPTIONS": {"MAX_ENTRIES": 5000},
|
||||
},
|
||||
"api": {
|
||||
"BACKEND": LOC_MEM_CACHE_BACKEND,
|
||||
"LOCATION": "api-cache",
|
||||
"TIMEOUT": 300, # 5 minutes
|
||||
"OPTIONS": {"MAX_ENTRIES": 2000},
|
||||
},
|
||||
}
|
||||
|
||||
# Development-friendly cache settings
|
||||
CACHE_MIDDLEWARE_SECONDS = 1 # Very short cache for development
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki_dev"
|
||||
|
||||
# Development email backend
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# Security settings for development
|
||||
SECURE_SSL_REDIRECT = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_SECURE = False
|
||||
|
||||
# Development monitoring tools
|
||||
DEVELOPMENT_APPS = [
|
||||
"silk",
|
||||
"debug_toolbar",
|
||||
"nplusone.ext.django",
|
||||
"django_extensions",
|
||||
]
|
||||
|
||||
# Add development apps if available
|
||||
for app in DEVELOPMENT_APPS:
|
||||
if app not in INSTALLED_APPS:
|
||||
INSTALLED_APPS.append(app)
|
||||
|
||||
# Development middleware
|
||||
DEVELOPMENT_MIDDLEWARE = [
|
||||
"silk.middleware.SilkyMiddleware",
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"nplusone.ext.django.NPlusOneMiddleware",
|
||||
"core.middleware.performance_middleware.PerformanceMiddleware",
|
||||
"core.middleware.performance_middleware.QueryCountMiddleware",
|
||||
]
|
||||
|
||||
# Add development middleware
|
||||
for middleware in DEVELOPMENT_MIDDLEWARE:
|
||||
if middleware not in MIDDLEWARE:
|
||||
MIDDLEWARE.insert(1, middleware) # Insert after security middleware
|
||||
|
||||
# Debug toolbar configuration
|
||||
INTERNAL_IPS = ["127.0.0.1", "::1"]
|
||||
|
||||
# Silk configuration for development
|
||||
# Disable profiler to avoid silk_profile installation issues
|
||||
SILKY_PYTHON_PROFILER = False
|
||||
SILKY_PYTHON_PROFILER_BINARY = False # Disable binary profiler
|
||||
SILKY_PYTHON_PROFILER_RESULT_PATH = (
|
||||
BASE_DIR / "profiles"
|
||||
) # Not needed when profiler is disabled
|
||||
SILKY_AUTHENTICATION = True # Require login to access Silk
|
||||
SILKY_AUTHORISATION = True # Enable authorization
|
||||
SILKY_MAX_REQUEST_BODY_SIZE = -1 # Don't limit request body size
|
||||
# Limit response body size to 1KB for performance
|
||||
SILKY_MAX_RESPONSE_BODY_SIZE = 1024
|
||||
SILKY_META = True # Record metadata about requests
|
||||
|
||||
# NPlusOne configuration
|
||||
NPLUSONE_LOGGER = logging.getLogger("nplusone")
|
||||
NPLUSONE_LOG_LEVEL = logging.WARN
|
||||
|
||||
# Enhanced development logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"format": (
|
||||
"%(levelname)s %(asctime)s %(module)s %(process)d "
|
||||
"%(thread)d %(message)s"
|
||||
),
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
"file": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "thrillwiki.log",
|
||||
"maxBytes": 1024 * 1024 * 10, # 10MB
|
||||
"backupCount": 5,
|
||||
"formatter": "json",
|
||||
},
|
||||
"performance": {
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": BASE_DIR / "logs" / "performance.log",
|
||||
"maxBytes": 1024 * 1024 * 10, # 10MB
|
||||
"backupCount": 5,
|
||||
"formatter": "json",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"django.db.backends": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"thrillwiki": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"performance": {
|
||||
"handlers": ["performance"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"query_optimization": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "WARNING",
|
||||
"propagate": False,
|
||||
},
|
||||
"nplusone": {
|
||||
"handlers": ["console"],
|
||||
"level": "WARNING",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
103
config/django/production.py
Normal file
103
config/django/production.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Production settings for thrillwiki project.
|
||||
"""
|
||||
|
||||
# Import the module and use its members, e.g., base.BASE_DIR, base***REMOVED***
|
||||
from . import base
|
||||
|
||||
# Import the module and use its members, e.g., database.DATABASES
|
||||
|
||||
# Import the module and use its members, e.g., email.EMAIL_HOST
|
||||
|
||||
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
|
||||
|
||||
# Import the module and use its members, e.g., email.EMAIL_HOST
|
||||
|
||||
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
|
||||
|
||||
# Production settings
|
||||
DEBUG = False
|
||||
|
||||
# Allowed hosts must be explicitly set in production
|
||||
ALLOWED_HOSTS = base.env.list("ALLOWED_HOSTS")
|
||||
|
||||
# CSRF trusted origins for production
|
||||
CSRF_TRUSTED_ORIGINS = base.env.list("CSRF_TRUSTED_ORIGINS")
|
||||
|
||||
# Security settings for production
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Production logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
"simple": {
|
||||
"format": "{levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"file": {
|
||||
"level": "INFO",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": base.BASE_DIR / "logs" / "django.log",
|
||||
"maxBytes": 1024 * 1024 * 15, # 15MB
|
||||
"backupCount": 10,
|
||||
"formatter": "verbose",
|
||||
},
|
||||
"error_file": {
|
||||
"level": "ERROR",
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": base.BASE_DIR / "logs" / "django_error.log",
|
||||
"maxBytes": 1024 * 1024 * 15, # 15MB
|
||||
"backupCount": 10,
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["file"],
|
||||
"level": "INFO",
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["file", "error_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"thrillwiki": {
|
||||
"handlers": ["file", "error_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Static files collection for production
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
# Cache settings for production (Redis recommended)
|
||||
redis_url = base.env.str("REDIS_URL", default=None)
|
||||
if redis_url:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": redis_url,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Use Redis for sessions in production
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
65
config/django/test.py
Normal file
65
config/django/test.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Test settings for thrillwiki project.
|
||||
"""
|
||||
|
||||
from .base import *
|
||||
|
||||
# Test-specific settings
|
||||
DEBUG = False
|
||||
|
||||
# Use in-memory database for faster tests
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.contrib.gis.db.backends.spatialite",
|
||||
"NAME": ":memory:",
|
||||
}
|
||||
}
|
||||
|
||||
# Use in-memory cache for tests
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "test-cache",
|
||||
}
|
||||
}
|
||||
|
||||
# Disable migrations for faster tests
|
||||
|
||||
|
||||
class DisableMigrations:
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
|
||||
def __getitem__(self, item):
|
||||
return None
|
||||
|
||||
|
||||
MIGRATION_MODULES = DisableMigrations()
|
||||
|
||||
# Email backend for tests
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
|
||||
# Password hashers for faster tests
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
|
||||
# Disable logging during tests
|
||||
LOGGING_CONFIG = None
|
||||
|
||||
# Media files for tests
|
||||
MEDIA_ROOT = BASE_DIR / "test_media"
|
||||
|
||||
# Static files for tests
|
||||
STATIC_ROOT = BASE_DIR / "test_static"
|
||||
|
||||
# Disable Turnstile for tests
|
||||
TURNSTILE_SITE_KEY = "test-key"
|
||||
TURNSTILE_SECRET_KEY = "test-secret"
|
||||
|
||||
# Test-specific middleware (remove caching middleware)
|
||||
MIDDLEWARE = [m for m in MIDDLEWARE if "cache" not in m.lower()]
|
||||
|
||||
# Celery settings for tests (if Celery is used)
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
44
config/django/test_accounts.py
Normal file
44
config/django/test_accounts.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Test Django settings for thrillwiki accounts app.
|
||||
"""
|
||||
|
||||
# Use in-memory database for tests
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.contrib.gis.db.backends.postgis",
|
||||
"NAME": "test_db",
|
||||
}
|
||||
}
|
||||
|
||||
# Use a faster password hasher for tests
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
|
||||
# Disable whitenoise for tests
|
||||
WHITENOISE_AUTOREFRESH = True
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"accounts",
|
||||
"core",
|
||||
"pghistory",
|
||||
"pgtrigger",
|
||||
"email_service",
|
||||
"parks",
|
||||
"rides",
|
||||
"media.apps.MediaConfig",
|
||||
]
|
||||
|
||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
||||
1
config/settings/__init__.py
Normal file
1
config/settings/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Settings modules package
|
||||
28
config/settings/database.py
Normal file
28
config/settings/database.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Database configuration for thrillwiki project.
|
||||
"""
|
||||
|
||||
import environ
|
||||
|
||||
env = environ.Env()
|
||||
|
||||
# Database configuration
|
||||
db_config = env.db("DATABASE_URL")
|
||||
|
||||
|
||||
# Force PostGIS backend for spatial data support
|
||||
db_config["ENGINE"] = "django.contrib.gis.db.backends.postgis"
|
||||
|
||||
DATABASES = {
|
||||
"default": db_config,
|
||||
}
|
||||
|
||||
# GeoDjango Settings - Environment specific
|
||||
GDAL_LIBRARY_PATH = env("GDAL_LIBRARY_PATH", default=None)
|
||||
GEOS_LIBRARY_PATH = env("GEOS_LIBRARY_PATH", default=None)
|
||||
|
||||
# Cache settings
|
||||
CACHES = {"default": env.cache("CACHE_URL", default="locmemcache://")}
|
||||
|
||||
CACHE_MIDDLEWARE_SECONDS = env.int("CACHE_MIDDLEWARE_SECONDS", default=300) # 5 minutes
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX = env("CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki")
|
||||
24
config/settings/email.py
Normal file
24
config/settings/email.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Email configuration for thrillwiki project.
|
||||
"""
|
||||
|
||||
import environ
|
||||
|
||||
env = environ.Env()
|
||||
|
||||
# Email settings
|
||||
EMAIL_BACKEND = env(
|
||||
"EMAIL_BACKEND", default="email_service.backends.ForwardEmailBackend"
|
||||
)
|
||||
FORWARD_EMAIL_BASE_URL = env(
|
||||
"FORWARD_EMAIL_BASE_URL", default="https://api.forwardemail.net"
|
||||
)
|
||||
SERVER_EMAIL = env("SERVER_EMAIL", default="django_webmaster@thrillwiki.com")
|
||||
|
||||
# Email URLs can be configured using EMAIL_URL environment variable
|
||||
# Example: EMAIL_URL=smtp://user:pass@localhost:587
|
||||
EMAIL_URL = env("EMAIL_URL", default=None)
|
||||
|
||||
if EMAIL_URL:
|
||||
email_config = env.email(EMAIL_URL)
|
||||
vars().update(email_config)
|
||||
36
config/settings/security.py
Normal file
36
config/settings/security.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Security configuration for thrillwiki project.
|
||||
"""
|
||||
|
||||
import environ
|
||||
|
||||
env = environ.Env()
|
||||
|
||||
# Cloudflare Turnstile settings
|
||||
TURNSTILE_SITE_KEY = env("TURNSTILE_SITE_KEY", default="")
|
||||
TURNSTILE_SECRET_KEY = env("TURNSTILE_SECRET_KEY", default="")
|
||||
TURNSTILE_VERIFY_URL = env(
|
||||
"TURNSTILE_VERIFY_URL",
|
||||
default="https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
)
|
||||
|
||||
# Security headers and settings (for production)
|
||||
SECURE_BROWSER_XSS_FILTER = env.bool("SECURE_BROWSER_XSS_FILTER", default=True)
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = env.bool("SECURE_CONTENT_TYPE_NOSNIFF", default=True)
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
|
||||
"SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True
|
||||
)
|
||||
SECURE_HSTS_SECONDS = env.int("SECURE_HSTS_SECONDS", default=31536000) # 1 year
|
||||
SECURE_REDIRECT_EXEMPT = env.list("SECURE_REDIRECT_EXEMPT", default=[])
|
||||
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=False)
|
||||
SECURE_PROXY_SSL_HEADER = env.tuple("SECURE_PROXY_SSL_HEADER", default=None)
|
||||
|
||||
# Session security
|
||||
SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=False)
|
||||
SESSION_COOKIE_HTTPONLY = env.bool("SESSION_COOKIE_HTTPONLY", default=True)
|
||||
SESSION_COOKIE_SAMESITE = env("SESSION_COOKIE_SAMESITE", default="Lax")
|
||||
|
||||
# CSRF security
|
||||
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False)
|
||||
CSRF_COOKIE_HTTPONLY = env.bool("CSRF_COOKIE_HTTPONLY", default=True)
|
||||
CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax")
|
||||
@@ -1,29 +1,26 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.html import format_html
|
||||
from .models import SlugHistory
|
||||
|
||||
|
||||
@admin.register(SlugHistory)
|
||||
class SlugHistoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['content_object_link', 'old_slug', 'created_at']
|
||||
list_filter = ['content_type', 'created_at']
|
||||
search_fields = ['old_slug', 'object_id']
|
||||
readonly_fields = ['content_type', 'object_id', 'old_slug', 'created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ['-created_at']
|
||||
list_display = ["content_object_link", "old_slug", "created_at"]
|
||||
list_filter = ["content_type", "created_at"]
|
||||
search_fields = ["old_slug", "object_id"]
|
||||
readonly_fields = ["content_type", "object_id", "old_slug", "created_at"]
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def content_object_link(self, obj):
|
||||
"""Create a link to the related object's admin page"""
|
||||
try:
|
||||
url = obj.content_object.get_absolute_url()
|
||||
return format_html(
|
||||
'<a href="{}">{}</a>',
|
||||
url,
|
||||
str(obj.content_object)
|
||||
)
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
except (AttributeError, ValueError):
|
||||
return str(obj.content_object)
|
||||
content_object_link.short_description = 'Object'
|
||||
|
||||
content_object_link.short_description = "Object"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of slug history records"""
|
||||
|
||||
@@ -3,47 +3,49 @@ from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class PageView(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='page_views')
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, related_name="page_views"
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.CharField(max_length=512, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['timestamp']),
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=["timestamp"]),
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_trending_items(cls, model_class, hours=24, limit=10):
|
||||
"""Get trending items of a specific model class based on views in last X hours.
|
||||
|
||||
|
||||
Args:
|
||||
model_class: The model class to get trending items for (e.g., Park, Ride)
|
||||
hours (int): Number of hours to look back for views (default: 24)
|
||||
limit (int): Maximum number of items to return (default: 10)
|
||||
|
||||
|
||||
Returns:
|
||||
QuerySet: The trending items ordered by view count
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(model_class)
|
||||
cutoff = timezone.now() - timezone.timedelta(hours=hours)
|
||||
|
||||
|
||||
# Query through the ContentType relationship
|
||||
item_ids = cls.objects.filter(
|
||||
content_type=content_type,
|
||||
timestamp__gte=cutoff
|
||||
).values('object_id').annotate(
|
||||
view_count=Count('id')
|
||||
).filter(
|
||||
view_count__gt=0
|
||||
).order_by('-view_count').values_list('object_id', flat=True)[:limit]
|
||||
item_ids = (
|
||||
cls.objects.filter(content_type=content_type, timestamp__gte=cutoff)
|
||||
.values("object_id")
|
||||
.annotate(view_count=Count("id"))
|
||||
.filter(view_count__gt=0)
|
||||
.order_by("-view_count")
|
||||
.values_list("object_id", flat=True)[:limit]
|
||||
)
|
||||
|
||||
# Get the actual items in the correct order
|
||||
if item_ids:
|
||||
@@ -51,7 +53,8 @@ class PageView(models.Model):
|
||||
id_list = list(item_ids)
|
||||
# Use Case/When to preserve the ordering
|
||||
from django.db.models import Case, When
|
||||
|
||||
preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(id_list)])
|
||||
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
|
||||
|
||||
|
||||
return model_class.objects.none()
|
||||
1
core/api/__init__.py
Normal file
1
core/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core API infrastructure for ThrillWiki
|
||||
205
core/api/exceptions.py
Normal file
205
core/api/exceptions.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Custom exception handling for ThrillWiki API.
|
||||
Provides standardized error responses following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.http import Http404
|
||||
from django.core.exceptions import (
|
||||
PermissionDenied,
|
||||
ValidationError as DjangoValidationError,
|
||||
)
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework.exceptions import (
|
||||
ValidationError as DRFValidationError,
|
||||
NotFound,
|
||||
PermissionDenied as DRFPermissionDenied,
|
||||
)
|
||||
|
||||
from ..exceptions import ThrillWikiException
|
||||
from ..logging import get_logger, log_exception
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def custom_exception_handler(
|
||||
exc: Exception, context: Dict[str, Any]
|
||||
) -> Optional[Response]:
|
||||
"""
|
||||
Custom exception handler for DRF that provides standardized error responses.
|
||||
|
||||
Returns:
|
||||
Response with standardized error format or None to fallback to default handler
|
||||
"""
|
||||
# Call REST framework's default exception handler first
|
||||
response = exception_handler(exc, context)
|
||||
|
||||
if response is not None:
|
||||
# Standardize the error response format
|
||||
custom_response_data = {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": _get_error_code(exc),
|
||||
"message": _get_error_message(exc, response.data),
|
||||
"details": _get_error_details(exc, response.data),
|
||||
},
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Add request context for debugging
|
||||
if hasattr(context.get("request"), "user"):
|
||||
custom_response_data["error"]["request_user"] = str(context["request"].user)
|
||||
|
||||
# Log the error for monitoring
|
||||
log_exception(
|
||||
logger,
|
||||
exc,
|
||||
context={"response_status": response.status_code},
|
||||
request=context.get("request"),
|
||||
)
|
||||
|
||||
response.data = custom_response_data
|
||||
|
||||
# Handle ThrillWiki custom exceptions
|
||||
elif isinstance(exc, ThrillWikiException):
|
||||
custom_response_data = {
|
||||
"status": "error",
|
||||
"error": exc.to_dict(),
|
||||
"data": None,
|
||||
}
|
||||
|
||||
log_exception(
|
||||
logger,
|
||||
exc,
|
||||
context={"response_status": exc.status_code},
|
||||
request=context.get("request"),
|
||||
)
|
||||
response = Response(custom_response_data, status=exc.status_code)
|
||||
|
||||
# Handle specific Django exceptions that DRF doesn't catch
|
||||
elif isinstance(exc, DjangoValidationError):
|
||||
custom_response_data = {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"details": _format_django_validation_errors(exc),
|
||||
},
|
||||
"data": None,
|
||||
}
|
||||
|
||||
log_exception(
|
||||
logger,
|
||||
exc,
|
||||
context={"response_status": status.HTTP_400_BAD_REQUEST},
|
||||
request=context.get("request"),
|
||||
)
|
||||
response = Response(custom_response_data, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
elif isinstance(exc, Http404):
|
||||
custom_response_data = {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "Resource not found",
|
||||
"details": str(exc) if str(exc) else None,
|
||||
},
|
||||
"data": None,
|
||||
}
|
||||
|
||||
log_exception(
|
||||
logger,
|
||||
exc,
|
||||
context={"response_status": status.HTTP_404_NOT_FOUND},
|
||||
request=context.get("request"),
|
||||
)
|
||||
response = Response(custom_response_data, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
elif isinstance(exc, PermissionDenied):
|
||||
custom_response_data = {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "PERMISSION_DENIED",
|
||||
"message": "Permission denied",
|
||||
"details": str(exc) if str(exc) else None,
|
||||
},
|
||||
"data": None,
|
||||
}
|
||||
|
||||
log_exception(
|
||||
logger,
|
||||
exc,
|
||||
context={"response_status": status.HTTP_403_FORBIDDEN},
|
||||
request=context.get("request"),
|
||||
)
|
||||
response = Response(custom_response_data, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _get_error_code(exc: Exception) -> str:
|
||||
"""Extract or determine error code from exception."""
|
||||
if hasattr(exc, "default_code"):
|
||||
return exc.default_code.upper()
|
||||
|
||||
if isinstance(exc, DRFValidationError):
|
||||
return "VALIDATION_ERROR"
|
||||
elif isinstance(exc, NotFound):
|
||||
return "NOT_FOUND"
|
||||
elif isinstance(exc, DRFPermissionDenied):
|
||||
return "PERMISSION_DENIED"
|
||||
|
||||
return exc.__class__.__name__.upper()
|
||||
|
||||
|
||||
def _get_error_message(exc: Exception, response_data: Any) -> str:
|
||||
"""Extract user-friendly error message."""
|
||||
if isinstance(response_data, dict):
|
||||
# Handle DRF validation errors
|
||||
if "detail" in response_data:
|
||||
return str(response_data["detail"])
|
||||
elif "non_field_errors" in response_data:
|
||||
errors = response_data["non_field_errors"]
|
||||
return errors[0] if isinstance(errors, list) and errors else str(errors)
|
||||
elif isinstance(response_data, dict) and len(response_data) == 1:
|
||||
key, value = next(iter(response_data.items()))
|
||||
if isinstance(value, list) and value:
|
||||
return f"{key}: {value[0]}"
|
||||
return f"{key}: {value}"
|
||||
|
||||
# Fallback to exception message
|
||||
return str(exc) if str(exc) else "An error occurred"
|
||||
|
||||
|
||||
def _get_error_details(exc: Exception, response_data: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Extract detailed error information for debugging."""
|
||||
if isinstance(response_data, dict) and len(response_data) > 1:
|
||||
return response_data
|
||||
|
||||
if hasattr(exc, "detail") and isinstance(exc.detail, dict):
|
||||
return exc.detail
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_django_validation_errors(
|
||||
exc: DjangoValidationError,
|
||||
) -> Dict[str, Any]:
|
||||
"""Format Django ValidationError for API response."""
|
||||
if hasattr(exc, "error_dict"):
|
||||
# Field-specific errors
|
||||
return {
|
||||
field: [str(error) for error in errors]
|
||||
for field, errors in exc.error_dict.items()
|
||||
}
|
||||
elif hasattr(exc, "error_list"):
|
||||
# Non-field errors
|
||||
return {"non_field_errors": [str(error) for error in exc.error_list]}
|
||||
|
||||
return {"non_field_errors": [str(exc)]}
|
||||
|
||||
|
||||
# Removed _log_api_error - using centralized logging instead
|
||||
260
core/api/mixins.py
Normal file
260
core/api/mixins.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Common mixins for API views following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class ApiMixin:
|
||||
"""
|
||||
Base mixin for API views providing standardized response formatting.
|
||||
"""
|
||||
|
||||
def create_response(
|
||||
self,
|
||||
*,
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
status_code: int = status.HTTP_200_OK,
|
||||
pagination: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized API response.
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
status_code: HTTP status code
|
||||
pagination: Pagination information
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
Standardized Response object
|
||||
"""
|
||||
response_data = {
|
||||
"status": "success" if status_code < 400 else "error",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
if message:
|
||||
response_data["message"] = message
|
||||
|
||||
if pagination:
|
||||
response_data["pagination"] = pagination
|
||||
|
||||
if metadata:
|
||||
response_data["metadata"] = metadata
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
def create_error_response(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
error_code: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized error response.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
status_code: HTTP status code
|
||||
error_code: Optional error code
|
||||
details: Additional error details
|
||||
|
||||
Returns:
|
||||
Standardized error Response object
|
||||
"""
|
||||
error_data = {
|
||||
"code": error_code or "GENERIC_ERROR",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if details:
|
||||
error_data["details"] = details
|
||||
|
||||
response_data = {
|
||||
"status": "error",
|
||||
"error": error_data,
|
||||
"data": None,
|
||||
}
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
class CreateApiMixin(ApiMixin):
|
||||
"""
|
||||
Mixin for create API endpoints with standardized input/output handling.
|
||||
"""
|
||||
|
||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Handle POST requests for creating resources."""
|
||||
serializer = self.get_input_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Create the object using the service layer
|
||||
obj = self.perform_create(**serializer.validated_data)
|
||||
|
||||
# Serialize the output
|
||||
output_serializer = self.get_output_serializer(obj)
|
||||
|
||||
return self.create_response(
|
||||
data=output_serializer.data,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
message="Resource created successfully",
|
||||
)
|
||||
|
||||
def perform_create(self, **validated_data):
|
||||
"""
|
||||
Override this method to implement object creation logic.
|
||||
Should use service layer methods.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement perform_create")
|
||||
|
||||
def get_input_serializer(self, *args, **kwargs):
|
||||
"""Get the input serializer for validation."""
|
||||
return self.InputSerializer(*args, **kwargs)
|
||||
|
||||
def get_output_serializer(self, *args, **kwargs):
|
||||
"""Get the output serializer for response."""
|
||||
return self.OutputSerializer(*args, **kwargs)
|
||||
|
||||
|
||||
class UpdateApiMixin(ApiMixin):
|
||||
"""
|
||||
Mixin for update API endpoints with standardized input/output handling.
|
||||
"""
|
||||
|
||||
def update(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Handle PUT/PATCH requests for updating resources."""
|
||||
instance = self.get_object()
|
||||
serializer = self.get_input_serializer(
|
||||
data=request.data, partial=kwargs.get("partial", False)
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Update the object using the service layer
|
||||
updated_obj = self.perform_update(instance, **serializer.validated_data)
|
||||
|
||||
# Serialize the output
|
||||
output_serializer = self.get_output_serializer(updated_obj)
|
||||
|
||||
return self.create_response(
|
||||
data=output_serializer.data,
|
||||
message="Resource updated successfully",
|
||||
)
|
||||
|
||||
def perform_update(self, instance, **validated_data):
|
||||
"""
|
||||
Override this method to implement object update logic.
|
||||
Should use service layer methods.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement perform_update")
|
||||
|
||||
def get_input_serializer(self, *args, **kwargs):
|
||||
"""Get the input serializer for validation."""
|
||||
return self.InputSerializer(*args, **kwargs)
|
||||
|
||||
def get_output_serializer(self, *args, **kwargs):
|
||||
"""Get the output serializer for response."""
|
||||
return self.OutputSerializer(*args, **kwargs)
|
||||
|
||||
|
||||
class ListApiMixin(ApiMixin):
|
||||
"""
|
||||
Mixin for list API endpoints with pagination and filtering.
|
||||
"""
|
||||
|
||||
def list(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Handle GET requests for listing resources."""
|
||||
# Use selector to get filtered queryset
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Apply pagination
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_output_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
# No pagination
|
||||
serializer = self.get_output_serializer(queryset, many=True)
|
||||
return self.create_response(data=serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Override this method to use selector patterns.
|
||||
Should call selector functions, not access model managers directly.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement get_queryset using selectors"
|
||||
)
|
||||
|
||||
def get_output_serializer(self, *args, **kwargs):
|
||||
"""Get the output serializer for response."""
|
||||
return self.OutputSerializer(*args, **kwargs)
|
||||
|
||||
|
||||
class RetrieveApiMixin(ApiMixin):
|
||||
"""
|
||||
Mixin for retrieve API endpoints.
|
||||
"""
|
||||
|
||||
def retrieve(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Handle GET requests for retrieving a single resource."""
|
||||
instance = self.get_object()
|
||||
serializer = self.get_output_serializer(instance)
|
||||
|
||||
return self.create_response(data=serializer.data)
|
||||
|
||||
def get_object(self):
|
||||
"""
|
||||
Override this method to use selector patterns.
|
||||
Should call selector functions for optimized queries.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement get_object using selectors"
|
||||
)
|
||||
|
||||
def get_output_serializer(self, *args, **kwargs):
|
||||
"""Get the output serializer for response."""
|
||||
return self.OutputSerializer(*args, **kwargs)
|
||||
|
||||
|
||||
class DestroyApiMixin(ApiMixin):
|
||||
"""
|
||||
Mixin for delete API endpoints.
|
||||
"""
|
||||
|
||||
def destroy(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Handle DELETE requests for destroying resources."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Delete using service layer
|
||||
self.perform_destroy(instance)
|
||||
|
||||
return self.create_response(
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
message="Resource deleted successfully",
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""
|
||||
Override this method to implement object deletion logic.
|
||||
Should use service layer methods.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement perform_destroy")
|
||||
|
||||
def get_object(self):
|
||||
"""
|
||||
Override this method to use selector patterns.
|
||||
Should call selector functions for optimized queries.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement get_object using selectors"
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
|
||||
1
core/decorators/__init__.py
Normal file
1
core/decorators/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Decorators module
|
||||
409
core/decorators/cache_decorators.py
Normal file
409
core/decorators/cache_decorators.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Advanced caching decorators for API views and functions.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Optional, List, Callable
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.vary import vary_on_headers
|
||||
from core.services.enhanced_cache_service import EnhancedCacheService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cache_api_response(
|
||||
timeout=1800, vary_on=None, key_prefix="api", cache_backend="api"
|
||||
):
|
||||
"""
|
||||
Advanced decorator for caching API responses with flexible configuration
|
||||
|
||||
Args:
|
||||
timeout: Cache timeout in seconds
|
||||
vary_on: List of request attributes to vary cache on
|
||||
key_prefix: Prefix for cache keys
|
||||
cache_backend: Cache backend to use
|
||||
"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(self, request, *args, **kwargs):
|
||||
# Only cache GET requests
|
||||
if request.method != "GET":
|
||||
return view_func(self, request, *args, **kwargs)
|
||||
|
||||
# Generate cache key based on view, user, and parameters
|
||||
cache_key_parts = [
|
||||
key_prefix,
|
||||
view_func.__name__,
|
||||
(
|
||||
str(request.user.id)
|
||||
if request.user.is_authenticated
|
||||
else "anonymous"
|
||||
),
|
||||
str(hash(frozenset(request.GET.items()))),
|
||||
]
|
||||
|
||||
# Add URL parameters to cache key
|
||||
if args:
|
||||
cache_key_parts.append(str(hash(args)))
|
||||
if kwargs:
|
||||
cache_key_parts.append(str(hash(frozenset(kwargs.items()))))
|
||||
|
||||
# Add custom vary_on fields
|
||||
if vary_on:
|
||||
for field in vary_on:
|
||||
value = getattr(request, field, "")
|
||||
cache_key_parts.append(str(value))
|
||||
|
||||
cache_key = ":".join(cache_key_parts)
|
||||
|
||||
# Try to get from cache
|
||||
cache_service = EnhancedCacheService()
|
||||
cached_response = getattr(cache_service, cache_backend + "_cache").get(
|
||||
cache_key
|
||||
)
|
||||
|
||||
if cached_response:
|
||||
logger.debug(
|
||||
f"Cache hit for API view {view_func.__name__}",
|
||||
extra={
|
||||
"cache_key": cache_key,
|
||||
"view": view_func.__name__,
|
||||
"cache_hit": True,
|
||||
},
|
||||
)
|
||||
return cached_response
|
||||
|
||||
# Execute view and cache result
|
||||
start_time = time.time()
|
||||
response = view_func(self, request, *args, **kwargs)
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Only cache successful responses
|
||||
if hasattr(response, "status_code") and response.status_code == 200:
|
||||
getattr(cache_service, cache_backend + "_cache").set(
|
||||
cache_key, response, timeout
|
||||
)
|
||||
logger.debug(
|
||||
f"Cached API response for view {view_func.__name__}",
|
||||
extra={
|
||||
"cache_key": cache_key,
|
||||
"view": view_func.__name__,
|
||||
"execution_time": execution_time,
|
||||
"cache_timeout": timeout,
|
||||
"cache_miss": True,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Not caching response for view {
|
||||
view_func.__name__} (status: {
|
||||
getattr(
|
||||
response,
|
||||
'status_code',
|
||||
'unknown')})"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cache_queryset_result(
|
||||
cache_key_template: str, timeout: int = 3600, cache_backend="default"
|
||||
):
|
||||
"""
|
||||
Decorator for caching expensive queryset operations
|
||||
|
||||
Args:
|
||||
cache_key_template: Template for cache key (can use format placeholders)
|
||||
timeout: Cache timeout in seconds
|
||||
cache_backend: Cache backend to use
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Generate cache key from template and arguments
|
||||
try:
|
||||
cache_key = cache_key_template.format(*args, **kwargs)
|
||||
except (KeyError, IndexError):
|
||||
# Fallback to simpler key generation
|
||||
cache_key = f"{cache_key_template}:{
|
||||
hash(
|
||||
str(args) +
|
||||
str(kwargs))}"
|
||||
|
||||
cache_service = EnhancedCacheService()
|
||||
cached_result = getattr(cache_service, cache_backend + "_cache").get(
|
||||
cache_key
|
||||
)
|
||||
|
||||
if cached_result is not None:
|
||||
logger.debug(
|
||||
f"Cache hit for queryset operation: {
|
||||
func.__name__}"
|
||||
)
|
||||
return cached_result
|
||||
|
||||
# Execute function and cache result
|
||||
start_time = time.time()
|
||||
result = func(*args, **kwargs)
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
getattr(cache_service, cache_backend + "_cache").set(
|
||||
cache_key, result, timeout
|
||||
)
|
||||
logger.debug(
|
||||
f"Cached queryset result for {func.__name__}",
|
||||
extra={
|
||||
"cache_key": cache_key,
|
||||
"function": func.__name__,
|
||||
"execution_time": execution_time,
|
||||
"cache_timeout": timeout,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def invalidate_cache_on_save(model_name: str, cache_patterns: List[str] = None):
|
||||
"""
|
||||
Decorator to invalidate cache when model instances are saved
|
||||
|
||||
Args:
|
||||
model_name: Name of the model
|
||||
cache_patterns: List of cache key patterns to invalidate
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
result = func(self, *args, **kwargs)
|
||||
|
||||
# Invalidate related cache entries
|
||||
cache_service = EnhancedCacheService()
|
||||
|
||||
# Standard model cache invalidation
|
||||
instance_id = getattr(self, "id", None)
|
||||
cache_service.invalidate_model_cache(model_name, instance_id)
|
||||
|
||||
# Custom pattern invalidation
|
||||
if cache_patterns:
|
||||
for pattern in cache_patterns:
|
||||
if instance_id:
|
||||
pattern = pattern.format(model=model_name, id=instance_id)
|
||||
cache_service.invalidate_pattern(pattern)
|
||||
|
||||
logger.info(
|
||||
f"Invalidated cache for {model_name} after save",
|
||||
extra={
|
||||
"model": model_name,
|
||||
"instance_id": instance_id,
|
||||
"patterns": cache_patterns,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class CachedAPIViewMixin:
|
||||
"""Mixin to add caching capabilities to API views"""
|
||||
|
||||
cache_timeout = 1800 # 30 minutes default
|
||||
cache_vary_on = ["version"]
|
||||
cache_key_prefix = "api"
|
||||
cache_backend = "api"
|
||||
|
||||
@method_decorator(vary_on_headers("User-Agent", "Accept-Language"))
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Add caching to the dispatch method"""
|
||||
if request.method == "GET" and getattr(self, "enable_caching", True):
|
||||
return self._cached_dispatch(request, *args, **kwargs)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def _cached_dispatch(self, request, *args, **kwargs):
|
||||
"""Handle cached dispatch for GET requests"""
|
||||
cache_key = self._generate_cache_key(request, *args, **kwargs)
|
||||
|
||||
cache_service = EnhancedCacheService()
|
||||
cached_response = getattr(cache_service, self.cache_backend + "_cache").get(
|
||||
cache_key
|
||||
)
|
||||
|
||||
if cached_response:
|
||||
logger.debug(f"Cache hit for view {self.__class__.__name__}")
|
||||
return cached_response
|
||||
|
||||
# Execute view
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
|
||||
# Cache successful responses
|
||||
if hasattr(response, "status_code") and response.status_code == 200:
|
||||
getattr(cache_service, self.cache_backend + "_cache").set(
|
||||
cache_key, response, self.cache_timeout
|
||||
)
|
||||
logger.debug(f"Cached response for view {self.__class__.__name__}")
|
||||
|
||||
return response
|
||||
|
||||
def _generate_cache_key(self, request, *args, **kwargs):
|
||||
"""Generate cache key for the request"""
|
||||
key_parts = [
|
||||
self.cache_key_prefix,
|
||||
self.__class__.__name__,
|
||||
request.method,
|
||||
(str(request.user.id) if request.user.is_authenticated else "anonymous"),
|
||||
str(hash(frozenset(request.GET.items()))),
|
||||
]
|
||||
|
||||
if args:
|
||||
key_parts.append(str(hash(args)))
|
||||
if kwargs:
|
||||
key_parts.append(str(hash(frozenset(kwargs.items()))))
|
||||
|
||||
# Add vary_on fields
|
||||
for field in self.cache_vary_on:
|
||||
value = getattr(request, field, "")
|
||||
key_parts.append(str(value))
|
||||
|
||||
return ":".join(key_parts)
|
||||
|
||||
|
||||
def smart_cache(
|
||||
timeout: int = 3600,
|
||||
key_func: Optional[Callable] = None,
|
||||
invalidate_on: Optional[List[str]] = None,
|
||||
cache_backend: str = "default",
|
||||
):
|
||||
"""
|
||||
Smart caching decorator that adapts to function arguments
|
||||
|
||||
Args:
|
||||
timeout: Cache timeout in seconds
|
||||
key_func: Custom function to generate cache key
|
||||
invalidate_on: List of signals to invalidate cache on
|
||||
cache_backend: Cache backend to use
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Generate cache key
|
||||
if key_func:
|
||||
cache_key = key_func(*args, **kwargs)
|
||||
else:
|
||||
# Default key generation
|
||||
key_data = {
|
||||
"func": f"{func.__module__}.{func.__name__}",
|
||||
"args": str(args),
|
||||
"kwargs": json.dumps(kwargs, sort_keys=True, default=str),
|
||||
}
|
||||
key_string = json.dumps(key_data, sort_keys=True)
|
||||
cache_key = f"smart_cache:{
|
||||
hashlib.md5(
|
||||
key_string.encode()).hexdigest()}"
|
||||
|
||||
# Try to get from cache
|
||||
cache_service = EnhancedCacheService()
|
||||
cached_result = getattr(cache_service, cache_backend + "_cache").get(
|
||||
cache_key
|
||||
)
|
||||
|
||||
if cached_result is not None:
|
||||
logger.debug(f"Smart cache hit for {func.__name__}")
|
||||
return cached_result
|
||||
|
||||
# Execute function
|
||||
start_time = time.time()
|
||||
result = func(*args, **kwargs)
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Cache result
|
||||
getattr(cache_service, cache_backend + "_cache").set(
|
||||
cache_key, result, timeout
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Smart cached result for {func.__name__}",
|
||||
extra={
|
||||
"cache_key": cache_key,
|
||||
"execution_time": execution_time,
|
||||
"function": func.__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# Add cache invalidation if specified
|
||||
if invalidate_on:
|
||||
wrapper._cache_invalidate_on = invalidate_on
|
||||
wrapper._cache_backend = cache_backend
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def conditional_cache(condition_func: Callable, **cache_kwargs):
|
||||
"""
|
||||
Cache decorator that only caches when condition is met
|
||||
|
||||
Args:
|
||||
condition_func: Function that returns True if caching should be applied
|
||||
**cache_kwargs: Arguments passed to smart_cache
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
cached_func = smart_cache(**cache_kwargs)(func)
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if condition_func(*args, **kwargs):
|
||||
return cached_func(*args, **kwargs)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Utility functions for cache key generation
|
||||
def generate_user_cache_key(user, suffix: str = ""):
|
||||
"""Generate cache key based on user"""
|
||||
user_id = user.id if user.is_authenticated else "anonymous"
|
||||
return f"user:{user_id}:{suffix}" if suffix else f"user:{user_id}"
|
||||
|
||||
|
||||
def generate_model_cache_key(model_instance, suffix: str = ""):
|
||||
"""Generate cache key based on model instance"""
|
||||
model_name = model_instance._meta.model_name
|
||||
instance_id = model_instance.id
|
||||
return (
|
||||
f"{model_name}:{instance_id}:{suffix}"
|
||||
if suffix
|
||||
else f"{model_name}:{instance_id}"
|
||||
)
|
||||
|
||||
|
||||
def generate_queryset_cache_key(queryset, params: dict = None):
|
||||
"""Generate cache key for queryset with parameters"""
|
||||
model_name = queryset.model._meta.model_name
|
||||
params_str = json.dumps(params or {}, sort_keys=True, default=str)
|
||||
params_hash = hashlib.md5(params_str.encode()).hexdigest()
|
||||
return f"queryset:{model_name}:{params_hash}"
|
||||
224
core/exceptions.py
Normal file
224
core/exceptions.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Custom exception classes for ThrillWiki.
|
||||
Provides domain-specific exceptions with proper error codes and messages.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class ThrillWikiException(Exception):
|
||||
"""Base exception for all ThrillWiki-specific errors."""
|
||||
|
||||
default_message = "An error occurred"
|
||||
error_code = "THRILLWIKI_ERROR"
|
||||
status_code = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: Optional[str] = None,
|
||||
error_code: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
self.message = message or self.default_message
|
||||
self.error_code = error_code or self.error_code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert exception to dictionary for API responses."""
|
||||
return {
|
||||
"error_code": self.error_code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class ValidationException(ThrillWikiException):
|
||||
"""Raised when data validation fails."""
|
||||
|
||||
default_message = "Validation failed"
|
||||
error_code = "VALIDATION_ERROR"
|
||||
status_code = 400
|
||||
|
||||
|
||||
class NotFoundError(ThrillWikiException):
|
||||
"""Raised when a requested resource is not found."""
|
||||
|
||||
default_message = "Resource not found"
|
||||
error_code = "NOT_FOUND"
|
||||
status_code = 404
|
||||
|
||||
|
||||
class PermissionDeniedError(ThrillWikiException):
|
||||
"""Raised when user lacks permission for an operation."""
|
||||
|
||||
default_message = "Permission denied"
|
||||
error_code = "PERMISSION_DENIED"
|
||||
status_code = 403
|
||||
|
||||
|
||||
class BusinessLogicError(ThrillWikiException):
|
||||
"""Raised when business logic constraints are violated."""
|
||||
|
||||
default_message = "Business logic violation"
|
||||
error_code = "BUSINESS_LOGIC_ERROR"
|
||||
status_code = 400
|
||||
|
||||
|
||||
class ExternalServiceError(ThrillWikiException):
|
||||
"""Raised when external service calls fail."""
|
||||
|
||||
default_message = "External service error"
|
||||
error_code = "EXTERNAL_SERVICE_ERROR"
|
||||
status_code = 502
|
||||
|
||||
|
||||
# Domain-specific exceptions
|
||||
|
||||
|
||||
class ParkError(ThrillWikiException):
|
||||
"""Base exception for park-related errors."""
|
||||
|
||||
error_code = "PARK_ERROR"
|
||||
|
||||
|
||||
class ParkNotFoundError(NotFoundError):
|
||||
"""Raised when a park is not found."""
|
||||
|
||||
default_message = "Park not found"
|
||||
error_code = "PARK_NOT_FOUND"
|
||||
|
||||
def __init__(self, park_slug: Optional[str] = None, **kwargs):
|
||||
if park_slug:
|
||||
kwargs["details"] = {"park_slug": park_slug}
|
||||
kwargs["message"] = f"Park with slug '{park_slug}' not found"
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class ParkOperationError(BusinessLogicError):
|
||||
"""Raised when park operation constraints are violated."""
|
||||
|
||||
default_message = "Invalid park operation"
|
||||
error_code = "PARK_OPERATION_ERROR"
|
||||
|
||||
|
||||
class RideError(ThrillWikiException):
|
||||
"""Base exception for ride-related errors."""
|
||||
|
||||
error_code = "RIDE_ERROR"
|
||||
|
||||
|
||||
class RideNotFoundError(NotFoundError):
|
||||
"""Raised when a ride is not found."""
|
||||
|
||||
default_message = "Ride not found"
|
||||
error_code = "RIDE_NOT_FOUND"
|
||||
|
||||
def __init__(self, ride_slug: Optional[str] = None, **kwargs):
|
||||
if ride_slug:
|
||||
kwargs["details"] = {"ride_slug": ride_slug}
|
||||
kwargs["message"] = f"Ride with slug '{ride_slug}' not found"
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class RideOperationError(BusinessLogicError):
|
||||
"""Raised when ride operation constraints are violated."""
|
||||
|
||||
default_message = "Invalid ride operation"
|
||||
error_code = "RIDE_OPERATION_ERROR"
|
||||
|
||||
|
||||
class LocationError(ThrillWikiException):
|
||||
"""Base exception for location-related errors."""
|
||||
|
||||
error_code = "LOCATION_ERROR"
|
||||
|
||||
|
||||
class InvalidCoordinatesError(ValidationException):
|
||||
"""Raised when geographic coordinates are invalid."""
|
||||
|
||||
default_message = "Invalid geographic coordinates"
|
||||
error_code = "INVALID_COORDINATES"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if latitude is not None or longitude is not None:
|
||||
kwargs["details"] = {"latitude": latitude, "longitude": longitude}
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class GeolocationError(ExternalServiceError):
|
||||
"""Raised when geolocation services fail."""
|
||||
|
||||
default_message = "Geolocation service unavailable"
|
||||
error_code = "GEOLOCATION_ERROR"
|
||||
|
||||
|
||||
class ReviewError(ThrillWikiException):
|
||||
"""Base exception for review-related errors."""
|
||||
|
||||
error_code = "REVIEW_ERROR"
|
||||
|
||||
|
||||
class ReviewModerationError(BusinessLogicError):
|
||||
"""Raised when review moderation constraints are violated."""
|
||||
|
||||
default_message = "Review moderation error"
|
||||
error_code = "REVIEW_MODERATION_ERROR"
|
||||
|
||||
|
||||
class DuplicateReviewError(BusinessLogicError):
|
||||
"""Raised when user tries to create duplicate reviews."""
|
||||
|
||||
default_message = "User has already reviewed this item"
|
||||
error_code = "DUPLICATE_REVIEW"
|
||||
|
||||
|
||||
class AccountError(ThrillWikiException):
|
||||
"""Base exception for account-related errors."""
|
||||
|
||||
error_code = "ACCOUNT_ERROR"
|
||||
|
||||
|
||||
class InsufficientPermissionsError(PermissionDeniedError):
|
||||
"""Raised when user lacks required permissions."""
|
||||
|
||||
default_message = "Insufficient permissions"
|
||||
error_code = "INSUFFICIENT_PERMISSIONS"
|
||||
|
||||
def __init__(self, required_permission: Optional[str] = None, **kwargs):
|
||||
if required_permission:
|
||||
kwargs["details"] = {"required_permission": required_permission}
|
||||
kwargs["message"] = f"Permission '{required_permission}' required"
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class EmailError(ExternalServiceError):
|
||||
"""Raised when email operations fail."""
|
||||
|
||||
default_message = "Email service error"
|
||||
error_code = "EMAIL_ERROR"
|
||||
|
||||
|
||||
class CacheError(ThrillWikiException):
|
||||
"""Raised when cache operations fail."""
|
||||
|
||||
default_message = "Cache operation failed"
|
||||
error_code = "CACHE_ERROR"
|
||||
status_code = 500
|
||||
|
||||
|
||||
class RoadTripError(ExternalServiceError):
|
||||
"""Raised when road trip planning fails."""
|
||||
|
||||
default_message = "Road trip planning error"
|
||||
error_code = "ROADTRIP_ERROR"
|
||||
|
||||
def __init__(self, service_name: Optional[str] = None, **kwargs):
|
||||
if service_name:
|
||||
kwargs["details"] = {"service": service_name}
|
||||
super().__init__(**kwargs)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Core forms and form components."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -8,20 +9,23 @@ 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.")
|
||||
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
|
||||
@@ -30,10 +34,10 @@ class BaseAutocomplete(Autocomplete):
|
||||
@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)
|
||||
block_unauth = getattr(settings, "AUTOCOMPLETE_BLOCK_UNAUTHENTICATED", True)
|
||||
if block_unauth and not request.user.is_authenticated:
|
||||
raise PermissionDenied(_("Authentication required"))
|
||||
raise PermissionDenied(_("Authentication required"))
|
||||
|
||||
168
core/forms/search.py
Normal file
168
core/forms/search.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class LocationSearchForm(forms.Form):
|
||||
"""
|
||||
A comprehensive search form that includes text search, location-based
|
||||
search, and content type filtering for a unified search experience.
|
||||
"""
|
||||
|
||||
# Text search query
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_("Search Query"),
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": _("Search parks, rides, companies..."),
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm "
|
||||
"focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 "
|
||||
"dark:border-gray-600 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Location-based search
|
||||
location = forms.CharField(
|
||||
required=False,
|
||||
label=_("Near Location"),
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": _("City, address, or coordinates..."),
|
||||
"id": "location-input",
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm "
|
||||
"focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 "
|
||||
"dark:border-gray-600 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Hidden fields for coordinates
|
||||
lat = forms.FloatField(
|
||||
required=False, widget=forms.HiddenInput(attrs={"id": "lat-input"})
|
||||
)
|
||||
lng = forms.FloatField(
|
||||
required=False, widget=forms.HiddenInput(attrs={"id": "lng-input"})
|
||||
)
|
||||
|
||||
# Search radius
|
||||
radius_km = forms.ChoiceField(
|
||||
required=False,
|
||||
label=_("Search Radius"),
|
||||
choices=[
|
||||
("", _("Any distance")),
|
||||
("5", _("5 km")),
|
||||
("10", _("10 km")),
|
||||
("25", _("25 km")),
|
||||
("50", _("50 km")),
|
||||
("100", _("100 km")),
|
||||
("200", _("200 km")),
|
||||
],
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"class": (
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm "
|
||||
"focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 "
|
||||
"dark:border-gray-600 dark:text-white"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Content type filters
|
||||
search_parks = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
label=_("Search Parks"),
|
||||
widget=forms.CheckboxInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"rounded border-gray-300 text-blue-600 focus:ring-blue-500 "
|
||||
"dark:border-gray-600 dark:bg-gray-700"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
search_rides = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Search Rides"),
|
||||
widget=forms.CheckboxInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"rounded border-gray-300 text-blue-600 focus:ring-blue-500 "
|
||||
"dark:border-gray-600 dark:bg-gray-700"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
search_companies = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Search Companies"),
|
||||
widget=forms.CheckboxInput(
|
||||
attrs={
|
||||
"class": (
|
||||
"rounded border-gray-300 text-blue-600 focus:ring-blue-500 "
|
||||
"dark:border-gray-600 dark:bg-gray-700"
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Geographic filters
|
||||
country = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": _("Country"),
|
||||
"class": (
|
||||
"w-full px-3 py-2 text-sm border border-gray-300 rounded-md "
|
||||
"shadow-sm focus:ring-blue-500 focus:border-blue-500 "
|
||||
"dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
state = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": _("State/Region"),
|
||||
"class": (
|
||||
"w-full px-3 py-2 text-sm border border-gray-300 rounded-md "
|
||||
"shadow-sm focus:ring-blue-500 focus:border-blue-500 "
|
||||
"dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
city = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"placeholder": _("City"),
|
||||
"class": (
|
||||
"w-full px-3 py-2 text-sm border border-gray-300 rounded-md "
|
||||
"shadow-sm focus:ring-blue-500 focus:border-blue-500 "
|
||||
"dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# If lat/lng are provided, ensure location field is populated for
|
||||
# display
|
||||
lat = cleaned_data.get("lat")
|
||||
lng = cleaned_data.get("lng")
|
||||
location = cleaned_data.get("location")
|
||||
|
||||
if lat and lng and not location:
|
||||
cleaned_data["location"] = f"{lat}, {lng}"
|
||||
|
||||
return cleaned_data
|
||||
1
core/health_checks/__init__.py
Normal file
1
core/health_checks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Health checks module
|
||||
325
core/health_checks/custom_checks.py
Normal file
325
core/health_checks/custom_checks.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Custom health checks for ThrillWiki application.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
from health_check.backends import BaseHealthCheckBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheHealthCheck(BaseHealthCheckBackend):
|
||||
"""Check Redis cache connectivity and performance"""
|
||||
|
||||
critical_service = True
|
||||
|
||||
def check_status(self):
|
||||
try:
|
||||
# Test cache write/read performance
|
||||
test_key = "health_check_test"
|
||||
test_value = "test_value_" + str(int(time.time()))
|
||||
|
||||
start_time = time.time()
|
||||
cache.set(test_key, test_value, timeout=30)
|
||||
cached_value = cache.get(test_key)
|
||||
cache_time = time.time() - start_time
|
||||
|
||||
if cached_value != test_value:
|
||||
self.add_error("Cache read/write test failed - values don't match")
|
||||
return
|
||||
|
||||
# Check cache performance
|
||||
if cache_time > 0.1: # Warn if cache operations take more than 100ms
|
||||
self.add_error(
|
||||
f"Cache performance degraded: {
|
||||
cache_time:.3f}s for read/write operation"
|
||||
)
|
||||
return
|
||||
|
||||
# Clean up test key
|
||||
cache.delete(test_key)
|
||||
|
||||
# Additional Redis-specific checks if using django-redis
|
||||
try:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
redis_client = get_redis_connection("default")
|
||||
info = redis_client.info()
|
||||
|
||||
# Check memory usage
|
||||
used_memory = info.get("used_memory", 0)
|
||||
max_memory = info.get("maxmemory", 0)
|
||||
|
||||
if max_memory > 0:
|
||||
memory_usage_percent = (used_memory / max_memory) * 100
|
||||
if memory_usage_percent > 90:
|
||||
self.add_error(
|
||||
f"Redis memory usage critical: {
|
||||
memory_usage_percent:.1f}%"
|
||||
)
|
||||
elif memory_usage_percent > 80:
|
||||
logger.warning(
|
||||
f"Redis memory usage high: {
|
||||
memory_usage_percent:.1f}%"
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
# django-redis not available, skip additional checks
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get Redis info: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.add_error(f"Cache service unavailable: {e}")
|
||||
|
||||
|
||||
class DatabasePerformanceCheck(BaseHealthCheckBackend):
|
||||
"""Check database performance and connectivity"""
|
||||
|
||||
critical_service = False
|
||||
|
||||
def check_status(self):
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Test basic connectivity
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result[0] != 1:
|
||||
self.add_error("Database connectivity test failed")
|
||||
return
|
||||
|
||||
basic_query_time = time.time() - start_time
|
||||
|
||||
# Test a more complex query (if it takes too long, there might be
|
||||
# performance issues)
|
||||
start_time = time.time()
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM django_content_type")
|
||||
cursor.fetchone()
|
||||
|
||||
complex_query_time = time.time() - start_time
|
||||
|
||||
# Performance thresholds
|
||||
if basic_query_time > 1.0:
|
||||
self.add_error(
|
||||
f"Database responding slowly: basic query took {
|
||||
basic_query_time:.2f}s"
|
||||
)
|
||||
elif basic_query_time > 0.5:
|
||||
logger.warning(
|
||||
f"Database performance degraded: basic query took {
|
||||
basic_query_time:.2f}s"
|
||||
)
|
||||
|
||||
if complex_query_time > 2.0:
|
||||
self.add_error(
|
||||
f"Database performance critical: complex query took {
|
||||
complex_query_time:.2f}s"
|
||||
)
|
||||
elif complex_query_time > 1.0:
|
||||
logger.warning(
|
||||
f"Database performance slow: complex query took {
|
||||
complex_query_time:.2f}s"
|
||||
)
|
||||
|
||||
# Check database version and settings if possible
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT version()")
|
||||
version = cursor.fetchone()[0]
|
||||
logger.debug(f"Database version: {version}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get database version: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.add_error(f"Database performance check failed: {e}")
|
||||
|
||||
|
||||
class ApplicationHealthCheck(BaseHealthCheckBackend):
|
||||
"""Check application-specific health indicators"""
|
||||
|
||||
critical_service = False
|
||||
|
||||
def check_status(self):
|
||||
try:
|
||||
# Check if we can import critical modules
|
||||
critical_modules = [
|
||||
"parks.models",
|
||||
"rides.models",
|
||||
"accounts.models",
|
||||
"core.services",
|
||||
]
|
||||
|
||||
for module_name in critical_modules:
|
||||
try:
|
||||
__import__(module_name)
|
||||
except ImportError as e:
|
||||
self.add_error(
|
||||
f"Critical module import failed: {module_name} - {e}"
|
||||
)
|
||||
|
||||
# Check if we can access critical models
|
||||
try:
|
||||
from parks.models import Park
|
||||
from rides.models import Ride
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Test that we can query these models (just count, don't load
|
||||
# data)
|
||||
park_count = Park.objects.count()
|
||||
ride_count = Ride.objects.count()
|
||||
user_count = User.objects.count()
|
||||
|
||||
logger.debug(
|
||||
f"Model counts - Parks: {park_count}, Rides: {ride_count}, Users: {user_count}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.add_error(f"Model access check failed: {e}")
|
||||
|
||||
# Check media and static file configuration
|
||||
from django.conf import settings
|
||||
import os
|
||||
|
||||
if not os.path.exists(settings.MEDIA_ROOT):
|
||||
self.add_error(
|
||||
f"Media directory does not exist: {
|
||||
settings.MEDIA_ROOT}"
|
||||
)
|
||||
|
||||
if not os.path.exists(settings.STATIC_ROOT) and not settings.DEBUG:
|
||||
self.add_error(
|
||||
f"Static directory does not exist: {settings.STATIC_ROOT}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.add_error(f"Application health check failed: {e}")
|
||||
|
||||
|
||||
class ExternalServiceHealthCheck(BaseHealthCheckBackend):
|
||||
"""Check external services and dependencies"""
|
||||
|
||||
critical_service = False
|
||||
|
||||
def check_status(self):
|
||||
# Check email service if configured
|
||||
try:
|
||||
from django.core.mail import get_connection
|
||||
from django.conf import settings
|
||||
|
||||
if (
|
||||
hasattr(settings, "EMAIL_BACKEND")
|
||||
and "console" not in settings.EMAIL_BACKEND
|
||||
):
|
||||
# Only check if not using console backend
|
||||
connection = get_connection()
|
||||
if hasattr(connection, "open"):
|
||||
try:
|
||||
connection.open()
|
||||
connection.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Email service check failed: {e}")
|
||||
# Don't fail the health check for email issues in
|
||||
# development
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Email service check error: {e}")
|
||||
|
||||
# Check if Sentry is configured and working
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
if sentry_sdk.Hub.current.client:
|
||||
# Sentry is configured
|
||||
try:
|
||||
# Test that we can capture a test message (this won't
|
||||
# actually send to Sentry)
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
scope.set_tag("health_check", True)
|
||||
# Don't actually send a message, just verify the SDK is
|
||||
# working
|
||||
logger.debug("Sentry SDK is operational")
|
||||
except Exception as e:
|
||||
logger.warning(f"Sentry SDK check failed: {e}")
|
||||
|
||||
except ImportError:
|
||||
logger.debug("Sentry SDK not installed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Sentry check error: {e}")
|
||||
|
||||
# Check Redis connection if configured
|
||||
try:
|
||||
from django.core.cache import caches
|
||||
from django.conf import settings
|
||||
|
||||
cache_config = settings.CACHES.get("default", {})
|
||||
if "redis" in cache_config.get("BACKEND", "").lower():
|
||||
# Redis is configured, test basic connectivity
|
||||
redis_cache = caches["default"]
|
||||
redis_cache.set("health_check_redis", "test", 10)
|
||||
value = redis_cache.get("health_check_redis")
|
||||
if value != "test":
|
||||
self.add_error("Redis cache connectivity test failed")
|
||||
else:
|
||||
redis_cache.delete("health_check_redis")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis connectivity check failed: {e}")
|
||||
|
||||
|
||||
class DiskSpaceHealthCheck(BaseHealthCheckBackend):
|
||||
"""Check available disk space"""
|
||||
|
||||
critical_service = False
|
||||
|
||||
def check_status(self):
|
||||
try:
|
||||
import shutil
|
||||
from django.conf import settings
|
||||
|
||||
# Check disk space for media directory
|
||||
media_usage = shutil.disk_usage(settings.MEDIA_ROOT)
|
||||
media_free_percent = (media_usage.free / media_usage.total) * 100
|
||||
|
||||
# Check disk space for logs directory if it exists
|
||||
logs_dir = getattr(settings, "BASE_DIR", "/tmp") / "logs"
|
||||
if logs_dir.exists():
|
||||
logs_usage = shutil.disk_usage(logs_dir)
|
||||
logs_free_percent = (logs_usage.free / logs_usage.total) * 100
|
||||
else:
|
||||
logs_free_percent = media_free_percent # Use same as media
|
||||
|
||||
# Alert thresholds
|
||||
if media_free_percent < 10:
|
||||
self.add_error(
|
||||
f"Critical disk space: {
|
||||
media_free_percent:.1f}% free in media directory"
|
||||
)
|
||||
elif media_free_percent < 20:
|
||||
logger.warning(
|
||||
f"Low disk space: {
|
||||
media_free_percent:.1f}% free in media directory"
|
||||
)
|
||||
|
||||
if logs_free_percent < 10:
|
||||
self.add_error(
|
||||
f"Critical disk space: {
|
||||
logs_free_percent:.1f}% free in logs directory"
|
||||
)
|
||||
elif logs_free_percent < 20:
|
||||
logger.warning(
|
||||
f"Low disk space: {
|
||||
logs_free_percent:.1f}% free in logs directory"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Disk space check failed: {e}")
|
||||
# Don't fail health check for disk space issues in development
|
||||
@@ -5,16 +5,22 @@ from django.conf import settings
|
||||
from typing import Any, Dict, Optional
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -25,15 +31,20 @@ class DiffMixin:
|
||||
return {}
|
||||
|
||||
skip_fields = {
|
||||
'pgh_id', 'pgh_created_at', 'pgh_label',
|
||||
'pgh_obj_id', 'pgh_context_id', '_state',
|
||||
'created_at', 'updated_at'
|
||||
"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'):
|
||||
if field.startswith("_") or field in skip_fields or field.endswith("_id"):
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -41,16 +52,18 @@ class DiffMixin:
|
||||
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"
|
||||
"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)
|
||||
|
||||
@@ -61,16 +74,18 @@ class TrackedModel(models.Model):
|
||||
"""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 event_model.objects.filter(pgh_obj_id=self.pk).order_by(
|
||||
"-pgh_created_at"
|
||||
)
|
||||
return self.__class__.objects.none()
|
||||
|
||||
|
||||
class HistoricalSlug(models.Model):
|
||||
"""Track historical slugs for models"""
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
slug = models.SlugField(max_length=255)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(
|
||||
@@ -78,14 +93,14 @@ class HistoricalSlug(models.Model):
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='historical_slugs'
|
||||
related_name="historical_slugs",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('content_type', 'slug')
|
||||
unique_together = ("content_type", "slug")
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=['slug']),
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["slug"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
261
core/logging.py
Normal file
261
core/logging.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Centralized logging configuration for ThrillWiki.
|
||||
Provides structured logging with proper formatting and context.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Dict, Any, Optional
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class ThrillWikiFormatter(logging.Formatter):
|
||||
"""Custom formatter for ThrillWiki logs with structured output."""
|
||||
|
||||
def format(self, record):
|
||||
# Add timestamp if not present
|
||||
if not hasattr(record, "timestamp"):
|
||||
record.timestamp = timezone.now().isoformat()
|
||||
|
||||
# Add request context if available
|
||||
if hasattr(record, "request"):
|
||||
record.request_id = getattr(record.request, "id", "unknown")
|
||||
record.user_id = (
|
||||
getattr(record.request.user, "id", "anonymous")
|
||||
if hasattr(record.request, "user")
|
||||
else "unknown"
|
||||
)
|
||||
record.path = getattr(record.request, "path", "unknown")
|
||||
record.method = getattr(record.request, "method", "unknown")
|
||||
|
||||
# Structure the log message
|
||||
if hasattr(record, "extra_data"):
|
||||
record.structured_data = record.extra_data
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
Get a configured logger for ThrillWiki components.
|
||||
|
||||
Args:
|
||||
name: Logger name (usually __name__)
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# Only configure if not already configured
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
formatter = ThrillWikiFormatter(
|
||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO if settings.DEBUG else logging.WARNING)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def log_exception(
|
||||
logger: logging.Logger,
|
||||
exception: Exception,
|
||||
*,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
request=None,
|
||||
level: int = logging.ERROR,
|
||||
) -> None:
|
||||
"""
|
||||
Log an exception with structured context.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
exception: Exception to log
|
||||
context: Additional context data
|
||||
request: Django request object
|
||||
level: Log level
|
||||
"""
|
||||
log_data = {
|
||||
"exception_type": exception.__class__.__name__,
|
||||
"exception_message": str(exception),
|
||||
"context": context or {},
|
||||
}
|
||||
|
||||
if request:
|
||||
log_data.update(
|
||||
{
|
||||
"request_path": getattr(request, "path", "unknown"),
|
||||
"request_method": getattr(request, "method", "unknown"),
|
||||
"user_id": (
|
||||
getattr(request.user, "id", "anonymous")
|
||||
if hasattr(request, "user")
|
||||
else "unknown"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
logger.log(
|
||||
level,
|
||||
f"Exception occurred: {exception}",
|
||||
extra={"extra_data": log_data},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def log_business_event(
|
||||
logger: logging.Logger,
|
||||
event_type: str,
|
||||
*,
|
||||
message: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
request=None,
|
||||
level: int = logging.INFO,
|
||||
) -> None:
|
||||
"""
|
||||
Log a business event with structured context.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
event_type: Type of business event
|
||||
message: Event message
|
||||
context: Additional context data
|
||||
request: Django request object
|
||||
level: Log level
|
||||
"""
|
||||
log_data = {"event_type": event_type, "context": context or {}}
|
||||
|
||||
if request:
|
||||
log_data.update(
|
||||
{
|
||||
"request_path": getattr(request, "path", "unknown"),
|
||||
"request_method": getattr(request, "method", "unknown"),
|
||||
"user_id": (
|
||||
getattr(request.user, "id", "anonymous")
|
||||
if hasattr(request, "user")
|
||||
else "unknown"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
logger.log(level, message, extra={"extra_data": log_data})
|
||||
|
||||
|
||||
def log_performance_metric(
|
||||
logger: logging.Logger,
|
||||
operation: str,
|
||||
*,
|
||||
duration_ms: float,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
level: int = logging.INFO,
|
||||
) -> None:
|
||||
"""
|
||||
Log a performance metric.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
operation: Operation name
|
||||
duration_ms: Duration in milliseconds
|
||||
context: Additional context data
|
||||
level: Log level
|
||||
"""
|
||||
log_data = {
|
||||
"metric_type": "performance",
|
||||
"operation": operation,
|
||||
"duration_ms": duration_ms,
|
||||
"context": context or {},
|
||||
}
|
||||
|
||||
message = f"Performance: {operation} took {duration_ms:.2f}ms"
|
||||
logger.log(level, message, extra={"extra_data": log_data})
|
||||
|
||||
|
||||
def log_api_request(
|
||||
logger: logging.Logger,
|
||||
request,
|
||||
*,
|
||||
response_status: Optional[int] = None,
|
||||
duration_ms: Optional[float] = None,
|
||||
level: int = logging.INFO,
|
||||
) -> None:
|
||||
"""
|
||||
Log an API request with context.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
request: Django request object
|
||||
response_status: HTTP response status code
|
||||
duration_ms: Request duration in milliseconds
|
||||
level: Log level
|
||||
"""
|
||||
log_data = {
|
||||
"request_type": "api",
|
||||
"path": getattr(request, "path", "unknown"),
|
||||
"method": getattr(request, "method", "unknown"),
|
||||
"user_id": (
|
||||
getattr(request.user, "id", "anonymous")
|
||||
if hasattr(request, "user")
|
||||
else "unknown"
|
||||
),
|
||||
"response_status": response_status,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
message = f"API Request: {request.method} {request.path}"
|
||||
if response_status:
|
||||
message += f" -> {response_status}"
|
||||
if duration_ms:
|
||||
message += f" ({duration_ms:.2f}ms)"
|
||||
|
||||
logger.log(level, message, extra={"extra_data": log_data})
|
||||
|
||||
|
||||
def log_security_event(
|
||||
logger: logging.Logger,
|
||||
event_type: str,
|
||||
*,
|
||||
message: str,
|
||||
severity: str = "medium",
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
request=None,
|
||||
) -> None:
|
||||
"""
|
||||
Log a security-related event.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
event_type: Type of security event
|
||||
message: Event message
|
||||
severity: Event severity (low, medium, high, critical)
|
||||
context: Additional context data
|
||||
request: Django request object
|
||||
"""
|
||||
log_data = {
|
||||
"security_event": True,
|
||||
"event_type": event_type,
|
||||
"severity": severity,
|
||||
"context": context or {},
|
||||
}
|
||||
|
||||
if request:
|
||||
log_data.update(
|
||||
{
|
||||
"request_path": getattr(request, "path", "unknown"),
|
||||
"request_method": getattr(request, "method", "unknown"),
|
||||
"user_id": (
|
||||
getattr(request.user, "id", "anonymous")
|
||||
if hasattr(request, "user")
|
||||
else "unknown"
|
||||
),
|
||||
"remote_addr": request.META.get("REMOTE_ADDR", "unknown"),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", "unknown"),
|
||||
}
|
||||
)
|
||||
|
||||
# Use WARNING for medium/high, ERROR for critical
|
||||
level = logging.ERROR if severity in ["high", "critical"] else logging.WARNING
|
||||
|
||||
logger.log(level, f"SECURITY: {message}", extra={"extra_data": log_data})
|
||||
@@ -2,19 +2,20 @@ from django.core.management.base import BaseCommand
|
||||
from django.core.cache import cache
|
||||
from parks.models import Park
|
||||
from rides.models import Ride
|
||||
from analytics.models import PageView
|
||||
from core.analytics import PageView
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Updates trending parks and rides cache based on views in the last 24 hours'
|
||||
help = "Updates trending parks and rides cache based on views in the last 24 hours"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
"""
|
||||
Updates the trending parks and rides in the cache.
|
||||
|
||||
|
||||
This command is designed to be run every hour via cron to keep the trending
|
||||
items up to date. It looks at page views from the last 24 hours and caches
|
||||
the top 10 most viewed parks and rides.
|
||||
|
||||
|
||||
The cached data is used by the home page to display trending items without
|
||||
having to query the database on every request.
|
||||
"""
|
||||
@@ -23,12 +24,12 @@ class Command(BaseCommand):
|
||||
trending_rides = PageView.get_trending_items(Ride, hours=24, limit=10)
|
||||
|
||||
# Cache the results for 1 hour
|
||||
cache.set('trending_parks', trending_parks, 3600) # 3600 seconds = 1 hour
|
||||
cache.set('trending_rides', trending_rides, 3600)
|
||||
cache.set("trending_parks", trending_parks, 3600) # 3600 seconds = 1 hour
|
||||
cache.set("trending_rides", trending_rides, 3600)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
'Successfully updated trending parks and rides. '
|
||||
'Cached 10 items each for parks and rides based on views in the last 24 hours.'
|
||||
"Successfully updated trending parks and rides. "
|
||||
"Cached 10 items each for parks and rides based on views in the last 24 hours."
|
||||
)
|
||||
)
|
||||
273
core/managers.py
Normal file
273
core/managers.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Custom managers and QuerySets for optimized database patterns.
|
||||
Following Django styleguide best practices for database access.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Union
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Avg, Max
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class BaseQuerySet(models.QuerySet):
|
||||
"""Base QuerySet with common optimizations and patterns."""
|
||||
|
||||
def active(self):
|
||||
"""Filter for active/enabled records."""
|
||||
if hasattr(self.model, "is_active"):
|
||||
return self.filter(is_active=True)
|
||||
return self
|
||||
|
||||
def published(self):
|
||||
"""Filter for published records."""
|
||||
if hasattr(self.model, "is_published"):
|
||||
return self.filter(is_published=True)
|
||||
return self
|
||||
|
||||
def recent(self, *, days: int = 30):
|
||||
"""Filter for recently created records."""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
return self.filter(created_at__gte=cutoff_date)
|
||||
|
||||
def search(self, *, query: str, fields: Optional[List[str]] = None):
|
||||
"""
|
||||
Full-text search across specified fields.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
fields: List of field names to search (defaults to name, description)
|
||||
"""
|
||||
if not query:
|
||||
return self
|
||||
|
||||
if fields is None:
|
||||
fields = ["name", "description"] if hasattr(self.model, "name") else []
|
||||
|
||||
q_objects = Q()
|
||||
for field in fields:
|
||||
if hasattr(self.model, field):
|
||||
q_objects |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
return self.filter(q_objects) if q_objects else self
|
||||
|
||||
def with_stats(self):
|
||||
"""Add basic statistics annotations."""
|
||||
return self
|
||||
|
||||
def optimized_for_list(self):
|
||||
"""Optimize queryset for list display."""
|
||||
return self.select_related().prefetch_related()
|
||||
|
||||
def optimized_for_detail(self):
|
||||
"""Optimize queryset for detail display."""
|
||||
return self.select_related().prefetch_related()
|
||||
|
||||
|
||||
class BaseManager(models.Manager):
|
||||
"""Base manager with common patterns."""
|
||||
|
||||
def get_queryset(self):
|
||||
return BaseQuerySet(self.model, using=self._db)
|
||||
|
||||
def active(self):
|
||||
return self.get_queryset().active()
|
||||
|
||||
def published(self):
|
||||
return self.get_queryset().published()
|
||||
|
||||
def recent(self, *, days: int = 30):
|
||||
return self.get_queryset().recent(days=days)
|
||||
|
||||
def search(self, *, query: str, fields: Optional[List[str]] = None):
|
||||
return self.get_queryset().search(query=query, fields=fields)
|
||||
|
||||
|
||||
class LocationQuerySet(BaseQuerySet):
|
||||
"""QuerySet for location-based models with geographic functionality."""
|
||||
|
||||
def near_point(self, *, point: Point, distance_km: float = 50):
|
||||
"""Filter locations near a geographic point."""
|
||||
if hasattr(self.model, "point"):
|
||||
return (
|
||||
self.filter(point__distance_lte=(point, Distance(km=distance_km)))
|
||||
.distance(point)
|
||||
.order_by("distance")
|
||||
)
|
||||
return self
|
||||
|
||||
def within_bounds(self, *, north: float, south: float, east: float, west: float):
|
||||
"""Filter locations within geographic bounds."""
|
||||
if hasattr(self.model, "point"):
|
||||
return self.filter(
|
||||
point__latitude__gte=south,
|
||||
point__latitude__lte=north,
|
||||
point__longitude__gte=west,
|
||||
point__longitude__lte=east,
|
||||
)
|
||||
return self
|
||||
|
||||
def by_country(self, *, country: str):
|
||||
"""Filter by country."""
|
||||
if hasattr(self.model, "country"):
|
||||
return self.filter(country__iexact=country)
|
||||
return self
|
||||
|
||||
def by_region(self, *, state: str):
|
||||
"""Filter by state/region."""
|
||||
if hasattr(self.model, "state"):
|
||||
return self.filter(state__iexact=state)
|
||||
return self
|
||||
|
||||
def by_city(self, *, city: str):
|
||||
"""Filter by city."""
|
||||
if hasattr(self.model, "city"):
|
||||
return self.filter(city__iexact=city)
|
||||
return self
|
||||
|
||||
|
||||
class LocationManager(BaseManager):
|
||||
"""Manager for location-based models."""
|
||||
|
||||
def get_queryset(self):
|
||||
return LocationQuerySet(self.model, using=self._db)
|
||||
|
||||
def near_point(self, *, point: Point, distance_km: float = 50):
|
||||
return self.get_queryset().near_point(point=point, distance_km=distance_km)
|
||||
|
||||
def within_bounds(self, *, north: float, south: float, east: float, west: float):
|
||||
return self.get_queryset().within_bounds(
|
||||
north=north, south=south, east=east, west=west
|
||||
)
|
||||
|
||||
|
||||
class ReviewableQuerySet(BaseQuerySet):
|
||||
"""QuerySet for models that can be reviewed."""
|
||||
|
||||
def with_review_stats(self):
|
||||
"""Add review statistics annotations."""
|
||||
return self.annotate(
|
||||
review_count=Count("reviews", filter=Q(reviews__is_published=True)),
|
||||
average_rating=Avg("reviews__rating", filter=Q(reviews__is_published=True)),
|
||||
latest_review_date=Max(
|
||||
"reviews__created_at", filter=Q(reviews__is_published=True)
|
||||
),
|
||||
)
|
||||
|
||||
def highly_rated(self, *, min_rating: float = 8.0):
|
||||
"""Filter for highly rated items."""
|
||||
return self.with_review_stats().filter(average_rating__gte=min_rating)
|
||||
|
||||
def recently_reviewed(self, *, days: int = 30):
|
||||
"""Filter for items with recent reviews."""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
return self.filter(
|
||||
reviews__created_at__gte=cutoff_date, reviews__is_published=True
|
||||
).distinct()
|
||||
|
||||
|
||||
class ReviewableManager(BaseManager):
|
||||
"""Manager for reviewable models."""
|
||||
|
||||
def get_queryset(self):
|
||||
return ReviewableQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_review_stats(self):
|
||||
return self.get_queryset().with_review_stats()
|
||||
|
||||
def highly_rated(self, *, min_rating: float = 8.0):
|
||||
return self.get_queryset().highly_rated(min_rating=min_rating)
|
||||
|
||||
|
||||
class HierarchicalQuerySet(BaseQuerySet):
|
||||
"""QuerySet for hierarchical models (with parent/child relationships)."""
|
||||
|
||||
def root_level(self):
|
||||
"""Filter for root-level items (no parent)."""
|
||||
if hasattr(self.model, "parent"):
|
||||
return self.filter(parent__isnull=True)
|
||||
return self
|
||||
|
||||
def children_of(self, *, parent_id: int):
|
||||
"""Get children of a specific parent."""
|
||||
if hasattr(self.model, "parent"):
|
||||
return self.filter(parent_id=parent_id)
|
||||
return self
|
||||
|
||||
def with_children_count(self):
|
||||
"""Add count of children."""
|
||||
if hasattr(self.model, "children"):
|
||||
return self.annotate(children_count=Count("children"))
|
||||
return self
|
||||
|
||||
|
||||
class HierarchicalManager(BaseManager):
|
||||
"""Manager for hierarchical models."""
|
||||
|
||||
def get_queryset(self):
|
||||
return HierarchicalQuerySet(self.model, using=self._db)
|
||||
|
||||
def root_level(self):
|
||||
return self.get_queryset().root_level()
|
||||
|
||||
|
||||
class TimestampedQuerySet(BaseQuerySet):
|
||||
"""QuerySet for models with created_at/updated_at timestamps."""
|
||||
|
||||
def created_between(self, *, start_date, end_date):
|
||||
"""Filter by creation date range."""
|
||||
return self.filter(created_at__date__range=[start_date, end_date])
|
||||
|
||||
def updated_since(self, *, since_date):
|
||||
"""Filter for records updated since a date."""
|
||||
return self.filter(updated_at__gte=since_date)
|
||||
|
||||
def by_creation_date(self, *, descending: bool = True):
|
||||
"""Order by creation date."""
|
||||
order = "-created_at" if descending else "created_at"
|
||||
return self.order_by(order)
|
||||
|
||||
|
||||
class TimestampedManager(BaseManager):
|
||||
"""Manager for timestamped models."""
|
||||
|
||||
def get_queryset(self):
|
||||
return TimestampedQuerySet(self.model, using=self._db)
|
||||
|
||||
def created_between(self, *, start_date, end_date):
|
||||
return self.get_queryset().created_between(
|
||||
start_date=start_date, end_date=end_date
|
||||
)
|
||||
|
||||
|
||||
class StatusQuerySet(BaseQuerySet):
|
||||
"""QuerySet for models with status fields."""
|
||||
|
||||
def with_status(self, *, status: Union[str, List[str]]):
|
||||
"""Filter by status."""
|
||||
if isinstance(status, list):
|
||||
return self.filter(status__in=status)
|
||||
return self.filter(status=status)
|
||||
|
||||
def operating(self):
|
||||
"""Filter for operating/active status."""
|
||||
return self.filter(status="OPERATING")
|
||||
|
||||
def closed(self):
|
||||
"""Filter for closed status."""
|
||||
return self.filter(status__in=["CLOSED_TEMP", "CLOSED_PERM"])
|
||||
|
||||
|
||||
class StatusManager(BaseManager):
|
||||
"""Manager for status-based models."""
|
||||
|
||||
def get_queryset(self):
|
||||
return StatusQuerySet(self.model, using=self._db)
|
||||
|
||||
def operating(self):
|
||||
return self.get_queryset().operating()
|
||||
|
||||
def closed(self):
|
||||
return self.get_queryset().closed()
|
||||
@@ -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
|
||||
22
core/middleware/__init__.py
Normal file
22
core/middleware/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Core middleware modules
|
||||
|
||||
# Import middleware classes from the analytics module
|
||||
from .analytics import PageViewMiddleware, PgHistoryContextMiddleware
|
||||
|
||||
# Import middleware classes from the performance_middleware.py module
|
||||
from .performance_middleware import (
|
||||
PerformanceMiddleware,
|
||||
QueryCountMiddleware,
|
||||
DatabaseConnectionMiddleware,
|
||||
CachePerformanceMiddleware,
|
||||
)
|
||||
|
||||
# Make all middleware classes available at the package level
|
||||
__all__ = [
|
||||
"PageViewMiddleware",
|
||||
"PgHistoryContextMiddleware",
|
||||
"PerformanceMiddleware",
|
||||
"QueryCountMiddleware",
|
||||
"DatabaseConnectionMiddleware",
|
||||
"CachePerformanceMiddleware",
|
||||
]
|
||||
84
core/middleware/analytics.py
Normal file
84
core/middleware/analytics.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Analytics and tracking middleware for Django application.
|
||||
"""
|
||||
|
||||
import pghistory
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.views.generic.detail import DetailView
|
||||
from core.analytics import PageView
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PageViewMiddleware(MiddlewareMixin):
|
||||
"""Middleware to track page views for DetailView-based pages."""
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
# Only track GET requests
|
||||
if request.method != "GET":
|
||||
return None
|
||||
|
||||
# Get view class if it exists
|
||||
view_class = getattr(view_func, "view_class", None)
|
||||
if not view_class or not issubclass(view_class, DetailView):
|
||||
return None
|
||||
|
||||
# Get the object if it's a detail view
|
||||
try:
|
||||
view_instance = view_class()
|
||||
view_instance.request = request
|
||||
view_instance.args = view_args
|
||||
view_instance.kwargs = view_kwargs
|
||||
obj = view_instance.get_object()
|
||||
except (AttributeError, Exception):
|
||||
return None
|
||||
|
||||
# Record the page view
|
||||
try:
|
||||
PageView.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(obj.__class__),
|
||||
object_id=obj.pk,
|
||||
ip_address=request.META.get("REMOTE_ADDR", ""),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", "")[:512],
|
||||
)
|
||||
except Exception:
|
||||
# Fail silently to not interrupt the request
|
||||
pass
|
||||
|
||||
return None
|
||||
317
core/middleware/performance_middleware.py
Normal file
317
core/middleware/performance_middleware.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Performance monitoring middleware for tracking request metrics.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from django.db import connection
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.conf import settings
|
||||
|
||||
performance_logger = logging.getLogger("performance")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PerformanceMiddleware(MiddlewareMixin):
|
||||
"""Middleware to collect performance metrics for each request"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Initialize performance tracking for the request"""
|
||||
request._performance_start_time = time.time()
|
||||
request._performance_initial_queries = (
|
||||
len(connection.queries) if hasattr(connection, "queries") else 0
|
||||
)
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Log performance metrics after response is ready"""
|
||||
# Skip performance tracking for certain paths
|
||||
skip_paths = [
|
||||
"/health/",
|
||||
"/admin/jsi18n/",
|
||||
"/static/",
|
||||
"/media/",
|
||||
"/__debug__/",
|
||||
]
|
||||
if any(request.path.startswith(path) for path in skip_paths):
|
||||
return response
|
||||
|
||||
# Calculate metrics
|
||||
end_time = time.time()
|
||||
start_time = getattr(request, "_performance_start_time", end_time)
|
||||
duration = end_time - start_time
|
||||
|
||||
initial_queries = getattr(request, "_performance_initial_queries", 0)
|
||||
total_queries = (
|
||||
len(connection.queries) - initial_queries
|
||||
if hasattr(connection, "queries")
|
||||
else 0
|
||||
)
|
||||
|
||||
# Get content length
|
||||
content_length = 0
|
||||
if hasattr(response, "content"):
|
||||
content_length = len(response.content)
|
||||
elif hasattr(response, "streaming_content"):
|
||||
# For streaming responses, we can't easily measure content length
|
||||
content_length = -1
|
||||
|
||||
# Build performance data
|
||||
performance_data = {
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"duration_seconds": round(duration, 3),
|
||||
"query_count": total_queries,
|
||||
"content_length_bytes": content_length,
|
||||
"user_id": (
|
||||
getattr(request.user, "id", None)
|
||||
if hasattr(request, "user") and request.user.is_authenticated
|
||||
else None
|
||||
),
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", "")[
|
||||
:100
|
||||
], # Truncate user agent
|
||||
"remote_addr": self._get_client_ip(request),
|
||||
}
|
||||
|
||||
# Add query details in debug mode
|
||||
if settings.DEBUG and hasattr(connection, "queries") and total_queries > 0:
|
||||
recent_queries = connection.queries[-total_queries:]
|
||||
performance_data["queries"] = [
|
||||
{
|
||||
"sql": (
|
||||
query["sql"][:200] + "..."
|
||||
if len(query["sql"]) > 200
|
||||
else query["sql"]
|
||||
),
|
||||
"time": float(query["time"]),
|
||||
}
|
||||
for query in recent_queries[-10:] # Last 10 queries only
|
||||
]
|
||||
|
||||
# Identify slow queries
|
||||
slow_queries = [q for q in recent_queries if float(q["time"]) > 0.1]
|
||||
if slow_queries:
|
||||
performance_data["slow_query_count"] = len(slow_queries)
|
||||
performance_data["slowest_query_time"] = max(
|
||||
float(q["time"]) for q in slow_queries
|
||||
)
|
||||
|
||||
# Determine log level based on performance
|
||||
log_level = self._get_log_level(duration, total_queries, response.status_code)
|
||||
|
||||
# Log the performance data
|
||||
performance_logger.log(
|
||||
log_level,
|
||||
f"Request performance: {request.method} {request.path} - "
|
||||
f"{duration:.3f}s, {total_queries} queries, {response.status_code}",
|
||||
extra=performance_data,
|
||||
)
|
||||
|
||||
# Add performance headers for debugging (only in debug mode)
|
||||
if settings.DEBUG:
|
||||
response["X-Response-Time"] = f"{duration * 1000:.2f}ms"
|
||||
response["X-Query-Count"] = str(total_queries)
|
||||
if total_queries > 0 and hasattr(connection, "queries"):
|
||||
total_query_time = sum(
|
||||
float(q["time"]) for q in connection.queries[-total_queries:]
|
||||
)
|
||||
response["X-Query-Time"] = f"{total_query_time * 1000:.2f}ms"
|
||||
|
||||
return response
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
"""Log performance data even when an exception occurs"""
|
||||
end_time = time.time()
|
||||
start_time = getattr(request, "_performance_start_time", end_time)
|
||||
duration = end_time - start_time
|
||||
|
||||
initial_queries = getattr(request, "_performance_initial_queries", 0)
|
||||
total_queries = (
|
||||
len(connection.queries) - initial_queries
|
||||
if hasattr(connection, "queries")
|
||||
else 0
|
||||
)
|
||||
|
||||
performance_data = {
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"status_code": 500, # Exception occurred
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"query_count": total_queries,
|
||||
"exception": str(exception),
|
||||
"exception_type": type(exception).__name__,
|
||||
"user_id": (
|
||||
getattr(request.user, "id", None)
|
||||
if hasattr(request, "user") and request.user.is_authenticated
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
performance_logger.error(
|
||||
f"Request exception: {
|
||||
request.method} {
|
||||
request.path} - "
|
||||
f"{
|
||||
duration:.3f}s, {total_queries} queries, {
|
||||
type(exception).__name__}: {exception}",
|
||||
extra=performance_data,
|
||||
)
|
||||
|
||||
return None # Don't handle the exception, just log it
|
||||
|
||||
def _get_client_ip(self, request):
|
||||
"""Extract client IP address from request"""
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.META.get("REMOTE_ADDR", "")
|
||||
return ip
|
||||
|
||||
def _get_log_level(self, duration, query_count, status_code):
|
||||
"""Determine appropriate log level based on performance metrics"""
|
||||
# Error responses
|
||||
if status_code >= 500:
|
||||
return logging.ERROR
|
||||
elif status_code >= 400:
|
||||
return logging.WARNING
|
||||
|
||||
# Performance-based log levels
|
||||
if duration > 5.0: # Very slow requests
|
||||
return logging.ERROR
|
||||
elif duration > 2.0 or query_count > 20: # Slow requests or high query count
|
||||
return logging.WARNING
|
||||
elif duration > 1.0 or query_count > 10: # Moderately slow
|
||||
return logging.INFO
|
||||
else:
|
||||
return logging.DEBUG
|
||||
|
||||
|
||||
class QueryCountMiddleware(MiddlewareMixin):
|
||||
"""Middleware to track and limit query counts per request"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.query_limit = getattr(settings, "MAX_QUERIES_PER_REQUEST", 50)
|
||||
super().__init__(get_response)
|
||||
|
||||
def process_request(self, request):
|
||||
"""Initialize query tracking"""
|
||||
request._query_count_start = (
|
||||
len(connection.queries) if hasattr(connection, "queries") else 0
|
||||
)
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Check query count and warn if excessive"""
|
||||
if not hasattr(connection, "queries"):
|
||||
return response
|
||||
|
||||
start_count = getattr(request, "_query_count_start", 0)
|
||||
current_count = len(connection.queries)
|
||||
request_query_count = current_count - start_count
|
||||
|
||||
if request_query_count > self.query_limit:
|
||||
logger.warning(
|
||||
f"Excessive query count: {
|
||||
request.path} executed {request_query_count} queries "
|
||||
f"(limit: {
|
||||
self.query_limit})",
|
||||
extra={
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"query_count": request_query_count,
|
||||
"query_limit": self.query_limit,
|
||||
"excessive_queries": True,
|
||||
},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class DatabaseConnectionMiddleware(MiddlewareMixin):
|
||||
"""Middleware to monitor database connection health"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Check database connection at start of request"""
|
||||
try:
|
||||
# Simple connection test
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Database connection failed at request start: {e}",
|
||||
extra={
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"database_error": str(e),
|
||||
},
|
||||
)
|
||||
# Don't block the request, let Django handle the database error
|
||||
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Close database connections properly"""
|
||||
try:
|
||||
from django.db import connection
|
||||
|
||||
connection.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing database connection: {e}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class CachePerformanceMiddleware(MiddlewareMixin):
|
||||
"""Middleware to monitor cache performance"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Initialize cache performance tracking"""
|
||||
request._cache_hits = 0
|
||||
request._cache_misses = 0
|
||||
request._cache_start_time = time.time()
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Log cache performance metrics"""
|
||||
cache_duration = time.time() - getattr(
|
||||
request, "_cache_start_time", time.time()
|
||||
)
|
||||
cache_hits = getattr(request, "_cache_hits", 0)
|
||||
cache_misses = getattr(request, "_cache_misses", 0)
|
||||
|
||||
if cache_hits + cache_misses > 0:
|
||||
hit_rate = (cache_hits / (cache_hits + cache_misses)) * 100
|
||||
|
||||
cache_data = {
|
||||
"path": request.path,
|
||||
"cache_hits": cache_hits,
|
||||
"cache_misses": cache_misses,
|
||||
"cache_hit_rate": round(hit_rate, 2),
|
||||
"cache_operations": cache_hits + cache_misses,
|
||||
# milliseconds
|
||||
"cache_duration": round(cache_duration * 1000, 2),
|
||||
}
|
||||
|
||||
# Log cache performance
|
||||
if hit_rate < 50 and cache_hits + cache_misses > 5:
|
||||
logger.warning(
|
||||
f"Low cache hit rate for {request.path}: {hit_rate:.1f}%",
|
||||
extra=cache_data,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Cache performance for {
|
||||
request.path}: {
|
||||
hit_rate:.1f}% hit rate",
|
||||
extra=cache_data,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-10 01:10
|
||||
# Generated by Django 5.1.4 on 2025-08-13 21:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
@@ -45,7 +45,8 @@ class Migration(migrations.Migration):
|
||||
name="core_slughi_content_8bbf56_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["old_slug"], name="core_slughi_old_slu_aaef7f_idx"
|
||||
fields=["old_slug"],
|
||||
name="core_slughi_old_slu_aaef7f_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
|
||||
102
core/migrations/0002_historicalslug_pageview.py
Normal file
102
core/migrations/0002_historicalslug_pageview.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-14 14:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("core", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="HistoricalSlug",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("slug", models.SlugField(max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="historical_slugs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="core_histor_content_b4c470_idx",
|
||||
),
|
||||
models.Index(fields=["slug"], name="core_histor_slug_8fd7b3_idx"),
|
||||
],
|
||||
"unique_together": {("content_type", "slug")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PageView",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
(
|
||||
"timestamp",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
("ip_address", models.GenericIPAddressField()),
|
||||
("user_agent", models.CharField(blank=True, max_length=512)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="page_views",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["timestamp"],
|
||||
name="core_pagevi_timesta_757ebb_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="core_pagevi_content_eda7ad_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
19
core/mixins/__init__.py
Normal file
19
core/mixins/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.views.generic.list import MultipleObjectMixin
|
||||
|
||||
|
||||
class HTMXFilterableMixin(MultipleObjectMixin):
|
||||
"""
|
||||
A mixin that provides filtering capabilities for HTMX requests.
|
||||
"""
|
||||
|
||||
filter_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["filter"] = self.filterset
|
||||
return context
|
||||
@@ -2,35 +2,41 @@ 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
|
||||
from core.history import TrackedModel
|
||||
|
||||
|
||||
class SlugHistory(models.Model):
|
||||
"""
|
||||
Model for tracking slug changes across all models that use slugs.
|
||||
Uses generic relations to work with any model.
|
||||
"""
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.CharField(max_length=50) # Using CharField to work with our custom IDs
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
object_id = models.CharField(
|
||||
max_length=50
|
||||
) # Using CharField to work with our custom IDs
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
old_slug = models.SlugField(max_length=200)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
models.Index(fields=['old_slug']),
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["old_slug"]),
|
||||
]
|
||||
verbose_name_plural = 'Slug histories'
|
||||
ordering = ['-created_at']
|
||||
verbose_name_plural = "Slug histories"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Old slug '{self.old_slug}' for {self.content_object}"
|
||||
|
||||
|
||||
class SluggedModel(TrackedModel):
|
||||
"""
|
||||
Abstract base model that provides slug functionality with history tracking.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True)
|
||||
|
||||
@@ -47,7 +53,7 @@ class SluggedModel(TrackedModel):
|
||||
SlugHistory.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(self),
|
||||
object_id=getattr(self, self.get_id_field_name()),
|
||||
old_slug=old_instance.slug
|
||||
old_slug=old_instance.slug,
|
||||
)
|
||||
except self.__class__.DoesNotExist:
|
||||
pass
|
||||
@@ -81,24 +87,27 @@ class SluggedModel(TrackedModel):
|
||||
history_model = cls.get_history_model()
|
||||
history_entry = (
|
||||
history_model.objects.filter(slug=slug)
|
||||
.order_by('-pgh_created_at')
|
||||
.order_by("-pgh_created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
if history_entry:
|
||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
||||
|
||||
|
||||
# Try to find in manual slug history as fallback
|
||||
history = SlugHistory.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(cls),
|
||||
old_slug=slug
|
||||
).order_by('-created_at').first()
|
||||
|
||||
if history:
|
||||
return cls.objects.get(
|
||||
**{cls.get_id_field_name(): history.object_id}
|
||||
), True
|
||||
|
||||
raise cls.DoesNotExist(
|
||||
f"{cls.__name__} with slug '{slug}' does not exist"
|
||||
history = (
|
||||
SlugHistory.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(cls),
|
||||
old_slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
if history:
|
||||
return (
|
||||
cls.objects.get(**{cls.get_id_field_name(): history.object_id}),
|
||||
True,
|
||||
)
|
||||
|
||||
raise cls.DoesNotExist(f"{cls.__name__} with slug '{slug}' does not exist")
|
||||
|
||||
322
core/selectors.py
Normal file
322
core/selectors.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Selectors for core functionality including map services and analytics.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.db.models import QuerySet, Q, Count
|
||||
from django.contrib.gis.geos import Point, Polygon
|
||||
from django.contrib.gis.measure import Distance
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .analytics import PageView
|
||||
from parks.models import Park
|
||||
from rides.models import Ride
|
||||
|
||||
|
||||
def unified_locations_for_map(
|
||||
*,
|
||||
bounds: Optional[Polygon] = None,
|
||||
location_types: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, QuerySet]:
|
||||
"""
|
||||
Get unified location data for map display across all location types.
|
||||
|
||||
Args:
|
||||
bounds: Geographic boundary polygon
|
||||
location_types: List of location types to include ('park', 'ride')
|
||||
filters: Additional filter parameters
|
||||
|
||||
Returns:
|
||||
Dictionary containing querysets for each location type
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Default to all location types if none specified
|
||||
if not location_types:
|
||||
location_types = ["park", "ride"]
|
||||
|
||||
# Parks
|
||||
if "park" in location_types:
|
||||
park_queryset = (
|
||||
Park.objects.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.annotate(ride_count_calculated=Count("rides"))
|
||||
)
|
||||
|
||||
if bounds:
|
||||
park_queryset = park_queryset.filter(location__coordinates__within=bounds)
|
||||
|
||||
if filters:
|
||||
if "status" in filters:
|
||||
park_queryset = park_queryset.filter(status=filters["status"])
|
||||
if "operator" in filters:
|
||||
park_queryset = park_queryset.filter(operator=filters["operator"])
|
||||
|
||||
results["parks"] = park_queryset.order_by("name")
|
||||
|
||||
# Rides
|
||||
if "ride" in location_types:
|
||||
ride_queryset = Ride.objects.select_related(
|
||||
"park", "manufacturer"
|
||||
).prefetch_related("park__location", "location")
|
||||
|
||||
if bounds:
|
||||
ride_queryset = ride_queryset.filter(
|
||||
Q(location__coordinates__within=bounds)
|
||||
| Q(park__location__coordinates__within=bounds)
|
||||
)
|
||||
|
||||
if filters:
|
||||
if "category" in filters:
|
||||
ride_queryset = ride_queryset.filter(category=filters["category"])
|
||||
if "manufacturer" in filters:
|
||||
ride_queryset = ride_queryset.filter(
|
||||
manufacturer=filters["manufacturer"]
|
||||
)
|
||||
if "park" in filters:
|
||||
ride_queryset = ride_queryset.filter(park=filters["park"])
|
||||
|
||||
results["rides"] = ride_queryset.order_by("park__name", "name")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def locations_near_point(
|
||||
*,
|
||||
point: Point,
|
||||
distance_km: float = 50,
|
||||
location_types: Optional[List[str]] = None,
|
||||
limit: int = 20,
|
||||
) -> Dict[str, QuerySet]:
|
||||
"""
|
||||
Get locations near a specific geographic point across all types.
|
||||
|
||||
Args:
|
||||
point: Geographic point (longitude, latitude)
|
||||
distance_km: Maximum distance in kilometers
|
||||
location_types: List of location types to include
|
||||
limit: Maximum number of results per type
|
||||
|
||||
Returns:
|
||||
Dictionary containing nearby locations by type
|
||||
"""
|
||||
results = {}
|
||||
|
||||
if not location_types:
|
||||
location_types = ["park", "ride"]
|
||||
|
||||
# Parks near point
|
||||
if "park" in location_types:
|
||||
results["parks"] = (
|
||||
Park.objects.filter(
|
||||
location__coordinates__distance_lte=(
|
||||
point,
|
||||
Distance(km=distance_km),
|
||||
)
|
||||
)
|
||||
.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.distance(point)
|
||||
.order_by("distance")[:limit]
|
||||
)
|
||||
|
||||
# Rides near point
|
||||
if "ride" in location_types:
|
||||
results["rides"] = (
|
||||
Ride.objects.filter(
|
||||
Q(
|
||||
location__coordinates__distance_lte=(
|
||||
point,
|
||||
Distance(km=distance_km),
|
||||
)
|
||||
)
|
||||
| Q(
|
||||
park__location__coordinates__distance_lte=(
|
||||
point,
|
||||
Distance(km=distance_km),
|
||||
)
|
||||
)
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.distance(point)
|
||||
.order_by("distance")[:limit]
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_all_locations(*, query: str, limit: int = 20) -> Dict[str, QuerySet]:
|
||||
"""
|
||||
Search across all location types for a query string.
|
||||
|
||||
Args:
|
||||
query: Search string
|
||||
limit: Maximum results per type
|
||||
|
||||
Returns:
|
||||
Dictionary containing search results by type
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Search parks
|
||||
results["parks"] = (
|
||||
Park.objects.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(description__icontains=query)
|
||||
| Q(location__city__icontains=query)
|
||||
| Q(location__region__icontains=query)
|
||||
)
|
||||
.select_related("operator")
|
||||
.prefetch_related("location")
|
||||
.order_by("name")[:limit]
|
||||
)
|
||||
|
||||
# Search rides
|
||||
results["rides"] = (
|
||||
Ride.objects.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(description__icontains=query)
|
||||
| Q(park__name__icontains=query)
|
||||
| Q(manufacturer__name__icontains=query)
|
||||
)
|
||||
.select_related("park", "manufacturer")
|
||||
.prefetch_related("park__location")
|
||||
.order_by("park__name", "name")[:limit]
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def page_views_for_analytics(
|
||||
*,
|
||||
start_date: Optional[timezone.datetime] = None,
|
||||
end_date: Optional[timezone.datetime] = None,
|
||||
path_pattern: Optional[str] = None,
|
||||
) -> QuerySet[PageView]:
|
||||
"""
|
||||
Get page views for analytics with optional filtering.
|
||||
|
||||
Args:
|
||||
start_date: Start date for filtering
|
||||
end_date: End date for filtering
|
||||
path_pattern: URL path pattern to filter by
|
||||
|
||||
Returns:
|
||||
QuerySet of page views
|
||||
"""
|
||||
queryset = PageView.objects.all()
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(timestamp__gte=start_date)
|
||||
|
||||
if end_date:
|
||||
queryset = queryset.filter(timestamp__lte=end_date)
|
||||
|
||||
if path_pattern:
|
||||
queryset = queryset.filter(path__icontains=path_pattern)
|
||||
|
||||
return queryset.order_by("-timestamp")
|
||||
|
||||
|
||||
def popular_pages_summary(*, days: int = 30) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary of most popular pages in the last N days.
|
||||
|
||||
Args:
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary containing popular pages statistics
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Most viewed pages
|
||||
popular_pages = (
|
||||
PageView.objects.filter(timestamp__gte=cutoff_date)
|
||||
.values("path")
|
||||
.annotate(view_count=Count("id"))
|
||||
.order_by("-view_count")[:10]
|
||||
)
|
||||
|
||||
# Total page views
|
||||
total_views = PageView.objects.filter(timestamp__gte=cutoff_date).count()
|
||||
|
||||
# Unique visitors (based on IP)
|
||||
unique_visitors = (
|
||||
PageView.objects.filter(timestamp__gte=cutoff_date)
|
||||
.values("ip_address")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"popular_pages": list(popular_pages),
|
||||
"total_views": total_views,
|
||||
"unique_visitors": unique_visitors,
|
||||
"period_days": days,
|
||||
}
|
||||
|
||||
|
||||
def geographic_distribution_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
Get geographic distribution statistics for all locations.
|
||||
|
||||
Returns:
|
||||
Dictionary containing geographic statistics
|
||||
"""
|
||||
# Parks by country
|
||||
parks_by_country = (
|
||||
Park.objects.filter(location__country__isnull=False)
|
||||
.values("location__country")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
# Rides by country (through park location)
|
||||
rides_by_country = (
|
||||
Ride.objects.filter(park__location__country__isnull=False)
|
||||
.values("park__location__country")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
return {
|
||||
"parks_by_country": list(parks_by_country),
|
||||
"rides_by_country": list(rides_by_country),
|
||||
}
|
||||
|
||||
|
||||
def system_health_metrics() -> Dict[str, Any]:
|
||||
"""
|
||||
Get system health and activity metrics.
|
||||
|
||||
Returns:
|
||||
Dictionary containing system health statistics
|
||||
"""
|
||||
now = timezone.now()
|
||||
last_24h = now - timedelta(hours=24)
|
||||
last_7d = now - timedelta(days=7)
|
||||
|
||||
return {
|
||||
"total_parks": Park.objects.count(),
|
||||
"operating_parks": Park.objects.filter(status="OPERATING").count(),
|
||||
"total_rides": Ride.objects.count(),
|
||||
"page_views_24h": PageView.objects.filter(timestamp__gte=last_24h).count(),
|
||||
"page_views_7d": PageView.objects.filter(timestamp__gte=last_7d).count(),
|
||||
"data_freshness": {
|
||||
"latest_park_update": (
|
||||
Park.objects.order_by("-updated_at").first().updated_at
|
||||
if Park.objects.exists()
|
||||
else None
|
||||
),
|
||||
"latest_ride_update": (
|
||||
Ride.objects.order_by("-updated_at").first().updated_at
|
||||
if Ride.objects.exists()
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
27
core/services/__init__.py
Normal file
27
core/services/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Core services for ThrillWiki unified map functionality.
|
||||
"""
|
||||
|
||||
from .map_service import UnifiedMapService
|
||||
from .clustering_service import ClusteringService
|
||||
from .map_cache_service import MapCacheService
|
||||
from .data_structures import (
|
||||
UnifiedLocation,
|
||||
LocationType,
|
||||
GeoBounds,
|
||||
MapFilters,
|
||||
MapResponse,
|
||||
ClusterData,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UnifiedMapService",
|
||||
"ClusteringService",
|
||||
"MapCacheService",
|
||||
"UnifiedLocation",
|
||||
"LocationType",
|
||||
"GeoBounds",
|
||||
"MapFilters",
|
||||
"MapResponse",
|
||||
"ClusterData",
|
||||
]
|
||||
365
core/services/clustering_service.py
Normal file
365
core/services/clustering_service.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Clustering service for map locations to improve performance and user experience.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
|
||||
from .data_structures import (
|
||||
UnifiedLocation,
|
||||
ClusterData,
|
||||
GeoBounds,
|
||||
LocationType,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterPoint:
|
||||
"""Internal representation of a point for clustering."""
|
||||
|
||||
location: UnifiedLocation
|
||||
x: float # Projected x coordinate
|
||||
y: float # Projected y coordinate
|
||||
|
||||
|
||||
class ClusteringService:
|
||||
"""
|
||||
Handles location clustering for map display using a simple grid-based approach
|
||||
with zoom-level dependent clustering radius.
|
||||
"""
|
||||
|
||||
# Clustering configuration
|
||||
DEFAULT_RADIUS = 40 # pixels
|
||||
MIN_POINTS_TO_CLUSTER = 2
|
||||
MAX_ZOOM_FOR_CLUSTERING = 15
|
||||
MIN_ZOOM_FOR_CLUSTERING = 3
|
||||
|
||||
# Zoom level configurations
|
||||
ZOOM_CONFIGS = {
|
||||
3: {"radius": 80, "min_points": 5}, # World level
|
||||
4: {"radius": 70, "min_points": 4}, # Continent level
|
||||
5: {"radius": 60, "min_points": 3}, # Country level
|
||||
6: {"radius": 50, "min_points": 3}, # Large region level
|
||||
7: {"radius": 45, "min_points": 2}, # Region level
|
||||
8: {"radius": 40, "min_points": 2}, # State level
|
||||
9: {"radius": 35, "min_points": 2}, # Metro area level
|
||||
10: {"radius": 30, "min_points": 2}, # City level
|
||||
11: {"radius": 25, "min_points": 2}, # District level
|
||||
12: {"radius": 20, "min_points": 2}, # Neighborhood level
|
||||
13: {"radius": 15, "min_points": 2}, # Block level
|
||||
14: {"radius": 10, "min_points": 2}, # Street level
|
||||
15: {"radius": 5, "min_points": 2}, # Building level
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.cluster_id_counter = 0
|
||||
|
||||
def should_cluster(self, zoom_level: int, point_count: int) -> bool:
|
||||
"""Determine if clustering should be applied based on zoom level and point count."""
|
||||
if zoom_level > self.MAX_ZOOM_FOR_CLUSTERING:
|
||||
return False
|
||||
if zoom_level < self.MIN_ZOOM_FOR_CLUSTERING:
|
||||
return True
|
||||
|
||||
config = self.ZOOM_CONFIGS.get(
|
||||
zoom_level, {"min_points": self.MIN_POINTS_TO_CLUSTER}
|
||||
)
|
||||
return point_count >= config["min_points"]
|
||||
|
||||
def cluster_locations(
|
||||
self,
|
||||
locations: List[UnifiedLocation],
|
||||
zoom_level: int,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
) -> Tuple[List[UnifiedLocation], List[ClusterData]]:
|
||||
"""
|
||||
Cluster locations based on zoom level and density.
|
||||
Returns (unclustered_locations, clusters).
|
||||
"""
|
||||
if not locations or not self.should_cluster(zoom_level, len(locations)):
|
||||
return locations, []
|
||||
|
||||
# Convert locations to projected coordinates for clustering
|
||||
cluster_points = self._project_locations(locations, bounds)
|
||||
|
||||
# Get clustering configuration for zoom level
|
||||
config = self.ZOOM_CONFIGS.get(
|
||||
zoom_level,
|
||||
{
|
||||
"radius": self.DEFAULT_RADIUS,
|
||||
"min_points": self.MIN_POINTS_TO_CLUSTER,
|
||||
},
|
||||
)
|
||||
|
||||
# Perform clustering
|
||||
clustered_groups = self._cluster_points(
|
||||
cluster_points, config["radius"], config["min_points"]
|
||||
)
|
||||
|
||||
# Separate individual locations from clusters
|
||||
unclustered_locations = []
|
||||
clusters = []
|
||||
|
||||
for group in clustered_groups:
|
||||
if len(group) < config["min_points"]:
|
||||
# Add individual locations
|
||||
unclustered_locations.extend([cp.location for cp in group])
|
||||
else:
|
||||
# Create cluster
|
||||
cluster = self._create_cluster(group)
|
||||
clusters.append(cluster)
|
||||
|
||||
return unclustered_locations, clusters
|
||||
|
||||
def _project_locations(
|
||||
self,
|
||||
locations: List[UnifiedLocation],
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
) -> List[ClusterPoint]:
|
||||
"""Convert lat/lng coordinates to projected x/y for clustering calculations."""
|
||||
cluster_points = []
|
||||
|
||||
# Use bounds or calculate from locations
|
||||
if not bounds:
|
||||
lats = [loc.latitude for loc in locations]
|
||||
lngs = [loc.longitude for loc in locations]
|
||||
bounds = GeoBounds(
|
||||
north=max(lats),
|
||||
south=min(lats),
|
||||
east=max(lngs),
|
||||
west=min(lngs),
|
||||
)
|
||||
|
||||
# Simple equirectangular projection (good enough for clustering)
|
||||
center_lat = (bounds.north + bounds.south) / 2
|
||||
lat_scale = 111320 # meters per degree latitude
|
||||
lng_scale = 111320 * math.cos(
|
||||
math.radians(center_lat)
|
||||
) # meters per degree longitude
|
||||
|
||||
for location in locations:
|
||||
# Convert to meters relative to bounds center
|
||||
x = (location.longitude - (bounds.west + bounds.east) / 2) * lng_scale
|
||||
y = (location.latitude - (bounds.north + bounds.south) / 2) * lat_scale
|
||||
|
||||
cluster_points.append(ClusterPoint(location=location, x=x, y=y))
|
||||
|
||||
return cluster_points
|
||||
|
||||
def _cluster_points(
|
||||
self, points: List[ClusterPoint], radius_pixels: int, min_points: int
|
||||
) -> List[List[ClusterPoint]]:
|
||||
"""
|
||||
Cluster points using a simple distance-based approach.
|
||||
Radius is in pixels, converted to meters based on zoom level.
|
||||
"""
|
||||
# Convert pixel radius to meters (rough approximation)
|
||||
# At zoom level 10, 1 pixel ≈ 150 meters
|
||||
radius_meters = radius_pixels * 150
|
||||
|
||||
clustered = [False] * len(points)
|
||||
clusters = []
|
||||
|
||||
for i, point in enumerate(points):
|
||||
if clustered[i]:
|
||||
continue
|
||||
|
||||
# Find all points within radius
|
||||
cluster_group = [point]
|
||||
clustered[i] = True
|
||||
|
||||
for j, other_point in enumerate(points):
|
||||
if i == j or clustered[j]:
|
||||
continue
|
||||
|
||||
distance = self._calculate_distance(point, other_point)
|
||||
if distance <= radius_meters:
|
||||
cluster_group.append(other_point)
|
||||
clustered[j] = True
|
||||
|
||||
clusters.append(cluster_group)
|
||||
|
||||
return clusters
|
||||
|
||||
def _calculate_distance(self, point1: ClusterPoint, point2: ClusterPoint) -> float:
|
||||
"""Calculate Euclidean distance between two projected points in meters."""
|
||||
dx = point1.x - point2.x
|
||||
dy = point1.y - point2.y
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
def _create_cluster(self, cluster_points: List[ClusterPoint]) -> ClusterData:
|
||||
"""Create a ClusterData object from a group of points."""
|
||||
locations = [cp.location for cp in cluster_points]
|
||||
|
||||
# Calculate cluster center (average position)
|
||||
avg_lat = sum(loc.latitude for loc in locations) / len(locations)
|
||||
avg_lng = sum(loc.longitude for loc in locations) / len(locations)
|
||||
|
||||
# Calculate cluster bounds
|
||||
lats = [loc.latitude for loc in locations]
|
||||
lngs = [loc.longitude for loc in locations]
|
||||
cluster_bounds = GeoBounds(
|
||||
north=max(lats), south=min(lats), east=max(lngs), west=min(lngs)
|
||||
)
|
||||
|
||||
# Collect location types in cluster
|
||||
types = set(loc.type for loc in locations)
|
||||
|
||||
# Select representative location (highest weight)
|
||||
representative = self._select_representative_location(locations)
|
||||
|
||||
# Generate cluster ID
|
||||
self.cluster_id_counter += 1
|
||||
cluster_id = f"cluster_{self.cluster_id_counter}"
|
||||
|
||||
return ClusterData(
|
||||
id=cluster_id,
|
||||
coordinates=(avg_lat, avg_lng),
|
||||
count=len(locations),
|
||||
types=types,
|
||||
bounds=cluster_bounds,
|
||||
representative_location=representative,
|
||||
)
|
||||
|
||||
def _select_representative_location(
|
||||
self, locations: List[UnifiedLocation]
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Select the most representative location for a cluster."""
|
||||
if not locations:
|
||||
return None
|
||||
|
||||
# Prioritize by: 1) Parks over rides/companies, 2) Higher weight, 3)
|
||||
# Better rating
|
||||
parks = [loc for loc in locations if loc.type == LocationType.PARK]
|
||||
if parks:
|
||||
return max(
|
||||
parks,
|
||||
key=lambda x: (
|
||||
x.cluster_weight,
|
||||
x.metadata.get("rating", 0) or 0,
|
||||
),
|
||||
)
|
||||
|
||||
rides = [loc for loc in locations if loc.type == LocationType.RIDE]
|
||||
if rides:
|
||||
return max(
|
||||
rides,
|
||||
key=lambda x: (
|
||||
x.cluster_weight,
|
||||
x.metadata.get("rating", 0) or 0,
|
||||
),
|
||||
)
|
||||
|
||||
companies = [loc for loc in locations if loc.type == LocationType.COMPANY]
|
||||
if companies:
|
||||
return max(companies, key=lambda x: x.cluster_weight)
|
||||
|
||||
# Fall back to highest weight location
|
||||
return max(locations, key=lambda x: x.cluster_weight)
|
||||
|
||||
def get_cluster_breakdown(self, clusters: List[ClusterData]) -> Dict[str, Any]:
|
||||
"""Get statistics about clustering results."""
|
||||
if not clusters:
|
||||
return {
|
||||
"total_clusters": 0,
|
||||
"total_points_clustered": 0,
|
||||
"average_cluster_size": 0,
|
||||
"type_distribution": {},
|
||||
"category_distribution": {},
|
||||
}
|
||||
|
||||
total_points = sum(cluster.count for cluster in clusters)
|
||||
type_counts = defaultdict(int)
|
||||
category_counts = defaultdict(int)
|
||||
|
||||
for cluster in clusters:
|
||||
for location_type in cluster.types:
|
||||
type_counts[location_type.value] += cluster.count
|
||||
|
||||
if cluster.representative_location:
|
||||
category_counts[cluster.representative_location.cluster_category] += 1
|
||||
|
||||
return {
|
||||
"total_clusters": len(clusters),
|
||||
"total_points_clustered": total_points,
|
||||
"average_cluster_size": total_points / len(clusters),
|
||||
"largest_cluster_size": max(cluster.count for cluster in clusters),
|
||||
"smallest_cluster_size": min(cluster.count for cluster in clusters),
|
||||
"type_distribution": dict(type_counts),
|
||||
"category_distribution": dict(category_counts),
|
||||
}
|
||||
|
||||
def expand_cluster(
|
||||
self, cluster: ClusterData, zoom_level: int
|
||||
) -> List[UnifiedLocation]:
|
||||
"""
|
||||
Expand a cluster to show individual locations (for drill-down functionality).
|
||||
This would typically require re-querying the database with the cluster bounds.
|
||||
"""
|
||||
# This is a placeholder - in practice, this would re-query the database
|
||||
# with the cluster bounds and higher detail level
|
||||
return []
|
||||
|
||||
|
||||
class SmartClusteringRules:
|
||||
"""
|
||||
Advanced clustering rules that consider location types and importance.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def should_cluster_together(loc1: UnifiedLocation, loc2: UnifiedLocation) -> bool:
|
||||
"""Determine if two locations should be clustered together."""
|
||||
|
||||
# Same park rides should cluster together more readily
|
||||
if loc1.type == LocationType.RIDE and loc2.type == LocationType.RIDE:
|
||||
park1_id = loc1.metadata.get("park_id")
|
||||
park2_id = loc2.metadata.get("park_id")
|
||||
if park1_id and park2_id and park1_id == park2_id:
|
||||
return True
|
||||
|
||||
# Major parks should resist clustering unless very close
|
||||
if (
|
||||
loc1.cluster_category == "major_park"
|
||||
or loc2.cluster_category == "major_park"
|
||||
):
|
||||
return False
|
||||
|
||||
# Similar types cluster more readily
|
||||
if loc1.type == loc2.type:
|
||||
return True
|
||||
|
||||
# Different types can cluster but with higher threshold
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def calculate_cluster_priority(
|
||||
locations: List[UnifiedLocation],
|
||||
) -> UnifiedLocation:
|
||||
"""Select the representative location for a cluster based on priority rules."""
|
||||
# Prioritize by: 1) Parks over rides, 2) Higher weight, 3) Better
|
||||
# rating
|
||||
parks = [loc for loc in locations if loc.type == LocationType.PARK]
|
||||
if parks:
|
||||
return max(
|
||||
parks,
|
||||
key=lambda x: (
|
||||
x.cluster_weight,
|
||||
x.metadata.get("rating", 0) or 0,
|
||||
x.metadata.get("ride_count", 0) or 0,
|
||||
),
|
||||
)
|
||||
|
||||
rides = [loc for loc in locations if loc.type == LocationType.RIDE]
|
||||
if rides:
|
||||
return max(
|
||||
rides,
|
||||
key=lambda x: (
|
||||
x.cluster_weight,
|
||||
x.metadata.get("rating", 0) or 0,
|
||||
),
|
||||
)
|
||||
|
||||
# Fall back to highest weight
|
||||
return max(locations, key=lambda x: x.cluster_weight)
|
||||
253
core/services/data_structures.py
Normal file
253
core/services/data_structures.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Data structures for the unified map service.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Set, Tuple, Any
|
||||
from django.contrib.gis.geos import Polygon
|
||||
|
||||
|
||||
class LocationType(Enum):
|
||||
"""Types of locations supported by the map service."""
|
||||
|
||||
PARK = "park"
|
||||
RIDE = "ride"
|
||||
COMPANY = "company"
|
||||
GENERIC = "generic"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoBounds:
|
||||
"""Geographic boundary box for spatial queries."""
|
||||
|
||||
north: float
|
||||
south: float
|
||||
east: float
|
||||
west: float
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate bounds after initialization."""
|
||||
if self.north < self.south:
|
||||
raise ValueError("North bound must be greater than south bound")
|
||||
if self.east < self.west:
|
||||
raise ValueError("East bound must be greater than west bound")
|
||||
if not (-90 <= self.south <= 90 and -90 <= self.north <= 90):
|
||||
raise ValueError("Latitude bounds must be between -90 and 90")
|
||||
if not (-180 <= self.west <= 180 and -180 <= self.east <= 180):
|
||||
raise ValueError("Longitude bounds must be between -180 and 180")
|
||||
|
||||
def to_polygon(self) -> Polygon:
|
||||
"""Convert bounds to PostGIS Polygon for database queries."""
|
||||
return Polygon.from_bbox((self.west, self.south, self.east, self.north))
|
||||
|
||||
def expand(self, factor: float = 1.1) -> "GeoBounds":
|
||||
"""Expand bounds by factor for buffer queries."""
|
||||
center_lat = (self.north + self.south) / 2
|
||||
center_lng = (self.east + self.west) / 2
|
||||
|
||||
lat_range = (self.north - self.south) * factor / 2
|
||||
lng_range = (self.east - self.west) * factor / 2
|
||||
|
||||
return GeoBounds(
|
||||
north=min(90, center_lat + lat_range),
|
||||
south=max(-90, center_lat - lat_range),
|
||||
east=min(180, center_lng + lng_range),
|
||||
west=max(-180, center_lng - lng_range),
|
||||
)
|
||||
|
||||
def contains_point(self, lat: float, lng: float) -> bool:
|
||||
"""Check if a point is within these bounds."""
|
||||
return self.south <= lat <= self.north and self.west <= lng <= self.east
|
||||
|
||||
def to_dict(self) -> Dict[str, float]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"north": self.north,
|
||||
"south": self.south,
|
||||
"east": self.east,
|
||||
"west": self.west,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MapFilters:
|
||||
"""Filtering options for map queries."""
|
||||
|
||||
location_types: Optional[Set[LocationType]] = None
|
||||
park_status: Optional[Set[str]] = None # OPERATING, CLOSED_TEMP, etc.
|
||||
ride_types: Optional[Set[str]] = None
|
||||
company_roles: Optional[Set[str]] = None # OPERATOR, MANUFACTURER, etc.
|
||||
search_query: Optional[str] = None
|
||||
min_rating: Optional[float] = None
|
||||
has_coordinates: bool = True
|
||||
country: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for caching and serialization."""
|
||||
return {
|
||||
"location_types": (
|
||||
[t.value for t in self.location_types] if self.location_types else None
|
||||
),
|
||||
"park_status": (list(self.park_status) if self.park_status else None),
|
||||
"ride_types": list(self.ride_types) if self.ride_types else None,
|
||||
"company_roles": (list(self.company_roles) if self.company_roles else None),
|
||||
"search_query": self.search_query,
|
||||
"min_rating": self.min_rating,
|
||||
"has_coordinates": self.has_coordinates,
|
||||
"country": self.country,
|
||||
"state": self.state,
|
||||
"city": self.city,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnifiedLocation:
|
||||
"""Unified location interface for all location types."""
|
||||
|
||||
id: str # Composite: f"{type}_{id}"
|
||||
type: LocationType
|
||||
name: str
|
||||
coordinates: Tuple[float, float] # (lat, lng)
|
||||
address: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
type_data: Dict[str, Any] = field(default_factory=dict)
|
||||
cluster_weight: int = 1
|
||||
cluster_category: str = "default"
|
||||
|
||||
@property
|
||||
def latitude(self) -> float:
|
||||
"""Get latitude from coordinates."""
|
||||
return self.coordinates[0]
|
||||
|
||||
@property
|
||||
def longitude(self) -> float:
|
||||
"""Get longitude from coordinates."""
|
||||
return self.coordinates[1]
|
||||
|
||||
def to_geojson_feature(self) -> Dict[str, Any]:
|
||||
"""Convert to GeoJSON feature for mapping libraries."""
|
||||
return {
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"id": self.id,
|
||||
"type": self.type.value,
|
||||
"name": self.name,
|
||||
"address": self.address,
|
||||
"metadata": self.metadata,
|
||||
"type_data": self.type_data,
|
||||
"cluster_weight": self.cluster_weight,
|
||||
"cluster_category": self.cluster_category,
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
# GeoJSON uses lng, lat
|
||||
"coordinates": [self.longitude, self.latitude],
|
||||
},
|
||||
}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type.value,
|
||||
"name": self.name,
|
||||
"coordinates": list(self.coordinates),
|
||||
"address": self.address,
|
||||
"metadata": self.metadata,
|
||||
"type_data": self.type_data,
|
||||
"cluster_weight": self.cluster_weight,
|
||||
"cluster_category": self.cluster_category,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterData:
|
||||
"""Represents a cluster of locations for map display."""
|
||||
|
||||
id: str
|
||||
coordinates: Tuple[float, float] # (lat, lng)
|
||||
count: int
|
||||
types: Set[LocationType]
|
||||
bounds: GeoBounds
|
||||
representative_location: Optional[UnifiedLocation] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"coordinates": list(self.coordinates),
|
||||
"count": self.count,
|
||||
"types": [t.value for t in self.types],
|
||||
"bounds": self.bounds.to_dict(),
|
||||
"representative": (
|
||||
self.representative_location.to_dict()
|
||||
if self.representative_location
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MapResponse:
|
||||
"""Response structure for map API calls."""
|
||||
|
||||
locations: List[UnifiedLocation] = field(default_factory=list)
|
||||
clusters: List[ClusterData] = field(default_factory=list)
|
||||
bounds: Optional[GeoBounds] = None
|
||||
total_count: int = 0
|
||||
filtered_count: int = 0
|
||||
zoom_level: Optional[int] = None
|
||||
clustered: bool = False
|
||||
cache_hit: bool = False
|
||||
query_time_ms: Optional[int] = None
|
||||
filters_applied: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON responses."""
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"locations": [loc.to_dict() for loc in self.locations],
|
||||
"clusters": [cluster.to_dict() for cluster in self.clusters],
|
||||
"bounds": self.bounds.to_dict() if self.bounds else None,
|
||||
"total_count": self.total_count,
|
||||
"filtered_count": self.filtered_count,
|
||||
"zoom_level": self.zoom_level,
|
||||
"clustered": self.clustered,
|
||||
},
|
||||
"meta": {
|
||||
"cache_hit": self.cache_hit,
|
||||
"query_time_ms": self.query_time_ms,
|
||||
"filters_applied": self.filters_applied,
|
||||
"pagination": {
|
||||
"has_more": False, # TODO: Implement pagination
|
||||
"total_pages": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryPerformanceMetrics:
|
||||
"""Performance metrics for query optimization."""
|
||||
|
||||
query_time_ms: int
|
||||
db_query_count: int
|
||||
cache_hit: bool
|
||||
result_count: int
|
||||
bounds_used: bool
|
||||
clustering_used: bool
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for logging."""
|
||||
return {
|
||||
"query_time_ms": self.query_time_ms,
|
||||
"db_query_count": self.db_query_count,
|
||||
"cache_hit": self.cache_hit,
|
||||
"result_count": self.result_count,
|
||||
"bounds_used": self.bounds_used,
|
||||
"clustering_used": self.clustering_used,
|
||||
}
|
||||
320
core/services/enhanced_cache_service.py
Normal file
320
core/services/enhanced_cache_service.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Enhanced caching service with multiple cache backends and strategies.
|
||||
"""
|
||||
|
||||
from typing import Optional, Any, Dict, Callable
|
||||
from django.core.cache import caches
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Define GeoBounds for type hinting
|
||||
class GeoBounds:
|
||||
def __init__(self, min_lat: float, min_lng: float, max_lat: float, max_lng: float):
|
||||
self.min_lat = min_lat
|
||||
self.min_lng = min_lng
|
||||
self.max_lat = max_lat
|
||||
self.max_lng = max_lng
|
||||
|
||||
|
||||
class EnhancedCacheService:
|
||||
"""Comprehensive caching service with multiple cache backends"""
|
||||
|
||||
def __init__(self):
|
||||
self.default_cache = caches["default"]
|
||||
try:
|
||||
self.api_cache = caches["api"]
|
||||
except Exception:
|
||||
# Fallback to default cache if api cache not configured
|
||||
self.api_cache = self.default_cache
|
||||
|
||||
# L1: Query-level caching
|
||||
def cache_queryset(
|
||||
self,
|
||||
cache_key: str,
|
||||
queryset_func: Callable,
|
||||
timeout: int = 3600,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Cache expensive querysets"""
|
||||
cached_result = self.default_cache.get(cache_key)
|
||||
if cached_result is None:
|
||||
start_time = time.time()
|
||||
result = queryset_func(**kwargs)
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Log cache miss and function execution time
|
||||
logger.info(
|
||||
f"Cache miss for key '{cache_key}', executed in {
|
||||
duration:.3f}s",
|
||||
extra={"cache_key": cache_key, "execution_time": duration},
|
||||
)
|
||||
|
||||
self.default_cache.set(cache_key, result, timeout)
|
||||
return result
|
||||
|
||||
logger.debug(f"Cache hit for key '{cache_key}'")
|
||||
return cached_result
|
||||
|
||||
# L2: API response caching
|
||||
def cache_api_response(
|
||||
self,
|
||||
view_name: str,
|
||||
params: Dict,
|
||||
response_data: Any,
|
||||
timeout: int = 1800,
|
||||
):
|
||||
"""Cache API responses based on view and parameters"""
|
||||
cache_key = self._generate_api_cache_key(view_name, params)
|
||||
self.api_cache.set(cache_key, response_data, timeout)
|
||||
logger.debug(f"Cached API response for view '{view_name}'")
|
||||
|
||||
def get_cached_api_response(self, view_name: str, params: Dict) -> Optional[Any]:
|
||||
"""Retrieve cached API response"""
|
||||
cache_key = self._generate_api_cache_key(view_name, params)
|
||||
result = self.api_cache.get(cache_key)
|
||||
|
||||
if result:
|
||||
logger.debug(f"Cache hit for API view '{view_name}'")
|
||||
else:
|
||||
logger.debug(f"Cache miss for API view '{view_name}'")
|
||||
|
||||
return result
|
||||
|
||||
# L3: Geographic caching (building on existing MapCacheService)
|
||||
def cache_geographic_data(
|
||||
self,
|
||||
bounds: "GeoBounds",
|
||||
data: Any,
|
||||
zoom_level: int,
|
||||
timeout: int = 1800,
|
||||
):
|
||||
"""Cache geographic data with spatial keys"""
|
||||
# Generate spatial cache key based on bounds and zoom level
|
||||
cache_key = f"geo:{
|
||||
bounds.min_lat}:{
|
||||
bounds.min_lng}:{
|
||||
bounds.max_lat}:{
|
||||
bounds.max_lng}:z{zoom_level}"
|
||||
self.default_cache.set(cache_key, data, timeout)
|
||||
logger.debug(f"Cached geographic data for bounds {bounds}")
|
||||
|
||||
def get_cached_geographic_data(
|
||||
self, bounds: "GeoBounds", zoom_level: int
|
||||
) -> Optional[Any]:
|
||||
"""Retrieve cached geographic data"""
|
||||
cache_key = f"geo:{
|
||||
bounds.min_lat}:{
|
||||
bounds.min_lng}:{
|
||||
bounds.max_lat}:{
|
||||
bounds.max_lng}:z{zoom_level}"
|
||||
return self.default_cache.get(cache_key)
|
||||
|
||||
# Cache invalidation utilities
|
||||
def invalidate_pattern(self, pattern: str):
|
||||
"""Invalidate cache keys matching a pattern (if backend supports it)"""
|
||||
try:
|
||||
# For Redis cache backends
|
||||
if hasattr(self.default_cache, "delete_pattern"):
|
||||
deleted_count = self.default_cache.delete_pattern(pattern)
|
||||
logger.info(
|
||||
f"Invalidated {deleted_count} cache keys matching pattern '{pattern}'"
|
||||
)
|
||||
return deleted_count
|
||||
else:
|
||||
logger.warning(
|
||||
f"Cache backend does not support pattern deletion for pattern '{pattern}'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error invalidating cache pattern '{pattern}': {e}")
|
||||
|
||||
def invalidate_model_cache(
|
||||
self, model_name: str, instance_id: Optional[int] = None
|
||||
):
|
||||
"""Invalidate cache keys related to a specific model"""
|
||||
if instance_id:
|
||||
pattern = f"*{model_name}:{instance_id}*"
|
||||
else:
|
||||
pattern = f"*{model_name}*"
|
||||
|
||||
self.invalidate_pattern(pattern)
|
||||
|
||||
# Cache warming utilities
|
||||
def warm_cache(
|
||||
self,
|
||||
cache_key: str,
|
||||
warm_func: Callable,
|
||||
timeout: int = 3600,
|
||||
**kwargs,
|
||||
):
|
||||
"""Proactively warm cache with data"""
|
||||
try:
|
||||
data = warm_func(**kwargs)
|
||||
self.default_cache.set(cache_key, data, timeout)
|
||||
logger.info(f"Warmed cache for key '{cache_key}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Error warming cache for key '{cache_key}': {e}")
|
||||
|
||||
def _generate_api_cache_key(self, view_name: str, params: Dict) -> str:
|
||||
"""Generate consistent cache keys for API responses"""
|
||||
# Sort params to ensure consistent key generation
|
||||
params_str = json.dumps(params, sort_keys=True, default=str)
|
||||
params_hash = hashlib.md5(params_str.encode()).hexdigest()
|
||||
return f"api:{view_name}:{params_hash}"
|
||||
|
||||
|
||||
# Cache decorators
|
||||
def cache_api_response(timeout=1800, vary_on=None, key_prefix=""):
|
||||
"""Decorator for caching API responses"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(self, request, *args, **kwargs):
|
||||
if request.method != "GET":
|
||||
return view_func(self, request, *args, **kwargs)
|
||||
|
||||
# Generate cache key based on view, user, and parameters
|
||||
cache_key_parts = [
|
||||
key_prefix or view_func.__name__,
|
||||
(
|
||||
str(request.user.id)
|
||||
if request.user.is_authenticated
|
||||
else "anonymous"
|
||||
),
|
||||
str(hash(frozenset(request.GET.items()))),
|
||||
]
|
||||
|
||||
if vary_on:
|
||||
for field in vary_on:
|
||||
cache_key_parts.append(str(getattr(request, field, "")))
|
||||
|
||||
cache_key = ":".join(cache_key_parts)
|
||||
|
||||
# Try to get from cache
|
||||
cache_service = EnhancedCacheService()
|
||||
cached_response = cache_service.api_cache.get(cache_key)
|
||||
if cached_response:
|
||||
logger.debug(f"Cache hit for API view {view_func.__name__}")
|
||||
return cached_response
|
||||
|
||||
# Execute view and cache result
|
||||
response = view_func(self, request, *args, **kwargs)
|
||||
if hasattr(response, "status_code") and response.status_code == 200:
|
||||
cache_service.api_cache.set(cache_key, response, timeout)
|
||||
logger.debug(
|
||||
f"Cached API response for view {
|
||||
view_func.__name__}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cache_queryset_result(cache_key_template: str, timeout: int = 3600):
|
||||
"""Decorator for caching queryset results"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Generate cache key from template and arguments
|
||||
cache_key = cache_key_template.format(*args, **kwargs)
|
||||
|
||||
cache_service = EnhancedCacheService()
|
||||
return cache_service.cache_queryset(
|
||||
cache_key, func, timeout, *args, **kwargs
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Context manager for cache warming
|
||||
class CacheWarmer:
|
||||
"""Context manager for batch cache warming operations"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache_service = EnhancedCacheService()
|
||||
self.warm_operations = []
|
||||
|
||||
def add(
|
||||
self,
|
||||
cache_key: str,
|
||||
warm_func: Callable,
|
||||
timeout: int = 3600,
|
||||
**kwargs,
|
||||
):
|
||||
"""Add a cache warming operation to the batch"""
|
||||
self.warm_operations.append(
|
||||
{
|
||||
"cache_key": cache_key,
|
||||
"warm_func": warm_func,
|
||||
"timeout": timeout,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Execute all cache warming operations"""
|
||||
logger.info(f"Warming {len(self.warm_operations)} cache entries")
|
||||
|
||||
for operation in self.warm_operations:
|
||||
try:
|
||||
self.cache_service.warm_cache(**operation)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error warming cache for {
|
||||
operation['cache_key']}: {e}"
|
||||
)
|
||||
|
||||
|
||||
# Cache statistics and monitoring
|
||||
class CacheMonitor:
|
||||
"""Monitor cache performance and statistics"""
|
||||
|
||||
def __init__(self):
|
||||
self.cache_service = EnhancedCacheService()
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics if available"""
|
||||
stats = {}
|
||||
|
||||
try:
|
||||
# Redis cache stats
|
||||
if hasattr(self.cache_service.default_cache, "_cache"):
|
||||
redis_client = self.cache_service.default_cache._cache.get_client()
|
||||
info = redis_client.info()
|
||||
stats["redis"] = {
|
||||
"used_memory": info.get("used_memory_human"),
|
||||
"connected_clients": info.get("connected_clients"),
|
||||
"total_commands_processed": info.get("total_commands_processed"),
|
||||
"keyspace_hits": info.get("keyspace_hits"),
|
||||
"keyspace_misses": info.get("keyspace_misses"),
|
||||
}
|
||||
|
||||
# Calculate hit rate
|
||||
hits = info.get("keyspace_hits", 0)
|
||||
misses = info.get("keyspace_misses", 0)
|
||||
if hits + misses > 0:
|
||||
stats["redis"]["hit_rate"] = hits / (hits + misses) * 100
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cache stats: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
def log_cache_performance(self):
|
||||
"""Log cache performance metrics"""
|
||||
stats = self.get_cache_stats()
|
||||
if stats:
|
||||
logger.info("Cache performance statistics", extra=stats)
|
||||
479
core/services/location_adapters.py
Normal file
479
core/services/location_adapters.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Location adapters for converting between domain-specific models and UnifiedLocation.
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
from typing import List, Optional
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
|
||||
from .data_structures import (
|
||||
UnifiedLocation,
|
||||
LocationType,
|
||||
GeoBounds,
|
||||
MapFilters,
|
||||
)
|
||||
from parks.models import ParkLocation, CompanyHeadquarters
|
||||
from rides.models import RideLocation
|
||||
from location.models import Location
|
||||
|
||||
|
||||
class BaseLocationAdapter:
|
||||
"""Base adapter class for location conversions."""
|
||||
|
||||
def to_unified_location(self, location_obj) -> Optional[UnifiedLocation]:
|
||||
"""Convert model instance to UnifiedLocation."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_queryset(
|
||||
self,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> QuerySet:
|
||||
"""Get optimized queryset for this location type."""
|
||||
raise NotImplementedError
|
||||
|
||||
def bulk_convert(self, queryset: QuerySet) -> List[UnifiedLocation]:
|
||||
"""Convert multiple location objects efficiently."""
|
||||
unified_locations = []
|
||||
for obj in queryset:
|
||||
unified_loc = self.to_unified_location(obj)
|
||||
if unified_loc:
|
||||
unified_locations.append(unified_loc)
|
||||
return unified_locations
|
||||
|
||||
|
||||
class ParkLocationAdapter(BaseLocationAdapter):
|
||||
"""Converts Park/ParkLocation to UnifiedLocation."""
|
||||
|
||||
def to_unified_location(
|
||||
self, park_location: ParkLocation
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert ParkLocation to UnifiedLocation."""
|
||||
if not park_location.point:
|
||||
return None
|
||||
|
||||
park = park_location.park
|
||||
|
||||
return UnifiedLocation(
|
||||
id=f"park_{park.id}",
|
||||
type=LocationType.PARK,
|
||||
name=park.name,
|
||||
coordinates=(park_location.latitude, park_location.longitude),
|
||||
address=park_location.formatted_address,
|
||||
metadata={
|
||||
"status": getattr(park, "status", "UNKNOWN"),
|
||||
"rating": (
|
||||
float(park.average_rating)
|
||||
if hasattr(park, "average_rating") and park.average_rating
|
||||
else None
|
||||
),
|
||||
"ride_count": getattr(park, "ride_count", 0),
|
||||
"coaster_count": getattr(park, "coaster_count", 0),
|
||||
"operator": (
|
||||
park.operator.name
|
||||
if hasattr(park, "operator") and park.operator
|
||||
else None
|
||||
),
|
||||
"city": park_location.city,
|
||||
"state": park_location.state,
|
||||
"country": park_location.country,
|
||||
},
|
||||
type_data={
|
||||
"slug": park.slug,
|
||||
"opening_date": (
|
||||
park.opening_date.isoformat()
|
||||
if hasattr(park, "opening_date") and park.opening_date
|
||||
else None
|
||||
),
|
||||
"website": getattr(park, "website", ""),
|
||||
"operating_season": getattr(park, "operating_season", ""),
|
||||
"highway_exit": park_location.highway_exit,
|
||||
"parking_notes": park_location.parking_notes,
|
||||
"best_arrival_time": (
|
||||
park_location.best_arrival_time.strftime("%H:%M")
|
||||
if park_location.best_arrival_time
|
||||
else None
|
||||
),
|
||||
"seasonal_notes": park_location.seasonal_notes,
|
||||
"url": self._get_park_url(park),
|
||||
},
|
||||
cluster_weight=self._calculate_park_weight(park),
|
||||
cluster_category=self._get_park_category(park),
|
||||
)
|
||||
|
||||
def get_queryset(
|
||||
self,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> QuerySet:
|
||||
"""Get optimized queryset for park locations."""
|
||||
queryset = ParkLocation.objects.select_related("park", "park__operator").filter(
|
||||
point__isnull=False
|
||||
)
|
||||
|
||||
# Spatial filtering
|
||||
if bounds:
|
||||
queryset = queryset.filter(point__within=bounds.to_polygon())
|
||||
|
||||
# Park-specific filters
|
||||
if filters:
|
||||
if filters.park_status:
|
||||
queryset = queryset.filter(park__status__in=filters.park_status)
|
||||
if filters.search_query:
|
||||
queryset = queryset.filter(park__name__icontains=filters.search_query)
|
||||
if filters.country:
|
||||
queryset = queryset.filter(country=filters.country)
|
||||
if filters.state:
|
||||
queryset = queryset.filter(state=filters.state)
|
||||
if filters.city:
|
||||
queryset = queryset.filter(city=filters.city)
|
||||
|
||||
return queryset.order_by("park__name")
|
||||
|
||||
def _calculate_park_weight(self, park) -> int:
|
||||
"""Calculate clustering weight based on park importance."""
|
||||
weight = 1
|
||||
if hasattr(park, "ride_count") and park.ride_count and park.ride_count > 20:
|
||||
weight += 2
|
||||
if (
|
||||
hasattr(park, "coaster_count")
|
||||
and park.coaster_count
|
||||
and park.coaster_count > 5
|
||||
):
|
||||
weight += 1
|
||||
if (
|
||||
hasattr(park, "average_rating")
|
||||
and park.average_rating
|
||||
and park.average_rating > 4.0
|
||||
):
|
||||
weight += 1
|
||||
return min(weight, 5) # Cap at 5
|
||||
|
||||
def _get_park_category(self, park) -> str:
|
||||
"""Determine park category for clustering."""
|
||||
coaster_count = getattr(park, "coaster_count", 0) or 0
|
||||
ride_count = getattr(park, "ride_count", 0) or 0
|
||||
|
||||
if coaster_count >= 10:
|
||||
return "major_park"
|
||||
elif ride_count >= 15:
|
||||
return "theme_park"
|
||||
else:
|
||||
return "small_park"
|
||||
|
||||
def _get_park_url(self, park) -> str:
|
||||
"""Get URL for park detail page."""
|
||||
try:
|
||||
return reverse("parks:detail", kwargs={"slug": park.slug})
|
||||
except BaseException:
|
||||
return f"/parks/{park.slug}/"
|
||||
|
||||
|
||||
class RideLocationAdapter(BaseLocationAdapter):
|
||||
"""Converts Ride/RideLocation to UnifiedLocation."""
|
||||
|
||||
def to_unified_location(
|
||||
self, ride_location: RideLocation
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert RideLocation to UnifiedLocation."""
|
||||
if not ride_location.point:
|
||||
return None
|
||||
|
||||
ride = ride_location.ride
|
||||
|
||||
return UnifiedLocation(
|
||||
id=f"ride_{ride.id}",
|
||||
type=LocationType.RIDE,
|
||||
name=ride.name,
|
||||
coordinates=(ride_location.latitude, ride_location.longitude),
|
||||
address=(
|
||||
f"{ride_location.park_area}, {ride.park.name}"
|
||||
if ride_location.park_area
|
||||
else ride.park.name
|
||||
),
|
||||
metadata={
|
||||
"park_id": ride.park.id,
|
||||
"park_name": ride.park.name,
|
||||
"park_area": ride_location.park_area,
|
||||
"ride_type": getattr(ride, "ride_type", "Unknown"),
|
||||
"status": getattr(ride, "status", "UNKNOWN"),
|
||||
"rating": (
|
||||
float(ride.average_rating)
|
||||
if hasattr(ride, "average_rating") and ride.average_rating
|
||||
else None
|
||||
),
|
||||
"manufacturer": (
|
||||
getattr(ride, "manufacturer", {}).get("name")
|
||||
if hasattr(ride, "manufacturer")
|
||||
else None
|
||||
),
|
||||
},
|
||||
type_data={
|
||||
"slug": ride.slug,
|
||||
"opening_date": (
|
||||
ride.opening_date.isoformat()
|
||||
if hasattr(ride, "opening_date") and ride.opening_date
|
||||
else None
|
||||
),
|
||||
"height_requirement": getattr(ride, "height_requirement", ""),
|
||||
"duration_minutes": getattr(ride, "duration_minutes", None),
|
||||
"max_speed_mph": getattr(ride, "max_speed_mph", None),
|
||||
"entrance_notes": ride_location.entrance_notes,
|
||||
"accessibility_notes": ride_location.accessibility_notes,
|
||||
"url": self._get_ride_url(ride),
|
||||
},
|
||||
cluster_weight=self._calculate_ride_weight(ride),
|
||||
cluster_category=self._get_ride_category(ride),
|
||||
)
|
||||
|
||||
def get_queryset(
|
||||
self,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> QuerySet:
|
||||
"""Get optimized queryset for ride locations."""
|
||||
queryset = RideLocation.objects.select_related(
|
||||
"ride", "ride__park", "ride__park__operator"
|
||||
).filter(point__isnull=False)
|
||||
|
||||
# Spatial filtering
|
||||
if bounds:
|
||||
queryset = queryset.filter(point__within=bounds.to_polygon())
|
||||
|
||||
# Ride-specific filters
|
||||
if filters:
|
||||
if filters.ride_types:
|
||||
queryset = queryset.filter(ride__ride_type__in=filters.ride_types)
|
||||
if filters.search_query:
|
||||
queryset = queryset.filter(ride__name__icontains=filters.search_query)
|
||||
|
||||
return queryset.order_by("ride__name")
|
||||
|
||||
def _calculate_ride_weight(self, ride) -> int:
|
||||
"""Calculate clustering weight based on ride importance."""
|
||||
weight = 1
|
||||
ride_type = getattr(ride, "ride_type", "").lower()
|
||||
if "coaster" in ride_type or "roller" in ride_type:
|
||||
weight += 1
|
||||
if (
|
||||
hasattr(ride, "average_rating")
|
||||
and ride.average_rating
|
||||
and ride.average_rating > 4.0
|
||||
):
|
||||
weight += 1
|
||||
return min(weight, 3) # Cap at 3 for rides
|
||||
|
||||
def _get_ride_category(self, ride) -> str:
|
||||
"""Determine ride category for clustering."""
|
||||
ride_type = getattr(ride, "ride_type", "").lower()
|
||||
if "coaster" in ride_type or "roller" in ride_type:
|
||||
return "coaster"
|
||||
elif "water" in ride_type or "splash" in ride_type:
|
||||
return "water_ride"
|
||||
else:
|
||||
return "other_ride"
|
||||
|
||||
def _get_ride_url(self, ride) -> str:
|
||||
"""Get URL for ride detail page."""
|
||||
try:
|
||||
return reverse("rides:detail", kwargs={"slug": ride.slug})
|
||||
except BaseException:
|
||||
return f"/rides/{ride.slug}/"
|
||||
|
||||
|
||||
class CompanyLocationAdapter(BaseLocationAdapter):
|
||||
"""Converts Company/CompanyHeadquarters to UnifiedLocation."""
|
||||
|
||||
def to_unified_location(
|
||||
self, company_headquarters: CompanyHeadquarters
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Convert CompanyHeadquarters to UnifiedLocation."""
|
||||
# Note: CompanyHeadquarters doesn't have coordinates, so we need to geocode
|
||||
# For now, we'll skip companies without coordinates
|
||||
# TODO: Implement geocoding service integration
|
||||
return None
|
||||
|
||||
def get_queryset(
|
||||
self,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> QuerySet:
|
||||
"""Get optimized queryset for company locations."""
|
||||
queryset = CompanyHeadquarters.objects.select_related("company")
|
||||
|
||||
# Company-specific filters
|
||||
if filters:
|
||||
if filters.company_roles:
|
||||
queryset = queryset.filter(
|
||||
company__roles__overlap=filters.company_roles
|
||||
)
|
||||
if filters.search_query:
|
||||
queryset = queryset.filter(
|
||||
company__name__icontains=filters.search_query
|
||||
)
|
||||
if filters.country:
|
||||
queryset = queryset.filter(country=filters.country)
|
||||
if filters.city:
|
||||
queryset = queryset.filter(city=filters.city)
|
||||
|
||||
return queryset.order_by("company__name")
|
||||
|
||||
|
||||
class GenericLocationAdapter(BaseLocationAdapter):
|
||||
"""Converts generic Location model to UnifiedLocation."""
|
||||
|
||||
def to_unified_location(self, location: Location) -> Optional[UnifiedLocation]:
|
||||
"""Convert generic Location to UnifiedLocation."""
|
||||
if not location.point and not (location.latitude and location.longitude):
|
||||
return None
|
||||
|
||||
# Use point coordinates if available, fall back to lat/lng fields
|
||||
if location.point:
|
||||
coordinates = (location.point.y, location.point.x)
|
||||
else:
|
||||
coordinates = (float(location.latitude), float(location.longitude))
|
||||
|
||||
return UnifiedLocation(
|
||||
id=f"generic_{location.id}",
|
||||
type=LocationType.GENERIC,
|
||||
name=location.name,
|
||||
coordinates=coordinates,
|
||||
address=location.get_formatted_address(),
|
||||
metadata={
|
||||
"location_type": location.location_type,
|
||||
"content_type": (
|
||||
location.content_type.model if location.content_type else None
|
||||
),
|
||||
"object_id": location.object_id,
|
||||
"city": location.city,
|
||||
"state": location.state,
|
||||
"country": location.country,
|
||||
},
|
||||
type_data={
|
||||
"created_at": (
|
||||
location.created_at.isoformat() if location.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
location.updated_at.isoformat() if location.updated_at else None
|
||||
),
|
||||
},
|
||||
cluster_weight=1,
|
||||
cluster_category="generic",
|
||||
)
|
||||
|
||||
def get_queryset(
|
||||
self,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> QuerySet:
|
||||
"""Get optimized queryset for generic locations."""
|
||||
queryset = Location.objects.select_related("content_type").filter(
|
||||
models.Q(point__isnull=False)
|
||||
| models.Q(latitude__isnull=False, longitude__isnull=False)
|
||||
)
|
||||
|
||||
# Spatial filtering
|
||||
if bounds:
|
||||
queryset = queryset.filter(
|
||||
models.Q(point__within=bounds.to_polygon())
|
||||
| models.Q(
|
||||
latitude__gte=bounds.south,
|
||||
latitude__lte=bounds.north,
|
||||
longitude__gte=bounds.west,
|
||||
longitude__lte=bounds.east,
|
||||
)
|
||||
)
|
||||
|
||||
# Generic filters
|
||||
if filters:
|
||||
if filters.search_query:
|
||||
queryset = queryset.filter(name__icontains=filters.search_query)
|
||||
if filters.country:
|
||||
queryset = queryset.filter(country=filters.country)
|
||||
if filters.city:
|
||||
queryset = queryset.filter(city=filters.city)
|
||||
|
||||
return queryset.order_by("name")
|
||||
|
||||
|
||||
class LocationAbstractionLayer:
|
||||
"""
|
||||
Abstraction layer handling different location model types.
|
||||
Implements the adapter pattern to provide unified access to all location types.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.adapters = {
|
||||
LocationType.PARK: ParkLocationAdapter(),
|
||||
LocationType.RIDE: RideLocationAdapter(),
|
||||
LocationType.COMPANY: CompanyLocationAdapter(),
|
||||
LocationType.GENERIC: GenericLocationAdapter(),
|
||||
}
|
||||
|
||||
def get_all_locations(
|
||||
self,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> List[UnifiedLocation]:
|
||||
"""Get locations from all sources within bounds."""
|
||||
all_locations = []
|
||||
|
||||
# Determine which location types to include
|
||||
location_types = (
|
||||
filters.location_types
|
||||
if filters and filters.location_types
|
||||
else set(LocationType)
|
||||
)
|
||||
|
||||
for location_type in location_types:
|
||||
adapter = self.adapters[location_type]
|
||||
queryset = adapter.get_queryset(bounds, filters)
|
||||
locations = adapter.bulk_convert(queryset)
|
||||
all_locations.extend(locations)
|
||||
|
||||
return all_locations
|
||||
|
||||
def get_locations_by_type(
|
||||
self,
|
||||
location_type: LocationType,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> List[UnifiedLocation]:
|
||||
"""Get locations of specific type."""
|
||||
adapter = self.adapters[location_type]
|
||||
queryset = adapter.get_queryset(bounds, filters)
|
||||
return adapter.bulk_convert(queryset)
|
||||
|
||||
def get_location_by_id(
|
||||
self, location_type: LocationType, location_id: int
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""Get single location with full details."""
|
||||
adapter = self.adapters[location_type]
|
||||
|
||||
try:
|
||||
if location_type == LocationType.PARK:
|
||||
obj = ParkLocation.objects.select_related("park", "park__operator").get(
|
||||
park_id=location_id
|
||||
)
|
||||
elif location_type == LocationType.RIDE:
|
||||
obj = RideLocation.objects.select_related("ride", "ride__park").get(
|
||||
ride_id=location_id
|
||||
)
|
||||
elif location_type == LocationType.COMPANY:
|
||||
obj = CompanyHeadquarters.objects.select_related("company").get(
|
||||
company_id=location_id
|
||||
)
|
||||
elif location_type == LocationType.GENERIC:
|
||||
obj = Location.objects.select_related("content_type").get(
|
||||
id=location_id
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
return adapter.to_unified_location(obj)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Import models after defining adapters to avoid circular imports
|
||||
465
core/services/location_search.py
Normal file
465
core/services/location_search.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
Location-aware search service for ThrillWiki.
|
||||
|
||||
Integrates PostGIS location data with existing search functionality
|
||||
to provide proximity-based search, location filtering, and geographic
|
||||
search capabilities.
|
||||
"""
|
||||
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
from django.db.models import Q
|
||||
from typing import Optional, List, Dict, Any, Set
|
||||
from dataclasses import dataclass
|
||||
|
||||
from parks.models import Park, Company, ParkLocation
|
||||
from rides.models import Ride
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocationSearchFilters:
|
||||
"""Filters for location-aware search queries."""
|
||||
|
||||
# Text search
|
||||
search_query: Optional[str] = None
|
||||
|
||||
# Location-based filters
|
||||
location_point: Optional[Point] = None
|
||||
radius_km: Optional[float] = None
|
||||
location_types: Optional[Set[str]] = None # 'park', 'ride', 'company'
|
||||
|
||||
# Geographic filters
|
||||
country: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
|
||||
# Content-specific filters
|
||||
park_status: Optional[List[str]] = None
|
||||
ride_types: Optional[List[str]] = None
|
||||
company_roles: Optional[List[str]] = None
|
||||
|
||||
# Result options
|
||||
include_distance: bool = True
|
||||
max_results: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocationSearchResult:
|
||||
"""Single search result with location data."""
|
||||
|
||||
# Core data
|
||||
content_type: str # 'park', 'ride', 'company'
|
||||
object_id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
# Location data
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
|
||||
# Distance data (if proximity search)
|
||||
distance_km: Optional[float] = None
|
||||
|
||||
# Additional metadata
|
||||
status: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
rating: Optional[float] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"content_type": self.content_type,
|
||||
"object_id": self.object_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"url": self.url,
|
||||
"location": {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"address": self.address,
|
||||
"city": self.city,
|
||||
"state": self.state,
|
||||
"country": self.country,
|
||||
},
|
||||
"distance_km": self.distance_km,
|
||||
"status": self.status,
|
||||
"tags": self.tags or [],
|
||||
"rating": self.rating,
|
||||
}
|
||||
|
||||
|
||||
class LocationSearchService:
|
||||
"""Service for performing location-aware searches across ThrillWiki content."""
|
||||
|
||||
def search(self, filters: LocationSearchFilters) -> List[LocationSearchResult]:
|
||||
"""
|
||||
Perform a comprehensive location-aware search.
|
||||
|
||||
Args:
|
||||
filters: Search filters and options
|
||||
|
||||
Returns:
|
||||
List of search results with location data
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Search each content type based on filters
|
||||
if not filters.location_types or "park" in filters.location_types:
|
||||
results.extend(self._search_parks(filters))
|
||||
|
||||
if not filters.location_types or "ride" in filters.location_types:
|
||||
results.extend(self._search_rides(filters))
|
||||
|
||||
if not filters.location_types or "company" in filters.location_types:
|
||||
results.extend(self._search_companies(filters))
|
||||
|
||||
# Sort by distance if proximity search, otherwise by relevance
|
||||
if filters.location_point and filters.include_distance:
|
||||
results.sort(key=lambda x: x.distance_km or float("inf"))
|
||||
else:
|
||||
results.sort(key=lambda x: x.name.lower())
|
||||
|
||||
# Apply max results limit
|
||||
return results[: filters.max_results]
|
||||
|
||||
def _search_parks(
|
||||
self, filters: LocationSearchFilters
|
||||
) -> List[LocationSearchResult]:
|
||||
"""Search parks with location data."""
|
||||
queryset = Park.objects.select_related("location", "operator").all()
|
||||
|
||||
# Apply location filters
|
||||
queryset = self._apply_location_filters(queryset, filters, "location__point")
|
||||
|
||||
# Apply text search
|
||||
if filters.search_query:
|
||||
query = (
|
||||
Q(name__icontains=filters.search_query)
|
||||
| Q(description__icontains=filters.search_query)
|
||||
| Q(location__city__icontains=filters.search_query)
|
||||
| Q(location__state__icontains=filters.search_query)
|
||||
| Q(location__country__icontains=filters.search_query)
|
||||
)
|
||||
queryset = queryset.filter(query)
|
||||
|
||||
# Apply park-specific filters
|
||||
if filters.park_status:
|
||||
queryset = queryset.filter(status__in=filters.park_status)
|
||||
|
||||
# Add distance annotation if proximity search
|
||||
if filters.location_point and filters.include_distance:
|
||||
queryset = queryset.annotate(
|
||||
distance=Distance("location__point", filters.location_point)
|
||||
).order_by("distance")
|
||||
|
||||
# Convert to search results
|
||||
results = []
|
||||
for park in queryset:
|
||||
result = LocationSearchResult(
|
||||
content_type="park",
|
||||
object_id=park.id,
|
||||
name=park.name,
|
||||
description=park.description,
|
||||
url=(
|
||||
park.get_absolute_url()
|
||||
if hasattr(park, "get_absolute_url")
|
||||
else None
|
||||
),
|
||||
status=park.get_status_display(),
|
||||
rating=(float(park.average_rating) if park.average_rating else None),
|
||||
tags=["park", park.status.lower()],
|
||||
)
|
||||
|
||||
# Add location data
|
||||
if hasattr(park, "location") and park.location:
|
||||
location = park.location
|
||||
result.latitude = location.latitude
|
||||
result.longitude = location.longitude
|
||||
result.address = location.formatted_address
|
||||
result.city = location.city
|
||||
result.state = location.state
|
||||
result.country = location.country
|
||||
|
||||
# Add distance if proximity search
|
||||
if (
|
||||
filters.location_point
|
||||
and filters.include_distance
|
||||
and hasattr(park, "distance")
|
||||
):
|
||||
result.distance_km = float(park.distance.km)
|
||||
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
def _search_rides(
|
||||
self, filters: LocationSearchFilters
|
||||
) -> List[LocationSearchResult]:
|
||||
"""Search rides with location data."""
|
||||
queryset = Ride.objects.select_related("park", "location").all()
|
||||
|
||||
# Apply location filters
|
||||
queryset = self._apply_location_filters(queryset, filters, "location__point")
|
||||
|
||||
# Apply text search
|
||||
if filters.search_query:
|
||||
query = (
|
||||
Q(name__icontains=filters.search_query)
|
||||
| Q(description__icontains=filters.search_query)
|
||||
| Q(park__name__icontains=filters.search_query)
|
||||
| Q(location__park_area__icontains=filters.search_query)
|
||||
)
|
||||
queryset = queryset.filter(query)
|
||||
|
||||
# Apply ride-specific filters
|
||||
if filters.ride_types:
|
||||
queryset = queryset.filter(ride_type__in=filters.ride_types)
|
||||
|
||||
# Add distance annotation if proximity search
|
||||
if filters.location_point and filters.include_distance:
|
||||
queryset = queryset.annotate(
|
||||
distance=Distance("location__point", filters.location_point)
|
||||
).order_by("distance")
|
||||
|
||||
# Convert to search results
|
||||
results = []
|
||||
for ride in queryset:
|
||||
result = LocationSearchResult(
|
||||
content_type="ride",
|
||||
object_id=ride.id,
|
||||
name=ride.name,
|
||||
description=ride.description,
|
||||
url=(
|
||||
ride.get_absolute_url()
|
||||
if hasattr(ride, "get_absolute_url")
|
||||
else None
|
||||
),
|
||||
status=ride.status,
|
||||
tags=[
|
||||
"ride",
|
||||
ride.ride_type.lower() if ride.ride_type else "attraction",
|
||||
],
|
||||
)
|
||||
|
||||
# Add location data from ride location or park location
|
||||
location = None
|
||||
if hasattr(ride, "location") and ride.location:
|
||||
location = ride.location
|
||||
result.latitude = location.latitude
|
||||
result.longitude = location.longitude
|
||||
result.address = (
|
||||
f"{ride.park.name} - {location.park_area}"
|
||||
if location.park_area
|
||||
else ride.park.name
|
||||
)
|
||||
|
||||
# Add distance if proximity search
|
||||
if (
|
||||
filters.location_point
|
||||
and filters.include_distance
|
||||
and hasattr(ride, "distance")
|
||||
):
|
||||
result.distance_km = float(ride.distance.km)
|
||||
|
||||
# Fall back to park location if no specific ride location
|
||||
elif ride.park and hasattr(ride.park, "location") and ride.park.location:
|
||||
park_location = ride.park.location
|
||||
result.latitude = park_location.latitude
|
||||
result.longitude = park_location.longitude
|
||||
result.address = park_location.formatted_address
|
||||
result.city = park_location.city
|
||||
result.state = park_location.state
|
||||
result.country = park_location.country
|
||||
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
def _search_companies(
|
||||
self, filters: LocationSearchFilters
|
||||
) -> List[LocationSearchResult]:
|
||||
"""Search companies with headquarters location data."""
|
||||
queryset = Company.objects.select_related("headquarters").all()
|
||||
|
||||
# Apply location filters
|
||||
queryset = self._apply_location_filters(
|
||||
queryset, filters, "headquarters__point"
|
||||
)
|
||||
|
||||
# Apply text search
|
||||
if filters.search_query:
|
||||
query = (
|
||||
Q(name__icontains=filters.search_query)
|
||||
| Q(description__icontains=filters.search_query)
|
||||
| Q(headquarters__city__icontains=filters.search_query)
|
||||
| Q(headquarters__state_province__icontains=filters.search_query)
|
||||
| Q(headquarters__country__icontains=filters.search_query)
|
||||
)
|
||||
queryset = queryset.filter(query)
|
||||
|
||||
# Apply company-specific filters
|
||||
if filters.company_roles:
|
||||
queryset = queryset.filter(roles__overlap=filters.company_roles)
|
||||
|
||||
# Add distance annotation if proximity search
|
||||
if filters.location_point and filters.include_distance:
|
||||
queryset = queryset.annotate(
|
||||
distance=Distance("headquarters__point", filters.location_point)
|
||||
).order_by("distance")
|
||||
|
||||
# Convert to search results
|
||||
results = []
|
||||
for company in queryset:
|
||||
result = LocationSearchResult(
|
||||
content_type="company",
|
||||
object_id=company.id,
|
||||
name=company.name,
|
||||
description=company.description,
|
||||
url=(
|
||||
company.get_absolute_url()
|
||||
if hasattr(company, "get_absolute_url")
|
||||
else None
|
||||
),
|
||||
tags=["company"] + (company.roles or []),
|
||||
)
|
||||
|
||||
# Add location data
|
||||
if hasattr(company, "headquarters") and company.headquarters:
|
||||
hq = company.headquarters
|
||||
result.latitude = hq.latitude
|
||||
result.longitude = hq.longitude
|
||||
result.address = hq.formatted_address
|
||||
result.city = hq.city
|
||||
result.state = hq.state_province
|
||||
result.country = hq.country
|
||||
|
||||
# Add distance if proximity search
|
||||
if (
|
||||
filters.location_point
|
||||
and filters.include_distance
|
||||
and hasattr(company, "distance")
|
||||
):
|
||||
result.distance_km = float(company.distance.km)
|
||||
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
def _apply_location_filters(
|
||||
self, queryset, filters: LocationSearchFilters, point_field: str
|
||||
):
|
||||
"""Apply common location filters to a queryset."""
|
||||
|
||||
# Proximity filter
|
||||
if filters.location_point and filters.radius_km:
|
||||
distance = Distance(km=filters.radius_km)
|
||||
queryset = queryset.filter(
|
||||
**{
|
||||
f"{point_field}__distance_lte": (
|
||||
filters.location_point,
|
||||
distance,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Geographic filters - adjust field names based on model
|
||||
if filters.country:
|
||||
if "headquarters" in point_field:
|
||||
queryset = queryset.filter(
|
||||
headquarters__country__icontains=filters.country
|
||||
)
|
||||
else:
|
||||
location_field = point_field.split("__")[0]
|
||||
queryset = queryset.filter(
|
||||
**{f"{location_field}__country__icontains": filters.country}
|
||||
)
|
||||
|
||||
if filters.state:
|
||||
if "headquarters" in point_field:
|
||||
queryset = queryset.filter(
|
||||
headquarters__state_province__icontains=filters.state
|
||||
)
|
||||
else:
|
||||
location_field = point_field.split("__")[0]
|
||||
queryset = queryset.filter(
|
||||
**{f"{location_field}__state__icontains": filters.state}
|
||||
)
|
||||
|
||||
if filters.city:
|
||||
location_field = point_field.split("__")[0]
|
||||
queryset = queryset.filter(
|
||||
**{f"{location_field}__city__icontains": filters.city}
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def suggest_locations(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get location suggestions for autocomplete.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of suggestions
|
||||
|
||||
Returns:
|
||||
List of location suggestions
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
if len(query) < 2:
|
||||
return suggestions
|
||||
|
||||
# Get park location suggestions
|
||||
park_locations = ParkLocation.objects.filter(
|
||||
Q(park__name__icontains=query)
|
||||
| Q(city__icontains=query)
|
||||
| Q(state__icontains=query)
|
||||
).select_related("park")[: limit // 3]
|
||||
|
||||
for location in park_locations:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "park",
|
||||
"name": location.park.name,
|
||||
"address": location.formatted_address,
|
||||
"coordinates": location.coordinates,
|
||||
"url": (
|
||||
location.park.get_absolute_url()
|
||||
if hasattr(location.park, "get_absolute_url")
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Get city suggestions
|
||||
cities = (
|
||||
ParkLocation.objects.filter(city__icontains=query)
|
||||
.values("city", "state", "country")
|
||||
.distinct()[: limit // 3]
|
||||
)
|
||||
|
||||
for city_data in cities:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "city",
|
||||
"name": f"{
|
||||
city_data['city']}, {
|
||||
city_data['state']}",
|
||||
"address": f"{
|
||||
city_data['city']}, {
|
||||
city_data['state']}, {
|
||||
city_data['country']}",
|
||||
"coordinates": None,
|
||||
}
|
||||
)
|
||||
|
||||
return suggestions[:limit]
|
||||
|
||||
|
||||
# Global instance
|
||||
location_search_service = LocationSearchService()
|
||||
438
core/services/map_cache_service.py
Normal file
438
core/services/map_cache_service.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
Caching service for map data to improve performance and reduce database load.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from .data_structures import (
|
||||
UnifiedLocation,
|
||||
ClusterData,
|
||||
GeoBounds,
|
||||
MapFilters,
|
||||
MapResponse,
|
||||
QueryPerformanceMetrics,
|
||||
)
|
||||
|
||||
|
||||
class MapCacheService:
|
||||
"""
|
||||
Handles caching of map data with geographic partitioning and intelligent invalidation.
|
||||
"""
|
||||
|
||||
# Cache configuration
|
||||
DEFAULT_TTL = 3600 # 1 hour
|
||||
CLUSTER_TTL = 7200 # 2 hours (clusters change less frequently)
|
||||
LOCATION_DETAIL_TTL = 1800 # 30 minutes
|
||||
BOUNDS_CACHE_TTL = 1800 # 30 minutes
|
||||
|
||||
# Cache key prefixes
|
||||
CACHE_PREFIX = "thrillwiki_map"
|
||||
LOCATIONS_PREFIX = f"{CACHE_PREFIX}:locations"
|
||||
CLUSTERS_PREFIX = f"{CACHE_PREFIX}:clusters"
|
||||
BOUNDS_PREFIX = f"{CACHE_PREFIX}:bounds"
|
||||
DETAIL_PREFIX = f"{CACHE_PREFIX}:detail"
|
||||
STATS_PREFIX = f"{CACHE_PREFIX}:stats"
|
||||
|
||||
# Geographic partitioning settings
|
||||
GEOHASH_PRECISION = 6 # ~1.2km precision for cache partitioning
|
||||
|
||||
def __init__(self):
|
||||
self.cache_stats = {
|
||||
"hits": 0,
|
||||
"misses": 0,
|
||||
"invalidations": 0,
|
||||
"geohash_partitions": 0,
|
||||
}
|
||||
|
||||
def get_locations_cache_key(
|
||||
self,
|
||||
bounds: Optional[GeoBounds],
|
||||
filters: Optional[MapFilters],
|
||||
zoom_level: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Generate cache key for location queries."""
|
||||
key_parts = [self.LOCATIONS_PREFIX]
|
||||
|
||||
if bounds:
|
||||
# Use geohash for spatial locality
|
||||
geohash = self._bounds_to_geohash(bounds)
|
||||
key_parts.append(f"geo:{geohash}")
|
||||
|
||||
if filters:
|
||||
# Create deterministic hash of filters
|
||||
filter_hash = self._hash_filters(filters)
|
||||
key_parts.append(f"filters:{filter_hash}")
|
||||
|
||||
if zoom_level is not None:
|
||||
key_parts.append(f"zoom:{zoom_level}")
|
||||
|
||||
return ":".join(key_parts)
|
||||
|
||||
def get_clusters_cache_key(
|
||||
self,
|
||||
bounds: Optional[GeoBounds],
|
||||
filters: Optional[MapFilters],
|
||||
zoom_level: int,
|
||||
) -> str:
|
||||
"""Generate cache key for cluster queries."""
|
||||
key_parts = [self.CLUSTERS_PREFIX, f"zoom:{zoom_level}"]
|
||||
|
||||
if bounds:
|
||||
geohash = self._bounds_to_geohash(bounds)
|
||||
key_parts.append(f"geo:{geohash}")
|
||||
|
||||
if filters:
|
||||
filter_hash = self._hash_filters(filters)
|
||||
key_parts.append(f"filters:{filter_hash}")
|
||||
|
||||
return ":".join(key_parts)
|
||||
|
||||
def get_location_detail_cache_key(
|
||||
self, location_type: str, location_id: int
|
||||
) -> str:
|
||||
"""Generate cache key for individual location details."""
|
||||
return f"{self.DETAIL_PREFIX}:{location_type}:{location_id}"
|
||||
|
||||
def cache_locations(
|
||||
self,
|
||||
cache_key: str,
|
||||
locations: List[UnifiedLocation],
|
||||
ttl: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Cache location data."""
|
||||
try:
|
||||
# Convert locations to serializable format
|
||||
cache_data = {
|
||||
"locations": [loc.to_dict() for loc in locations],
|
||||
"cached_at": timezone.now().isoformat(),
|
||||
"count": len(locations),
|
||||
}
|
||||
|
||||
cache.set(cache_key, cache_data, ttl or self.DEFAULT_TTL)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the request
|
||||
print(f"Cache write error for key {cache_key}: {e}")
|
||||
|
||||
def cache_clusters(
|
||||
self,
|
||||
cache_key: str,
|
||||
clusters: List[ClusterData],
|
||||
ttl: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Cache cluster data."""
|
||||
try:
|
||||
cache_data = {
|
||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||
"cached_at": timezone.now().isoformat(),
|
||||
"count": len(clusters),
|
||||
}
|
||||
|
||||
cache.set(cache_key, cache_data, ttl or self.CLUSTER_TTL)
|
||||
except Exception as e:
|
||||
print(f"Cache write error for clusters {cache_key}: {e}")
|
||||
|
||||
def cache_map_response(
|
||||
self, cache_key: str, response: MapResponse, ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Cache complete map response."""
|
||||
try:
|
||||
cache_data = response.to_dict()
|
||||
cache_data["cached_at"] = timezone.now().isoformat()
|
||||
|
||||
cache.set(cache_key, cache_data, ttl or self.DEFAULT_TTL)
|
||||
except Exception as e:
|
||||
print(f"Cache write error for response {cache_key}: {e}")
|
||||
|
||||
def get_cached_locations(self, cache_key: str) -> Optional[List[UnifiedLocation]]:
|
||||
"""Retrieve cached location data."""
|
||||
try:
|
||||
cache_data = cache.get(cache_key)
|
||||
if not cache_data:
|
||||
self.cache_stats["misses"] += 1
|
||||
return None
|
||||
|
||||
self.cache_stats["hits"] += 1
|
||||
|
||||
# Convert back to UnifiedLocation objects
|
||||
locations = []
|
||||
for loc_data in cache_data["locations"]:
|
||||
# Reconstruct UnifiedLocation from dictionary
|
||||
locations.append(self._dict_to_unified_location(loc_data))
|
||||
|
||||
return locations
|
||||
|
||||
except Exception as e:
|
||||
print(f"Cache read error for key {cache_key}: {e}")
|
||||
self.cache_stats["misses"] += 1
|
||||
return None
|
||||
|
||||
def get_cached_clusters(self, cache_key: str) -> Optional[List[ClusterData]]:
|
||||
"""Retrieve cached cluster data."""
|
||||
try:
|
||||
cache_data = cache.get(cache_key)
|
||||
if not cache_data:
|
||||
self.cache_stats["misses"] += 1
|
||||
return None
|
||||
|
||||
self.cache_stats["hits"] += 1
|
||||
|
||||
# Convert back to ClusterData objects
|
||||
clusters = []
|
||||
for cluster_data in cache_data["clusters"]:
|
||||
clusters.append(self._dict_to_cluster_data(cluster_data))
|
||||
|
||||
return clusters
|
||||
|
||||
except Exception as e:
|
||||
print(f"Cache read error for clusters {cache_key}: {e}")
|
||||
self.cache_stats["misses"] += 1
|
||||
return None
|
||||
|
||||
def get_cached_map_response(self, cache_key: str) -> Optional[MapResponse]:
|
||||
"""Retrieve cached map response."""
|
||||
try:
|
||||
cache_data = cache.get(cache_key)
|
||||
if not cache_data:
|
||||
self.cache_stats["misses"] += 1
|
||||
return None
|
||||
|
||||
self.cache_stats["hits"] += 1
|
||||
|
||||
# Convert back to MapResponse object
|
||||
return self._dict_to_map_response(cache_data["data"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Cache read error for response {cache_key}: {e}")
|
||||
self.cache_stats["misses"] += 1
|
||||
return None
|
||||
|
||||
def invalidate_location_cache(
|
||||
self, location_type: str, location_id: Optional[int] = None
|
||||
) -> None:
|
||||
"""Invalidate cache for specific location or all locations of a type."""
|
||||
try:
|
||||
if location_id:
|
||||
# Invalidate specific location detail
|
||||
detail_key = self.get_location_detail_cache_key(
|
||||
location_type, location_id
|
||||
)
|
||||
cache.delete(detail_key)
|
||||
|
||||
# Invalidate related location and cluster caches
|
||||
# In a production system, you'd want more sophisticated cache
|
||||
# tagging
|
||||
cache.delete_many(
|
||||
[f"{self.LOCATIONS_PREFIX}:*", f"{self.CLUSTERS_PREFIX}:*"]
|
||||
)
|
||||
|
||||
self.cache_stats["invalidations"] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Cache invalidation error: {e}")
|
||||
|
||||
def invalidate_bounds_cache(self, bounds: GeoBounds) -> None:
|
||||
"""Invalidate cache for specific geographic bounds."""
|
||||
try:
|
||||
geohash = self._bounds_to_geohash(bounds)
|
||||
pattern = f"{self.LOCATIONS_PREFIX}:geo:{geohash}*"
|
||||
|
||||
# In production, you'd use cache tagging or Redis SCAN
|
||||
# For now, we'll invalidate broader patterns
|
||||
cache.delete_many([pattern])
|
||||
|
||||
self.cache_stats["invalidations"] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Bounds cache invalidation error: {e}")
|
||||
|
||||
def clear_all_map_cache(self) -> None:
|
||||
"""Clear all map-related cache data."""
|
||||
try:
|
||||
cache.delete_many(
|
||||
[
|
||||
f"{self.LOCATIONS_PREFIX}:*",
|
||||
f"{self.CLUSTERS_PREFIX}:*",
|
||||
f"{self.BOUNDS_PREFIX}:*",
|
||||
f"{self.DETAIL_PREFIX}:*",
|
||||
]
|
||||
)
|
||||
|
||||
self.cache_stats["invalidations"] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Cache clear error: {e}")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache performance statistics."""
|
||||
total_requests = self.cache_stats["hits"] + self.cache_stats["misses"]
|
||||
hit_rate = (
|
||||
(self.cache_stats["hits"] / total_requests * 100)
|
||||
if total_requests > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"hits": self.cache_stats["hits"],
|
||||
"misses": self.cache_stats["misses"],
|
||||
"hit_rate_percent": round(hit_rate, 2),
|
||||
"invalidations": self.cache_stats["invalidations"],
|
||||
"geohash_partitions": self.cache_stats["geohash_partitions"],
|
||||
}
|
||||
|
||||
def record_performance_metrics(self, metrics: QueryPerformanceMetrics) -> None:
|
||||
"""Record query performance metrics for analysis."""
|
||||
try:
|
||||
# 5-minute buckets
|
||||
stats_key = f"{
|
||||
self.STATS_PREFIX}:performance:{
|
||||
int(
|
||||
time.time() //
|
||||
300)}"
|
||||
|
||||
current_stats = cache.get(
|
||||
stats_key,
|
||||
{
|
||||
"query_count": 0,
|
||||
"total_time_ms": 0,
|
||||
"cache_hits": 0,
|
||||
"db_queries": 0,
|
||||
},
|
||||
)
|
||||
|
||||
current_stats["query_count"] += 1
|
||||
current_stats["total_time_ms"] += metrics.query_time_ms
|
||||
current_stats["cache_hits"] += 1 if metrics.cache_hit else 0
|
||||
current_stats["db_queries"] += metrics.db_query_count
|
||||
|
||||
cache.set(stats_key, current_stats, 3600) # Keep for 1 hour
|
||||
|
||||
except Exception as e:
|
||||
print(f"Performance metrics recording error: {e}")
|
||||
|
||||
def _bounds_to_geohash(self, bounds: GeoBounds) -> str:
|
||||
"""Convert geographic bounds to geohash for cache partitioning."""
|
||||
# Use center point of bounds for geohash
|
||||
center_lat = (bounds.north + bounds.south) / 2
|
||||
center_lng = (bounds.east + bounds.west) / 2
|
||||
|
||||
# Simple geohash implementation (in production, use a library)
|
||||
return self._encode_geohash(center_lat, center_lng, self.GEOHASH_PRECISION)
|
||||
|
||||
def _encode_geohash(self, lat: float, lng: float, precision: int) -> str:
|
||||
"""Simple geohash encoding implementation."""
|
||||
# This is a simplified implementation
|
||||
# In production, use the `geohash` library
|
||||
lat_range = [-90.0, 90.0]
|
||||
lng_range = [-180.0, 180.0]
|
||||
|
||||
geohash = ""
|
||||
bits = 0
|
||||
bit_count = 0
|
||||
even_bit = True
|
||||
|
||||
while len(geohash) < precision:
|
||||
if even_bit:
|
||||
# longitude
|
||||
mid = (lng_range[0] + lng_range[1]) / 2
|
||||
if lng >= mid:
|
||||
bits = (bits << 1) + 1
|
||||
lng_range[0] = mid
|
||||
else:
|
||||
bits = bits << 1
|
||||
lng_range[1] = mid
|
||||
else:
|
||||
# latitude
|
||||
mid = (lat_range[0] + lat_range[1]) / 2
|
||||
if lat >= mid:
|
||||
bits = (bits << 1) + 1
|
||||
lat_range[0] = mid
|
||||
else:
|
||||
bits = bits << 1
|
||||
lat_range[1] = mid
|
||||
|
||||
even_bit = not even_bit
|
||||
bit_count += 1
|
||||
|
||||
if bit_count == 5:
|
||||
# Convert 5 bits to base32 character
|
||||
geohash += "0123456789bcdefghjkmnpqrstuvwxyz"[bits]
|
||||
bits = 0
|
||||
bit_count = 0
|
||||
|
||||
return geohash
|
||||
|
||||
def _hash_filters(self, filters: MapFilters) -> str:
|
||||
"""Create deterministic hash of filters for cache keys."""
|
||||
filter_dict = filters.to_dict()
|
||||
# Sort to ensure consistent ordering
|
||||
filter_str = json.dumps(filter_dict, sort_keys=True)
|
||||
return hashlib.md5(filter_str.encode()).hexdigest()[:8]
|
||||
|
||||
def _dict_to_unified_location(self, data: Dict[str, Any]) -> UnifiedLocation:
|
||||
"""Convert dictionary back to UnifiedLocation object."""
|
||||
from .data_structures import LocationType
|
||||
|
||||
return UnifiedLocation(
|
||||
id=data["id"],
|
||||
type=LocationType(data["type"]),
|
||||
name=data["name"],
|
||||
coordinates=tuple(data["coordinates"]),
|
||||
address=data.get("address"),
|
||||
metadata=data.get("metadata", {}),
|
||||
type_data=data.get("type_data", {}),
|
||||
cluster_weight=data.get("cluster_weight", 1),
|
||||
cluster_category=data.get("cluster_category", "default"),
|
||||
)
|
||||
|
||||
def _dict_to_cluster_data(self, data: Dict[str, Any]) -> ClusterData:
|
||||
"""Convert dictionary back to ClusterData object."""
|
||||
from .data_structures import LocationType
|
||||
|
||||
bounds = GeoBounds(**data["bounds"])
|
||||
types = {LocationType(t) for t in data["types"]}
|
||||
|
||||
representative = None
|
||||
if data.get("representative"):
|
||||
representative = self._dict_to_unified_location(data["representative"])
|
||||
|
||||
return ClusterData(
|
||||
id=data["id"],
|
||||
coordinates=tuple(data["coordinates"]),
|
||||
count=data["count"],
|
||||
types=types,
|
||||
bounds=bounds,
|
||||
representative_location=representative,
|
||||
)
|
||||
|
||||
def _dict_to_map_response(self, data: Dict[str, Any]) -> MapResponse:
|
||||
"""Convert dictionary back to MapResponse object."""
|
||||
locations = [
|
||||
self._dict_to_unified_location(loc) for loc in data.get("locations", [])
|
||||
]
|
||||
clusters = [
|
||||
self._dict_to_cluster_data(cluster) for cluster in data.get("clusters", [])
|
||||
]
|
||||
|
||||
bounds = None
|
||||
if data.get("bounds"):
|
||||
bounds = GeoBounds(**data["bounds"])
|
||||
|
||||
return MapResponse(
|
||||
locations=locations,
|
||||
clusters=clusters,
|
||||
bounds=bounds,
|
||||
total_count=data.get("total_count", 0),
|
||||
filtered_count=data.get("filtered_count", 0),
|
||||
zoom_level=data.get("zoom_level"),
|
||||
clustered=data.get("clustered", False),
|
||||
)
|
||||
|
||||
|
||||
# Global cache service instance
|
||||
map_cache = MapCacheService()
|
||||
474
core/services/map_service.py
Normal file
474
core/services/map_service.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Unified Map Service - Main orchestrating service for all map functionality.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any, Set
|
||||
from django.db import connection
|
||||
|
||||
from .data_structures import (
|
||||
UnifiedLocation,
|
||||
ClusterData,
|
||||
GeoBounds,
|
||||
MapFilters,
|
||||
MapResponse,
|
||||
LocationType,
|
||||
QueryPerformanceMetrics,
|
||||
)
|
||||
from .location_adapters import LocationAbstractionLayer
|
||||
from .clustering_service import ClusteringService
|
||||
from .map_cache_service import MapCacheService
|
||||
|
||||
|
||||
class UnifiedMapService:
|
||||
"""
|
||||
Main service orchestrating map data retrieval, filtering, clustering, and caching.
|
||||
Provides a unified interface for all location types with performance optimization.
|
||||
"""
|
||||
|
||||
# Performance thresholds
|
||||
MAX_UNCLUSTERED_POINTS = 500
|
||||
MAX_CLUSTERED_POINTS = 2000
|
||||
DEFAULT_ZOOM_LEVEL = 10
|
||||
|
||||
def __init__(self):
|
||||
self.location_layer = LocationAbstractionLayer()
|
||||
self.clustering_service = ClusteringService()
|
||||
self.cache_service = MapCacheService()
|
||||
|
||||
def get_map_data(
|
||||
self,
|
||||
*,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
zoom_level: int = DEFAULT_ZOOM_LEVEL,
|
||||
cluster: bool = True,
|
||||
use_cache: bool = True,
|
||||
) -> MapResponse:
|
||||
"""
|
||||
Primary method for retrieving unified map data.
|
||||
|
||||
Args:
|
||||
bounds: Geographic bounds to query within
|
||||
filters: Filtering criteria for locations
|
||||
zoom_level: Map zoom level for clustering decisions
|
||||
cluster: Whether to apply clustering
|
||||
use_cache: Whether to use cached data
|
||||
|
||||
Returns:
|
||||
MapResponse with locations, clusters, and metadata
|
||||
"""
|
||||
start_time = time.time()
|
||||
initial_query_count = len(connection.queries)
|
||||
cache_hit = False
|
||||
|
||||
try:
|
||||
# Generate cache key
|
||||
cache_key = None
|
||||
if use_cache:
|
||||
cache_key = self._generate_cache_key(
|
||||
bounds, filters, zoom_level, cluster
|
||||
)
|
||||
|
||||
# Try to get from cache first
|
||||
cached_response = self.cache_service.get_cached_map_response(cache_key)
|
||||
if cached_response:
|
||||
cached_response.cache_hit = True
|
||||
cached_response.query_time_ms = int(
|
||||
(time.time() - start_time) * 1000
|
||||
)
|
||||
return cached_response
|
||||
|
||||
# Get locations from database
|
||||
locations = self._get_locations_from_db(bounds, filters)
|
||||
|
||||
# Apply smart limiting based on zoom level and density
|
||||
locations = self._apply_smart_limiting(locations, bounds, zoom_level)
|
||||
|
||||
# Determine if clustering should be applied
|
||||
should_cluster = cluster and self.clustering_service.should_cluster(
|
||||
zoom_level, len(locations)
|
||||
)
|
||||
|
||||
# Apply clustering if needed
|
||||
clusters = []
|
||||
if should_cluster:
|
||||
locations, clusters = self.clustering_service.cluster_locations(
|
||||
locations, zoom_level, bounds
|
||||
)
|
||||
|
||||
# Calculate response bounds
|
||||
response_bounds = self._calculate_response_bounds(
|
||||
locations, clusters, bounds
|
||||
)
|
||||
|
||||
# Create response
|
||||
response = MapResponse(
|
||||
locations=locations,
|
||||
clusters=clusters,
|
||||
bounds=response_bounds,
|
||||
total_count=len(locations) + sum(cluster.count for cluster in clusters),
|
||||
filtered_count=len(locations),
|
||||
zoom_level=zoom_level,
|
||||
clustered=should_cluster,
|
||||
cache_hit=cache_hit,
|
||||
query_time_ms=int((time.time() - start_time) * 1000),
|
||||
filters_applied=self._get_applied_filters_list(filters),
|
||||
)
|
||||
|
||||
# Cache the response
|
||||
if use_cache and cache_key:
|
||||
self.cache_service.cache_map_response(cache_key, response)
|
||||
|
||||
# Record performance metrics
|
||||
self._record_performance_metrics(
|
||||
start_time,
|
||||
initial_query_count,
|
||||
cache_hit,
|
||||
len(locations) + len(clusters),
|
||||
bounds is not None,
|
||||
should_cluster,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception:
|
||||
# Return error response
|
||||
return MapResponse(
|
||||
locations=[],
|
||||
clusters=[],
|
||||
total_count=0,
|
||||
filtered_count=0,
|
||||
query_time_ms=int((time.time() - start_time) * 1000),
|
||||
cache_hit=False,
|
||||
)
|
||||
|
||||
def get_location_details(
|
||||
self, location_type: str, location_id: int
|
||||
) -> Optional[UnifiedLocation]:
|
||||
"""
|
||||
Get detailed information for a specific location.
|
||||
|
||||
Args:
|
||||
location_type: Type of location (park, ride, company, generic)
|
||||
location_id: ID of the location
|
||||
|
||||
Returns:
|
||||
UnifiedLocation with full details or None if not found
|
||||
"""
|
||||
try:
|
||||
# Check cache first
|
||||
cache_key = self.cache_service.get_location_detail_cache_key(
|
||||
location_type, location_id
|
||||
)
|
||||
cached_locations = self.cache_service.get_cached_locations(cache_key)
|
||||
if cached_locations:
|
||||
return cached_locations[0] if cached_locations else None
|
||||
|
||||
# Get from database
|
||||
location_type_enum = LocationType(location_type.lower())
|
||||
location = self.location_layer.get_location_by_id(
|
||||
location_type_enum, location_id
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
if location:
|
||||
self.cache_service.cache_locations(
|
||||
cache_key,
|
||||
[location],
|
||||
self.cache_service.LOCATION_DETAIL_TTL,
|
||||
)
|
||||
|
||||
return location
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting location details: {e}")
|
||||
return None
|
||||
|
||||
def search_locations(
|
||||
self,
|
||||
query: str,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
location_types: Optional[Set[LocationType]] = None,
|
||||
limit: int = 50,
|
||||
) -> List[UnifiedLocation]:
|
||||
"""
|
||||
Search locations with text query.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
bounds: Optional geographic bounds to search within
|
||||
location_types: Optional set of location types to search
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching UnifiedLocation objects
|
||||
"""
|
||||
try:
|
||||
# Create search filters
|
||||
filters = MapFilters(
|
||||
search_query=query,
|
||||
location_types=location_types or {LocationType.PARK, LocationType.RIDE},
|
||||
has_coordinates=True,
|
||||
)
|
||||
|
||||
# Get locations
|
||||
locations = self.location_layer.get_all_locations(bounds, filters)
|
||||
|
||||
# Apply limit
|
||||
return locations[:limit]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching locations: {e}")
|
||||
return []
|
||||
|
||||
def get_locations_by_bounds(
|
||||
self,
|
||||
north: float,
|
||||
south: float,
|
||||
east: float,
|
||||
west: float,
|
||||
location_types: Optional[Set[LocationType]] = None,
|
||||
zoom_level: int = DEFAULT_ZOOM_LEVEL,
|
||||
) -> MapResponse:
|
||||
"""
|
||||
Get locations within specific geographic bounds.
|
||||
|
||||
Args:
|
||||
north, south, east, west: Bounding box coordinates
|
||||
location_types: Optional filter for location types
|
||||
zoom_level: Map zoom level for optimization
|
||||
|
||||
Returns:
|
||||
MapResponse with locations in bounds
|
||||
"""
|
||||
try:
|
||||
bounds = GeoBounds(north=north, south=south, east=east, west=west)
|
||||
filters = (
|
||||
MapFilters(location_types=location_types) if location_types else None
|
||||
)
|
||||
|
||||
return self.get_map_data(
|
||||
bounds=bounds, filters=filters, zoom_level=zoom_level
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
# Invalid bounds
|
||||
return MapResponse(
|
||||
locations=[], clusters=[], total_count=0, filtered_count=0
|
||||
)
|
||||
|
||||
def get_clustered_locations(
|
||||
self,
|
||||
zoom_level: int,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
filters: Optional[MapFilters] = None,
|
||||
) -> MapResponse:
|
||||
"""
|
||||
Get clustered location data for map display.
|
||||
|
||||
Args:
|
||||
zoom_level: Map zoom level for clustering configuration
|
||||
bounds: Optional geographic bounds
|
||||
filters: Optional filtering criteria
|
||||
|
||||
Returns:
|
||||
MapResponse with clustered data
|
||||
"""
|
||||
return self.get_map_data(
|
||||
bounds=bounds, filters=filters, zoom_level=zoom_level, cluster=True
|
||||
)
|
||||
|
||||
def get_locations_by_type(
|
||||
self,
|
||||
location_type: LocationType,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[UnifiedLocation]:
|
||||
"""
|
||||
Get locations of a specific type.
|
||||
|
||||
Args:
|
||||
location_type: Type of locations to retrieve
|
||||
bounds: Optional geographic bounds
|
||||
limit: Optional limit on results
|
||||
|
||||
Returns:
|
||||
List of UnifiedLocation objects
|
||||
"""
|
||||
try:
|
||||
filters = MapFilters(location_types={location_type})
|
||||
locations = self.location_layer.get_locations_by_type(
|
||||
location_type, bounds, filters
|
||||
)
|
||||
|
||||
if limit:
|
||||
locations = locations[:limit]
|
||||
|
||||
return locations
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting locations by type: {e}")
|
||||
return []
|
||||
|
||||
def invalidate_cache(
|
||||
self,
|
||||
location_type: Optional[str] = None,
|
||||
location_id: Optional[int] = None,
|
||||
bounds: Optional[GeoBounds] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Invalidate cached map data.
|
||||
|
||||
Args:
|
||||
location_type: Optional specific location type to invalidate
|
||||
location_id: Optional specific location ID to invalidate
|
||||
bounds: Optional specific bounds to invalidate
|
||||
"""
|
||||
if location_type and location_id:
|
||||
self.cache_service.invalidate_location_cache(location_type, location_id)
|
||||
elif bounds:
|
||||
self.cache_service.invalidate_bounds_cache(bounds)
|
||||
else:
|
||||
self.cache_service.clear_all_map_cache()
|
||||
|
||||
def get_service_stats(self) -> Dict[str, Any]:
|
||||
"""Get service performance and usage statistics."""
|
||||
cache_stats = self.cache_service.get_cache_stats()
|
||||
|
||||
return {
|
||||
"cache_performance": cache_stats,
|
||||
"clustering_available": True,
|
||||
"supported_location_types": [t.value for t in LocationType],
|
||||
"max_unclustered_points": self.MAX_UNCLUSTERED_POINTS,
|
||||
"max_clustered_points": self.MAX_CLUSTERED_POINTS,
|
||||
"service_version": "1.0.0",
|
||||
}
|
||||
|
||||
def _get_locations_from_db(
|
||||
self, bounds: Optional[GeoBounds], filters: Optional[MapFilters]
|
||||
) -> List[UnifiedLocation]:
|
||||
"""Get locations from database using the abstraction layer."""
|
||||
return self.location_layer.get_all_locations(bounds, filters)
|
||||
|
||||
def _apply_smart_limiting(
|
||||
self,
|
||||
locations: List[UnifiedLocation],
|
||||
bounds: Optional[GeoBounds],
|
||||
zoom_level: int,
|
||||
) -> List[UnifiedLocation]:
|
||||
"""Apply intelligent limiting based on zoom level and density."""
|
||||
if zoom_level < 6: # Very zoomed out - show only major parks
|
||||
major_parks = [
|
||||
loc
|
||||
for loc in locations
|
||||
if (
|
||||
loc.type == LocationType.PARK
|
||||
and loc.cluster_category in ["major_park", "theme_park"]
|
||||
)
|
||||
]
|
||||
return major_parks[:200]
|
||||
elif zoom_level < 10: # Regional level
|
||||
return locations[:1000]
|
||||
else: # City level and closer
|
||||
return locations[: self.MAX_CLUSTERED_POINTS]
|
||||
|
||||
def _calculate_response_bounds(
|
||||
self,
|
||||
locations: List[UnifiedLocation],
|
||||
clusters: List[ClusterData],
|
||||
request_bounds: Optional[GeoBounds],
|
||||
) -> Optional[GeoBounds]:
|
||||
"""Calculate the actual bounds of the response data."""
|
||||
if request_bounds:
|
||||
return request_bounds
|
||||
|
||||
all_coords = []
|
||||
|
||||
# Add location coordinates
|
||||
for loc in locations:
|
||||
all_coords.append((loc.latitude, loc.longitude))
|
||||
|
||||
# Add cluster coordinates
|
||||
for cluster in clusters:
|
||||
all_coords.append(cluster.coordinates)
|
||||
|
||||
if not all_coords:
|
||||
return None
|
||||
|
||||
lats, lngs = zip(*all_coords)
|
||||
return GeoBounds(
|
||||
north=max(lats), south=min(lats), east=max(lngs), west=min(lngs)
|
||||
)
|
||||
|
||||
def _get_applied_filters_list(self, filters: Optional[MapFilters]) -> List[str]:
|
||||
"""Get list of applied filter types for metadata."""
|
||||
if not filters:
|
||||
return []
|
||||
|
||||
applied = []
|
||||
if filters.location_types:
|
||||
applied.append("location_types")
|
||||
if filters.search_query:
|
||||
applied.append("search_query")
|
||||
if filters.park_status:
|
||||
applied.append("park_status")
|
||||
if filters.ride_types:
|
||||
applied.append("ride_types")
|
||||
if filters.company_roles:
|
||||
applied.append("company_roles")
|
||||
if filters.min_rating:
|
||||
applied.append("min_rating")
|
||||
if filters.country:
|
||||
applied.append("country")
|
||||
if filters.state:
|
||||
applied.append("state")
|
||||
if filters.city:
|
||||
applied.append("city")
|
||||
|
||||
return applied
|
||||
|
||||
def _generate_cache_key(
|
||||
self,
|
||||
bounds: Optional[GeoBounds],
|
||||
filters: Optional[MapFilters],
|
||||
zoom_level: int,
|
||||
cluster: bool,
|
||||
) -> str:
|
||||
"""Generate cache key for the request."""
|
||||
if cluster:
|
||||
return self.cache_service.get_clusters_cache_key(
|
||||
bounds, filters, zoom_level
|
||||
)
|
||||
else:
|
||||
return self.cache_service.get_locations_cache_key(
|
||||
bounds, filters, zoom_level
|
||||
)
|
||||
|
||||
def _record_performance_metrics(
|
||||
self,
|
||||
start_time: float,
|
||||
initial_query_count: int,
|
||||
cache_hit: bool,
|
||||
result_count: int,
|
||||
bounds_used: bool,
|
||||
clustering_used: bool,
|
||||
) -> None:
|
||||
"""Record performance metrics for monitoring."""
|
||||
query_time_ms = int((time.time() - start_time) * 1000)
|
||||
db_query_count = len(connection.queries) - initial_query_count
|
||||
|
||||
metrics = QueryPerformanceMetrics(
|
||||
query_time_ms=query_time_ms,
|
||||
db_query_count=db_query_count,
|
||||
cache_hit=cache_hit,
|
||||
result_count=result_count,
|
||||
bounds_used=bounds_used,
|
||||
clustering_used=clustering_used,
|
||||
)
|
||||
|
||||
self.cache_service.record_performance_metrics(metrics)
|
||||
|
||||
|
||||
# Global service instance
|
||||
unified_map_service = UnifiedMapService()
|
||||
407
core/services/performance_monitoring.py
Normal file
407
core/services/performance_monitoring.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
Performance monitoring utilities and context managers.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.db import connection
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger("performance")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def monitor_performance(operation_name: str, **tags):
|
||||
"""Context manager for monitoring operation performance"""
|
||||
start_time = time.time()
|
||||
initial_queries = len(connection.queries)
|
||||
|
||||
# Create performance context
|
||||
performance_context = {
|
||||
"operation": operation_name,
|
||||
"start_time": start_time,
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
**tags,
|
||||
}
|
||||
|
||||
try:
|
||||
yield performance_context
|
||||
except Exception as e:
|
||||
performance_context["error"] = str(e)
|
||||
performance_context["status"] = "error"
|
||||
raise
|
||||
else:
|
||||
performance_context["status"] = "success"
|
||||
finally:
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
total_queries = len(connection.queries) - initial_queries
|
||||
|
||||
# Update performance context with final metrics
|
||||
performance_context.update(
|
||||
{
|
||||
"duration_seconds": duration,
|
||||
"duration_ms": round(duration * 1000, 2),
|
||||
"query_count": total_queries,
|
||||
"end_time": end_time,
|
||||
}
|
||||
)
|
||||
|
||||
# Log performance data
|
||||
log_level = (
|
||||
logging.WARNING if duration > 2.0 or total_queries > 10 else logging.INFO
|
||||
)
|
||||
logger.log(
|
||||
log_level,
|
||||
f"Performance: {operation_name} completed in {
|
||||
duration:.3f}s with {total_queries} queries",
|
||||
extra=performance_context,
|
||||
)
|
||||
|
||||
# Log slow operations with additional detail
|
||||
if duration > 2.0:
|
||||
logger.warning(
|
||||
f"Slow operation detected: {operation_name} took {
|
||||
duration:.3f}s",
|
||||
extra={
|
||||
"slow_operation": True,
|
||||
"threshold_exceeded": "duration",
|
||||
**performance_context,
|
||||
},
|
||||
)
|
||||
|
||||
if total_queries > 10:
|
||||
logger.warning(
|
||||
f"High query count: {operation_name} executed {total_queries} queries",
|
||||
extra={
|
||||
"high_query_count": True,
|
||||
"threshold_exceeded": "query_count",
|
||||
**performance_context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def track_queries(operation_name: str, warn_threshold: int = 10):
|
||||
"""Context manager to track database queries for specific operations"""
|
||||
if not settings.DEBUG:
|
||||
yield
|
||||
return
|
||||
|
||||
initial_queries = len(connection.queries)
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
end_time = time.time()
|
||||
total_queries = len(connection.queries) - initial_queries
|
||||
execution_time = end_time - start_time
|
||||
|
||||
query_details = []
|
||||
if hasattr(connection, "queries") and total_queries > 0:
|
||||
recent_queries = connection.queries[-total_queries:]
|
||||
query_details = [
|
||||
{
|
||||
"sql": (
|
||||
query["sql"][:200] + "..."
|
||||
if len(query["sql"]) > 200
|
||||
else query["sql"]
|
||||
),
|
||||
"time": float(query["time"]),
|
||||
}
|
||||
for query in recent_queries
|
||||
]
|
||||
|
||||
performance_data = {
|
||||
"operation": operation_name,
|
||||
"query_count": total_queries,
|
||||
"execution_time": execution_time,
|
||||
"queries": query_details if settings.DEBUG else [],
|
||||
}
|
||||
|
||||
if total_queries > warn_threshold or execution_time > 1.0:
|
||||
logger.warning(
|
||||
f"Performance concern in {operation_name}: "
|
||||
f"{total_queries} queries, {execution_time:.2f}s",
|
||||
extra=performance_data,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Query tracking for {operation_name}: "
|
||||
f"{total_queries} queries, {execution_time:.2f}s",
|
||||
extra=performance_data,
|
||||
)
|
||||
|
||||
|
||||
class PerformanceProfiler:
|
||||
"""Advanced performance profiling with detailed metrics"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
self.checkpoints = []
|
||||
self.initial_queries = 0
|
||||
self.memory_usage = {}
|
||||
|
||||
def start(self):
|
||||
"""Start profiling"""
|
||||
self.start_time = time.time()
|
||||
self.initial_queries = len(connection.queries)
|
||||
|
||||
# Track memory usage if psutil is available
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process()
|
||||
self.memory_usage["start"] = process.memory_info().rss
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger.debug(f"Started profiling: {self.name}")
|
||||
|
||||
def checkpoint(self, name: str):
|
||||
"""Add a checkpoint"""
|
||||
if self.start_time is None:
|
||||
logger.warning(f"Checkpoint '{name}' called before profiling started")
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self.start_time
|
||||
queries_since_start = len(connection.queries) - self.initial_queries
|
||||
|
||||
checkpoint = {
|
||||
"name": name,
|
||||
"timestamp": current_time,
|
||||
"elapsed_seconds": elapsed,
|
||||
"queries_since_start": queries_since_start,
|
||||
}
|
||||
|
||||
# Memory usage if available
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process()
|
||||
checkpoint["memory_rss"] = process.memory_info().rss
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
self.checkpoints.append(checkpoint)
|
||||
logger.debug(f"Checkpoint '{name}' at {elapsed:.3f}s")
|
||||
|
||||
def stop(self):
|
||||
"""Stop profiling and log results"""
|
||||
if self.start_time is None:
|
||||
logger.warning("Profiling stopped before it was started")
|
||||
return
|
||||
|
||||
self.end_time = time.time()
|
||||
total_duration = self.end_time - self.start_time
|
||||
total_queries = len(connection.queries) - self.initial_queries
|
||||
|
||||
# Final memory usage
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process()
|
||||
self.memory_usage["end"] = process.memory_info().rss
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Create detailed profiling report
|
||||
report = {
|
||||
"profiler_name": self.name,
|
||||
"total_duration": total_duration,
|
||||
"total_queries": total_queries,
|
||||
"checkpoints": self.checkpoints,
|
||||
"memory_usage": self.memory_usage,
|
||||
"queries_per_second": (
|
||||
total_queries / total_duration if total_duration > 0 else 0
|
||||
),
|
||||
}
|
||||
|
||||
# Calculate checkpoint intervals
|
||||
if len(self.checkpoints) > 1:
|
||||
intervals = []
|
||||
for i in range(1, len(self.checkpoints)):
|
||||
prev = self.checkpoints[i - 1]
|
||||
curr = self.checkpoints[i]
|
||||
intervals.append(
|
||||
{
|
||||
"from": prev["name"],
|
||||
"to": curr["name"],
|
||||
"duration": curr["elapsed_seconds"] - prev["elapsed_seconds"],
|
||||
"queries": curr["queries_since_start"]
|
||||
- prev["queries_since_start"],
|
||||
}
|
||||
)
|
||||
report["checkpoint_intervals"] = intervals
|
||||
|
||||
# Log the complete report
|
||||
log_level = logging.WARNING if total_duration > 1.0 else logging.INFO
|
||||
logger.log(
|
||||
log_level,
|
||||
f"Profiling complete: {
|
||||
self.name} took {
|
||||
total_duration:.3f}s with {total_queries} queries",
|
||||
extra=report,
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@contextmanager
|
||||
def profile_operation(name: str):
|
||||
"""Context manager for detailed operation profiling"""
|
||||
profiler = PerformanceProfiler(name)
|
||||
profiler.start()
|
||||
|
||||
try:
|
||||
yield profiler
|
||||
finally:
|
||||
profiler.stop()
|
||||
|
||||
|
||||
class DatabaseQueryAnalyzer:
|
||||
"""Analyze database query patterns and performance"""
|
||||
|
||||
@staticmethod
|
||||
def analyze_queries(queries: List[Dict]) -> Dict[str, Any]:
|
||||
"""Analyze a list of queries for patterns and issues"""
|
||||
if not queries:
|
||||
return {}
|
||||
|
||||
total_time = sum(float(q.get("time", 0)) for q in queries)
|
||||
query_count = len(queries)
|
||||
|
||||
# Group queries by type
|
||||
query_types = {}
|
||||
for query in queries:
|
||||
sql = query.get("sql", "").strip().upper()
|
||||
query_type = sql.split()[0] if sql else "UNKNOWN"
|
||||
query_types[query_type] = query_types.get(query_type, 0) + 1
|
||||
|
||||
# Find slow queries (top 10% by time)
|
||||
sorted_queries = sorted(
|
||||
queries, key=lambda q: float(q.get("time", 0)), reverse=True
|
||||
)
|
||||
slow_query_count = max(1, query_count // 10)
|
||||
slow_queries = sorted_queries[:slow_query_count]
|
||||
|
||||
# Detect duplicate queries
|
||||
query_signatures = {}
|
||||
for query in queries:
|
||||
# Simplified signature - remove literals and normalize whitespace
|
||||
sql = query.get("sql", "")
|
||||
signature = " ".join(sql.split()) # Normalize whitespace
|
||||
query_signatures[signature] = query_signatures.get(signature, 0) + 1
|
||||
|
||||
duplicates = {
|
||||
sig: count for sig, count in query_signatures.items() if count > 1
|
||||
}
|
||||
|
||||
analysis = {
|
||||
"total_queries": query_count,
|
||||
"total_time": total_time,
|
||||
"average_time": total_time / query_count if query_count > 0 else 0,
|
||||
"query_types": query_types,
|
||||
"slow_queries": [
|
||||
{
|
||||
"sql": (
|
||||
q.get("sql", "")[:200] + "..."
|
||||
if len(q.get("sql", "")) > 200
|
||||
else q.get("sql", "")
|
||||
),
|
||||
"time": float(q.get("time", 0)),
|
||||
}
|
||||
for q in slow_queries
|
||||
],
|
||||
"duplicate_query_count": len(duplicates),
|
||||
"duplicate_queries": (
|
||||
duplicates
|
||||
if len(duplicates) <= 10
|
||||
else dict(list(duplicates.items())[:10])
|
||||
),
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
||||
@classmethod
|
||||
def analyze_current_queries(cls) -> Dict[str, Any]:
|
||||
"""Analyze the current request's queries"""
|
||||
if hasattr(connection, "queries"):
|
||||
return cls.analyze_queries(connection.queries)
|
||||
return {}
|
||||
|
||||
|
||||
# Performance monitoring decorators
|
||||
def monitor_function_performance(operation_name: Optional[str] = None):
|
||||
"""Decorator to monitor function performance"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
name = operation_name or f"{func.__module__}.{func.__name__}"
|
||||
with monitor_performance(
|
||||
name, function=func.__name__, module=func.__module__
|
||||
):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def track_database_queries(warn_threshold: int = 10):
|
||||
"""Decorator to track database queries for a function"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
operation_name = f"{func.__module__}.{func.__name__}"
|
||||
with track_queries(operation_name, warn_threshold):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Performance metrics collection
|
||||
class PerformanceMetrics:
|
||||
"""Collect and aggregate performance metrics"""
|
||||
|
||||
def __init__(self):
|
||||
self.metrics = []
|
||||
|
||||
def record_metric(self, name: str, value: float, tags: Optional[Dict] = None):
|
||||
"""Record a performance metric"""
|
||||
metric = {
|
||||
"name": name,
|
||||
"value": value,
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"tags": tags or {},
|
||||
}
|
||||
self.metrics.append(metric)
|
||||
|
||||
# Log the metric
|
||||
logger.info(f"Performance metric: {name} = {value}", extra=metric)
|
||||
|
||||
def get_metrics(self, name: Optional[str] = None) -> List[Dict]:
|
||||
"""Get recorded metrics, optionally filtered by name"""
|
||||
if name:
|
||||
return [m for m in self.metrics if m["name"] == name]
|
||||
return self.metrics.copy()
|
||||
|
||||
def clear_metrics(self):
|
||||
"""Clear all recorded metrics"""
|
||||
self.metrics.clear()
|
||||
|
||||
|
||||
# Global performance metrics instance
|
||||
performance_metrics = PerformanceMetrics()
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user