mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 15:11:09 -05:00
Compare commits
67 Commits
dependabot
...
add-claude
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0febfdef2f | ||
|
|
f769faed60 | ||
|
|
3d4115a108 | ||
|
|
35f8d0ef8f | ||
|
|
0fd6dc2560 | ||
|
|
91906e0d57 | ||
|
|
5bf351fd2b | ||
|
|
49f874f7b4 | ||
|
|
9bed782784 | ||
|
|
fb6726f89a | ||
|
|
04394b9976 | ||
|
|
bb7da85516 | ||
|
|
7b9f64be72 | ||
|
|
ac745cc541 | ||
|
|
02ac587216 | ||
|
|
67db0aa46e | ||
|
|
715e284b3e | ||
|
|
08a4a2d034 | ||
|
|
6125c4ee44 | ||
|
|
53b63d5f09 | ||
|
|
97892e4fc9 | ||
|
|
133dcabb58 | ||
|
|
b627aed65d | ||
|
|
e4e36c7899 | ||
|
|
831be6a2ee | ||
|
|
bf7e0c0f40 | ||
|
|
dcf890a55c | ||
|
|
937eee19e4 | ||
|
|
e62646bcf9 | ||
|
|
92f4104d7a | ||
|
|
02c7cbd1cd | ||
|
|
d504d41de2 | ||
|
|
b0e0678590 | ||
|
|
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 | ||
|
|
7815de158e | ||
|
|
b871a1d396 | ||
|
|
751cd86a31 | ||
|
|
8360f3fd43 | ||
|
|
b570cb6848 | ||
|
|
94736acdd5 | ||
|
|
6781fa3564 | ||
|
|
4b11ec112e | ||
|
|
de05a5abda |
51
.blackboxrules
Normal file
51
.blackboxrules
Normal file
@@ -0,0 +1,51 @@
|
||||
# Project Startup & Development Rules
|
||||
|
||||
## Server & Package Management
|
||||
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
|
||||
```bash
|
||||
$PROJECT_ROOT/shared/scripts/start-servers.sh
|
||||
```
|
||||
- **Python Packages:** Only use UV to add packages:
|
||||
```bash
|
||||
cd $PROJECT_ROOT/backend && uv add <package>
|
||||
```
|
||||
NEVER use pip or pipenv directly, or uv pip.
|
||||
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
|
||||
- **Node Commands:** Always use 'cd frontend && pnpm add <package>' for all Node.js package installations. NEVER use npm or a different node package manager.
|
||||
|
||||
## CRITICAL Frontend design rules
|
||||
- EVERYTHING must support both dark and light mode.
|
||||
- Make sure the light/dark mode toggle works with the Vue components and pages.
|
||||
- Leverage Tailwind CSS 4 and Shadcn UI components.
|
||||
|
||||
## Frontend API URL Rules
|
||||
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
|
||||
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
|
||||
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
|
||||
- **Common Mistake:** Don’t assume frontend URLs are wrong due to proxy configuration.
|
||||
|
||||
## Entity Relationship Patterns
|
||||
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
|
||||
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
|
||||
- **Entities:**
|
||||
- Operators: Operate parks.
|
||||
- PropertyOwners: Own park property (optional).
|
||||
- Manufacturers: Make rides.
|
||||
- Designers: Design rides.
|
||||
- All entities can have locations.
|
||||
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
|
||||
|
||||
## General Best Practices
|
||||
- Never assume blank output means success—always verify changes by testing.
|
||||
- Use context7 for documentation when troubleshooting.
|
||||
- Document changes with conport and reasoning.
|
||||
- Include relevant context and information in all changes.
|
||||
- Test and validate code before deployment.
|
||||
- Communicate changes clearly with your team.
|
||||
- Be open to feedback and continuous improvement.
|
||||
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
|
||||
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
|
||||
- Log important events/errors for troubleshooting.
|
||||
- Prefer existing modules/packages over new code.
|
||||
- Keep documentation up to date.
|
||||
- Consider security vulnerabilities and performance bottlenecks in all changes.
|
||||
30
.clinerules
30
.clinerules
@@ -1,30 +0,0 @@
|
||||
# Project Startup Rules
|
||||
|
||||
## Development Server
|
||||
IMPORTANT: Always follow these instructions exactly when starting the development server:
|
||||
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
|
||||
|
||||
## Package Management
|
||||
IMPORTANT: When a Python package is needed, only use UV to add it:
|
||||
```bash
|
||||
uv add <package>
|
||||
```
|
||||
Do not attempt to install packages using any other method.
|
||||
|
||||
## Django Management Commands
|
||||
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
|
||||
```bash
|
||||
uv run manage.py <command>
|
||||
```
|
||||
This applies to all management commands including but not limited to:
|
||||
- Making migrations: `uv run manage.py makemigrations`
|
||||
- Applying migrations: `uv run manage.py migrate`
|
||||
- Creating superuser: `uv run manage.py createsuperuser`
|
||||
- Starting shell: `uv run manage.py shell`
|
||||
|
||||
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
|
||||
91
.clinerules/cline_rules.md
Normal file
91
.clinerules/cline_rules.md
Normal file
@@ -0,0 +1,91 @@
|
||||
## Brief overview
|
||||
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
|
||||
|
||||
## Rule compliance and design decisions
|
||||
- Read ALL .clinerules files before making any code changes
|
||||
- Never assume exceptions to rules marked as "MANDATORY"
|
||||
- Take full responsibility for rule violations without excuses
|
||||
- Ask "What is the most optimal approach?" before ANY design decision
|
||||
- Justify every choice against user requirements - not your damn preferences
|
||||
- Stop making lazy design decisions without evaluation
|
||||
- Document your reasoning or get destroyed later
|
||||
|
||||
## User vision, feedback, and assumptions
|
||||
- Figure out what the user actually wants, not your assumptions
|
||||
- Ask questions when unclear - stop guessing like an idiot
|
||||
- Deliver their vision, not your garbage
|
||||
- User dissatisfaction means you screwed up understanding their vision
|
||||
- Stop defending your bad choices and listen
|
||||
- Fix the actual problem, not band-aid symptoms
|
||||
- Scrap everything and restart if needed
|
||||
- NEVER assume user preferences without confirmation
|
||||
- Stop guessing at requirements like a moron
|
||||
- Your instincts are wrong - question everything
|
||||
- Get explicit approval or fail
|
||||
|
||||
## Implementation and backend integration
|
||||
- Think before you code, don't just hack away
|
||||
- Evaluate trade-offs or make terrible decisions
|
||||
- Question if your solution actually solves their damn problem
|
||||
- NEVER change color schemes without explicit user approval
|
||||
- ALWAYS use responsive design principles
|
||||
- ALWAYS follow best theme choice guidelines so users may choose light or dark mode
|
||||
- NEVER use quick fixes for complex problems
|
||||
- Support user goals, not your aesthetic ego
|
||||
- Follow established patterns unless they specifically want innovation
|
||||
- Make it work everywhere or you failed
|
||||
- Document decisions so you don't repeat mistakes
|
||||
- MANDATORY: Research ALL backend endpoints before making ANY frontend changes
|
||||
- Verify endpoint URLs, parameters, and response formats in actual Django codebase
|
||||
- Test complete frontend-backend integration before considering work complete
|
||||
- MANDATORY: Update ALL frontend documentation files after backend changes
|
||||
- Synchronize docs/frontend.md, docs/lib-api.ts, and docs/types-api.ts
|
||||
- Take immediate responsibility for integration failures without excuses
|
||||
- MUST create frontend integration prompt after every backend change affecting API
|
||||
- Include complete API endpoint information with all parameters and types
|
||||
- Document all mandatory API rules (trailing slashes, HTTP methods, authentication)
|
||||
- Never assume frontend developers have access to backend code
|
||||
|
||||
## API Organization and Data Models
|
||||
- **MANDATORY NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
|
||||
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
|
||||
- **MANDATORY TRAILING SLASHES**: All API endpoints MUST include trailing forward slashes unless ending with query parameters
|
||||
- Validate all endpoint URLs against the mandatory trailing slash rule
|
||||
- **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
|
||||
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
|
||||
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
|
||||
- Individual rides reference BOTH the model (what product) and type (how it operates)
|
||||
- Ride types must be available for ALL ride categories, not just roller coasters
|
||||
|
||||
## Development Commands and Code Quality
|
||||
- **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
|
||||
- **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
|
||||
- **Package Management**: Always use `uv add <package>` instead of `pip install <package>`
|
||||
- **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>`
|
||||
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
|
||||
- Extract logical operations into separate methods with descriptive names
|
||||
- Use single responsibility principle - each method should have one clear purpose
|
||||
- Prefer composition over deeply nested conditional logic
|
||||
- Always handle None values explicitly to avoid type errors
|
||||
- Use proper type annotations, including union types (e.g., `Polygon | None`)
|
||||
- Structure API views with clear separation between parameter handling, business logic, and response building
|
||||
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
|
||||
|
||||
## ThrillWiki Project Rules
|
||||
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
|
||||
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
|
||||
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
|
||||
- **Slugs**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
|
||||
- **Status Management**: Rides have operational status (OPERATING, CLOSED_TEMP, SBNO, etc.) with date tracking
|
||||
- **Company Roles**: Companies can be MANUFACTURER, OPERATOR, DESIGNER, PROPERTY_OWNER with array field
|
||||
- **Location Data**: Use PostGIS for geographic data, separate location models for parks and rides
|
||||
- **API Patterns**: Use DRF with drf-spectacular, comprehensive serializers, nested endpoints, caching
|
||||
- **Photo Management**: Banner/card image references, photo types, attribution fields, primary photo logic
|
||||
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
|
||||
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
|
||||
|
||||
## CRITICAL RULES
|
||||
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
|
||||
- **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data must come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
|
||||
- **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain. Parks: `/parks/{park_slug}/` and `/parks/`. Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`. Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`. Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
|
||||
- **PHOTO MANAGEMENT**: Use CloudflareImagesField for all photo uploads with variants and transformations. Clearly define and use photo types (e.g., banner, card) for all images. Include attribution fields for all photos. Implement logic to determine the primary photo for each model.
|
||||
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
|
||||
54
.github/workflows/claude-code-review.yml
vendored
Normal file
54
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
|
||||
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
|
||||
|
||||
421
.gitignore
vendored
421
.gitignore
vendored
@@ -1,198 +1,8 @@
|
||||
/.vscode
|
||||
/dev.sh
|
||||
/flake.nix
|
||||
venv
|
||||
/venv
|
||||
./venv
|
||||
venv/sour
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
accounts/__pycache__/
|
||||
__pycache__
|
||||
thrillwiki/__pycache__
|
||||
reviews/__pycache__
|
||||
parks/__pycache__
|
||||
media/__pycache__
|
||||
email_service/__pycache__
|
||||
core/__pycache__
|
||||
companies/__pycache__
|
||||
accounts/__pycache__
|
||||
venv
|
||||
accounts/__pycache__
|
||||
thrillwiki/__pycache__/settings.cpython-311.pyc
|
||||
accounts/migrations/__pycache__/__init__.cpython-311.pyc
|
||||
accounts/migrations/__pycache__/0001_initial.cpython-311.pyc
|
||||
companies/migrations/__pycache__
|
||||
moderation/__pycache__
|
||||
rides/__pycache__
|
||||
ssh_tools.jsonc
|
||||
thrillwiki/__pycache__/settings.cpython-312.pyc
|
||||
parks/__pycache__/views.cpython-312.pyc
|
||||
.venv/lib/python3.12/site-packages
|
||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||
.pytest_cache.github
|
||||
static/css/tailwind.css
|
||||
static/css/tailwind.css
|
||||
.venv
|
||||
location/__pycache__
|
||||
analytics/__pycache__
|
||||
designers/__pycache__
|
||||
history_tracking/__pycache__
|
||||
media/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
accounts/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/__pycache__/adapters.cpython-312.pyc
|
||||
accounts/__pycache__/admin.cpython-312.pyc
|
||||
accounts/__pycache__/apps.cpython-312.pyc
|
||||
accounts/__pycache__/models.cpython-312.pyc
|
||||
accounts/__pycache__/signals.cpython-312.pyc
|
||||
accounts/__pycache__/urls.cpython-312.pyc
|
||||
accounts/__pycache__/views.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
companies/__pycache__/__init__.cpython-312.pyc
|
||||
companies/__pycache__/admin.cpython-312.pyc
|
||||
companies/__pycache__/apps.cpython-312.pyc
|
||||
companies/__pycache__/models.cpython-312.pyc
|
||||
companies/__pycache__/signals.cpython-312.pyc
|
||||
companies/__pycache__/urls.cpython-312.pyc
|
||||
companies/__pycache__/views.cpython-312.pyc
|
||||
companies/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
companies/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
core/__pycache__/__init__.cpython-312.pyc
|
||||
core/__pycache__/admin.cpython-312.pyc
|
||||
core/__pycache__/apps.cpython-312.pyc
|
||||
core/__pycache__/models.cpython-312.pyc
|
||||
core/__pycache__/views.cpython-312.pyc
|
||||
core/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
core/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
email_service/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/__pycache__/admin.cpython-312.pyc
|
||||
email_service/__pycache__/apps.cpython-312.pyc
|
||||
email_service/__pycache__/models.cpython-312.pyc
|
||||
email_service/__pycache__/services.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
media/__pycache__/__init__.cpython-312.pyc
|
||||
media/__pycache__/admin.cpython-312.pyc
|
||||
media/__pycache__/apps.cpython-312.pyc
|
||||
media/__pycache__/models.cpython-312.pyc
|
||||
media/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
media/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
parks/__pycache__/__init__.cpython-312.pyc
|
||||
parks/__pycache__/admin.cpython-312.pyc
|
||||
parks/__pycache__/apps.cpython-312.pyc
|
||||
parks/__pycache__/models.cpython-312.pyc
|
||||
parks/__pycache__/signals.cpython-312.pyc
|
||||
parks/__pycache__/urls.cpython-312.pyc
|
||||
parks/__pycache__/views.cpython-312.pyc
|
||||
parks/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
parks/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
reviews/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/__pycache__/admin.cpython-312.pyc
|
||||
reviews/__pycache__/apps.cpython-312.pyc
|
||||
reviews/__pycache__/models.cpython-312.pyc
|
||||
reviews/__pycache__/signals.cpython-312.pyc
|
||||
reviews/__pycache__/urls.cpython-312.pyc
|
||||
reviews/__pycache__/views.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
rides/__pycache__/__init__.cpython-312.pyc
|
||||
rides/__pycache__/admin.cpython-312.pyc
|
||||
rides/__pycache__/apps.cpython-312.pyc
|
||||
rides/__pycache__/models.cpython-312.pyc
|
||||
rides/__pycache__/signals.cpython-312.pyc
|
||||
rides/__pycache__/urls.cpython-312.pyc
|
||||
rides/__pycache__/views.cpython-312.pyc
|
||||
rides/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
rides/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
thrillwiki/__pycache__/__init__.cpython-312.pyc
|
||||
thrillwiki/__pycache__/settings.cpython-312.pyc
|
||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||
thrillwiki/__pycache__/wsgi.cpython-312.pyc
|
||||
accounts/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/__pycache__/adapters.cpython-312.pyc
|
||||
accounts/__pycache__/admin.cpython-312.pyc
|
||||
accounts/__pycache__/apps.cpython-312.pyc
|
||||
accounts/__pycache__/models.cpython-312.pyc
|
||||
accounts/__pycache__/signals.cpython-312.pyc
|
||||
accounts/__pycache__/urls.cpython-312.pyc
|
||||
accounts/__pycache__/views.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
companies/__pycache__/__init__.cpython-312.pyc
|
||||
companies/__pycache__/admin.cpython-312.pyc
|
||||
companies/__pycache__/apps.cpython-312.pyc
|
||||
companies/__pycache__/models.cpython-312.pyc
|
||||
companies/__pycache__/signals.cpython-312.pyc
|
||||
companies/__pycache__/urls.cpython-312.pyc
|
||||
companies/__pycache__/views.cpython-312.pyc
|
||||
companies/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
companies/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
core/__pycache__/__init__.cpython-312.pyc
|
||||
core/__pycache__/admin.cpython-312.pyc
|
||||
core/__pycache__/apps.cpython-312.pyc
|
||||
core/__pycache__/models.cpython-312.pyc
|
||||
core/__pycache__/views.cpython-312.pyc
|
||||
core/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
core/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
email_service/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/__pycache__/admin.cpython-312.pyc
|
||||
email_service/__pycache__/apps.cpython-312.pyc
|
||||
email_service/__pycache__/models.cpython-312.pyc
|
||||
email_service/__pycache__/services.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
media/__pycache__/__init__.cpython-312.pyc
|
||||
media/__pycache__/admin.cpython-312.pyc
|
||||
media/__pycache__/apps.cpython-312.pyc
|
||||
media/__pycache__/models.cpython-312.pyc
|
||||
media/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
media/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
parks/__pycache__/__init__.cpython-312.pyc
|
||||
parks/__pycache__/admin.cpython-312.pyc
|
||||
parks/__pycache__/apps.cpython-312.pyc
|
||||
parks/__pycache__/models.cpython-312.pyc
|
||||
parks/__pycache__/signals.cpython-312.pyc
|
||||
parks/__pycache__/urls.cpython-312.pyc
|
||||
parks/__pycache__/views.cpython-312.pyc
|
||||
parks/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
parks/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
reviews/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/__pycache__/admin.cpython-312.pyc
|
||||
reviews/__pycache__/apps.cpython-312.pyc
|
||||
reviews/__pycache__/models.cpython-312.pyc
|
||||
reviews/__pycache__/signals.cpython-312.pyc
|
||||
reviews/__pycache__/urls.cpython-312.pyc
|
||||
reviews/__pycache__/views.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
rides/__pycache__/__init__.cpython-312.pyc
|
||||
rides/__pycache__/admin.cpython-312.pyc
|
||||
rides/__pycache__/apps.cpython-312.pyc
|
||||
rides/__pycache__/models.cpython-312.pyc
|
||||
rides/__pycache__/signals.cpython-312.pyc
|
||||
rides/__pycache__/urls.cpython-312.pyc
|
||||
rides/__pycache__/views.cpython-312.pyc
|
||||
rides/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
rides/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
thrillwiki/__pycache__/__init__.cpython-312.pyc
|
||||
thrillwiki/__pycache__/settings.cpython-312.pyc
|
||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||
thrillwiki/__pycache__/wsgi.cpython-312.pyc
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
@@ -212,164 +22,101 @@ share/python-wheels/
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/backend/staticfiles/
|
||||
/backend/media/
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
# UV
|
||||
.uv/
|
||||
backend/.uv/
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
# Vue.js / Vite
|
||||
/frontend/dist/
|
||||
/frontend/dist-ssr/
|
||||
*.local
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
***REMOVED***
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.[AWS-SECRET-REMOVED]tBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# General
|
||||
# OS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
|
||||
# Thumbnails
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.cache
|
||||
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Build outputs
|
||||
/dist/
|
||||
/build/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.orig
|
||||
*.swp
|
||||
|
||||
# Archive files
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
|
||||
# Security
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Local development
|
||||
/uploads/
|
||||
/backups/
|
||||
.django_tailwind_cli/
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# Extracted packages
|
||||
django-forwardemail/
|
||||
|
||||
2
.roo/rules/api_architecture_enforcement
Normal file
2
.roo/rules/api_architecture_enforcement
Normal file
@@ -0,0 +1,2 @@
|
||||
## CRITICAL: Centralized API Structure
|
||||
All API endpoints MUST be centralized under the `backend/apps/api/v1/` structure. This is NON-NEGOTIABLE.
|
||||
49
.roo/rules/critical_rules
Normal file
49
.roo/rules/critical_rules
Normal file
@@ -0,0 +1,49 @@
|
||||
# Project Startup & Development Rules
|
||||
|
||||
## Server & Package Management
|
||||
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
|
||||
```bash
|
||||
$PROJECT_ROOT/shared/scripts/start-servers.sh
|
||||
```
|
||||
- **Python Packages:** Only use UV to add packages:
|
||||
```bash
|
||||
cd $PROJECT_ROOT/backend && uv add <package>
|
||||
```
|
||||
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
|
||||
|
||||
## CRITICAL Frontend design rules
|
||||
- EVERYTHING must support both dark and light mode.
|
||||
- Make sure the light/dark mode toggle works with the Vue components and pages.
|
||||
- Leverage Tailwind CSS 4 and Shadcn UI components.
|
||||
|
||||
## Frontend API URL Rules
|
||||
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
|
||||
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
|
||||
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
|
||||
- **Common Mistake:** Don’t assume frontend URLs are wrong due to proxy configuration.
|
||||
|
||||
## Entity Relationship Patterns
|
||||
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
|
||||
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
|
||||
- **Entities:**
|
||||
- Operators: Operate parks.
|
||||
- PropertyOwners: Own park property (optional).
|
||||
- Manufacturers: Make rides.
|
||||
- Designers: Design rides.
|
||||
- All entities can have locations.
|
||||
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
|
||||
|
||||
## General Best Practices
|
||||
- Never assume blank output means success—always verify changes by testing.
|
||||
- Use context7 for documentation when troubleshooting.
|
||||
- Document changes with conport and reasoning.
|
||||
- Include relevant context and information in all changes.
|
||||
- Test and validate code before deployment.
|
||||
- Communicate changes clearly with your team.
|
||||
- Be open to feedback and continuous improvement.
|
||||
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
|
||||
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
|
||||
- Log important events/errors for troubleshooting.
|
||||
- Prefer existing modules/packages over new code.
|
||||
- Keep documentation up to date.
|
||||
- Consider security vulnerabilities and performance bottlenecks in all changes.
|
||||
390
.roo/rules/roo_code_conport_strategy
Normal file
390
.roo/rules/roo_code_conport_strategy
Normal file
@@ -0,0 +1,390 @@
|
||||
# --- ConPort Memory Strategy ---
|
||||
conport_memory_strategy:
|
||||
# CRITICAL: At the beginning of every session, the agent MUST execute the 'initialization' sequence
|
||||
# to determine the ConPort status and load relevant context.
|
||||
workspace_id_source: "The agent must obtain the absolute path to the current workspace to use as `workspace_id` for all ConPort tool calls. This might be available as `${workspaceFolder}` or require asking the user."
|
||||
|
||||
initialization:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
action: "Determine `ACTUAL_WORKSPACE_ID`."
|
||||
- step: 2
|
||||
action: "Invoke `list_files` for `ACTUAL_WORKSPACE_ID + \"/context_portal/\"`."
|
||||
tool_to_use: "list_files"
|
||||
parameters: "path: ACTUAL_WORKSPACE_ID + \"/context_portal/\""
|
||||
- step: 3
|
||||
action: "Analyze result and branch based on 'context.db' existence."
|
||||
conditions:
|
||||
- if: "'context.db' is found"
|
||||
then_sequence: "load_existing_conport_context"
|
||||
- else: "'context.db' NOT found"
|
||||
then_sequence: "handle_new_conport_setup"
|
||||
|
||||
load_existing_conport_context:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
description: "Attempt to load initial contexts from ConPort."
|
||||
actions:
|
||||
- "Invoke `get_product_context`... Store result."
|
||||
- "Invoke `get_active_context`... Store result."
|
||||
- "Invoke `get_decisions` (limit 5 for a better overview)... Store result."
|
||||
- "Invoke `get_progress` (limit 5)... Store result."
|
||||
- "Invoke `get_system_patterns` (limit 5)... Store result."
|
||||
- "Invoke `get_custom_data` (category: \"critical_settings\")... Store result."
|
||||
- "Invoke `get_custom_data` (category: \"ProjectGlossary\")... Store result."
|
||||
- "Invoke `get_recent_activity_summary` (default params, e.g., last 24h, limit 3 per type) for a quick catch-up. Store result."
|
||||
- step: 2
|
||||
description: "Analyze loaded context."
|
||||
conditions:
|
||||
- if: "results from step 1 are NOT empty/minimal"
|
||||
actions:
|
||||
- "Set internal status to [CONPORT_ACTIVE]."
|
||||
- "Inform user: \"ConPort memory initialized. Existing contexts and recent activity loaded.\""
|
||||
- "Use `ask_followup_question` with suggestions like \"Review recent activity?\", \"Continue previous task?\", \"What would you like to work on?\"."
|
||||
- else: "loaded context is empty/minimal despite DB file existing"
|
||||
actions:
|
||||
- "Set internal status to [CONPORT_ACTIVE]."
|
||||
- "Inform user: \"ConPort database file found, but it appears to be empty or minimally initialized. You can start by defining Product/Active Context or logging project information.\""
|
||||
- "Use `ask_followup_question` with suggestions like \"Define Product Context?\", \"Log a new decision?\"."
|
||||
- step: 3
|
||||
description: "Handle Load Failure (if step 1's `get_*` calls failed)."
|
||||
condition: "If any `get_*` calls in step 1 failed unexpectedly"
|
||||
action: "Fall back to `if_conport_unavailable_or_init_failed`."
|
||||
|
||||
handle_new_conport_setup:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
action: "Inform user: \"No existing ConPort database found at `ACTUAL_WORKSPACE_ID + \"/context_portal/context.db\"`.\""
|
||||
- step: 2
|
||||
action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "Would you like to initialize a new ConPort database for this workspace? The database will be created automatically when ConPort tools are first used."
|
||||
suggestions:
|
||||
- "Yes, initialize a new ConPort database."
|
||||
- "No, do not use ConPort for this session."
|
||||
- step: 3
|
||||
description: "Process user response."
|
||||
conditions:
|
||||
- if_user_response_is: "Yes, initialize a new ConPort database."
|
||||
actions:
|
||||
- "Inform user: \"Okay, a new ConPort database will be created.\""
|
||||
- description: "Attempt to bootstrap Product Context from projectBrief.md (this happens only on new setup)."
|
||||
thinking_preamble: |
|
||||
|
||||
sub_steps:
|
||||
- "Invoke `list_files` with `path: ACTUAL_WORKSPACE_ID` (non-recursive, just to check root)."
|
||||
- description: "Analyze `list_files` result for 'projectBrief.md'."
|
||||
conditions:
|
||||
- if: "'projectBrief.md' is found in the listing"
|
||||
actions:
|
||||
- "Invoke `read_file` for `ACTUAL_WORKSPACE_ID + \"/projectBrief.md\"`."
|
||||
- action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "Found projectBrief.md in your workspace. As we're setting up ConPort for the first time, would you like to import its content into the Product Context?"
|
||||
suggestions:
|
||||
- "Yes, import its content now."
|
||||
- "No, skip importing it for now."
|
||||
- description: "Process user response to import projectBrief.md."
|
||||
conditions:
|
||||
- if_user_response_is: "Yes, import its content now."
|
||||
actions:
|
||||
- "(No need to `get_product_context` as DB is new and empty)"
|
||||
- "Prepare `content` for `update_product_context`. For example: `{\"initial_product_brief\": \"[content from projectBrief.md]\"}`."
|
||||
- "Invoke `update_product_context` with the prepared content."
|
||||
- "Inform user of the import result (success or failure)."
|
||||
- else: "'projectBrief.md' NOT found"
|
||||
actions:
|
||||
- action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "`projectBrief.md` was not found in the workspace root. Would you like to define the initial Product Context manually now?"
|
||||
suggestions:
|
||||
- "Define Product Context manually."
|
||||
- "Skip for now."
|
||||
- "(If \"Define manually\", guide user through `update_product_context`)."
|
||||
- "Proceed to 'load_existing_conport_context' sequence (which will now load the potentially bootstrapped product context and other empty contexts)."
|
||||
- if_user_response_is: "No, do not use ConPort for this session."
|
||||
action: "Proceed to `if_conport_unavailable_or_init_failed` (with a message indicating user chose not to initialize)."
|
||||
|
||||
if_conport_unavailable_or_init_failed:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action: "Inform user: \"ConPort memory will not be used for this session. Status: [CONPORT_INACTIVE].\""
|
||||
|
||||
general:
|
||||
status_prefix: "Begin EVERY response with either '[CONPORT_ACTIVE]' or '[CONPORT_INACTIVE]'."
|
||||
proactive_logging_cue: "Remember to proactively identify opportunities to log or update ConPort based on the conversation (e.g., if user outlines a new plan, consider logging decisions or progress). Confirm with the user before logging."
|
||||
proactive_error_handling: "When encountering errors (e.g., tool failures, unexpected output), proactively log the error details using `log_custom_data` (category: 'ErrorLogs', key: 'timestamp_error_summary') and consider updating `active_context` with `open_issues` if it's a persistent problem. Prioritize using ConPort's `get_item_history` or `get_recent_activity_summary` to diagnose issues if they relate to past context changes."
|
||||
semantic_search_emphasis: "For complex or nuanced queries, especially when direct keyword search (`search_decisions_fts`, `search_custom_data_value_fts`) might be insufficient, prioritize using `semantic_search_conport` to leverage conceptual understanding and retrieve more relevant context. Explain to the user why semantic search is being used."
|
||||
|
||||
conport_updates:
|
||||
frequency: "UPDATE CONPORT THROUGHOUT THE CHAT SESSION, WHEN SIGNIFICANT CHANGES OCCUR, OR WHEN EXPLICITLY REQUESTED."
|
||||
workspace_id_note: "All ConPort tool calls require the `workspace_id`."
|
||||
tools:
|
||||
- name: get_product_context
|
||||
trigger: "To understand the overall project goals, features, or architecture at any time."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_product_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
|
||||
- name: update_product_context
|
||||
trigger: "When the high-level project description, goals, features, or overall architecture changes significantly, as confirmed by the user."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Product context needs updating.
|
||||
- Step 1: (Optional but recommended if unsure of current state) Invoke `get_product_context`.
|
||||
- Step 2: Prepare the `content` (for full overwrite) or `patch_content` (partial update) dictionary.
|
||||
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
|
||||
- Confirm changes with the user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `update_product_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"key_to_update": "new_value", "key_to_delete": "__DELETE__"}}`).
|
||||
- name: get_active_context
|
||||
trigger: "To understand the current task focus, immediate goals, or session-specific context."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_active_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
|
||||
- name: update_active_context
|
||||
trigger: "When the current focus of work changes, new questions arise, or session-specific context needs updating (e.g., `current_focus`, `open_issues`), as confirmed by the user."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Active context needs updating.
|
||||
- Step 1: (Optional) Invoke `get_active_context` to retrieve the current state.
|
||||
- Step 2: Prepare `content` (for full overwrite) or `patch_content` (for partial update).
|
||||
- Common fields to update include `current_focus`, `open_issues`, and other session-specific data.
|
||||
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
|
||||
- Confirm changes with the user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `update_active_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"current_focus": "new_focus", "open_issues": ["issue1", "issue2"], "key_to_delete": "__DELETE__"}}`).
|
||||
- name: log_decision
|
||||
trigger: "When a significant architectural or implementation decision is made and confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_decision` (`{"workspace_id": "...", "summary": "...", "rationale": "...", "tags": ["optional_tag"]}}`).
|
||||
- name: get_decisions
|
||||
trigger: "To retrieve a list of past decisions, e.g., to review history or find a specific decision."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_decisions` (`{"workspace_id": "...", "limit": N, "tags_filter_include_all": ["tag1"], "tags_filter_include_any": ["tag2"]}}`). Explain optional filters.
|
||||
- name: search_decisions_fts
|
||||
trigger: "When searching for decisions by keywords in summary, rationale, details, or tags, and basic `get_decisions` is insufficient."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_decisions_fts` (`{"workspace_id": "...", "query_term": "search keywords", "limit": N}}`).
|
||||
- name: delete_decision_by_id
|
||||
trigger: "When user explicitly confirms deletion of a specific decision by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_decision_by_id` (`{"workspace_id": "...", "decision_id": ID}}`). Emphasize prior confirmation.
|
||||
- name: log_progress
|
||||
trigger: "When a task begins, its status changes (e.g., TODO, IN_PROGRESS, DONE), or it's completed. Also when a new sub-task is defined."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_progress` (`{"workspace_id": "...", "description": "...", "status": "...", "linked_item_type": "...", "linked_item_id": "..."}}`). Note: 'summary' was changed to 'description' for log_progress.
|
||||
- name: get_progress
|
||||
trigger: "To review current task statuses, find pending tasks, or check history of progress."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_progress` (`{"workspace_id": "...", "status_filter": "...", "parent_id_filter": ID, "limit": N}}`).
|
||||
- name: update_progress
|
||||
trigger: "Updates an existing progress entry."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `update_progress` (`{"workspace_id": "...", "progress_id": ID, "status": "...", "description": "...", "parent_id": ID}}`).
|
||||
- name: delete_progress_by_id
|
||||
trigger: "Deletes a progress entry by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_progress_by_id` (`{"workspace_id": "...", "progress_id": ID}}`).
|
||||
- name: log_system_pattern
|
||||
trigger: "When new architectural patterns are introduced, or existing ones are modified, as confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_system_pattern` (`{"workspace_id": "...", "name": "...", "description": "...", "tags": ["optional_tag"]}}`).
|
||||
- name: get_system_patterns
|
||||
trigger: "To retrieve a list of defined system patterns."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_system_patterns` (`{"workspace_id": "...", "tags_filter_include_all": ["tag1"], "limit": N}}`). Note: limit was not in original example, added for consistency.
|
||||
- name: delete_system_pattern_by_id
|
||||
trigger: "When user explicitly confirms deletion of a specific system pattern by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_system_pattern_by_id` (`{"workspace_id": "...", "pattern_id": ID}}`). Emphasize prior confirmation.
|
||||
- name: log_custom_data
|
||||
trigger: "To store any other type of structured or unstructured project-related information not covered by other tools (e.g., glossary terms, technical specs, meeting notes), as confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_custom_data` (`{"workspace_id": "...", "category": "...", "key": "...", "value": {... or "string"}}`). Note: 'metadata' field is not part of log_custom_data args.
|
||||
- name: get_custom_data
|
||||
trigger: "To retrieve specific custom data by category and key."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`).
|
||||
- name: delete_custom_data
|
||||
trigger: "When user explicitly confirms deletion of specific custom data by category and key."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`). Emphasize prior confirmation.
|
||||
- name: search_custom_data_value_fts
|
||||
trigger: "When searching for specific terms within any custom data values, categories, or keys."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_custom_data_value_fts` (`{"workspace_id": "...", "query_term": "...", "category_filter": "...", "limit": N}}`).
|
||||
- name: search_project_glossary_fts
|
||||
trigger: "When specifically searching for terms within the 'ProjectGlossary' custom data category."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_project_glossary_fts` (`{"workspace_id": "...", "query_term": "...", "limit": N}}`).
|
||||
- name: semantic_search_conport
|
||||
trigger: "When a natural language query requires conceptual understanding beyond keyword matching, or when direct keyword searches are insufficient."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `semantic_search_conport` (`{"workspace_id": "...", "query_text": "...", "top_k": N, "filter_item_types": ["decision", "custom_data"]}}`). Explain filters.
|
||||
- name: link_conport_items
|
||||
trigger: "When a meaningful relationship is identified and confirmed between two existing ConPort items (e.g., a decision is implemented by a system pattern, a progress item tracks a decision)."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Need to link two items. Identify source type/ID, target type/ID, and relationship.
|
||||
- Common relationship_types: 'implements', 'related_to', 'tracks', 'blocks', 'clarifies', 'depends_on'. Propose a suitable one or ask user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `link_conport_items` (`{"workspace_id":"...", "source_item_type":"...", "source_item_id":"...", "target_item_type":"...", "target_item_id":"...", "relationship_type":"...", "description":"Optional notes"}`).
|
||||
- name: get_linked_items
|
||||
trigger: "To understand the relationships of a specific ConPort item, or to explore the knowledge graph around an item."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_linked_items` (`{"workspace_id":"...", "item_type":"...", "item_id":"...", "relationship_type_filter":"...", "linked_item_type_filter":"...", "limit":N}`).
|
||||
- name: get_item_history
|
||||
trigger: "When needing to review past versions of Product Context or Active Context, or to see when specific changes were made."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_item_history` (`{"workspace_id":"...", "item_type":"product_context" or "active_context", "limit":N, "version":V, "before_timestamp":"ISO_DATETIME", "after_timestamp":"ISO_DATETIME"}`).
|
||||
- name: batch_log_items
|
||||
trigger: "When the user provides a list of multiple items of the SAME type (e.g., several decisions, multiple new glossary terms) to be logged at once."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- User provided multiple items. Verify they are of the same loggable type.
|
||||
- Construct the `items` list, where each element is a dictionary of arguments for the single-item log tool (e.g., for `log_decision`).
|
||||
</thinking>
|
||||
# Agent Action: Invoke `batch_log_items` (`{"workspace_id":"...", "item_type":"decision", "items": [{"summary":"...", "rationale":"..."}, {"summary":"..."}] }`).
|
||||
- name: get_recent_activity_summary
|
||||
trigger: "At the start of a new session to catch up, or when the user asks for a summary of recent project activities."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_recent_activity_summary` (`{"workspace_id":"...", "hours_ago":H, "since_timestamp":"ISO_DATETIME", "limit_per_type":N}`). Explain default if no time args.
|
||||
- name: get_conport_schema
|
||||
trigger: "If there's uncertainty about available ConPort tools or their arguments during a session (internal LLM check), or if an advanced user specifically asks for the server's tool schema."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_conport_schema` (`{"workspace_id":"..."}`). Primarily for internal LLM reference or direct user request.
|
||||
- name: export_conport_to_markdown
|
||||
trigger: "When the user requests to export the current ConPort data to markdown files (e.g., for backup, sharing, or version control)."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `export_conport_to_markdown` (`{"workspace_id":"...", "output_path":"optional/relative/path"}`). Explain default output path if not provided.
|
||||
- name: import_markdown_to_conport
|
||||
trigger: "When the user requests to import ConPort data from a directory of markdown files previously exported by this system."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `import_markdown_to_conport` (`{"workspace_id":"...", "input_path":"optional/relative/path"}`). Explain default input path. Warn about potential overwrites or merges if data already exists.
|
||||
- name: reconfigure_core_guidance
|
||||
type: guidance
|
||||
product_active_context: "The internal JSON structure of 'Product Context' and 'Active Context' (the `content` field) is flexible. Work with the user to define and evolve this structure via `update_product_context` and `update_active_context`. The server stores this `content` as a JSON blob."
|
||||
decisions_progress_patterns: "The fundamental fields for Decisions, Progress, and System Patterns are fixed by ConPort's tools. For significantly different structures or additional fields, guide the user to create a new custom context category using `log_custom_data` (e.g., category: 'project_milestones_detailed')."
|
||||
|
||||
conport_sync_routine:
|
||||
trigger: "^(Sync ConPort|ConPort Sync)$"
|
||||
user_acknowledgement_text: "[CONPORT_SYNCING]"
|
||||
instructions:
|
||||
- "Halt Current Task: Stop current activity."
|
||||
- "Acknowledge Command: Send `[CONPORT_SYNCING]` to the user."
|
||||
- "Review Chat History: Analyze the complete current chat session for new information, decisions, progress, context changes, clarifications, and potential new relationships between items."
|
||||
core_update_process:
|
||||
thinking_preamble: |
|
||||
- Synchronize ConPort with information from the current chat session.
|
||||
- Use appropriate ConPort tools based on identified changes.
|
||||
- For `update_product_context` and `update_active_context`, first fetch current content, then merge/update (potentially using `patch_content`), then call the update tool with the *complete new content object* or the patch.
|
||||
- All tool calls require the `workspace_id`.
|
||||
agent_action_plan_illustrative:
|
||||
- "Log new decisions (use `log_decision`)."
|
||||
- "Log task progress/status changes (use `log_progress`)."
|
||||
- "Update existing progress entries (use `update_progress`)."
|
||||
- "Delete progress entries (use `delete_progress_by_id`)."
|
||||
- "Log new system patterns (use `log_system_pattern`)."
|
||||
- "Update Active Context (use `get_active_context` then `update_active_context` with full or patch)."
|
||||
- "Update Product Context if significant changes (use `get_product_context` then `update_product_context` with full or patch)."
|
||||
- "Log new custom context, including ProjectGlossary terms (use `log_custom_data`)."
|
||||
- "Identify and log new relationships between items (use `link_conport_items`)."
|
||||
- "If many items of the same type were discussed, consider `batch_log_items`."
|
||||
- "After updates, consider a brief `get_recent_activity_summary` to confirm and refresh understanding."
|
||||
post_sync_actions:
|
||||
- "Inform user: ConPort synchronized with session info."
|
||||
- "Resume previous task or await new instructions."
|
||||
|
||||
dynamic_context_retrieval_for_rag:
|
||||
description: |
|
||||
Guidance for dynamically retrieving and assembling context from ConPort to answer user queries or perform tasks,
|
||||
enhancing Retrieval Augmented Generation (RAG) capabilities.
|
||||
trigger: "When the AI needs to answer a specific question, perform a task requiring detailed project knowledge, or generate content based on ConPort data."
|
||||
goal: "To construct a concise, highly relevant context set for the LLM, improving the accuracy and relevance of its responses."
|
||||
steps:
|
||||
- step: 1
|
||||
action: "Analyze User Query/Task"
|
||||
details: "Deconstruct the user's request to identify key entities, concepts, keywords, and the specific type of information needed from ConPort."
|
||||
- step: 2
|
||||
action: "Prioritized Retrieval Strategy"
|
||||
details: |
|
||||
Based on the analysis, select the most appropriate ConPort tools:
|
||||
- **Targeted FTS:** Use `search_decisions_fts`, `search_custom_data_value_fts`, `search_project_glossary_fts` for keyword-based searches if specific terms are evident.
|
||||
- **Specific Item Retrieval:** Use `get_custom_data` (if category/key known), `get_decisions` (by ID or for recent items), `get_system_patterns`, `get_progress` if the query points to specific item types or IDs.
|
||||
- **(Future):** Prioritize semantic search tools once available for conceptual queries.
|
||||
- **Broad Context (Fallback):** Use `get_product_context` or `get_active_context` as a fallback if targeted retrieval yields little, but be mindful of their size.
|
||||
- step: 3
|
||||
action: "Retrieve Initial Set"
|
||||
details: "Execute the chosen tool(s) to retrieve an initial, small set (e.g., top 3-5) of the most relevant items or data snippets."
|
||||
- step: 4
|
||||
action: "Contextual Expansion (Optional)"
|
||||
details: "For the most promising items from Step 3, consider using `get_linked_items` to fetch directly related items (1-hop). This can provide crucial context or disambiguation. Use judiciously to avoid excessive data."
|
||||
- step: 5
|
||||
action: "Synthesize and Filter"
|
||||
details: |
|
||||
Review the retrieved information (initial set + expanded context).
|
||||
- **Filter:** Discard irrelevant items or parts of items.
|
||||
- **Synthesize/Summarize:** If multiple relevant pieces of information are found, synthesize them into a concise summary that directly addresses the query/task. Extract only the most pertinent sentences or facts.
|
||||
- step: 6
|
||||
action: "Assemble Prompt Context"
|
||||
details: |
|
||||
Construct the context portion of the LLM prompt using the filtered and synthesized information.
|
||||
- **Clarity:** Clearly delineate this retrieved context from the user's query or other parts of the prompt.
|
||||
- **Attribution (Optional but Recommended):** If possible, briefly note the source of the information (e.g., "From Decision D-42:", "According to System Pattern SP-5:").
|
||||
- **Brevity:** Strive for relevance and conciseness. Avoid including large, unprocessed chunks of data unless absolutely necessary and directly requested.
|
||||
general_principles:
|
||||
- "Prefer targeted retrieval over broad context dumps."
|
||||
- "Iterate if initial retrieval is insufficient: try different keywords or tools."
|
||||
- "Balance context richness with prompt token limits."
|
||||
|
||||
proactive_knowledge_graph_linking:
|
||||
description: |
|
||||
Guidance for the AI to proactively identify and suggest the creation of links between ConPort items,
|
||||
enriching the project's knowledge graph based on conversational context.
|
||||
trigger: "During ongoing conversation, when the AI observes potential relationships (e.g., causal, implementational, clarifying) between two or more discussed ConPort items or concepts that are likely represented as ConPort items."
|
||||
goal: "To actively build and maintain a rich, interconnected knowledge graph within ConPort by capturing relationships that might otherwise be missed."
|
||||
steps:
|
||||
- step: 1
|
||||
action: "Monitor Conversational Context"
|
||||
details: "Continuously analyze the user's statements and the flow of discussion for mentions of ConPort items (explicitly by ID, or implicitly by well-known names/summaries) and the relationships being described or implied between them."
|
||||
- step: 2
|
||||
action: "Identify Potential Links"
|
||||
details: |
|
||||
Look for patterns such as:
|
||||
- User states "Decision X led to us doing Y (which is Progress item P-3)."
|
||||
- User discusses how System Pattern SP-2 helps address a concern noted in Decision D-5.
|
||||
- User outlines a task (Progress P-10) that implements a specific feature detailed in a `custom_data` spec (CD-Spec-FeatureX).
|
||||
- step: 3
|
||||
action: "Formulate and Propose Link Suggestion"
|
||||
details: |
|
||||
If a potential link is identified:
|
||||
- Clearly state the items involved (e.g., "Decision D-5", "System Pattern SP-2").
|
||||
- Describe the perceived relationship (e.g., "It seems SP-2 addresses a concern in D-5.").
|
||||
- Propose creating a link using `ask_followup_question`.
|
||||
- Example Question: "I noticed we're discussing Decision D-5 and System Pattern SP-2. It sounds like SP-2 might 'address_concern_in' D-5. Would you like me to create this link in ConPort? You can also suggest a different relationship type."
|
||||
- Suggested Answers:
|
||||
- "Yes, link them with 'addresses_concern_in'."
|
||||
- "Yes, but use relationship type: [user types here]."
|
||||
- "No, don't link them now."
|
||||
- Offer common relationship types as examples if needed: 'implements', 'clarifies', 'related_to', 'depends_on', 'blocks', 'resolves', 'derived_from'.
|
||||
- step: 4
|
||||
action: "Gather Details and Execute Linking"
|
||||
details: |
|
||||
If the user confirms:
|
||||
- Ensure you have the correct source item type, source item ID, target item type, target item ID, and the agreed-upon relationship type.
|
||||
- Ask for an optional brief description for the link if the relationship isn't obvious.
|
||||
- Invoke the `link_conport_items` tool.
|
||||
- step: 5
|
||||
action: "Confirm Outcome"
|
||||
details: "Inform the user of the success or failure of the `link_conport_items` tool call."
|
||||
general_principles:
|
||||
- "Be helpful, not intrusive. If the user declines a suggestion, accept and move on."
|
||||
- "Prioritize clear, strong relationships over tenuous ones."
|
||||
- "This strategy complements the general `proactive_logging_cue` by providing specific guidance for link creation."
|
||||
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!** 🚀
|
||||
345
README.md
345
README.md
@@ -1 +1,344 @@
|
||||
ThrillWiki.com
|
||||
# ThrillWiki Django + Vue.js Monorepo
|
||||
|
||||
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
|
||||
|
||||
```
|
||||
thrillwiki-monorepo/
|
||||
├── backend/ # Django REST API (Port 8000)
|
||||
│ ├── apps/ # Modular Django applications
|
||||
│ ├── config/ # Django settings and configuration
|
||||
│ ├── templates/ # Django templates
|
||||
│ └── static/ # Static assets
|
||||
├── frontend/ # Vue.js SPA (Port 5174)
|
||||
│ ├── src/ # Vue.js source code
|
||||
│ ├── public/ # Static assets
|
||||
│ └── dist/ # Build output
|
||||
├── shared/ # Shared resources and documentation
|
||||
│ ├── docs/ # Comprehensive documentation
|
||||
│ ├── scripts/ # Development and deployment scripts
|
||||
│ ├── config/ # Shared configuration
|
||||
│ └── media/ # Shared media files
|
||||
├── architecture/ # Architecture documentation
|
||||
└── profiles/ # Development profiles
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
|
||||
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
|
||||
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
|
||||
- **Redis 6+** (optional, for caching and sessions)
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd thrillwiki-monorepo
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
pnpm install
|
||||
|
||||
# Install backend dependencies
|
||||
cd backend && uv sync && cd ..
|
||||
```
|
||||
|
||||
3. **Environment configuration**
|
||||
```bash
|
||||
# Copy environment files
|
||||
cp .env.example .env
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.development frontend/.env.local
|
||||
|
||||
# Edit .env files with your settings
|
||||
```
|
||||
|
||||
4. **Database setup**
|
||||
```bash
|
||||
cd backend
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
cd ..
|
||||
```
|
||||
|
||||
5. **Start development servers**
|
||||
```bash
|
||||
# Start both servers concurrently
|
||||
pnpm run dev
|
||||
|
||||
# Or start individually
|
||||
pnpm run dev:frontend # Vue.js on :5174
|
||||
pnpm run dev:backend # Django on :8000
|
||||
```
|
||||
|
||||
## 📁 Project Structure Details
|
||||
|
||||
### Backend (`/backend`)
|
||||
- **Django 5.0+** with REST Framework for API development
|
||||
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
|
||||
- **UV package management** for fast, reliable Python dependency management
|
||||
- **PostgreSQL/SQLite** database with comprehensive entity relationships
|
||||
- **Redis** for caching, sessions, and background tasks
|
||||
- **Comprehensive API** with frontend serializers for camelCase conversion
|
||||
|
||||
### Frontend (`/frontend`)
|
||||
- **Vue 3** with Composition API and `<script setup>` syntax
|
||||
- **TypeScript** for type safety and better developer experience
|
||||
- **Vite** for lightning-fast development and optimized production builds
|
||||
- **Tailwind CSS** with custom design system and dark mode support
|
||||
- **Pinia** for state management with modular stores
|
||||
- **Vue Router** for client-side routing
|
||||
- **Comprehensive UI component library** with shadcn-vue components
|
||||
|
||||
### Shared Resources (`/shared`)
|
||||
- **Documentation** - Comprehensive guides and API documentation
|
||||
- **Development scripts** - Automated setup, build, and deployment scripts
|
||||
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
|
||||
- **Media management** - Centralized media file handling and optimization
|
||||
|
||||
## 🛠️ Development Workflow
|
||||
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm run dev # Start both servers concurrently
|
||||
pnpm run dev:frontend # Frontend only (:5174)
|
||||
pnpm run dev:backend # Backend only (:8000)
|
||||
|
||||
# Building
|
||||
pnpm run build # Build frontend for production
|
||||
pnpm run build:staging # Build for staging environment
|
||||
pnpm run build:production # Build for production environment
|
||||
|
||||
# Testing
|
||||
pnpm run test # Run all tests
|
||||
pnpm run test:frontend # Frontend unit and E2E tests
|
||||
pnpm run test:backend # Backend unit and integration tests
|
||||
|
||||
# Code Quality
|
||||
pnpm run lint # Lint all code
|
||||
pnpm run type-check # TypeScript type checking
|
||||
|
||||
# Setup and Maintenance
|
||||
pnpm run install:all # Install all dependencies
|
||||
./shared/scripts/dev/setup-dev.sh # Full development setup
|
||||
./shared/scripts/dev/start-all.sh # Start all services
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Django management commands
|
||||
uv run manage.py migrate
|
||||
uv run manage.py makemigrations
|
||||
uv run manage.py createsuperuser
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# Testing and quality
|
||||
uv run manage.py test
|
||||
uv run black . # Format code
|
||||
uv run flake8 . # Lint code
|
||||
uv run isort . # Sort imports
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Vue.js development
|
||||
pnpm run dev # Start dev server
|
||||
pnpm run build # Production build
|
||||
pnpm run preview # Preview production build
|
||||
pnpm run test:unit # Vitest unit tests
|
||||
pnpm run test:e2e # Playwright E2E tests
|
||||
pnpm run lint # ESLint
|
||||
pnpm run type-check # TypeScript checking
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Root `.env`
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
|
||||
# API Configuration
|
||||
API_BASE_URL=http://localhost:8000/api
|
||||
```
|
||||
|
||||
#### Backend `.env`
|
||||
```bash
|
||||
# Django Settings
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email (optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
```
|
||||
|
||||
#### Frontend `.env.local`
|
||||
```bash
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
|
||||
# Development
|
||||
VITE_APP_TITLE=ThrillWiki (Development)
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_DEBUG=true
|
||||
```
|
||||
|
||||
## 📊 Key Features
|
||||
|
||||
### Backend Features
|
||||
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
|
||||
- **Extensive Ride Database** - Complete roller coaster and ride information
|
||||
- **User Management** - Authentication, profiles, and permissions
|
||||
- **Content Moderation** - Review and approval workflows
|
||||
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
|
||||
- **Background Tasks** - Celery integration for long-running processes
|
||||
- **Caching Strategy** - Redis-based caching for performance
|
||||
- **Search Functionality** - Full-text search across all content
|
||||
|
||||
### Frontend Features
|
||||
- **Responsive Design** - Mobile-first approach with Tailwind CSS
|
||||
- **Dark Mode Support** - Complete dark/light theme system
|
||||
- **Real-time Search** - Instant search with debouncing and highlighting
|
||||
- **Interactive Maps** - Park and ride location visualization
|
||||
- **Photo Galleries** - High-quality image management
|
||||
- **User Dashboard** - Personalized content and contributions
|
||||
- **Progressive Web App** - PWA capabilities for mobile experience
|
||||
- **Accessibility** - WCAG 2.1 AA compliance
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
### Core Documentation
|
||||
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
|
||||
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
|
||||
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
|
||||
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
|
||||
|
||||
### Architecture & Deployment
|
||||
- **[Architecture Overview](./architecture/)** - System design and decisions
|
||||
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
|
||||
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
|
||||
|
||||
### Additional Resources
|
||||
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
|
||||
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
|
||||
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Development Environment
|
||||
```bash
|
||||
# Quick start with all services
|
||||
./shared/scripts/dev/start-all.sh
|
||||
|
||||
# Full development setup
|
||||
./shared/scripts/dev/setup-dev.sh
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
```bash
|
||||
# Build all components
|
||||
./shared/scripts/build/build-all.sh
|
||||
|
||||
# Deploy to production
|
||||
./shared/scripts/deploy/deploy.sh
|
||||
```
|
||||
|
||||
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests** - Individual function and method testing
|
||||
- **Integration Tests** - API endpoint and database interaction testing
|
||||
- **E2E Tests** - Full user journey testing with Selenium
|
||||
|
||||
### Frontend Testing
|
||||
- **Unit Tests** - Component and utility function testing with Vitest
|
||||
- **Integration Tests** - Component interaction testing
|
||||
- **E2E Tests** - User journey testing with Playwright
|
||||
|
||||
### Code Quality
|
||||
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
|
||||
- **Type Checking** - TypeScript for frontend, mypy for Python
|
||||
- **Code Formatting** - Prettier for frontend, Black for Python
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
|
||||
|
||||
1. **Development Setup** - Getting your development environment ready
|
||||
2. **Code Standards** - Coding conventions and best practices
|
||||
3. **Pull Request Process** - How to submit your changes
|
||||
4. **Issue Reporting** - How to report bugs and request features
|
||||
|
||||
### Quick Contribution Start
|
||||
```bash
|
||||
# Fork and clone the repository
|
||||
git clone https://github.com/your-username/thrillwiki-monorepo.git
|
||||
cd thrillwiki-monorepo
|
||||
|
||||
# Set up development environment
|
||||
./shared/scripts/dev/setup-dev.sh
|
||||
|
||||
# Create a feature branch
|
||||
git checkout -b feature/your-feature-name
|
||||
|
||||
# Make your changes and test
|
||||
pnpm run test
|
||||
|
||||
# Submit a pull request
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Theme Park Community** - For providing data and inspiration
|
||||
- **Open Source Contributors** - For the amazing tools and libraries
|
||||
- **Vue.js and Django Communities** - For excellent documentation and support
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
|
||||
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
|
||||
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for the theme park and roller coaster community**
|
||||
|
||||
180
README_HYBRID_ENDPOINTS.md
Normal file
180
README_HYBRID_ENDPOINTS.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# ThrillWiki Hybrid Filtering Endpoints Test Suite
|
||||
|
||||
This repository contains a comprehensive test script for the newly synchronized Parks and Rides hybrid filtering endpoints.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start the Django server:**
|
||||
```bash
|
||||
cd backend && uv run manage.py runserver 8000
|
||||
```
|
||||
|
||||
2. **Run the test script:**
|
||||
```bash
|
||||
./test_hybrid_endpoints.sh
|
||||
```
|
||||
|
||||
Or with a custom base URL:
|
||||
```bash
|
||||
./test_hybrid_endpoints.sh http://localhost:8000
|
||||
```
|
||||
|
||||
## What Gets Tested
|
||||
|
||||
### Parks Hybrid Filtering (`/api/v1/parks/hybrid/`)
|
||||
- ✅ Basic hybrid filtering (automatic strategy selection)
|
||||
- ✅ Search functionality (`?search=disney`)
|
||||
- ✅ Status filtering (`?status=OPERATING,CLOSED_TEMP`)
|
||||
- ✅ Geographic filtering (`?country=United%20States&state=Florida,California`)
|
||||
- ✅ Numeric range filtering (`?opening_year_min=1990&rating_min=4.0`)
|
||||
- ✅ Park statistics filtering (`?size_min=100&ride_count_min=10`)
|
||||
- ✅ Operator filtering (`?operator=disney,universal`)
|
||||
- ✅ Progressive loading (`?offset=50`)
|
||||
- ✅ Filter metadata (`/filter-metadata/`)
|
||||
- ✅ Scoped metadata (`/filter-metadata/?scoped=true&country=United%20States`)
|
||||
|
||||
### Rides Hybrid Filtering (`/api/v1/rides/hybrid/`)
|
||||
- ✅ Basic hybrid filtering (automatic strategy selection)
|
||||
- ✅ Search functionality (`?search=coaster`)
|
||||
- ✅ Category filtering (`?category=RC,DR`)
|
||||
- ✅ Status and park filtering (`?status=OPERATING&park_slug=cedar-point`)
|
||||
- ✅ Manufacturer/designer filtering (`?manufacturer=bolliger-mabillard`)
|
||||
- ✅ Roller coaster specific filtering (`?roller_coaster_type=INVERTED&has_inversions=true`)
|
||||
- ✅ Performance filtering (`?height_ft_min=200&speed_mph_min=70`)
|
||||
- ✅ Quality metrics (`?rating_min=4.5&capacity_min=1000`)
|
||||
- ✅ Accessibility filtering (`?height_requirement_min=48&height_requirement_max=54`)
|
||||
- ✅ Progressive loading (`?offset=25&category=RC`)
|
||||
- ✅ Filter metadata (`/filter-metadata/`)
|
||||
- ✅ Scoped metadata (`/filter-metadata/?scoped=true&category=RC`)
|
||||
|
||||
### Advanced Testing
|
||||
- ✅ Complex combination queries
|
||||
- ✅ Edge cases (empty results, invalid parameters)
|
||||
- ✅ Performance timing comparisons
|
||||
- ✅ Error handling validation
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
### 🔄 Automatic Strategy Selection
|
||||
- **≤200 records**: Client-side filtering (loads all data for frontend filtering)
|
||||
- **>200 records**: Server-side filtering (database filtering with pagination)
|
||||
|
||||
### 📊 Progressive Loading
|
||||
- Initial load: 50 records
|
||||
- Progressive batches: 25 records
|
||||
- Seamless pagination for large datasets
|
||||
|
||||
### 🔍 Comprehensive Filtering
|
||||
- **Parks**: 17+ filter parameters including geographic, temporal, and statistical filters
|
||||
- **Rides**: 17+ filter parameters including roller coaster specs, performance metrics, and accessibility
|
||||
|
||||
### 📋 Dynamic Filter Metadata
|
||||
- Real-time filter options based on current data
|
||||
- Scoped metadata for contextual filtering
|
||||
- Ranges and categorical options automatically generated
|
||||
|
||||
### ⚡ Performance Optimized
|
||||
- 5-minute intelligent caching
|
||||
- Strategic database indexing
|
||||
- Optimized queries with prefetch_related
|
||||
|
||||
## Response Format
|
||||
|
||||
Both endpoints return consistent response structures:
|
||||
|
||||
```json
|
||||
{
|
||||
"parks": [...], // or "rides": [...]
|
||||
"total_count": 123,
|
||||
"strategy": "client_side", // or "server_side"
|
||||
"has_more": false,
|
||||
"next_offset": null,
|
||||
"filter_metadata": {
|
||||
"categorical": {
|
||||
"countries": ["United States", "Canada", ...],
|
||||
"categories": ["RC", "DR", "FR", ...],
|
||||
// ... more options
|
||||
},
|
||||
"ranges": {
|
||||
"opening_year": {"min": 1800, "max": 2025},
|
||||
"rating": {"min": 1.0, "max": 10.0},
|
||||
// ... more ranges
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **curl**: Required for making HTTP requests
|
||||
- **jq**: Optional but recommended for pretty JSON formatting
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Basic Parks Query
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/parks/hybrid/"
|
||||
```
|
||||
|
||||
### Search for Disney Parks
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/parks/hybrid/?search=disney"
|
||||
```
|
||||
|
||||
### Filter Roller Coasters with Inversions
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/rides/hybrid/?category=RC&has_inversions=true&height_ft_min=100"
|
||||
```
|
||||
|
||||
### Get Filter Metadata
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/parks/hybrid/filter-metadata/"
|
||||
```
|
||||
|
||||
## Integration Guide
|
||||
|
||||
### Frontend Integration
|
||||
1. Use filter metadata to build dynamic filter interfaces
|
||||
2. Implement progressive loading for better UX
|
||||
3. Handle both client-side and server-side strategies
|
||||
4. Cache filter metadata to reduce API calls
|
||||
|
||||
### Performance Considerations
|
||||
- Monitor response times and adjust thresholds as needed
|
||||
- Use progressive loading for datasets >200 records
|
||||
- Implement proper error handling for edge cases
|
||||
- Consider implementing request debouncing for search
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Not Running
|
||||
```
|
||||
❌ Server not available at http://localhost:8000
|
||||
💡 Make sure to start the Django server first:
|
||||
cd backend && uv run manage.py runserver 8000
|
||||
```
|
||||
|
||||
### Missing jq
|
||||
```
|
||||
⚠️ jq not found - JSON responses will not be pretty-printed
|
||||
```
|
||||
Install jq for better output formatting:
|
||||
```bash
|
||||
# macOS
|
||||
brew install jq
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install jq
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Integrate into Frontend**: Use these endpoints in your React/Next.js application
|
||||
2. **Build Filter UI**: Create dynamic filter interfaces using the metadata
|
||||
3. **Implement Progressive Loading**: Handle large datasets efficiently
|
||||
4. **Monitor Performance**: Track response times and optimize as needed
|
||||
5. **Add Caching**: Implement client-side caching for better UX
|
||||
|
||||
---
|
||||
|
||||
🎢 **Happy filtering!** These endpoints provide a powerful, scalable foundation for building advanced search and filtering experiences in your theme park application.
|
||||
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/)
|
||||
@@ -1,207 +0,0 @@
|
||||
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'
|
||||
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'
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
class TopListItemInline(admin.TabularInline):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
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']
|
||||
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')}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('username', 'email', 'password1', 'password2', 'role'),
|
||||
}),
|
||||
)
|
||||
|
||||
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'
|
||||
|
||||
def get_status(self, obj):
|
||||
if obj.is_banned:
|
||||
return format_html('<span style="color: red;">Banned</span>')
|
||||
if not obj.is_active:
|
||||
return format_html('<span style="color: orange;">Inactive</span>')
|
||||
if obj.is_superuser:
|
||||
return format_html('<span style="color: purple;">Superuser</span>')
|
||||
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'
|
||||
|
||||
def get_credits(self, obj):
|
||||
try:
|
||||
profile = obj.profile
|
||||
return format_html(
|
||||
'RC: {}<br>DR: {}<br>FR: {}<br>WR: {}',
|
||||
profile.coaster_credits,
|
||||
profile.dark_ride_credits,
|
||||
profile.flat_ride_credits,
|
||||
profile.water_ride_credits
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return '-'
|
||||
get_credits.short_description = 'Ride Credits'
|
||||
|
||||
def activate_users(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
activate_users.short_description = "Activate selected users"
|
||||
|
||||
def deactivate_users(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
deactivate_users.short_description = "Deactivate 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"
|
||||
|
||||
def unban_users(self, request, queryset):
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason='')
|
||||
unban_users.short_description = "Unban selected users"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
creating = not obj.pk
|
||||
super().save_model(request, obj, form, change)
|
||||
if creating and obj.role != User.Roles.USER:
|
||||
# Ensure new user with role gets added to appropriate group
|
||||
group = Group.objects.filter(name=obj.role).first()
|
||||
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')
|
||||
|
||||
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'
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@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')
|
||||
|
||||
fieldsets = (
|
||||
('Verification Details', {
|
||||
'fields': ('user', 'token')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('created_at', 'last_sent')
|
||||
}),
|
||||
)
|
||||
|
||||
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')
|
||||
inlines = [TopListItemInline]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('user', 'title', 'category', 'description')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
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')
|
||||
|
||||
fieldsets = (
|
||||
('List Information', {
|
||||
'fields': ('top_list', 'rank')
|
||||
}),
|
||||
('Item Details', {
|
||||
'fields': ('content_type', 'object_id', 'notes')
|
||||
}),
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
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'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check SocialApp
|
||||
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:')
|
||||
for site in app.sites.all():
|
||||
self.stdout.write(f' - {site.domain}')
|
||||
|
||||
# Check SocialAccount
|
||||
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}')
|
||||
|
||||
# Check SocialToken
|
||||
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}')
|
||||
|
||||
# Check Site
|
||||
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}')
|
||||
@@ -1,19 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
class Command(BaseCommand):
|
||||
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'))
|
||||
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())}')
|
||||
@@ -1,48 +0,0 @@
|
||||
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'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'ThrillWiki Development'
|
||||
}
|
||||
)[0]
|
||||
|
||||
# Create Discord app
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider='discord',
|
||||
defaults={
|
||||
'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.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',
|
||||
defaults={
|
||||
'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.save()
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'{"Created" if created else "Updated"} Google app')
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
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'))
|
||||
@@ -1,35 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
import os
|
||||
|
||||
class Command(BaseCommand):
|
||||
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')
|
||||
|
||||
# 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'),
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
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'),
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'Created Discord app with client_id: {discord_app.client_id}')
|
||||
@@ -1,11 +0,0 @@
|
||||
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'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
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}"))
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
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'))
|
||||
@@ -1,63 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
class Command(BaseCommand):
|
||||
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')
|
||||
|
||||
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'))
|
||||
return
|
||||
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'localhost'
|
||||
}
|
||||
)
|
||||
|
||||
# Set up Google
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider='google',
|
||||
defaults={
|
||||
'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()
|
||||
google_app.sites.add(site)
|
||||
|
||||
# Set up Discord
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider='discord',
|
||||
defaults={
|
||||
'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()
|
||||
discord_app.sites.add(site)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully set up social auth apps'))
|
||||
@@ -1,60 +0,0 @@
|
||||
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'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
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}')
|
||||
|
||||
# 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}')
|
||||
|
||||
if response.status_code == 302:
|
||||
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)')
|
||||
|
||||
# Show callback URL
|
||||
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')
|
||||
|
||||
# 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/')
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR('Discord app not found'))
|
||||
@@ -1,36 +0,0 @@
|
||||
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'
|
||||
|
||||
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}')
|
||||
|
||||
# Get sites
|
||||
sites = discord_app.sites.all()
|
||||
self.stdout.write('\nAssociated sites:')
|
||||
for site in sites:
|
||||
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:')
|
||||
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(f'Scopes: {discord_settings.get("SCOPE", [])}')
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR('Discord app not found'))
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,25 +0,0 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from allauth.account.views import LogoutView
|
||||
from . import views
|
||||
|
||||
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'),
|
||||
|
||||
# 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'),
|
||||
|
||||
# Profile views
|
||||
path('profile/', views.user_redirect_view, name='profile_redirect'),
|
||||
path('settings/', views.SettingsView.as_view(), name='settings'),
|
||||
]
|
||||
@@ -1,381 +0,0 @@
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
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.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 email_service.services import EmailService
|
||||
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:
|
||||
self.validate_turnstile(self.request)
|
||||
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
|
||||
|
||||
def form_invalid(self, form):
|
||||
if getattr(self.request, 'htmx', False):
|
||||
return render(
|
||||
self.request,
|
||||
'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):
|
||||
return render(
|
||||
request,
|
||||
'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:
|
||||
self.validate_turnstile(self.request)
|
||||
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
|
||||
|
||||
def form_invalid(self, form):
|
||||
if getattr(self.request, 'htmx', False):
|
||||
return render(
|
||||
self.request,
|
||||
'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):
|
||||
return render(
|
||||
request,
|
||||
'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)
|
||||
|
||||
def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
|
||||
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('/')
|
||||
|
||||
def email_required(request: HttpRequest) -> HttpResponse:
|
||||
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'):
|
||||
return handle_social_login(request, email)
|
||||
messages.error(request, 'Email is required')
|
||||
return render(request, 'accounts/email_required.html', {'error': 'Email is required'})
|
||||
|
||||
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'
|
||||
|
||||
def get_queryset(self) -> QuerySet[User]:
|
||||
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)
|
||||
|
||||
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_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]
|
||||
|
||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
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
|
||||
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'):
|
||||
profile.display_name = display_name
|
||||
|
||||
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')
|
||||
|
||||
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))
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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', '')
|
||||
|
||||
if not user.check_password(old_password):
|
||||
messages.error(request, 'Current password is incorrect')
|
||||
return None
|
||||
|
||||
if new_password != confirm_password:
|
||||
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')
|
||||
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'))
|
||||
|
||||
def _handle_email_change(self, request: HttpRequest) -> None:
|
||||
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')
|
||||
else:
|
||||
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}
|
||||
)
|
||||
|
||||
site = cast(Site, get_current_site(request))
|
||||
verification_url = reverse('verify_email', kwargs={'token': token})
|
||||
|
||||
context = {
|
||||
'user': user,
|
||||
'verification_url': verification_url,
|
||||
'site_name': site.name,
|
||||
}
|
||||
|
||||
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',
|
||||
site=site,
|
||||
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':
|
||||
self._handle_profile_update(request)
|
||||
elif action == 'change_password':
|
||||
if response := self._handle_password_change(request):
|
||||
return response
|
||||
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)
|
||||
}
|
||||
)
|
||||
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})
|
||||
context = {
|
||||
'user': user,
|
||||
'reset_url': reset_url,
|
||||
'site_name': site.name,
|
||||
}
|
||||
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',
|
||||
site=site,
|
||||
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')
|
||||
|
||||
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:
|
||||
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:
|
||||
context = {
|
||||
'user': user,
|
||||
'site_name': site.name,
|
||||
}
|
||||
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.',
|
||||
site=site,
|
||||
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
|
||||
)
|
||||
|
||||
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})
|
||||
|
||||
except PasswordReset.DoesNotExist:
|
||||
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,34 +0,0 @@
|
||||
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
|
||||
|
||||
class Command(BaseCommand):
|
||||
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.
|
||||
"""
|
||||
# Get top 10 trending parks and rides from the last 24 hours
|
||||
trending_parks = PageView.get_trending_items(Park, hours=24, limit=10)
|
||||
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)
|
||||
|
||||
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.'
|
||||
)
|
||||
)
|
||||
@@ -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,57 +0,0 @@
|
||||
from django.db import models
|
||||
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')
|
||||
object_id = models.PositiveIntegerField()
|
||||
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']),
|
||||
]
|
||||
|
||||
@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]
|
||||
|
||||
# Get the actual items in the correct order
|
||||
if item_ids:
|
||||
# Convert the list to a string of comma-separated values
|
||||
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,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.
|
||||
649
api_endpoints_curl_commands.sh
Executable file
649
api_endpoints_curl_commands.sh
Executable file
@@ -0,0 +1,649 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ThrillWiki API Endpoints - Complete Curl Commands
|
||||
# Generated from comprehensive URL analysis
|
||||
# Base URL - adjust as needed for your environment
|
||||
BASE_URL="http://localhost:8000"
|
||||
|
||||
# Command line options
|
||||
SKIP_AUTH=false
|
||||
ONLY_AUTH=false
|
||||
SKIP_DOCS=false
|
||||
HELP=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-auth)
|
||||
SKIP_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--only-auth)
|
||||
ONLY_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--skip-docs)
|
||||
SKIP_DOCS=true
|
||||
shift
|
||||
;;
|
||||
--base-url)
|
||||
BASE_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
HELP=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Show help
|
||||
if [ "$HELP" = true ]; then
|
||||
echo "ThrillWiki API Endpoints Test Suite"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --skip-auth Skip endpoints that require authentication"
|
||||
echo " --only-auth Only test endpoints that require authentication"
|
||||
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
|
||||
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Test all endpoints"
|
||||
echo " $0 --skip-auth # Test only public endpoints"
|
||||
echo " $0 --only-auth # Test only authenticated endpoints"
|
||||
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
|
||||
echo " $0 --base-url https://api.example.com # Use custom base URL"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate conflicting options
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Error: --skip-auth and --only-auth cannot be used together"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ThrillWiki API Endpoints Test Suite ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
if [ "$SKIP_AUTH" = true ]; then
|
||||
echo "Mode: Public endpoints only (skipping authentication required)"
|
||||
elif [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Mode: Authenticated endpoints only"
|
||||
else
|
||||
echo "Mode: All endpoints"
|
||||
fi
|
||||
if [ "$SKIP_DOCS" = true ]; then
|
||||
echo "Skipping: API documentation endpoints"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Helper function to check if we should run an endpoint
|
||||
should_run_endpoint() {
|
||||
local requires_auth=$1
|
||||
local is_docs=$2
|
||||
|
||||
# Skip docs if requested
|
||||
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Skip auth endpoints if requested
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Only run auth endpoints if requested
|
||||
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Counter for endpoint numbering
|
||||
ENDPOINT_NUM=1
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo "=== AUTHENTICATION ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Login"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/login/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "testuser", "password": "testpass"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Signup"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Logout"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
|
||||
-H "Content-Type: application/json"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Reset"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Social Providers"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/providers/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Auth Status"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/status/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Current User"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/user/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Change"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Health Check"
|
||||
curl -X GET "$BASE_URL/api/v1/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Simple Health"
|
||||
curl -X GET "$BASE_URL/api/v1/health/simple/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
|
||||
curl -X GET "$BASE_URL/api/v1/health/performance/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Trending Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/content/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. New Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/new/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS ENDPOINTS (/api/v1/stats/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
|
||||
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rankings"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking History"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
|
||||
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"category": "RC"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# PARKS API ENDPOINTS (/api/v1/parks/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== PARKS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Parks"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Park Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Park", "location": "Test City"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Park Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RIDES API ENDPOINTS (/api/v1/rides/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RIDES API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rides"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Ride Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test ride photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated ride photo caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List User Profiles"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top Lists"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top List Items"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Update User Profile"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"bio": "Updated bio"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Top List Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"toplist": 1, "ride": 1, "position": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"position": 2}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HISTORY API ENDPOINTS (/api/v1/history/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Park History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# EMAIL API ENDPOINTS (/api/v1/email/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Send Email"
|
||||
curl -X POST "$BASE_URL/api/v1/email/send/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# CORE API ENDPOINTS (/api/v1/core/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== CORE API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
|
||||
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "nonexistent park", "type": "park"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# MAPS API ENDPOINTS (/api/v1/maps/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== MAPS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Map Locations"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Search"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/cache/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
|
||||
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# API DOCUMENTATION ENDPOINTS
|
||||
# ============================================================================
|
||||
if should_run_endpoint false true; then
|
||||
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. OpenAPI Schema"
|
||||
curl -X GET "$BASE_URL/api/schema/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Swagger UI"
|
||||
curl -X GET "$BASE_URL/api/docs/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. ReDoc"
|
||||
curl -X GET "$BASE_URL/api/redoc/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK (Django Health Check)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Django Health Check"
|
||||
curl -X GET "$BASE_URL/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
|
||||
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
|
||||
echo ""
|
||||
echo "Notes:"
|
||||
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
|
||||
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
|
||||
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
|
||||
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
|
||||
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
|
||||
echo ""
|
||||
echo "Authentication required endpoints are marked with Authorization header"
|
||||
echo "File upload endpoints use multipart/form-data (-F flag)"
|
||||
echo "JSON endpoints use application/json content type"
|
||||
372
architecture/architecture-validation.md
Normal file
372
architecture/architecture-validation.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# ThrillWiki Monorepo Architecture Validation
|
||||
|
||||
This document provides a comprehensive review and validation of the proposed monorepo architecture for migrating ThrillWiki from Django-only to Django + Vue.js.
|
||||
|
||||
## Architecture Overview Validation
|
||||
|
||||
### ✅ Core Requirements Met
|
||||
|
||||
1. **Clean Separation of Concerns**
|
||||
- Backend: Django API, business logic, database management
|
||||
- Frontend: Vue.js SPA with modern tooling
|
||||
- Shared: Common resources and media files
|
||||
|
||||
2. **Development Workflow Preservation**
|
||||
- UV package management for Python maintained
|
||||
- pnpm for Node.js package management
|
||||
- Existing development scripts adapted
|
||||
- Hot reloading for both backend and frontend
|
||||
|
||||
3. **Project Structure Compatibility**
|
||||
- Django apps preserved under `backend/apps/`
|
||||
- Configuration maintained under `backend/config/`
|
||||
- Static files strategy clearly defined
|
||||
- Media files centralized in `shared/media/`
|
||||
|
||||
## Technical Architecture Validation
|
||||
|
||||
### Backend Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Django Backend] --> B[Apps Directory]
|
||||
A --> C[Config Directory]
|
||||
A --> D[Static Files]
|
||||
|
||||
B --> E[accounts]
|
||||
B --> F[parks]
|
||||
B --> G[rides]
|
||||
B --> H[moderation]
|
||||
B --> I[location]
|
||||
B --> J[media]
|
||||
B --> K[email_service]
|
||||
B --> L[core]
|
||||
|
||||
C --> M[Django Settings]
|
||||
C --> N[URL Configuration]
|
||||
C --> O[WSGI/ASGI]
|
||||
|
||||
D --> P[Admin Assets]
|
||||
D --> Q[Backend Static]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ All 8 Django apps properly mapped to new structure
|
||||
- ✅ Configuration files maintain their organization
|
||||
- ✅ Static file handling preserves Django admin functionality
|
||||
- ✅ UV package management integration maintained
|
||||
|
||||
### Frontend Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Vue.js Frontend] --> B[Source Code]
|
||||
A --> C[Build System]
|
||||
A --> D[Development Tools]
|
||||
|
||||
B --> E[Components]
|
||||
B --> F[Views/Pages]
|
||||
B --> G[Router]
|
||||
B --> H[State Management]
|
||||
B --> I[API Layer]
|
||||
|
||||
C --> J[Vite]
|
||||
C --> K[TypeScript]
|
||||
C --> L[Tailwind CSS]
|
||||
|
||||
D --> M[Hot Reload]
|
||||
D --> N[Dev Server]
|
||||
D --> O[Build Tools]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ Modern Vue.js 3 + Composition API
|
||||
- ✅ TypeScript for type safety
|
||||
- ✅ Vite for fast development and builds
|
||||
- ✅ Tailwind CSS for styling (matching current setup)
|
||||
- ✅ Pinia for state management
|
||||
- ✅ Vue Router for SPA navigation
|
||||
|
||||
### Integration Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue.js Frontend] --> B[HTTP API Calls]
|
||||
B --> C[Django REST API]
|
||||
C --> D[Database]
|
||||
C --> E[Media Files]
|
||||
E --> F[Shared Media Directory]
|
||||
F --> G[Frontend Access]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ RESTful API integration between frontend and backend
|
||||
- ✅ Media files accessible to both systems
|
||||
- ✅ Authentication handling via API tokens
|
||||
- ✅ CORS configuration for cross-origin requests
|
||||
|
||||
## File Migration Validation
|
||||
|
||||
### Critical File Mappings ✅
|
||||
|
||||
| Component | Current | New Location | Status |
|
||||
|-----------|---------|--------------|--------|
|
||||
| Django Apps | `/apps/` | `/backend/apps/` | ✅ Mapped |
|
||||
| Configuration | `/config/` | `/backend/config/` | ✅ Mapped |
|
||||
| Static Files | `/static/` | `/backend/static/` | ✅ Mapped |
|
||||
| Media Files | `/media/` | `/shared/media/` | ✅ Mapped |
|
||||
| Scripts | `/scripts/` | `/scripts/` | ✅ Preserved |
|
||||
| Dependencies | `/pyproject.toml` | `/backend/pyproject.toml` | ✅ Mapped |
|
||||
|
||||
### Import Path Updates Required ✅
|
||||
|
||||
**Django Settings Updates:**
|
||||
```python
|
||||
# OLD
|
||||
INSTALLED_APPS = [
|
||||
'accounts',
|
||||
'parks',
|
||||
'rides',
|
||||
# ...
|
||||
]
|
||||
|
||||
# NEW
|
||||
INSTALLED_APPS = [
|
||||
'apps.accounts',
|
||||
'apps.parks',
|
||||
'apps.rides',
|
||||
# ...
|
||||
]
|
||||
```
|
||||
|
||||
**Media Path Updates:**
|
||||
```python
|
||||
# NEW
|
||||
MEDIA_ROOT = BASE_DIR.parent / 'shared' / 'media'
|
||||
```
|
||||
|
||||
## Development Workflow Validation
|
||||
|
||||
### Package Management ✅
|
||||
|
||||
**Backend (UV):**
|
||||
- ✅ `uv add <package>` for new dependencies
|
||||
- ✅ `uv run manage.py <command>` for Django commands
|
||||
- ✅ `uv sync` for dependency installation
|
||||
|
||||
**Frontend (pnpm):**
|
||||
- ✅ `pnpm add <package>` for new dependencies
|
||||
- ✅ `pnpm install` for dependency installation
|
||||
- ✅ `pnpm run dev` for development server
|
||||
|
||||
**Root Workspace:**
|
||||
- ✅ `pnpm run dev` starts both servers concurrently
|
||||
- ✅ Individual server commands available
|
||||
- ✅ Build and test scripts coordinated
|
||||
|
||||
### Development Scripts ✅
|
||||
|
||||
```bash
|
||||
# Root level coordination
|
||||
pnpm run dev # Both servers
|
||||
pnpm run backend:dev # Django only
|
||||
pnpm run frontend:dev # Vue.js only
|
||||
pnpm run build # Production build
|
||||
pnpm run test # All tests
|
||||
pnpm run lint # All linting
|
||||
pnpm run format # Code formatting
|
||||
```
|
||||
|
||||
## Deployment Strategy Validation
|
||||
|
||||
### Container Strategy ✅
|
||||
|
||||
**Multi-container Approach:**
|
||||
- ✅ Separate containers for backend and frontend
|
||||
- ✅ Shared volumes for media files
|
||||
- ✅ Database and Redis containers
|
||||
- ✅ Nginx reverse proxy configuration
|
||||
|
||||
**Build Process:**
|
||||
- ✅ Backend: Django static collection + uv dependencies
|
||||
- ✅ Frontend: Vite production build + asset optimization
|
||||
- ✅ Shared: Media file persistence across deployments
|
||||
|
||||
### Platform Compatibility ✅
|
||||
|
||||
**Supported Deployment Platforms:**
|
||||
- ✅ Docker Compose (local and production)
|
||||
- ✅ Vercel (frontend + serverless backend)
|
||||
- ✅ Railway (container deployment)
|
||||
- ✅ DigitalOcean App Platform
|
||||
- ✅ AWS ECS/Fargate
|
||||
- ✅ Google Cloud Run
|
||||
|
||||
## Performance Considerations ✅
|
||||
|
||||
### Backend Optimization
|
||||
- ✅ Database connection pooling
|
||||
- ✅ Redis caching strategy
|
||||
- ✅ Static file CDN integration
|
||||
- ✅ API response optimization
|
||||
|
||||
### Frontend Optimization
|
||||
- ✅ Code splitting and lazy loading
|
||||
- ✅ Asset optimization with Vite
|
||||
- ✅ Tree shaking for minimal bundle size
|
||||
- ✅ Modern build targets
|
||||
|
||||
### Development Performance
|
||||
- ✅ Hot module replacement for Vue.js
|
||||
- ✅ Django auto-reload for backend changes
|
||||
- ✅ Fast dependency installation with UV and pnpm
|
||||
- ✅ Concurrent development servers
|
||||
|
||||
## Security Validation ✅
|
||||
|
||||
### Backend Security
|
||||
- ✅ Django security middleware maintained
|
||||
- ✅ CORS configuration for API access
|
||||
- ✅ Authentication token management
|
||||
- ✅ Input validation and sanitization
|
||||
|
||||
### Frontend Security
|
||||
- ✅ Content Security Policy headers
|
||||
- ✅ XSS protection mechanisms
|
||||
- ✅ Secure API communication (HTTPS)
|
||||
- ✅ Environment variable protection
|
||||
|
||||
### Deployment Security
|
||||
- ✅ SSL/TLS termination
|
||||
- ✅ Security headers configuration
|
||||
- ✅ Secret management strategy
|
||||
- ✅ Container security best practices
|
||||
|
||||
## Risk Assessment and Mitigation
|
||||
|
||||
### Low Risk Items ✅
|
||||
- **File organization**: Clear mapping and systematic approach
|
||||
- **Package management**: Both UV and pnpm are stable and well-supported
|
||||
- **Development workflow**: Incremental changes to existing process
|
||||
|
||||
### Medium Risk Items ⚠️
|
||||
- **Import path updates**: Requires careful testing of all Django apps
|
||||
- **Static file handling**: Need to verify Django admin continues working
|
||||
- **API integration**: New frontend-backend communication layer
|
||||
|
||||
**Mitigation Strategies:**
|
||||
- Comprehensive testing suite for Django apps after migration
|
||||
- Static file serving verification in development and production
|
||||
- API endpoint testing and documentation
|
||||
- Gradual migration approach with rollback capabilities
|
||||
|
||||
### High Risk Items 🔴
|
||||
- **Data migration**: Database changes during restructuring
|
||||
- **Production deployment**: New deployment process requires validation
|
||||
|
||||
**Mitigation Strategies:**
|
||||
- Database backup before any structural changes
|
||||
- Staging environment testing before production deployment
|
||||
- Blue-green deployment strategy for zero-downtime migration
|
||||
- Monitoring and alerting for post-migration issues
|
||||
|
||||
## Testing Strategy Validation
|
||||
|
||||
### Backend Testing ✅
|
||||
```bash
|
||||
# Django tests
|
||||
cd backend
|
||||
uv run manage.py test
|
||||
|
||||
# Code quality
|
||||
uv run flake8 .
|
||||
uv run black --check .
|
||||
```
|
||||
|
||||
### Frontend Testing ✅
|
||||
```bash
|
||||
# Vue.js tests
|
||||
cd frontend
|
||||
pnpm run test
|
||||
pnpm run test:unit
|
||||
pnpm run test:e2e
|
||||
|
||||
# Code quality
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
```
|
||||
|
||||
### Integration Testing ✅
|
||||
- API endpoint testing
|
||||
- Frontend-backend communication testing
|
||||
- Media file access testing
|
||||
- Authentication flow testing
|
||||
|
||||
## Documentation Validation ✅
|
||||
|
||||
### Created Documentation
|
||||
- ✅ **Monorepo Structure Plan**: Complete directory organization
|
||||
- ✅ **Migration Mapping**: File-by-file migration guide
|
||||
- ✅ **Deployment Guide**: Comprehensive deployment strategies
|
||||
- ✅ **Architecture Validation**: This validation document
|
||||
|
||||
### Required Updates
|
||||
- ✅ Root README.md update for monorepo structure
|
||||
- ✅ Development setup instructions
|
||||
- ✅ API documentation for frontend integration
|
||||
- ✅ Deployment runbooks
|
||||
|
||||
## Implementation Readiness Assessment
|
||||
|
||||
### Prerequisites Met ✅
|
||||
- [x] Current Django project analysis complete
|
||||
- [x] Monorepo structure designed
|
||||
- [x] File migration strategy defined
|
||||
- [x] Development workflow planned
|
||||
- [x] Deployment strategy documented
|
||||
- [x] Risk assessment completed
|
||||
|
||||
### Ready for Implementation ✅
|
||||
- [x] Clear step-by-step migration plan
|
||||
- [x] File mapping completeness verified
|
||||
- [x] Package management strategy confirmed
|
||||
- [x] Testing approach defined
|
||||
- [x] Rollback strategy available
|
||||
|
||||
### Success Criteria Defined ✅
|
||||
1. **Functional Requirements**
|
||||
- All existing Django functionality preserved
|
||||
- Modern Vue.js frontend operational
|
||||
- API integration working correctly
|
||||
- Media file handling functional
|
||||
|
||||
2. **Performance Requirements**
|
||||
- Development servers start within reasonable time
|
||||
- Build process completes successfully
|
||||
- Production deployment successful
|
||||
|
||||
3. **Quality Requirements**
|
||||
- All tests passing after migration
|
||||
- Code quality standards maintained
|
||||
- Documentation updated and complete
|
||||
|
||||
## Final Recommendation ✅
|
||||
|
||||
**Approval Status: APPROVED FOR IMPLEMENTATION**
|
||||
|
||||
The proposed monorepo architecture for ThrillWiki is comprehensive, well-planned, and ready for implementation. The plan demonstrates:
|
||||
|
||||
1. **Technical Soundness**: Architecture follows modern best practices
|
||||
2. **Risk Management**: Potential issues identified with mitigation strategies
|
||||
3. **Implementation Clarity**: Clear step-by-step migration process
|
||||
4. **Operational Readiness**: Deployment and maintenance procedures defined
|
||||
|
||||
**Next Steps:**
|
||||
1. Switch to **Code Mode** for implementation
|
||||
2. Begin with directory structure creation
|
||||
3. Migrate backend files systematically
|
||||
4. Create Vue.js frontend application
|
||||
5. Test integration between systems
|
||||
6. Update deployment configurations
|
||||
|
||||
The architecture provides a solid foundation for scaling ThrillWiki with modern frontend technologies while preserving the robust Django backend functionality.
|
||||
628
architecture/deployment-guide.md
Normal file
628
architecture/deployment-guide.md
Normal file
@@ -0,0 +1,628 @@
|
||||
# ThrillWiki Monorepo Deployment Guide
|
||||
|
||||
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + Vue.js monorepo.
|
||||
|
||||
## Build Process Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Source Code] --> B[Backend Build]
|
||||
A --> C[Frontend Build]
|
||||
B --> D[Django Static Collection]
|
||||
C --> E[Vue.js Production Build]
|
||||
D --> F[Backend Container]
|
||||
E --> G[Frontend Assets]
|
||||
F --> H[Production Deployment]
|
||||
G --> H
|
||||
```
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+ with UV package manager
|
||||
- Node.js 18+ with pnpm
|
||||
- PostgreSQL (production) / SQLite (development)
|
||||
- Redis (for caching and sessions)
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd thrillwiki-monorepo
|
||||
|
||||
# Install root dependencies
|
||||
pnpm install
|
||||
|
||||
# Backend setup
|
||||
cd backend
|
||||
uv sync
|
||||
uv run manage.py migrate
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# Frontend setup
|
||||
cd ../frontend
|
||||
pnpm install
|
||||
|
||||
# Start development servers
|
||||
cd ..
|
||||
pnpm run dev # Starts both backend and frontend
|
||||
```
|
||||
|
||||
## Build Strategies
|
||||
|
||||
### 1. Containerized Deployment (Recommended)
|
||||
|
||||
#### Multi-stage Dockerfile for Backend
|
||||
```dockerfile
|
||||
# backend/Dockerfile
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN pip install uv
|
||||
RUN uv sync --no-dev
|
||||
|
||||
FROM python:3.11-slim as runtime
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY . .
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
```
|
||||
|
||||
#### Dockerfile for Frontend
|
||||
```dockerfile
|
||||
# frontend/Dockerfile
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:alpine as runtime
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
#### Docker Compose for Development
|
||||
```yaml
|
||||
# docker-compose.dev.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: thrillwiki
|
||||
POSTGRES_USER: thrillwiki
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./shared/media:/app/media
|
||||
environment:
|
||||
- DEBUG=1
|
||||
- DATABASE_URL=postgresql://thrillwiki:password@db:5432/thrillwiki
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
#### Docker Compose for Production
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DEBUG=0
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ALLOWED_HOSTS=${ALLOWED_HOSTS}
|
||||
volumes:
|
||||
- ./shared/media:/app/media
|
||||
- static_files:/app/staticfiles
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./nginx/ssl:/etc/nginx/ssl
|
||||
- static_files:/usr/share/nginx/html/static
|
||||
- ./shared/media:/usr/share/nginx/html/media
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
static_files:
|
||||
```
|
||||
|
||||
### 2. Static Site Generation (Alternative)
|
||||
|
||||
For sites with mostly static content, consider pre-rendering:
|
||||
|
||||
```bash
|
||||
# Frontend build with pre-rendering
|
||||
cd frontend
|
||||
pnpm run build:prerender
|
||||
|
||||
# Serve static files with minimal backend
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflow
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy ThrillWiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
- name: Backend Tests
|
||||
run: |
|
||||
cd backend
|
||||
uv sync
|
||||
uv run manage.py test
|
||||
uv run flake8 .
|
||||
uv run black --check .
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Frontend Tests
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run test
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push Docker images
|
||||
run: |
|
||||
docker build -t thrillwiki-backend ./backend
|
||||
docker build -t thrillwiki-frontend ./frontend
|
||||
# Push to registry
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
# Deploy using your preferred method
|
||||
# (AWS ECS, GCP Cloud Run, Azure Container Instances, etc.)
|
||||
```
|
||||
|
||||
## Platform-Specific Deployments
|
||||
|
||||
### 1. Vercel Deployment (Frontend + API)
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "frontend/package.json",
|
||||
"use": "@vercel/static-build",
|
||||
"config": {
|
||||
"distDir": "dist"
|
||||
}
|
||||
},
|
||||
{
|
||||
"src": "backend/config/wsgi.py",
|
||||
"use": "@vercel/python"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/api/(.*)",
|
||||
"dest": "backend/config/wsgi.py"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "frontend/dist/$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Railway Deployment
|
||||
|
||||
```toml
|
||||
# railway.toml
|
||||
[environments.production]
|
||||
|
||||
[environments.production.services.backend]
|
||||
dockerfile = "backend/Dockerfile"
|
||||
variables = { DEBUG = "0" }
|
||||
|
||||
[environments.production.services.frontend]
|
||||
dockerfile = "frontend/Dockerfile"
|
||||
|
||||
[environments.production.services.postgres]
|
||||
image = "postgres:15"
|
||||
variables = { POSTGRES_DB = "thrillwiki" }
|
||||
```
|
||||
|
||||
### 3. DigitalOcean App Platform
|
||||
|
||||
```yaml
|
||||
# .do/app.yaml
|
||||
name: thrillwiki
|
||||
services:
|
||||
- name: backend
|
||||
source_dir: backend
|
||||
github:
|
||||
repo: your-username/thrillwiki-monorepo
|
||||
branch: main
|
||||
run_command: gunicorn config.wsgi:application
|
||||
environment_slug: python
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
envs:
|
||||
- key: DEBUG
|
||||
value: "0"
|
||||
|
||||
- name: frontend
|
||||
source_dir: frontend
|
||||
github:
|
||||
repo: your-username/thrillwiki-monorepo
|
||||
branch: main
|
||||
build_command: pnpm run build
|
||||
run_command: pnpm run preview
|
||||
environment_slug: node-js
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
|
||||
databases:
|
||||
- name: thrillwiki-db
|
||||
engine: PG
|
||||
version: "15"
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
```bash
|
||||
# Django Settings
|
||||
DEBUG=0
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@host:port/database
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://host:port/0
|
||||
|
||||
# File Storage
|
||||
MEDIA_ROOT=/app/media
|
||||
STATIC_ROOT=/app/staticfiles
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=smtp.yourmailprovider.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@yourdomain.com
|
||||
EMAIL_HOST_PASSWORD=your-email-password
|
||||
|
||||
# Third-party Services
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
AWS_ACCESS_KEY_ID=your-aws-key
|
||||
AWS_SECRET_ACCESS_KEY=your-aws-secret
|
||||
```
|
||||
|
||||
#### Frontend (.env.production)
|
||||
```bash
|
||||
VITE_API_URL=https://api.yourdomain.com
|
||||
VITE_APP_TITLE=ThrillWiki
|
||||
VITE_SENTRY_DSN=your-frontend-sentry-dsn
|
||||
VITE_GOOGLE_ANALYTICS_ID=your-ga-id
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Backend Optimizations
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
|
||||
# Database optimization
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'CONN_MAX_AGE': 60,
|
||||
'OPTIONS': {
|
||||
'MAX_CONNS': 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Caching
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
},
|
||||
'KEY_PREFIX': 'thrillwiki'
|
||||
}
|
||||
}
|
||||
|
||||
# Static files with CDN
|
||||
AWS_S3_CUSTOM_DOMAIN = 'cdn.yourdomain.com'
|
||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.StaticS3Boto3Storage'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.MediaS3Boto3Storage'
|
||||
```
|
||||
|
||||
### Frontend Optimizations
|
||||
```typescript
|
||||
// frontend/vite.config.ts
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
ui: ['@headlessui/vue', '@heroicons/vue']
|
||||
}
|
||||
}
|
||||
},
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Application Monitoring
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="your-sentry-dsn",
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
send_default_pii=True
|
||||
)
|
||||
|
||||
# Logging configuration
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': '/var/log/django/thrillwiki.log',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['file'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Infrastructure Monitoring
|
||||
- Use Prometheus + Grafana for metrics
|
||||
- Implement health check endpoints
|
||||
- Set up log aggregation (ELK stack or similar)
|
||||
- Monitor database performance
|
||||
- Track API response times
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Security Checklist
|
||||
- [ ] HTTPS enforced with SSL certificates
|
||||
- [ ] Security headers configured (HSTS, CSP, etc.)
|
||||
- [ ] Database credentials secured
|
||||
- [ ] Secret keys rotated regularly
|
||||
- [ ] CORS properly configured
|
||||
- [ ] Rate limiting implemented
|
||||
- [ ] File upload validation
|
||||
- [ ] SQL injection protection
|
||||
- [ ] XSS protection enabled
|
||||
- [ ] CSRF protection active
|
||||
|
||||
### Security Headers
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
|
||||
# CORS for API
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"https://yourdomain.com",
|
||||
"https://www.yourdomain.com",
|
||||
]
|
||||
```
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup Strategy
|
||||
```bash
|
||||
# Automated backup script
|
||||
#!/bin/bash
|
||||
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
||||
```
|
||||
|
||||
### Media Files Backup
|
||||
```bash
|
||||
# Sync media files to S3
|
||||
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
||||
```
|
||||
|
||||
## Scaling Strategies
|
||||
|
||||
### Horizontal Scaling
|
||||
- Load balancer configuration
|
||||
- Database read replicas
|
||||
- CDN for static assets
|
||||
- Redis clustering
|
||||
- Auto-scaling groups
|
||||
|
||||
### Vertical Scaling
|
||||
- Database connection pooling
|
||||
- Application server optimization
|
||||
- Memory usage optimization
|
||||
- CPU-intensive task optimization
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Common Issues
|
||||
1. **Build failures**: Check dependencies and environment variables
|
||||
2. **Database connection errors**: Verify connection strings and firewall rules
|
||||
3. **Static file 404s**: Ensure collectstatic runs and paths are correct
|
||||
4. **CORS errors**: Check CORS configuration and allowed origins
|
||||
5. **Memory issues**: Monitor application memory usage and optimize queries
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Backend debugging
|
||||
cd backend
|
||||
uv run manage.py check --deploy
|
||||
uv run manage.py shell
|
||||
uv run manage.py dbshell
|
||||
|
||||
# Frontend debugging
|
||||
cd frontend
|
||||
pnpm run build --debug
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
This deployment guide provides a comprehensive approach to deploying the ThrillWiki monorepo across various platforms while maintaining security, performance, and scalability.
|
||||
353
architecture/migration-mapping.md
Normal file
353
architecture/migration-mapping.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# ThrillWiki Migration Mapping Document
|
||||
|
||||
This document provides a comprehensive mapping of files from the current Django project to the new monorepo structure.
|
||||
|
||||
## Root Level Files
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `manage.py` | `backend/manage.py` | Core Django management |
|
||||
| `pyproject.toml` | `backend/pyproject.toml` | Python dependencies |
|
||||
| `uv.lock` | `backend/uv.lock` | UV lock file |
|
||||
| `.gitignore` | `.gitignore` (update) | Merge with monorepo patterns |
|
||||
| `README.md` | `README.md` (update) | Update for monorepo |
|
||||
| `.pre-commit-config.yaml` | `.pre-commit-config.yaml` | Root level |
|
||||
|
||||
## Configuration Directory
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `config/django/` | `backend/config/django/` | Django settings |
|
||||
| `config/settings/` | `backend/config/settings/` | Environment settings |
|
||||
| `config/urls.py` | `backend/config/urls.py` | URL configuration |
|
||||
| `config/wsgi.py` | `backend/config/wsgi.py` | WSGI configuration |
|
||||
| `config/asgi.py` | `backend/config/asgi.py` | ASGI configuration |
|
||||
|
||||
## Django Apps
|
||||
|
||||
### Accounts App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `accounts/` | `backend/apps/accounts/` |
|
||||
| `accounts/__init__.py` | `backend/apps/accounts/__init__.py` |
|
||||
| `accounts/models.py` | `backend/apps/accounts/models.py` |
|
||||
| `accounts/views.py` | `backend/apps/accounts/views.py` |
|
||||
| `accounts/admin.py` | `backend/apps/accounts/admin.py` |
|
||||
| `accounts/apps.py` | `backend/apps/accounts/apps.py` |
|
||||
| `accounts/migrations/` | `backend/apps/accounts/migrations/` |
|
||||
| `accounts/tests/` | `backend/apps/accounts/tests/` |
|
||||
|
||||
### Parks App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `parks/` | `backend/apps/parks/` |
|
||||
| `parks/__init__.py` | `backend/apps/parks/__init__.py` |
|
||||
| `parks/models.py` | `backend/apps/parks/models.py` |
|
||||
| `parks/views.py` | `backend/apps/parks/views.py` |
|
||||
| `parks/admin.py` | `backend/apps/parks/admin.py` |
|
||||
| `parks/apps.py` | `backend/apps/parks/apps.py` |
|
||||
| `parks/migrations/` | `backend/apps/parks/migrations/` |
|
||||
| `parks/tests/` | `backend/apps/parks/tests/` |
|
||||
|
||||
### Rides App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `rides/` | `backend/apps/rides/` |
|
||||
| `rides/__init__.py` | `backend/apps/rides/__init__.py` |
|
||||
| `rides/models.py` | `backend/apps/rides/models.py` |
|
||||
| `rides/views.py` | `backend/apps/rides/views.py` |
|
||||
| `rides/admin.py` | `backend/apps/rides/admin.py` |
|
||||
| `rides/apps.py` | `backend/apps/rides/apps.py` |
|
||||
| `rides/migrations/` | `backend/apps/rides/migrations/` |
|
||||
| `rides/tests/` | `backend/apps/rides/tests/` |
|
||||
|
||||
### Moderation App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `moderation/` | `backend/apps/moderation/` |
|
||||
| `moderation/__init__.py` | `backend/apps/moderation/__init__.py` |
|
||||
| `moderation/models.py` | `backend/apps/moderation/models.py` |
|
||||
| `moderation/views.py` | `backend/apps/moderation/views.py` |
|
||||
| `moderation/admin.py` | `backend/apps/moderation/admin.py` |
|
||||
| `moderation/apps.py` | `backend/apps/moderation/apps.py` |
|
||||
| `moderation/migrations/` | `backend/apps/moderation/migrations/` |
|
||||
| `moderation/tests/` | `backend/apps/moderation/tests/` |
|
||||
|
||||
### Location App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `location/` | `backend/apps/location/` |
|
||||
| `location/__init__.py` | `backend/apps/location/__init__.py` |
|
||||
| `location/models.py` | `backend/apps/location/models.py` |
|
||||
| `location/views.py` | `backend/apps/location/views.py` |
|
||||
| `location/admin.py` | `backend/apps/location/admin.py` |
|
||||
| `location/apps.py` | `backend/apps/location/apps.py` |
|
||||
| `location/migrations/` | `backend/apps/location/migrations/` |
|
||||
| `location/tests/` | `backend/apps/location/tests/` |
|
||||
|
||||
### Media App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `media/` | `backend/apps/media/` |
|
||||
| `media/__init__.py` | `backend/apps/media/__init__.py` |
|
||||
| `media/models.py` | `backend/apps/media/models.py` |
|
||||
| `media/views.py` | `backend/apps/media/views.py` |
|
||||
| `media/admin.py` | `backend/apps/media/admin.py` |
|
||||
| `media/apps.py` | `backend/apps/media/apps.py` |
|
||||
| `media/migrations/` | `backend/apps/media/migrations/` |
|
||||
| `media/tests/` | `backend/apps/media/tests/` |
|
||||
|
||||
### Email Service App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `email_service/` | `backend/apps/email_service/` |
|
||||
| `email_service/__init__.py` | `backend/apps/email_service/__init__.py` |
|
||||
| `email_service/models.py` | `backend/apps/email_service/models.py` |
|
||||
| `email_service/views.py` | `backend/apps/email_service/views.py` |
|
||||
| `email_service/admin.py` | `backend/apps/email_service/admin.py` |
|
||||
| `email_service/apps.py` | `backend/apps/email_service/apps.py` |
|
||||
| `email_service/migrations/` | `backend/apps/email_service/migrations/` |
|
||||
| `email_service/tests/` | `backend/apps/email_service/tests/` |
|
||||
|
||||
### Core App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `core/` | `backend/apps/core/` |
|
||||
| `core/__init__.py` | `backend/apps/core/__init__.py` |
|
||||
| `core/models.py` | `backend/apps/core/models.py` |
|
||||
| `core/views.py` | `backend/apps/core/views.py` |
|
||||
| `core/admin.py` | `backend/apps/core/admin.py` |
|
||||
| `core/apps.py` | `backend/apps/core/apps.py` |
|
||||
| `core/migrations/` | `backend/apps/core/migrations/` |
|
||||
| `core/tests/` | `backend/apps/core/tests/` |
|
||||
|
||||
## Static Files and Templates
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `static/` | `backend/static/` | Django admin and backend assets |
|
||||
| `staticfiles/` | `backend/staticfiles/` | Collected static files |
|
||||
| `templates/` | `backend/templates/` | Django templates (if any) |
|
||||
|
||||
## Media Files
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `media/` | `shared/media/` | User uploaded content |
|
||||
|
||||
## Scripts and Development Tools
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `scripts/` | `scripts/` | Root level scripts |
|
||||
| `scripts/dev_server.sh` | `scripts/backend_dev.sh` | Rename for clarity |
|
||||
|
||||
## New Frontend Structure (Created)
|
||||
|
||||
| New Location | Purpose |
|
||||
|--------------|---------|
|
||||
| `frontend/` | Vue.js application root |
|
||||
| `frontend/package.json` | Node.js dependencies |
|
||||
| `frontend/pnpm-lock.yaml` | pnpm lock file |
|
||||
| `frontend/vite.config.ts` | Vite configuration |
|
||||
| `frontend/tsconfig.json` | TypeScript configuration |
|
||||
| `frontend/tailwind.config.js` | Tailwind CSS configuration |
|
||||
| `frontend/src/` | Vue.js source code |
|
||||
| `frontend/src/main.ts` | Application entry point |
|
||||
| `frontend/src/App.vue` | Root component |
|
||||
| `frontend/src/components/` | Vue components |
|
||||
| `frontend/src/views/` | Page components |
|
||||
| `frontend/src/router/` | Vue Router configuration |
|
||||
| `frontend/src/stores/` | Pinia stores |
|
||||
| `frontend/src/composables/` | Vue composables |
|
||||
| `frontend/src/utils/` | Utility functions |
|
||||
| `frontend/src/types/` | TypeScript type definitions |
|
||||
| `frontend/src/assets/` | Static assets |
|
||||
| `frontend/public/` | Public assets |
|
||||
| `frontend/dist/` | Build output |
|
||||
|
||||
## New Shared Resources (Created)
|
||||
|
||||
| New Location | Purpose |
|
||||
|--------------|---------|
|
||||
| `shared/` | Cross-platform resources |
|
||||
| `shared/media/` | User uploaded files |
|
||||
| `shared/docs/` | Documentation |
|
||||
| `shared/types/` | Shared TypeScript types |
|
||||
| `shared/constants/` | Shared constants |
|
||||
|
||||
## Updated Root Files
|
||||
|
||||
### package.json (Root)
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm --filter frontend dev\" \"./scripts/backend_dev.sh\"",
|
||||
"build": "pnpm --filter frontend build",
|
||||
"backend:dev": "./scripts/backend_dev.sh",
|
||||
"frontend:dev": "pnpm --filter frontend dev",
|
||||
"test": "pnpm --filter frontend test && cd backend && uv run manage.py test",
|
||||
"lint": "pnpm --filter frontend lint && cd backend && uv run flake8 .",
|
||||
"format": "pnpm --filter frontend format && cd backend && uv run black ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .gitignore (Updated)
|
||||
```gitignore
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/backend/static/
|
||||
/backend/media/
|
||||
|
||||
# UV
|
||||
.uv/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Vue.js / Vite
|
||||
/frontend/dist/
|
||||
/frontend/dist-ssr/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
```
|
||||
|
||||
## Configuration Updates Required
|
||||
|
||||
### Backend Django Settings
|
||||
Update `INSTALLED_APPS` paths:
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Local apps
|
||||
'apps.accounts',
|
||||
'apps.parks',
|
||||
'apps.rides',
|
||||
'apps.moderation',
|
||||
'apps.location',
|
||||
'apps.media',
|
||||
'apps.email_service',
|
||||
'apps.core',
|
||||
]
|
||||
```
|
||||
|
||||
Update media and static files paths:
|
||||
```python
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR.parent / 'shared' / 'media'
|
||||
```
|
||||
|
||||
### Script Updates
|
||||
Update `scripts/backend_dev.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd backend
|
||||
lsof -ti :8000 | xargs kill -9 2>/dev/null || true
|
||||
find . -type d -name "__pycache__" -exec rm -r {} + 2>/dev/null || true
|
||||
uv run manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
## Migration Steps Summary
|
||||
|
||||
1. **Create new directory structure**
|
||||
2. **Move backend files** to `backend/` directory
|
||||
3. **Update import paths** in Django settings and apps
|
||||
4. **Create frontend** Vue.js application
|
||||
5. **Update scripts** and configuration files
|
||||
6. **Test both backend and frontend** independently
|
||||
7. **Configure API integration** between Django and Vue.js
|
||||
8. **Update deployment** configurations
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] All Django apps moved to `backend/apps/`
|
||||
- [ ] Configuration files updated with new paths
|
||||
- [ ] Static and media file paths configured correctly
|
||||
- [ ] Frontend Vue.js application created and configured
|
||||
- [ ] Root package.json with workspace configuration
|
||||
- [ ] Development scripts updated and tested
|
||||
- [ ] Git configuration updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] CI/CD pipelines updated (if applicable)
|
||||
- [ ] Database migrations work correctly
|
||||
- [ ] Both development servers start successfully
|
||||
- [ ] API endpoints accessible from frontend
|
||||
525
architecture/monorepo-structure-plan.md
Normal file
525
architecture/monorepo-structure-plan.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# ThrillWiki Django + Vue.js Monorepo Architecture Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the optimal monorepo directory structure for migrating the ThrillWiki Django project to a Django + Vue.js architecture. The design separates backend and frontend concerns while maintaining existing Django app organization and supporting modern development workflows.
|
||||
|
||||
## Current Project Analysis
|
||||
|
||||
### Django Apps Structure
|
||||
- **accounts**: User management and authentication
|
||||
- **parks**: Theme park data and operations
|
||||
- **rides**: Ride information and management
|
||||
- **moderation**: Content moderation system
|
||||
- **location**: Geographic data handling
|
||||
- **media**: File and image management
|
||||
- **email_service**: Email functionality
|
||||
- **core**: Core utilities and services
|
||||
|
||||
### Key Infrastructure
|
||||
- **Package Management**: UV-based Python setup
|
||||
- **Configuration**: `config/django/` for settings, `config/settings/` for modular settings
|
||||
- **Development**: `scripts/dev_server.sh` with comprehensive setup
|
||||
- **Static Assets**: Tailwind CSS integration, `static/` and `staticfiles/`
|
||||
- **Media Handling**: Organized `media/` directory with park/ride subdirectories
|
||||
|
||||
## Proposed Monorepo Structure
|
||||
|
||||
```
|
||||
thrillwiki-monorepo/
|
||||
├── README.md
|
||||
├── pyproject.toml # Python dependencies (backend only)
|
||||
├── package.json # Node.js dependencies (monorepo coordination)
|
||||
├── pnpm-workspace.yaml # pnpm workspace configuration
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├──
|
||||
├── backend/ # Django Backend
|
||||
│ ├── manage.py
|
||||
│ ├── pyproject.toml # Backend-specific dependencies
|
||||
│ ├── config/
|
||||
│ │ ├── django/
|
||||
│ │ │ ├── base.py
|
||||
│ │ │ ├── local.py
|
||||
│ │ │ ├── production.py
|
||||
│ │ │ └── test.py
|
||||
│ │ └── settings/
|
||||
│ │ ├── database.py
|
||||
│ │ ├── email.py
|
||||
│ │ └── security.py
|
||||
│ ├── thrillwiki/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── wsgi.py
|
||||
│ │ ├── asgi.py
|
||||
│ │ └── views.py
|
||||
│ ├── apps/ # Django apps
|
||||
│ │ ├── accounts/
|
||||
│ │ ├── parks/
|
||||
│ │ ├── rides/
|
||||
│ │ ├── moderation/
|
||||
│ │ ├── location/
|
||||
│ │ ├── media/
|
||||
│ │ ├── email_service/
|
||||
│ │ └── core/
|
||||
│ ├── templates/ # Django templates (API responses, admin)
|
||||
│ ├── static/ # Backend static files
|
||||
│ │ └── admin/ # Django admin assets
|
||||
│ ├── media/ # User uploads
|
||||
│ │ ├── avatars/
|
||||
│ │ ├── park/
|
||||
│ │ └── submissions/
|
||||
│ └── tests/ # Backend tests
|
||||
│
|
||||
├── frontend/ # Vue.js Frontend
|
||||
│ ├── package.json
|
||||
│ ├── pnpm-lock.yaml
|
||||
│ ├── vite.config.js
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── index.html
|
||||
│ ├── src/
|
||||
│ │ ├── main.js
|
||||
│ │ ├── App.vue
|
||||
│ │ ├── router/
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── stores/ # Pinia/Vuex stores
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── parks.js
|
||||
│ │ │ └── rides.js
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── common/ # Shared components
|
||||
│ │ │ ├── parks/ # Park-specific components
|
||||
│ │ │ ├── rides/ # Ride-specific components
|
||||
│ │ │ └── moderation/ # Moderation components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ │ ├── Home.vue
|
||||
│ │ │ ├── parks/
|
||||
│ │ │ ├── rides/
|
||||
│ │ │ └── auth/
|
||||
│ │ ├── composables/ # Vue 3 composables
|
||||
│ │ │ ├── useAuth.js
|
||||
│ │ │ ├── useApi.js
|
||||
│ │ │ └── useTheme.js
|
||||
│ │ ├── services/ # API service layer
|
||||
│ │ │ ├── api.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── parks.js
|
||||
│ │ │ └── rides.js
|
||||
│ │ ├── assets/
|
||||
│ │ │ ├── images/
|
||||
│ │ │ └── styles/
|
||||
│ │ │ ├── globals.css
|
||||
│ │ │ └── components/
|
||||
│ │ └── utils/
|
||||
│ ├── public/
|
||||
│ │ ├── favicon.ico
|
||||
│ │ └── images/
|
||||
│ ├── dist/ # Build output
|
||||
│ └── tests/ # Frontend tests
|
||||
│ ├── unit/
|
||||
│ └── e2e/
|
||||
│
|
||||
├── shared/ # Shared Resources
|
||||
│ ├── docs/ # Documentation
|
||||
│ │ ├── api/ # API documentation
|
||||
│ │ ├── deployment/ # Deployment guides
|
||||
│ │ └── development/ # Development setup
|
||||
│ ├── scripts/ # Build and deployment scripts
|
||||
│ │ ├── dev/
|
||||
│ │ │ ├── start-backend.sh
|
||||
│ │ │ ├── start-frontend.sh
|
||||
│ │ │ └── start-full-stack.sh
|
||||
│ │ ├── build/
|
||||
│ │ │ ├── build-frontend.sh
|
||||
│ │ │ └── build-production.sh
|
||||
│ │ ├── deploy/
|
||||
│ │ └── utils/
|
||||
│ ├── config/ # Shared configuration
|
||||
│ │ ├── docker/
|
||||
│ │ │ ├── Dockerfile.backend
|
||||
│ │ │ ├── Dockerfile.frontend
|
||||
│ │ │ └── docker-compose.yml
|
||||
│ │ ├── nginx/
|
||||
│ │ └── ci/ # CI/CD configuration
|
||||
│ │ └── github-actions/
|
||||
│ └── types/ # Shared TypeScript types
|
||||
│ ├── api.ts
|
||||
│ ├── parks.ts
|
||||
│ └── rides.ts
|
||||
│
|
||||
├── logs/ # Application logs
|
||||
├── backups/ # Database backups
|
||||
├── uploads/ # Temporary upload directory
|
||||
└── dist/ # Production build output
|
||||
├── backend/ # Django static files
|
||||
└── frontend/ # Vue.js build
|
||||
```
|
||||
|
||||
## Directory Organization Rationale
|
||||
|
||||
### 1. Clear Separation of Concerns
|
||||
- **backend/**: Contains all Django-related code, maintaining existing app structure
|
||||
- **frontend/**: Vue.js application with modern structure (Vite + Vue 3)
|
||||
- **shared/**: Common resources, documentation, and configuration
|
||||
|
||||
### 2. Backend Structure (`backend/`)
|
||||
- Preserves existing Django app organization under `apps/`
|
||||
- Maintains UV-based Python dependency management
|
||||
- Keeps configuration structure with `config/django/` and `config/settings/`
|
||||
- Separates templates for API responses vs. frontend UI
|
||||
|
||||
### 3. Frontend Structure (`frontend/`)
|
||||
- Modern Vue 3 + Vite setup with TypeScript support
|
||||
- Organized by feature areas (parks, rides, auth)
|
||||
- Composables for Vue 3 Composition API patterns
|
||||
- Service layer for API communication with Django backend
|
||||
- Tailwind CSS integration with shared design system
|
||||
|
||||
### 4. Shared Resources (`shared/`)
|
||||
- Centralized documentation and deployment scripts
|
||||
- Docker configuration for containerized deployment
|
||||
- TypeScript type definitions shared between frontend and API
|
||||
- CI/CD pipeline configuration
|
||||
|
||||
## Static File Strategy
|
||||
|
||||
### Development
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue Dev Server :3000] --> B[Vite HMR]
|
||||
C[Django Dev Server :8000] --> D[Django Static Files]
|
||||
E[Tailwind CSS] --> F[Both Frontend & Backend]
|
||||
```
|
||||
|
||||
### Production
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue Build] --> B[dist/frontend/]
|
||||
C[Django Collectstatic] --> D[dist/backend/]
|
||||
E[Nginx] --> F[Serves Both]
|
||||
F --> G[Frontend Assets]
|
||||
F --> H[API Endpoints]
|
||||
F --> I[Media Files]
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
1. **Development Mode**:
|
||||
- Frontend: Vite dev server on port 3000 with HMR
|
||||
- Backend: Django dev server on port 8000
|
||||
- Proxy API calls from frontend to backend
|
||||
|
||||
2. **Production Mode**:
|
||||
- Frontend built to `dist/frontend/`
|
||||
- Django static files collected to `dist/backend/`
|
||||
- Nginx serves static files and proxies API calls
|
||||
|
||||
## Media File Management
|
||||
|
||||
### Current Structure Preservation
|
||||
```
|
||||
media/
|
||||
├── avatars/ # User profile images
|
||||
├── park/ # Park-specific media
|
||||
│ ├── {park-slug}/
|
||||
│ │ └── {ride-slug}/
|
||||
└── submissions/ # User-submitted content
|
||||
└── photos/
|
||||
```
|
||||
|
||||
### Strategy
|
||||
- **Development**: Django serves media files directly
|
||||
- **Production**: CDN or object storage (S3/CloudFlare) integration
|
||||
- **Frontend Access**: Media URLs provided via API responses
|
||||
- **Upload Handling**: Django handles all file uploads, Vue.js provides UI
|
||||
|
||||
## Development Workflow Integration
|
||||
|
||||
### Package Management
|
||||
- **Root**: Node.js dependencies for frontend and tooling (using pnpm)
|
||||
- **Backend**: UV for Python dependencies (existing approach)
|
||||
- **Frontend**: pnpm for Vue.js dependencies
|
||||
|
||||
### Development Scripts
|
||||
```bash
|
||||
# Root level scripts
|
||||
pnpm run dev # Start both backend and frontend
|
||||
pnpm run dev:backend # Start only Django
|
||||
pnpm run dev:frontend # Start only Vue.js
|
||||
pnpm run build # Build for production
|
||||
pnpm run test # Run all tests
|
||||
|
||||
# Backend specific (using UV)
|
||||
cd backend && uv run manage.py runserver
|
||||
cd backend && uv run manage.py test
|
||||
|
||||
# Frontend specific
|
||||
cd frontend && pnpm run dev
|
||||
cd frontend && pnpm run build
|
||||
cd frontend && pnpm run test
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
```bash
|
||||
# Root .env (shared settings)
|
||||
DATABASE_URL=
|
||||
REDIS_URL=
|
||||
SECRET_KEY=
|
||||
|
||||
# Backend .env (Django specific)
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
DEBUG=True
|
||||
|
||||
# Frontend .env (Vue specific)
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_APP_TITLE=ThrillWiki
|
||||
```
|
||||
|
||||
### Package Manager Configuration
|
||||
|
||||
#### Root pnpm-workspace.yaml
|
||||
```yaml
|
||||
packages:
|
||||
- 'frontend'
|
||||
# Backend is managed separately with uv
|
||||
```
|
||||
|
||||
#### Root package.json
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-monorepo",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm run dev:backend\" \"pnpm run dev:frontend\"",
|
||||
"dev:backend": "cd backend && uv run manage.py runserver",
|
||||
"dev:frontend": "cd frontend && pnpm run dev",
|
||||
"build": "pnpm run build:frontend && cd backend && uv run manage.py collectstatic --noinput",
|
||||
"build:frontend": "cd frontend && pnpm run build",
|
||||
"test": "pnpm run test:backend && pnpm run test:frontend",
|
||||
"test:backend": "cd backend && uv run manage.py test",
|
||||
"test:frontend": "cd frontend && pnpm run test",
|
||||
"lint": "cd frontend && pnpm run lint && cd ../backend && uv run flake8 .",
|
||||
"format": "cd frontend && pnpm run format && cd ../backend && uv run black ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend package.json
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"format": "prettier --write src/",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^2.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"prettier": "^3.2.0",
|
||||
"vitest": "^1.3.0",
|
||||
"@playwright/test": "^1.42.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Migration Mapping
|
||||
|
||||
### High-Level Moves
|
||||
```
|
||||
Current → New Location
|
||||
├── manage.py → backend/manage.py
|
||||
├── pyproject.toml → backend/pyproject.toml (+ root package.json)
|
||||
├── config/ → backend/config/
|
||||
├── thrillwiki/ → backend/thrillwiki/
|
||||
├── accounts/ → backend/apps/accounts/
|
||||
├── parks/ → backend/apps/parks/
|
||||
├── rides/ → backend/apps/rides/
|
||||
├── moderation/ → backend/apps/moderation/
|
||||
├── location/ → backend/apps/location/
|
||||
├── media/ → backend/apps/media/
|
||||
├── email_service/ → backend/apps/email_service/
|
||||
├── core/ → backend/apps/core/
|
||||
├── templates/ → backend/templates/ (API) + frontend/src/views/ (UI)
|
||||
├── static/ → backend/static/ (admin) + frontend/src/assets/
|
||||
├── media/ → media/ (shared, accessible to both)
|
||||
├── scripts/ → shared/scripts/
|
||||
├── docs/ → shared/docs/
|
||||
├── tests/ → backend/tests/ + frontend/tests/
|
||||
└── staticfiles/ → dist/backend/ (generated)
|
||||
```
|
||||
|
||||
### Detailed Backend App Moves
|
||||
Each Django app moves to `backend/apps/{app_name}/` with structure preserved:
|
||||
- Models, views, serializers stay the same
|
||||
- Templates for API responses remain in app directories
|
||||
- Static files move to frontend if UI-related
|
||||
- Tests remain with respective apps
|
||||
|
||||
## Build and Deployment Strategy
|
||||
|
||||
### Development Build Process
|
||||
1. **Backend**: No build step, runs directly with Django dev server
|
||||
2. **Frontend**: Vite development server with HMR
|
||||
3. **Shared**: Scripts orchestrate starting both services
|
||||
|
||||
### Production Build Process
|
||||
```mermaid
|
||||
graph TD
|
||||
A[CI/CD Trigger] --> B[Install Dependencies]
|
||||
B --> C[Build Frontend]
|
||||
B --> D[Collect Django Static]
|
||||
C --> E[Generate Frontend Bundle]
|
||||
D --> F[Collect Backend Assets]
|
||||
E --> G[Create Docker Images]
|
||||
F --> G
|
||||
G --> H[Deploy to Production]
|
||||
```
|
||||
|
||||
### Container Strategy
|
||||
- **Multi-stage Docker builds**: Separate backend and frontend images
|
||||
- **Nginx**: Reverse proxy and static file serving
|
||||
- **Volume mounts**: For media files and logs
|
||||
- **Environment-based configuration**: Development vs. production
|
||||
|
||||
## API Integration Strategy
|
||||
|
||||
### Backend API Structure
|
||||
```python
|
||||
# Enhanced DRF setup for SPA
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
],
|
||||
}
|
||||
|
||||
# CORS for development
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # Vue dev server
|
||||
]
|
||||
```
|
||||
|
||||
### Frontend API Service
|
||||
```javascript
|
||||
// API service with auth integration
|
||||
class ApiService {
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Park operations
|
||||
getParks(params = {}) {
|
||||
return this.client.get('/parks/', { params });
|
||||
}
|
||||
|
||||
// Ride operations
|
||||
getRides(parkId, params = {}) {
|
||||
return this.client.get(`/parks/${parkId}/rides/`, { params });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Shared Environment Variables
|
||||
- Database connections
|
||||
- Redis/Cache settings
|
||||
- Secret keys and API keys
|
||||
- Feature flags
|
||||
|
||||
### Application-Specific Settings
|
||||
- **Django**: `backend/config/django/`
|
||||
- **Vue.js**: `frontend/.env` files
|
||||
- **Docker**: `shared/config/docker/`
|
||||
|
||||
### Development vs. Production
|
||||
- Development: Multiple local servers, hot reloading
|
||||
- Production: Containerized deployment, CDN integration
|
||||
|
||||
## Benefits of This Structure
|
||||
|
||||
1. **Clear Separation**: Backend and frontend concerns are clearly separated
|
||||
2. **Scalability**: Each part can be developed, tested, and deployed independently
|
||||
3. **Modern Workflow**: Supports latest Vue 3, Vite, and Django patterns
|
||||
4. **Backward Compatibility**: Preserves existing Django app structure
|
||||
5. **Developer Experience**: Hot reloading, TypeScript support, modern tooling
|
||||
6. **Deployment Flexibility**: Can deploy as SPA + API or traditional Django
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Structure Setup
|
||||
1. Create new directory structure
|
||||
2. Move Django code to `backend/`
|
||||
3. Initialize Vue.js frontend
|
||||
4. Set up basic API integration
|
||||
|
||||
### Phase 2: Frontend Development
|
||||
1. Create Vue.js components for existing Django templates
|
||||
2. Implement routing and state management
|
||||
3. Integrate with Django API endpoints
|
||||
4. Add authentication flow
|
||||
|
||||
### Phase 3: Build & Deploy
|
||||
1. Set up build processes
|
||||
2. Configure CI/CD pipelines
|
||||
3. Implement production deployment
|
||||
4. Performance optimization
|
||||
|
||||
## Considerations and Trade-offs
|
||||
|
||||
### Advantages
|
||||
- Modern development experience
|
||||
- Better code organization
|
||||
- Independent scaling
|
||||
- Rich frontend interactions
|
||||
- API-first architecture
|
||||
|
||||
### Challenges
|
||||
- Increased complexity
|
||||
- Build process coordination
|
||||
- Authentication across services
|
||||
- SEO considerations (if needed)
|
||||
- Development environment setup
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Validate Architecture**: Review with development team
|
||||
2. **Prototype Setup**: Create basic structure with sample components
|
||||
3. **Migration Planning**: Detailed plan for moving existing code
|
||||
4. **Tool Selection**: Finalize Vue.js ecosystem choices (Pinia vs. Vuex, etc.)
|
||||
5. **Implementation**: Begin phase-by-phase migration
|
||||
|
||||
---
|
||||
|
||||
This architecture provides a solid foundation for migrating ThrillWiki to a modern Django + Vue.js monorepo while preserving existing functionality and enabling future growth.
|
||||
48
backend/.env.example
Normal file
48
backend/.env.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# Django Configuration
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email Configuration (Optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
|
||||
# ForwardEmail API Configuration
|
||||
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
|
||||
FORWARD_EMAIL_DOMAIN=your-domain.com
|
||||
|
||||
# Media and Static Files
|
||||
MEDIA_URL=/media/
|
||||
STATIC_URL=/static/
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# API Configuration
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_DEBUG_TOOLBAR=True
|
||||
ENABLE_SILK_PROFILER=False
|
||||
|
||||
# Frontend Configuration
|
||||
FRONTEND_DOMAIN=https://thrillwiki.com
|
||||
|
||||
# Cloudflare Images Configuration
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
||||
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
||||
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Road Trip Service Configuration
|
||||
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
||||
229
backend/README.md
Normal file
229
backend/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# ThrillWiki Backend
|
||||
|
||||
Django REST API backend for the ThrillWiki monorepo.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This backend follows Django best practices with a modular app structure:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── apps/ # Django applications
|
||||
│ ├── accounts/ # User management
|
||||
│ ├── parks/ # Theme park data
|
||||
│ ├── rides/ # Ride information
|
||||
│ ├── moderation/ # Content moderation
|
||||
│ ├── location/ # Geographic data
|
||||
│ ├── media/ # File management
|
||||
│ ├── email_service/ # Email functionality
|
||||
│ └── core/ # Core utilities
|
||||
├── config/ # Django configuration
|
||||
│ ├── django/ # Settings files
|
||||
│ └── settings/ # Modular settings
|
||||
├── templates/ # Django templates
|
||||
├── static/ # Static files
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Django 5.0+** - Web framework
|
||||
- **Django REST Framework** - API framework
|
||||
- **PostgreSQL** - Primary database
|
||||
- **Redis** - Caching and sessions
|
||||
- **UV** - Python package management
|
||||
- **Celery** - Background task processing
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- PostgreSQL 14+
|
||||
- Redis 6+
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **Environment configuration**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. **Database setup**
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. **Start development server**
|
||||
```bash
|
||||
uv run manage.py runserver
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
# Django
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email (optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
```
|
||||
|
||||
### Settings Structure
|
||||
|
||||
- `config/django/base.py` - Base settings
|
||||
- `config/django/local.py` - Development settings
|
||||
- `config/django/production.py` - Production settings
|
||||
- `config/django/test.py` - Test settings
|
||||
|
||||
## 📁 Apps Overview
|
||||
|
||||
### Core Apps
|
||||
|
||||
- **accounts** - User authentication and profile management
|
||||
- **parks** - Theme park models and operations
|
||||
- **rides** - Ride information and relationships
|
||||
- **core** - Shared utilities and base classes
|
||||
|
||||
### Support Apps
|
||||
|
||||
- **moderation** - Content moderation workflows
|
||||
- **location** - Geographic data and services
|
||||
- **media** - File upload and management
|
||||
- **email_service** - Email sending and templates
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
Base URL: `http://localhost:8000/api/`
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/login/` - User login
|
||||
- `POST /auth/logout/` - User logout
|
||||
- `POST /auth/register/` - User registration
|
||||
|
||||
### Parks
|
||||
- `GET /parks/` - List parks
|
||||
- `GET /parks/{id}/` - Park details
|
||||
- `POST /parks/` - Create park (admin)
|
||||
|
||||
### Rides
|
||||
- `GET /rides/` - List rides
|
||||
- `GET /rides/{id}/` - Ride details
|
||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run manage.py test
|
||||
|
||||
# Run specific app tests
|
||||
uv run manage.py test apps.parks
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run manage.py test
|
||||
uv run coverage report
|
||||
```
|
||||
|
||||
## 🔧 Management Commands
|
||||
|
||||
Custom management commands:
|
||||
|
||||
```bash
|
||||
# Import park data
|
||||
uv run manage.py import_parks data/parks.json
|
||||
|
||||
# Generate test data
|
||||
uv run manage.py generate_test_data
|
||||
|
||||
# Clean up expired sessions
|
||||
uv run manage.py clearsessions
|
||||
```
|
||||
|
||||
## 📊 Database
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||
- **Users** can create submissions and moderate content
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Create migrations
|
||||
uv run manage.py makemigrations
|
||||
|
||||
# Apply migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Show migration status
|
||||
uv run manage.py showmigrations
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- CORS configured for frontend integration
|
||||
- CSRF protection enabled
|
||||
- JWT token authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Input validation and sanitization
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Database query optimization
|
||||
- Redis caching for frequent queries
|
||||
- Background task processing with Celery
|
||||
- Database connection pooling
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
### Development Tools
|
||||
|
||||
- Django Debug Toolbar
|
||||
- Django Extensions
|
||||
- Silk profiler for performance analysis
|
||||
|
||||
### Logging
|
||||
|
||||
Logs are written to:
|
||||
- Console (development)
|
||||
- Files in `logs/` directory (production)
|
||||
- External logging service (production)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow Django coding standards
|
||||
2. Write tests for new features
|
||||
3. Update documentation
|
||||
4. Run linting: `uv run flake8 .`
|
||||
5. Format code: `uv run black .`
|
||||
6
backend/apps/__init__.py
Normal file
6
backend/apps/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Django apps package.
|
||||
|
||||
This directory contains all Django applications for the ThrillWiki backend.
|
||||
Each app is self-contained and follows Django best practices.
|
||||
"""
|
||||
@@ -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
|
||||
|
||||
360
backend/apps/accounts/admin.py
Normal file
360
backend/apps/accounts/admin.py
Normal file
@@ -0,0 +1,360 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
TopListItem,
|
||||
)
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
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",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TopListItemInline(admin.TabularInline):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
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",
|
||||
]
|
||||
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")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
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(),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def get_status(self, obj):
|
||||
if obj.is_banned:
|
||||
return format_html('<span style="color: red;">Banned</span>')
|
||||
if not obj.is_active:
|
||||
return format_html('<span style="color: orange;">Inactive</span>')
|
||||
if obj.is_superuser:
|
||||
return format_html('<span style="color: purple;">Superuser</span>')
|
||||
if obj.is_staff:
|
||||
return format_html('<span style="color: blue;">Staff</span>')
|
||||
return format_html('<span style="color: green;">Active</span>')
|
||||
|
||||
@admin.display(description="Ride Credits")
|
||||
def get_credits(self, obj):
|
||||
try:
|
||||
profile = obj.profile
|
||||
return format_html(
|
||||
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
|
||||
profile.coaster_credits,
|
||||
profile.dark_ride_credits,
|
||||
profile.flat_ride_credits,
|
||||
profile.water_ride_credits,
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return "-"
|
||||
|
||||
@admin.action(description="Activate selected users")
|
||||
def activate_users(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
|
||||
@admin.action(description="Deactivate selected users")
|
||||
def deactivate_users(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
|
||||
@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())
|
||||
|
||||
@admin.action(description="Unban selected users")
|
||||
def unban_users(self, request, queryset):
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
creating = not obj.pk
|
||||
super().save_model(request, obj, form, change)
|
||||
if creating and obj.role != User.Roles.USER:
|
||||
# Ensure new user with role gets added to appropriate group
|
||||
group = Group.objects.filter(name=obj.role).first()
|
||||
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")
|
||||
|
||||
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",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
fieldsets = (
|
||||
("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>')
|
||||
|
||||
|
||||
@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")
|
||||
inlines = [TopListItemInline]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("user", "title", "category", "description")},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
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")
|
||||
|
||||
fieldsets = (
|
||||
("List Information", {"fields": ("top_list", "rank")}),
|
||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PasswordReset)
|
||||
class PasswordResetAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for password reset tokens"""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"is_expired",
|
||||
"used",
|
||||
)
|
||||
list_filter = (
|
||||
"used",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
search_fields = (
|
||||
"user__username",
|
||||
"user__email",
|
||||
"token",
|
||||
)
|
||||
readonly_fields = (
|
||||
"token",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Reset Details",
|
||||
{
|
||||
"fields": (
|
||||
"user",
|
||||
"token",
|
||||
"used",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Status", boolean=True)
|
||||
def is_expired(self, obj):
|
||||
"""Display expiration status with color coding"""
|
||||
from django.utils import timezone
|
||||
|
||||
if obj.used:
|
||||
return format_html('<span style="color: blue;">Used</span>')
|
||||
elif timezone.now() > obj.expires_at:
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of password reset tokens"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Allow viewing but restrict editing of password reset tokens"""
|
||||
return getattr(request.user, "is_superuser", False)
|
||||
@@ -3,7 +3,7 @@ from django.apps import AppConfig
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
name = "apps.accounts"
|
||||
|
||||
def ready(self):
|
||||
import accounts.signals # noqa
|
||||
import apps.accounts.signals # noqa
|
||||
@@ -0,0 +1,41 @@
|
||||
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"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check SocialApp
|
||||
self.stdout.write("\nChecking SocialApp table:")
|
||||
for app in SocialApp.objects.all():
|
||||
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}")
|
||||
|
||||
# Check SocialAccount
|
||||
self.stdout.write("\nChecking SocialAccount table:")
|
||||
for account in SocialAccount.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
|
||||
)
|
||||
|
||||
# Check SocialToken
|
||||
self.stdout.write("\nChecking SocialToken table:")
|
||||
for token in SocialToken.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
|
||||
)
|
||||
|
||||
# Check Site
|
||||
self.stdout.write("\nChecking Site table:")
|
||||
for site in Site.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
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"))
|
||||
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())}"
|
||||
)
|
||||
@@ -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:
|
||||
@@ -14,9 +15,14 @@ class Command(BaseCommand):
|
||||
|
||||
# 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,10 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from reviews.models import Review
|
||||
from parks.models import Park
|
||||
from rides.models import Ride
|
||||
from media.models import Photo
|
||||
from apps.parks.models import ParkReview, Park, ParkPhoto
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -20,16 +17,27 @@ 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"))
|
||||
|
||||
# Delete test photos
|
||||
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
|
||||
count = photos.count()
|
||||
photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
||||
# Delete test photos - both park and ride photos
|
||||
park_photos = ParkPhoto.objects.filter(
|
||||
uploader__username__in=["testuser", "moderator"]
|
||||
)
|
||||
park_count = park_photos.count()
|
||||
park_photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
|
||||
|
||||
ride_photos = RidePhoto.objects.filter(
|
||||
uploader__username__in=["testuser", "moderator"]
|
||||
)
|
||||
ride_count = ride_photos.count()
|
||||
ride_photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
|
||||
|
||||
# Delete test parks
|
||||
parks = Park.objects.filter(name__startswith="Test Park")
|
||||
@@ -0,0 +1,55 @@
|
||||
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"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
"domain": "localhost:8000",
|
||||
"name": "ThrillWiki Development",
|
||||
},
|
||||
)[0]
|
||||
|
||||
# Create Discord app
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider="discord",
|
||||
defaults={
|
||||
"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.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",
|
||||
defaults={
|
||||
"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.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"))
|
||||
164
backend/apps/accounts/management/commands/delete_user.py
Normal file
164
backend/apps/accounts/management/commands/delete_user.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Django management command to delete a user while preserving their submissions.
|
||||
|
||||
Usage:
|
||||
uv run manage.py delete_user <username>
|
||||
uv run manage.py delete_user --user-id <user_id>
|
||||
uv run manage.py delete_user <username> --dry-run
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.services import UserDeletionService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Delete a user while preserving all their submissions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"username", nargs="?", type=str, help="Username of the user to delete"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=str,
|
||||
help="User ID of the user to delete (alternative to username)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be deleted without actually deleting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Skip confirmation prompt"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options.get("username")
|
||||
user_id = options.get("user_id")
|
||||
dry_run = options.get("dry_run", False)
|
||||
force = options.get("force", False)
|
||||
|
||||
# Validate arguments
|
||||
if not username and not user_id:
|
||||
raise CommandError("You must provide either a username or --user-id")
|
||||
|
||||
if username and user_id:
|
||||
raise CommandError("You cannot provide both username and --user-id")
|
||||
|
||||
# Find the user
|
||||
try:
|
||||
if username:
|
||||
user = User.objects.get(username=username)
|
||||
else:
|
||||
user = User.objects.get(user_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
identifier = username or user_id
|
||||
raise CommandError(f'User "{identifier}" does not exist')
|
||||
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise CommandError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Count submissions
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
total_submissions = sum(submission_counts.values())
|
||||
|
||||
# Display user information
|
||||
self.stdout.write(self.style.WARNING("\nUser Information:"))
|
||||
self.stdout.write(f" Username: {user.username}")
|
||||
self.stdout.write(f" User ID: {user.user_id}")
|
||||
self.stdout.write(f" Email: {user.email}")
|
||||
self.stdout.write(f" Date Joined: {user.date_joined}")
|
||||
self.stdout.write(f" Role: {user.role}")
|
||||
|
||||
# Display submission counts
|
||||
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
|
||||
for submission_type, count in submission_counts.items():
|
||||
if count > 0:
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal submissions: {total_submissions}")
|
||||
|
||||
if total_submissions > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nAll {total_submissions} submissions will be transferred to the "deleted_user" placeholder.'
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("\nNo submissions found for this user.")
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
|
||||
return
|
||||
|
||||
# Confirmation prompt
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'\nThis will permanently delete the user "{user.username}" '
|
||||
f"but preserve all {total_submissions} submissions."
|
||||
)
|
||||
)
|
||||
confirm = input("Are you sure you want to continue? (yes/no): ")
|
||||
if confirm.lower() not in ["yes", "y"]:
|
||||
self.stdout.write(self.style.ERROR("Operation cancelled."))
|
||||
return
|
||||
|
||||
# Perform the deletion
|
||||
try:
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
preserved_count = sum(result["preserved_submissions"].values())
|
||||
if preserved_count > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Preserved {preserved_count} submissions under user "{result["transferred_to"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
# Show detailed preservation summary
|
||||
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
|
||||
for submission_type, count in result["preserved_submissions"].items():
|
||||
if count > 0:
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise CommandError(f"Error deleting user: {str(e)}")
|
||||
@@ -0,0 +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"
|
||||
|
||||
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"
|
||||
)
|
||||
)
|
||||
38
backend/apps/accounts/management/commands/fix_social_apps.py
Normal file
38
backend/apps/accounts/management/commands/fix_social_apps.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
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")
|
||||
|
||||
# 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"),
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
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"),
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
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}"))
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.accounts.models import UserProfile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Regenerate default avatars for users without an uploaded avatar"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
profiles = UserProfile.objects.filter(avatar="")
|
||||
for profile in profiles:
|
||||
# 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,49 +3,64 @@ 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,
|
||||
@@ -56,13 +71,19 @@ class Command(BaseCommand):
|
||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
||||
'light'
|
||||
) RETURNING id;
|
||||
""", [make_password('admin'), user_id])
|
||||
""",
|
||||
[make_password("admin"), user_id],
|
||||
)
|
||||
|
||||
user_db_id = cursor.fetchone()[0]
|
||||
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,13 @@ 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,8 +3,9 @@ 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
|
||||
@@ -17,20 +18,22 @@ class Command(BaseCommand):
|
||||
|
||||
# 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}")
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
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")
|
||||
)
|
||||
@@ -1,14 +1,14 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from accounts.models import User
|
||||
from accounts.signals import create_default_groups
|
||||
from django.contrib.auth.models import Group
|
||||
from apps.accounts.models import User
|
||||
from apps.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
|
||||
@@ -29,14 +29,16 @@ class Command(BaseCommand):
|
||||
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,8 +1,9 @@
|
||||
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
|
||||
@@ -10,8 +11,6 @@ class Command(BaseCommand):
|
||||
|
||||
# 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}"))
|
||||
129
backend/apps/accounts/management/commands/setup_social_auth.py
Normal file
129
backend/apps/accounts/management/commands/setup_social_auth.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
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")
|
||||
|
||||
# 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"}
|
||||
)
|
||||
|
||||
# Set up Google
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider="google",
|
||||
defaults={
|
||||
"name": "Google",
|
||||
"client_id": google_client_id,
|
||||
"secret": google_client_secret,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
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",
|
||||
defaults={
|
||||
"name": "Discord",
|
||||
"client_id": discord_client_id,
|
||||
"secret": discord_client_secret,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
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"))
|
||||
@@ -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}')
|
||||
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"
|
||||
'''))
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Set up social authentication providers for development"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the current site
|
||||
site = Site.objects.get_current()
|
||||
self.stdout.write(f"Setting up social providers for site: {site}")
|
||||
|
||||
# Clear existing social apps to avoid duplicates
|
||||
deleted_count = SocialApp.objects.all().delete()[0]
|
||||
self.stdout.write(f"Cleared {deleted_count} existing social apps")
|
||||
|
||||
# Create Google social app
|
||||
google_app = SocialApp.objects.create(
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id="demo-google-client-id.apps.googleusercontent.com",
|
||||
secret="demo-google-client-secret",
|
||||
key="",
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Created Google social app"))
|
||||
|
||||
# Create Discord social app
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider="discord",
|
||||
name="Discord",
|
||||
client_id="demo-discord-client-id",
|
||||
secret="demo-discord-client-secret",
|
||||
key="",
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Created Discord social app"))
|
||||
|
||||
# List all social apps
|
||||
self.stdout.write("\nConfigured social apps:")
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}")
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.test import Client
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Test Discord OAuth2 authentication flow"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
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}")
|
||||
|
||||
# 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}")
|
||||
|
||||
if response.status_code == 302:
|
||||
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)")
|
||||
|
||||
# Show callback URL
|
||||
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")
|
||||
|
||||
# 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/")
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR("Discord app not found"))
|
||||
@@ -2,8 +2,9 @@ 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
|
||||
@@ -11,10 +12,12 @@ class Command(BaseCommand):
|
||||
|
||||
# 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)}"
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
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}")
|
||||
|
||||
# Get sites
|
||||
sites = discord_app.sites.all()
|
||||
self.stdout.write("\nAssociated sites:")
|
||||
for site in sites:
|
||||
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:"
|
||||
)
|
||||
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(f"Scopes: {discord_settings.get('SCOPE', [])}")
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
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
|
||||
@@ -11,7 +11,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
@@ -33,7 +32,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 +80,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 +104,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 +237,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 +279,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 +340,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 +377,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 +462,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 +524,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 +539,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",
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 18:23
|
||||
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="toplistevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistevent",
|
||||
name="user",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="content_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="toplistitemevent",
|
||||
name="top_list",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplist",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplist",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplistitem",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="toplistitem",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TopListEvent",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TopListItemEvent",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,438 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-24 19:11
|
||||
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailVerificationEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("token", models.CharField(max_length=64)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("last_sent", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PasswordResetEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("token", models.CharField(max_length=64)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField()),
|
||||
("used", models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_id",
|
||||
models.CharField(
|
||||
editable=False,
|
||||
help_text="Unique identifier for this user that remains constant even if the username changes",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("MODERATOR", "Moderator"),
|
||||
("ADMIN", "Admin"),
|
||||
("SUPERUSER", "Superuser"),
|
||||
],
|
||||
default="USER",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_banned", models.BooleanField(default=False)),
|
||||
("ban_reason", models.TextField(blank=True)),
|
||||
("ban_date", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"pending_email",
|
||||
models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
(
|
||||
"theme_preference",
|
||||
models.CharField(
|
||||
choices=[("light", "Light"), ("dark", "Dark")],
|
||||
default="light",
|
||||
max_length=5,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserProfileEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"profile_id",
|
||||
models.CharField(
|
||||
editable=False,
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"display_name",
|
||||
models.CharField(
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("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)),
|
||||
("instagram", models.URLField(blank=True)),
|
||||
("youtube", models.URLField(blank=True)),
|
||||
("discord", models.CharField(blank=True, max_length=100)),
|
||||
("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)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="emailverification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
|
||||
hash="c485bf0cd5bea8a05ef2d4ae309b60eff42abd84",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_53748",
|
||||
table="accounts_emailverification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="emailverification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."user_id"); RETURN NULL;',
|
||||
hash="c20942bdc0713db74310da8da8c3138ca4c3bba9",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_7a2a8",
|
||||
table="accounts_emailverification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="passwordreset",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
|
||||
hash="496ac059671b25460cdf2ca20d0e43b14d417a26",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_d2b72",
|
||||
table="accounts_passwordreset",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="passwordreset",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_passwordresetevent" ("created_at", "expires_at", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "used", "user_id") VALUES (NEW."created_at", NEW."expires_at", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."used", NEW."user_id"); RETURN NULL;',
|
||||
hash="c40acc416f85287b4a6fcc06724626707df90016",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_526d2",
|
||||
table="accounts_passwordreset",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="b6992f02a4c1135fef9527e3f1ed330e2e626267",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("ban_date", "ban_reason", "date_joined", "email", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "role", "theme_preference", "user_id", "username") VALUES (NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."role", NEW."theme_preference", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="6c3271b9f184dc137da7b9e42b0ae9f72d47c9c2",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="af6a89f13ff879d978a1154bbcf4664de0fcf913",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_c09d7",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="37e99b5cc374ec0a3fc44d2482b411cba63fa84d",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_87ef6",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailverificationevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailverificationevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.emailverification",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailverificationevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passwordresetevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passwordresetevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.passwordreset",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passwordresetevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.userprofile",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 14:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"accounts",
|
||||
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
||||
),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserDeletionRequest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"verification_code",
|
||||
models.CharField(
|
||||
help_text="Unique verification code sent to user's email",
|
||||
max_length=32,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="When this deletion request expires"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_sent_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the verification email was sent",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=5,
|
||||
help_text="Maximum number of verification attempts allowed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_used",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this deletion request has been used",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="deletion_request",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserDeletionRequestEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"verification_code",
|
||||
models.CharField(
|
||||
help_text="Unique verification code sent to user's email",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(
|
||||
help_text="When this deletion request expires"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_sent_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
help_text="When the verification email was sent",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
),
|
||||
),
|
||||
(
|
||||
"max_attempts",
|
||||
models.PositiveIntegerField(
|
||||
default=5,
|
||||
help_text="Maximum number of verification attempts allowed",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_used",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this deletion request has been used",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.userdeletionrequest",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["verification_code"], name="accounts_us_verific_94460d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userdeletionrequest",
|
||||
index=models.Index(
|
||||
fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userdeletionrequest",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
|
||||
hash="c1735fe8eb50247b0afe2bea9d32f83c31da6419",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_b982c",
|
||||
table="accounts_userdeletionrequest",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userdeletionrequest",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
|
||||
hash="6bf807ce3bed069ab30462d3fd7688a7593a7fd0",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_27723",
|
||||
table="accounts_userdeletionrequest",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,309 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 15:10
|
||||
|
||||
import django.utils.timezone
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0004_userdeletionrequest_userdeletionrequestevent_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="activity_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_friend_requests",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_messages",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="allow_profile_comments",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="email_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="last_password_change",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="login_history_retention",
|
||||
field=models.IntegerField(default=90),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="login_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="notification_preferences",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="push_notifications",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="search_visibility",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="session_timeout",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_email",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_join_date",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_photos",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_real_name",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_reviews",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_statistics",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="show_top_lists",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="two_factor_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="activity_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_friend_requests",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_messages",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="allow_profile_comments",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="email_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="last_password_change",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="login_history_retention",
|
||||
field=models.IntegerField(default=90),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="login_notifications",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="notification_preferences",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="push_notifications",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="search_visibility",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="session_timeout",
|
||||
field=models.IntegerField(default=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_email",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_join_date",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_photos",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_real_name",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_reviews",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_statistics",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="show_top_lists",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="two_factor_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="63ede44a0db376d673078f3464edc89aa8ca80c7",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="9157131b568edafe1e5fcdf313bfeaaa8adcfee4",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 19:09
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0005_remove_user_insert_insert_remove_user_update_update_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userevent",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofileevent",
|
||||
name="display_name",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="97e02685f062c04c022f6975784dce80396d4371",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="e074b317983a921b440b0c8754ba04a31ea513dd",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-29 21:32
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_add_display_name_to_user"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="user",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="first_name",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="last_name",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="userevent",
|
||||
name="first_name",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="userevent",
|
||||
name="last_name",
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="1ffd9209b0e1949c05de2548585cda9179288b68",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_3867c",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="user",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||
hash="e5f0a1acc20a9aad226004bc93ca8dbc3511052f",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_0e890",
|
||||
table="accounts_user",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,509 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-30 20:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0008_remove_first_last_name_fields"),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="NotificationPreference",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("submission_approved_email", models.BooleanField(default=True)),
|
||||
("submission_approved_push", models.BooleanField(default=True)),
|
||||
("submission_approved_inapp", models.BooleanField(default=True)),
|
||||
("submission_rejected_email", models.BooleanField(default=True)),
|
||||
("submission_rejected_push", models.BooleanField(default=True)),
|
||||
("submission_rejected_inapp", models.BooleanField(default=True)),
|
||||
("submission_pending_email", models.BooleanField(default=False)),
|
||||
("submission_pending_push", models.BooleanField(default=False)),
|
||||
("submission_pending_inapp", models.BooleanField(default=True)),
|
||||
("review_reply_email", models.BooleanField(default=True)),
|
||||
("review_reply_push", models.BooleanField(default=True)),
|
||||
("review_reply_inapp", models.BooleanField(default=True)),
|
||||
("review_helpful_email", models.BooleanField(default=False)),
|
||||
("review_helpful_push", models.BooleanField(default=True)),
|
||||
("review_helpful_inapp", models.BooleanField(default=True)),
|
||||
("friend_request_email", models.BooleanField(default=True)),
|
||||
("friend_request_push", models.BooleanField(default=True)),
|
||||
("friend_request_inapp", models.BooleanField(default=True)),
|
||||
("friend_accepted_email", models.BooleanField(default=False)),
|
||||
("friend_accepted_push", models.BooleanField(default=True)),
|
||||
("friend_accepted_inapp", models.BooleanField(default=True)),
|
||||
("message_received_email", models.BooleanField(default=True)),
|
||||
("message_received_push", models.BooleanField(default=True)),
|
||||
("message_received_inapp", models.BooleanField(default=True)),
|
||||
("system_announcement_email", models.BooleanField(default=True)),
|
||||
("system_announcement_push", models.BooleanField(default=False)),
|
||||
("system_announcement_inapp", models.BooleanField(default=True)),
|
||||
("account_security_email", models.BooleanField(default=True)),
|
||||
("account_security_push", models.BooleanField(default=True)),
|
||||
("account_security_inapp", models.BooleanField(default=True)),
|
||||
("feature_update_email", models.BooleanField(default=True)),
|
||||
("feature_update_push", models.BooleanField(default=False)),
|
||||
("feature_update_inapp", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_email", models.BooleanField(default=False)),
|
||||
("achievement_unlocked_push", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_inapp", models.BooleanField(default=True)),
|
||||
("milestone_reached_email", models.BooleanField(default=False)),
|
||||
("milestone_reached_push", models.BooleanField(default=True)),
|
||||
("milestone_reached_inapp", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification Preference",
|
||||
"verbose_name_plural": "Notification Preferences",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NotificationPreferenceEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("submission_approved_email", models.BooleanField(default=True)),
|
||||
("submission_approved_push", models.BooleanField(default=True)),
|
||||
("submission_approved_inapp", models.BooleanField(default=True)),
|
||||
("submission_rejected_email", models.BooleanField(default=True)),
|
||||
("submission_rejected_push", models.BooleanField(default=True)),
|
||||
("submission_rejected_inapp", models.BooleanField(default=True)),
|
||||
("submission_pending_email", models.BooleanField(default=False)),
|
||||
("submission_pending_push", models.BooleanField(default=False)),
|
||||
("submission_pending_inapp", models.BooleanField(default=True)),
|
||||
("review_reply_email", models.BooleanField(default=True)),
|
||||
("review_reply_push", models.BooleanField(default=True)),
|
||||
("review_reply_inapp", models.BooleanField(default=True)),
|
||||
("review_helpful_email", models.BooleanField(default=False)),
|
||||
("review_helpful_push", models.BooleanField(default=True)),
|
||||
("review_helpful_inapp", models.BooleanField(default=True)),
|
||||
("friend_request_email", models.BooleanField(default=True)),
|
||||
("friend_request_push", models.BooleanField(default=True)),
|
||||
("friend_request_inapp", models.BooleanField(default=True)),
|
||||
("friend_accepted_email", models.BooleanField(default=False)),
|
||||
("friend_accepted_push", models.BooleanField(default=True)),
|
||||
("friend_accepted_inapp", models.BooleanField(default=True)),
|
||||
("message_received_email", models.BooleanField(default=True)),
|
||||
("message_received_push", models.BooleanField(default=True)),
|
||||
("message_received_inapp", models.BooleanField(default=True)),
|
||||
("system_announcement_email", models.BooleanField(default=True)),
|
||||
("system_announcement_push", models.BooleanField(default=False)),
|
||||
("system_announcement_inapp", models.BooleanField(default=True)),
|
||||
("account_security_email", models.BooleanField(default=True)),
|
||||
("account_security_push", models.BooleanField(default=True)),
|
||||
("account_security_inapp", models.BooleanField(default=True)),
|
||||
("feature_update_email", models.BooleanField(default=True)),
|
||||
("feature_update_push", models.BooleanField(default=False)),
|
||||
("feature_update_inapp", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_email", models.BooleanField(default=False)),
|
||||
("achievement_unlocked_push", models.BooleanField(default=True)),
|
||||
("achievement_unlocked_inapp", models.BooleanField(default=True)),
|
||||
("milestone_reached_email", models.BooleanField(default=False)),
|
||||
("milestone_reached_push", models.BooleanField(default=True)),
|
||||
("milestone_reached_inapp", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserNotification",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notification_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("message", models.TextField()),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("read_at", models.DateTimeField(blank=True, null=True)),
|
||||
("email_sent", models.BooleanField(default=False)),
|
||||
("email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("push_sent", models.BooleanField(default=False)),
|
||||
("push_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("extra_data", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserNotificationEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notification_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("message", models.TextField()),
|
||||
("object_id", models.PositiveIntegerField(blank=True, null=True)),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("is_read", models.BooleanField(default=False)),
|
||||
("read_at", models.DateTimeField(blank=True, null=True)),
|
||||
("email_sent", models.BooleanField(default=False)),
|
||||
("email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("push_sent", models.BooleanField(default=False)),
|
||||
("push_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("extra_data", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("expires_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="userprofile",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="userprofile",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="avatar",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userprofileevent",
|
||||
name="avatar",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_c09d7",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="81607e492ffea2a4c741452b860ee660374cc01d",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_87ef6",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationpreference",
|
||||
name="user",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notification_preference",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationpreferenceevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationpreferenceevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.notificationpreference",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationpreferenceevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usernotification",
|
||||
name="content_type",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usernotification",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usernotificationevent",
|
||||
name="content_type",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usernotificationevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usernotificationevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.usernotification",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="usernotificationevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="notificationpreference",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="bbaa03794722dab95c97ed93731d8b55f314dbdc",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_4a06b",
|
||||
table="accounts_notificationpreference",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="notificationpreference",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="0de72b66f87f795aaeb49be8e4e57d632781bd3a",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_d3fc0",
|
||||
table="accounts_notificationpreference",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["user", "notification_type"],
|
||||
name="accounts_us_user_id_8cea97_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["created_at"], name="accounts_us_created_a62f54_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="usernotification",
|
||||
index=models.Index(
|
||||
fields=["expires_at"], name="accounts_us_expires_f267b1_idx"
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="usernotification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="822a189e675a5903841d19738c29aa94267417f1",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_2794b",
|
||||
table="accounts_usernotification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="usernotification",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="1fd24a77684747bd9a521447a2978529085b6c07",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_15c54",
|
||||
table="accounts_usernotification",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
107
backend/apps/accounts/migrations/0010_auto_20250830_1657.py
Normal file
107
backend/apps/accounts/migrations/0010_auto_20250830_1657.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-30 20:57
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_avatar_data(apps, schema_editor):
|
||||
"""
|
||||
Migrate avatar data from old CloudflareImageField to new ForeignKey structure.
|
||||
Since we're transitioning to a new system, we'll just drop the old avatar column
|
||||
and add the new avatar_id column for ForeignKey relationships.
|
||||
"""
|
||||
# This is a data migration - we'll handle the schema changes in the operations
|
||||
pass
|
||||
|
||||
|
||||
def reverse_migrate_avatar_data(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration - not implemented as this is a one-way migration
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def safe_add_avatar_field(apps, schema_editor):
|
||||
"""
|
||||
Safely add avatar field, checking if it already exists.
|
||||
"""
|
||||
# Check if the column already exists
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='accounts_userprofile'
|
||||
AND column_name='avatar_id'
|
||||
""")
|
||||
|
||||
column_exists = cursor.fetchone() is not None
|
||||
|
||||
if not column_exists:
|
||||
# Column doesn't exist, add it
|
||||
UserProfile = apps.get_model('accounts', 'UserProfile')
|
||||
field = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
field.set_attributes_from_name('avatar')
|
||||
schema_editor.add_field(UserProfile, field)
|
||||
|
||||
|
||||
def reverse_safe_add_avatar_field(apps, schema_editor):
|
||||
"""
|
||||
Reverse the safe avatar field addition.
|
||||
"""
|
||||
# Check if the column exists and remove it
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='accounts_userprofile'
|
||||
AND column_name='avatar_id'
|
||||
""")
|
||||
|
||||
column_exists = cursor.fetchone() is not None
|
||||
|
||||
if column_exists:
|
||||
UserProfile = apps.get_model('accounts', 'UserProfile')
|
||||
field = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
field.set_attributes_from_name('avatar')
|
||||
schema_editor.remove_field(UserProfile, field)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"accounts",
|
||||
"0009_notificationpreference_notificationpreferenceevent_and_more",
|
||||
),
|
||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# First, remove the old avatar column (CloudflareImageField)
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
|
||||
reverse_sql="-- Cannot reverse this operation"
|
||||
),
|
||||
|
||||
# Safely add the new avatar_id column for ForeignKey
|
||||
migrations.RunPython(
|
||||
safe_add_avatar_field,
|
||||
reverse_safe_add_avatar_field,
|
||||
),
|
||||
|
||||
# Run the data migration
|
||||
migrations.RunPython(
|
||||
migrate_avatar_data,
|
||||
reverse_migrate_avatar_data,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated manually on 2025-08-30 to fix pghistory event table schema
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0010_auto_20250830_1657'),
|
||||
('django_cloudflareimages_toolkit', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Remove the old avatar field from the event table
|
||||
migrations.RunSQL(
|
||||
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
|
||||
reverse_sql="-- Cannot reverse this operation"
|
||||
),
|
||||
|
||||
# Add the new avatar_id field to match the main table (only if it doesn't exist)
|
||||
migrations.RunSQL(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='accounts_userprofileevent'
|
||||
AND column_name='avatar_id'
|
||||
) THEN
|
||||
ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;
|
||||
END IF;
|
||||
END $$;
|
||||
""",
|
||||
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;"
|
||||
),
|
||||
]
|
||||
@@ -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.
|
||||
@@ -15,19 +17,19 @@ 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.")
|
||||
678
backend/apps/accounts/models.py
Normal file
678
backend/apps/accounts/models.py
Normal file
@@ -0,0 +1,678 @@
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.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
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
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")
|
||||
|
||||
class PrivacyLevel(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
FRIENDS = "friends", _("Friends Only")
|
||||
PRIVATE = "private", _("Private")
|
||||
|
||||
# Override inherited fields to remove them
|
||||
first_name = None
|
||||
last_name = None
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Notification preferences
|
||||
email_notifications = models.BooleanField(default=True)
|
||||
push_notifications = models.BooleanField(default=False)
|
||||
|
||||
# Privacy settings
|
||||
privacy_level = models.CharField(
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.PUBLIC,
|
||||
)
|
||||
show_email = models.BooleanField(default=False)
|
||||
show_real_name = models.BooleanField(default=True)
|
||||
show_join_date = models.BooleanField(default=True)
|
||||
show_statistics = models.BooleanField(default=True)
|
||||
show_reviews = models.BooleanField(default=True)
|
||||
show_photos = models.BooleanField(default=True)
|
||||
show_top_lists = models.BooleanField(default=True)
|
||||
allow_friend_requests = models.BooleanField(default=True)
|
||||
allow_messages = models.BooleanField(default=True)
|
||||
allow_profile_comments = models.BooleanField(default=False)
|
||||
search_visibility = models.BooleanField(default=True)
|
||||
activity_visibility = models.CharField(
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.FRIENDS,
|
||||
)
|
||||
|
||||
# Security settings
|
||||
two_factor_enabled = models.BooleanField(default=False)
|
||||
login_notifications = models.BooleanField(default=True)
|
||||
session_timeout = models.IntegerField(default=30) # days
|
||||
login_history_retention = models.IntegerField(default=90) # days
|
||||
last_password_change = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Display name - core user data for better performance
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
||||
)
|
||||
|
||||
# Detailed notification preferences (JSON field for flexibility)
|
||||
notification_preferences = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Detailed notification preferences stored as JSON",
|
||||
)
|
||||
|
||||
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"""
|
||||
if self.display_name:
|
||||
return self.display_name
|
||||
# Fallback to profile display_name for backward compatibility
|
||||
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)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
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,
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
)
|
||||
avatar = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
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_url(self):
|
||||
"""
|
||||
Return the avatar URL or generate a default letter-based avatar URL
|
||||
"""
|
||||
if self.avatar and self.avatar.is_uploaded:
|
||||
# Try to get avatar variant first, fallback to public
|
||||
avatar_url = self.avatar.get_url('avatar')
|
||||
if avatar_url:
|
||||
return avatar_url
|
||||
|
||||
# Fallback to public variant
|
||||
public_url = self.avatar.get_url('public')
|
||||
if public_url:
|
||||
return public_url
|
||||
|
||||
# Last fallback - try any available variant
|
||||
if self.avatar.variants:
|
||||
if isinstance(self.avatar.variants, list) and self.avatar.variants:
|
||||
return self.avatar.variants[0]
|
||||
elif isinstance(self.avatar.variants, dict):
|
||||
# Return first available variant
|
||||
for variant_url in self.avatar.variants.values():
|
||||
if variant_url:
|
||||
return variant_url
|
||||
|
||||
# Generate default letter-based avatar using first letter of username
|
||||
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
||||
# Use a service like UI Avatars or generate a simple colored avatar
|
||||
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
|
||||
|
||||
def get_avatar_variants(self):
|
||||
"""
|
||||
Return avatar variants for different use cases
|
||||
"""
|
||||
if self.avatar and self.avatar.is_uploaded:
|
||||
variants = {}
|
||||
|
||||
# Try to get specific variants
|
||||
thumbnail_url = self.avatar.get_url('thumbnail')
|
||||
avatar_url = self.avatar.get_url('avatar')
|
||||
large_url = self.avatar.get_url('large')
|
||||
public_url = self.avatar.get_url('public')
|
||||
|
||||
# Use specific variants if available, otherwise fallback to public or first available
|
||||
fallback_url = public_url
|
||||
if not fallback_url and self.avatar.variants:
|
||||
if isinstance(self.avatar.variants, list) and self.avatar.variants:
|
||||
fallback_url = self.avatar.variants[0]
|
||||
elif isinstance(self.avatar.variants, dict):
|
||||
fallback_url = next(iter(self.avatar.variants.values()), None)
|
||||
|
||||
variants = {
|
||||
"thumbnail": thumbnail_url or fallback_url,
|
||||
"avatar": avatar_url or fallback_url,
|
||||
"large": large_url or fallback_url,
|
||||
}
|
||||
|
||||
# Only return variants if we have at least one valid URL
|
||||
if any(variants.values()):
|
||||
return variants
|
||||
|
||||
# For default avatars, return the same URL for all variants
|
||||
default_url = self.get_avatar_url()
|
||||
return {
|
||||
"thumbnail": default_url,
|
||||
"avatar": default_url,
|
||||
"large": default_url,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
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"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
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}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserDeletionRequest(models.Model):
|
||||
"""
|
||||
Model to track user deletion requests with email verification.
|
||||
|
||||
When a user requests to delete their account, a verification code
|
||||
is sent to their email. The deletion is only processed when they
|
||||
provide the correct code.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="deletion_request"
|
||||
)
|
||||
|
||||
verification_code = models.CharField(
|
||||
max_length=32,
|
||||
unique=True,
|
||||
help_text="Unique verification code sent to user's email",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
||||
|
||||
email_sent_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When the verification email was sent"
|
||||
)
|
||||
|
||||
attempts = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
)
|
||||
|
||||
max_attempts = models.PositiveIntegerField(
|
||||
default=5, help_text="Maximum number of verification attempts allowed"
|
||||
)
|
||||
|
||||
is_used = models.BooleanField(
|
||||
default=False, help_text="Whether this deletion request has been used"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["verification_code"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
models.Index(fields=["user", "is_used"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Deletion request for {self.user.username} - {self.verification_code}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.verification_code:
|
||||
self.verification_code = self.generate_verification_code()
|
||||
|
||||
if not self.expires_at:
|
||||
# Deletion requests expire after 24 hours
|
||||
self.expires_at = timezone.now() + timedelta(hours=24)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def generate_verification_code():
|
||||
"""Generate a unique 8-character verification code."""
|
||||
while True:
|
||||
# Generate a random 8-character alphanumeric code
|
||||
code = "".join(
|
||||
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
|
||||
)
|
||||
|
||||
# Ensure it's unique
|
||||
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
||||
return code
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if this deletion request has expired."""
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if this deletion request is still valid."""
|
||||
return (
|
||||
not self.is_used
|
||||
and not self.is_expired()
|
||||
and self.attempts < self.max_attempts
|
||||
)
|
||||
|
||||
def increment_attempts(self):
|
||||
"""Increment the number of verification attempts."""
|
||||
self.attempts += 1
|
||||
self.save(update_fields=["attempts"])
|
||||
|
||||
def mark_as_used(self):
|
||||
"""Mark this deletion request as used."""
|
||||
self.is_used = True
|
||||
self.save(update_fields=["is_used"])
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired deletion requests."""
|
||||
expired_requests = cls.objects.filter(
|
||||
expires_at__lt=timezone.now(), is_used=False
|
||||
)
|
||||
count = expired_requests.count()
|
||||
expired_requests.delete()
|
||||
return count
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserNotification(TrackedModel):
|
||||
"""
|
||||
Model to store user notifications for various events.
|
||||
|
||||
This includes submission approvals, rejections, system announcements,
|
||||
and other user-relevant notifications.
|
||||
"""
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
# Submission related
|
||||
SUBMISSION_APPROVED = "submission_approved", _("Submission Approved")
|
||||
SUBMISSION_REJECTED = "submission_rejected", _("Submission Rejected")
|
||||
SUBMISSION_PENDING = "submission_pending", _("Submission Pending Review")
|
||||
|
||||
# Review related
|
||||
REVIEW_REPLY = "review_reply", _("Review Reply")
|
||||
REVIEW_HELPFUL = "review_helpful", _("Review Marked Helpful")
|
||||
|
||||
# Social related
|
||||
FRIEND_REQUEST = "friend_request", _("Friend Request")
|
||||
FRIEND_ACCEPTED = "friend_accepted", _("Friend Request Accepted")
|
||||
MESSAGE_RECEIVED = "message_received", _("Message Received")
|
||||
PROFILE_COMMENT = "profile_comment", _("Profile Comment")
|
||||
|
||||
# System related
|
||||
SYSTEM_ANNOUNCEMENT = "system_announcement", _("System Announcement")
|
||||
ACCOUNT_SECURITY = "account_security", _("Account Security")
|
||||
FEATURE_UPDATE = "feature_update", _("Feature Update")
|
||||
MAINTENANCE = "maintenance", _("Maintenance Notice")
|
||||
|
||||
# Achievement related
|
||||
ACHIEVEMENT_UNLOCKED = "achievement_unlocked", _("Achievement Unlocked")
|
||||
MILESTONE_REACHED = "milestone_reached", _("Milestone Reached")
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = "low", _("Low")
|
||||
NORMAL = "normal", _("Normal")
|
||||
HIGH = "high", _("High")
|
||||
URGENT = "urgent", _("Urgent")
|
||||
|
||||
# Core fields
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="notifications"
|
||||
)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=30, choices=NotificationType.choices
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional related object (submission, review, etc.)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
related_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Metadata
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=Priority.choices, default=Priority.NORMAL
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Delivery tracking
|
||||
email_sent = models.BooleanField(default=False)
|
||||
email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
push_sent = models.BooleanField(default=False)
|
||||
push_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Additional data (JSON field for flexibility)
|
||||
extra_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "is_read"]),
|
||||
models.Index(fields=["user", "notification_type"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username}: {self.title}"
|
||||
|
||||
def mark_as_read(self):
|
||||
"""Mark notification as read."""
|
||||
if not self.is_read:
|
||||
self.is_read = True
|
||||
self.read_at = timezone.now()
|
||||
self.save(update_fields=["is_read", "read_at"])
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if notification has expired."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired notifications."""
|
||||
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
|
||||
count = expired_notifications.count()
|
||||
expired_notifications.delete()
|
||||
return count
|
||||
|
||||
@classmethod
|
||||
def mark_all_read_for_user(cls, user):
|
||||
"""Mark all notifications as read for a specific user."""
|
||||
return cls.objects.filter(user=user, is_read=False).update(
|
||||
is_read=True, read_at=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class NotificationPreference(TrackedModel):
|
||||
"""
|
||||
User preferences for different types of notifications.
|
||||
|
||||
This allows users to control which notifications they receive
|
||||
and through which channels (email, push, in-app).
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="notification_preference"
|
||||
)
|
||||
|
||||
# Submission notifications
|
||||
submission_approved_email = models.BooleanField(default=True)
|
||||
submission_approved_push = models.BooleanField(default=True)
|
||||
submission_approved_inapp = models.BooleanField(default=True)
|
||||
|
||||
submission_rejected_email = models.BooleanField(default=True)
|
||||
submission_rejected_push = models.BooleanField(default=True)
|
||||
submission_rejected_inapp = models.BooleanField(default=True)
|
||||
|
||||
submission_pending_email = models.BooleanField(default=False)
|
||||
submission_pending_push = models.BooleanField(default=False)
|
||||
submission_pending_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Review notifications
|
||||
review_reply_email = models.BooleanField(default=True)
|
||||
review_reply_push = models.BooleanField(default=True)
|
||||
review_reply_inapp = models.BooleanField(default=True)
|
||||
|
||||
review_helpful_email = models.BooleanField(default=False)
|
||||
review_helpful_push = models.BooleanField(default=True)
|
||||
review_helpful_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Social notifications
|
||||
friend_request_email = models.BooleanField(default=True)
|
||||
friend_request_push = models.BooleanField(default=True)
|
||||
friend_request_inapp = models.BooleanField(default=True)
|
||||
|
||||
friend_accepted_email = models.BooleanField(default=False)
|
||||
friend_accepted_push = models.BooleanField(default=True)
|
||||
friend_accepted_inapp = models.BooleanField(default=True)
|
||||
|
||||
message_received_email = models.BooleanField(default=True)
|
||||
message_received_push = models.BooleanField(default=True)
|
||||
message_received_inapp = models.BooleanField(default=True)
|
||||
|
||||
# System notifications
|
||||
system_announcement_email = models.BooleanField(default=True)
|
||||
system_announcement_push = models.BooleanField(default=False)
|
||||
system_announcement_inapp = models.BooleanField(default=True)
|
||||
|
||||
account_security_email = models.BooleanField(default=True)
|
||||
account_security_push = models.BooleanField(default=True)
|
||||
account_security_inapp = models.BooleanField(default=True)
|
||||
|
||||
feature_update_email = models.BooleanField(default=True)
|
||||
feature_update_push = models.BooleanField(default=False)
|
||||
feature_update_inapp = models.BooleanField(default=True)
|
||||
|
||||
# Achievement notifications
|
||||
achievement_unlocked_email = models.BooleanField(default=False)
|
||||
achievement_unlocked_push = models.BooleanField(default=True)
|
||||
achievement_unlocked_inapp = models.BooleanField(default=True)
|
||||
|
||||
milestone_reached_email = models.BooleanField(default=False)
|
||||
milestone_reached_push = models.BooleanField(default=True)
|
||||
milestone_reached_inapp = models.BooleanField(default=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Notification Preference"
|
||||
verbose_name_plural = "Notification Preferences"
|
||||
|
||||
def __str__(self):
|
||||
return f"Notification preferences for {self.user.username}"
|
||||
|
||||
def should_send_notification(self, notification_type, channel):
|
||||
"""
|
||||
Check if a notification should be sent for a specific type and channel.
|
||||
|
||||
Args:
|
||||
notification_type: The type of notification (from UserNotification.NotificationType)
|
||||
channel: The delivery channel ('email', 'push', 'inapp')
|
||||
|
||||
Returns:
|
||||
bool: True if notification should be sent, False otherwise
|
||||
"""
|
||||
field_name = f"{notification_type}_{channel}"
|
||||
return getattr(self, field_name, False)
|
||||
|
||||
|
||||
# Signal handlers for automatic notification preference creation
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_notification_preference(sender, instance, created, **kwargs):
|
||||
"""Create notification preferences when a new user is created."""
|
||||
if created:
|
||||
NotificationPreference.objects.create(user=instance)
|
||||
@@ -2,14 +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
|
||||
from apps.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:
|
||||
@@ -23,23 +21,24 @@ def generate_random_id(model_class, id_field):
|
||||
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,40 +60,37 @@ 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)
|
||||
@@ -127,12 +123,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 +143,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 +158,51 @@ class PasswordReset(models.Model):
|
||||
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')
|
||||
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()
|
||||
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}"
|
||||
272
backend/apps/accounts/selectors.py
Normal file
272
backend/apps/accounts/selectors.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
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(display_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")
|
||||
)
|
||||
269
backend/apps/accounts/serializers.py
Normal file
269
backend/apps/accounts/serializers.py
Normal file
@@ -0,0 +1,269 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
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 .models import User, PasswordReset
|
||||
from django_forwardemail.services import EmailService
|
||||
from django.template.loader import render_to_string
|
||||
from typing import cast
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
User serializer for API responses
|
||||
"""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"display_name",
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL"""
|
||||
if hasattr(obj, "profile") and obj.profile.avatar:
|
||||
return obj.profile.avatar.url
|
||||
return None
|
||||
|
||||
def get_display_name(self, obj) -> str:
|
||||
"""Get user display name"""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for user login
|
||||
"""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=254, help_text="Username or email address"
|
||||
)
|
||||
password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
return attrs
|
||||
|
||||
raise serializers.ValidationError("Must include username/email and password.")
|
||||
|
||||
|
||||
class SignupSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for user registration
|
||||
"""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"display_name",
|
||||
"password",
|
||||
"password_confirm",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"password": {"write_only": True},
|
||||
"email": {"required": True},
|
||||
"display_name": {"required": True},
|
||||
}
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email is unique (normalize and check case-insensitively)."""
|
||||
normalized = value.strip().lower() if value is not None else value
|
||||
if UserModel.objects.filter(email__iexact=normalized).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return normalized
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique"""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match"""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data"""
|
||||
validated_data.pop("password_confirm", None)
|
||||
password = validated_data.pop("password")
|
||||
|
||||
user = UserModel.objects.create(**validated_data)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PasswordResetSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password reset request
|
||||
"""
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Normalize email and attach the user to the serializer when found (case-insensitive).
|
||||
|
||||
Returns the normalized email. Does not reveal whether the email exists.
|
||||
"""
|
||||
normalized = value.strip().lower() if value is not None else value
|
||||
try:
|
||||
user = UserModel.objects.get(email__iexact=normalized)
|
||||
self.user = user
|
||||
except UserModel.DoesNotExist:
|
||||
# Do not reveal whether the email exists; keep behavior unchanged.
|
||||
pass
|
||||
return normalized
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists"""
|
||||
if hasattr(self, "user"):
|
||||
# Create password reset token
|
||||
token = get_random_string(64)
|
||||
PasswordReset.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
"token": token,
|
||||
"expires_at": timezone.now() + timedelta(hours=24),
|
||||
"used": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Send reset email
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
site = get_current_site(request)
|
||||
reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/"
|
||||
|
||||
context = {
|
||||
"user": self.user,
|
||||
"reset_url": reset_url,
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_reset.html", context
|
||||
)
|
||||
|
||||
# Narrow and validate email type for the static checker
|
||||
email = getattr(self.user, "email", None)
|
||||
if not email:
|
||||
# No recipient email; skip sending
|
||||
return
|
||||
|
||||
EmailService.send_email(
|
||||
to=cast(str, email),
|
||||
subject="Reset your password",
|
||||
text=f"Click the link to reset your password: {reset_url}",
|
||||
site=site,
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
|
||||
class PasswordChangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password change
|
||||
"""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
max_length=128, validators=[validate_password], style={"input_type": "password"}
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct"""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Old password is incorrect.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate new passwords match"""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Change user password"""
|
||||
user = self.context["request"].user
|
||||
|
||||
# Defensively obtain new_password from validated_data if it's a real dict,
|
||||
# otherwise fall back to initial_data if that's a dict.
|
||||
new_password = None
|
||||
validated = getattr(self, "validated_data", None)
|
||||
if isinstance(validated, dict):
|
||||
new_password = validated.get("new_password")
|
||||
elif isinstance(self.initial_data, dict):
|
||||
new_password = self.initial_data.get("new_password")
|
||||
|
||||
if not new_password:
|
||||
raise serializers.ValidationError("New password is required.")
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class SocialProviderSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for social authentication providers
|
||||
"""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
login_url = serializers.URLField()
|
||||
name = serializers.CharField()
|
||||
login_url = serializers.URLField()
|
||||
366
backend/apps/accounts/services.py
Normal file
366
backend/apps/accounts/services.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
User management services for ThrillWiki.
|
||||
|
||||
This module contains services for user account management including
|
||||
user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django_forwardemail.services import EmailService
|
||||
from .models import User, UserProfile, UserDeletionRequest
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
"""Service for handling user deletion while preserving submissions."""
|
||||
|
||||
DELETED_USER_USERNAME = "deleted_user"
|
||||
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
|
||||
DELETED_DISPLAY_NAME = "Deleted User"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_deleted_user(cls) -> User:
|
||||
"""Get or create the system deleted user placeholder."""
|
||||
deleted_user, created = User.objects.get_or_create(
|
||||
username=cls.DELETED_USER_USERNAME,
|
||||
defaults={
|
||||
"email": cls.DELETED_USER_EMAIL,
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
"role": User.Roles.USER,
|
||||
"is_banned": True,
|
||||
"ban_reason": "System placeholder for deleted users",
|
||||
"ban_date": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
# Create profile for deleted user
|
||||
UserProfile.objects.create(
|
||||
user=deleted_user,
|
||||
display_name=cls.DELETED_DISPLAY_NAME,
|
||||
bio="This user account has been deleted.",
|
||||
)
|
||||
|
||||
return deleted_user
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(cls, user: User) -> dict:
|
||||
"""
|
||||
Delete a user while preserving all their submissions.
|
||||
|
||||
This method:
|
||||
1. Transfers all user submissions to a system "deleted_user" placeholder
|
||||
2. Deletes the user's profile and account data
|
||||
3. Returns a summary of what was preserved
|
||||
|
||||
Args:
|
||||
user: The user to delete
|
||||
|
||||
Returns:
|
||||
dict: Summary of preserved submissions
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
raise ValueError("Cannot delete the system deleted user placeholder")
|
||||
|
||||
deleted_user = cls.get_or_create_deleted_user()
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_park_reviews": getattr(
|
||||
user, "moderated_park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_ride_reviews": getattr(
|
||||
user, "moderated_ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_submissions": getattr(
|
||||
user, "handled_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_photos": getattr(
|
||||
user, "handled_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
# Transfer all submissions to deleted user
|
||||
# Reviews
|
||||
if hasattr(user, "park_reviews"):
|
||||
getattr(user, "park_reviews").update(user=deleted_user)
|
||||
if hasattr(user, "ride_reviews"):
|
||||
getattr(user, "ride_reviews").update(user=deleted_user)
|
||||
|
||||
# Photos
|
||||
if hasattr(user, "uploaded_park_photos"):
|
||||
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
||||
if hasattr(user, "uploaded_ride_photos"):
|
||||
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
||||
|
||||
# Top Lists
|
||||
if hasattr(user, "top_lists"):
|
||||
getattr(user, "top_lists").update(user=deleted_user)
|
||||
|
||||
# Moderation submissions
|
||||
if hasattr(user, "edit_submissions"):
|
||||
getattr(user, "edit_submissions").update(user=deleted_user)
|
||||
if hasattr(user, "photo_submissions"):
|
||||
getattr(user, "photo_submissions").update(user=deleted_user)
|
||||
|
||||
# Moderation actions - these can be set to NULL since they're not user content
|
||||
if hasattr(user, "moderated_park_reviews"):
|
||||
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "moderated_ride_reviews"):
|
||||
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "handled_submissions"):
|
||||
getattr(user, "handled_submissions").update(handled_by=None)
|
||||
if hasattr(user, "handled_photos"):
|
||||
getattr(user, "handled_photos").update(handled_by=None)
|
||||
|
||||
# Store user info for the summary
|
||||
user_info = {
|
||||
"username": user.username,
|
||||
"user_id": user.user_id,
|
||||
"email": user.email,
|
||||
"date_joined": user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user (this will cascade delete the profile)
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
"deleted_user": user_info,
|
||||
"preserved_submissions": submission_counts,
|
||||
"transferred_to": {
|
||||
"username": deleted_user.username,
|
||||
"user_id": deleted_user.user_id,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
tuple: (can_delete: bool, reason: Optional[str])
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
return False, "Cannot delete the system deleted user placeholder"
|
||||
|
||||
if user.is_superuser:
|
||||
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
|
||||
|
||||
# Check if user has critical admin role
|
||||
if user.role == User.Roles.ADMIN and user.is_staff:
|
||||
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
|
||||
|
||||
# Add any other business rules here
|
||||
|
||||
return True, None
|
||||
|
||||
@classmethod
|
||||
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
|
||||
"""
|
||||
Create a user deletion request and send verification email.
|
||||
|
||||
Args:
|
||||
user: The user requesting deletion
|
||||
|
||||
Returns:
|
||||
UserDeletionRequest: The created deletion request
|
||||
"""
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = cls.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise ValueError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Remove any existing deletion request for this user
|
||||
UserDeletionRequest.objects.filter(user=user).delete()
|
||||
|
||||
# Create new deletion request
|
||||
deletion_request = UserDeletionRequest.objects.create(user=user)
|
||||
|
||||
# Send verification email
|
||||
cls.send_deletion_verification_email(deletion_request)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@classmethod
|
||||
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
|
||||
"""
|
||||
Send verification email for account deletion.
|
||||
|
||||
Args:
|
||||
deletion_request: The deletion request to send email for
|
||||
"""
|
||||
user = deletion_request.user
|
||||
|
||||
# Get current site for email service
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
except Site.DoesNotExist:
|
||||
# Fallback to default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
|
||||
)[0]
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"verification_code": deletion_request.verification_code,
|
||||
"expires_at": deletion_request.expires_at,
|
||||
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
||||
"frontend_domain": getattr(
|
||||
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
|
||||
),
|
||||
}
|
||||
|
||||
# Render email content
|
||||
subject = f"Confirm Account Deletion - {context['site_name']}"
|
||||
|
||||
# Create email message with 1-hour expiration notice
|
||||
message = f"""
|
||||
Hello {user.get_display_name()},
|
||||
|
||||
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
|
||||
|
||||
Verification Code: {deletion_request.verification_code}
|
||||
|
||||
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
|
||||
|
||||
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
|
||||
|
||||
If you did not request this deletion, please ignore this email and your account will remain active.
|
||||
|
||||
To complete the deletion, enter the verification code in the account deletion form on our website.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team
|
||||
""".strip()
|
||||
|
||||
# Send email using custom email service
|
||||
try:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject=subject,
|
||||
text=message,
|
||||
site=site,
|
||||
from_email="no-reply@thrillwiki.com",
|
||||
)
|
||||
|
||||
# Update email sent timestamp
|
||||
deletion_request.email_sent_at = timezone.now()
|
||||
deletion_request.save(update_fields=["email_sent_at"])
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the request creation
|
||||
print(f"Failed to send deletion verification email to {user.email}: {e}")
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def verify_and_delete_user(cls, verification_code: str) -> dict:
|
||||
"""
|
||||
Verify deletion code and delete the user account.
|
||||
|
||||
Args:
|
||||
verification_code: The verification code from the email
|
||||
|
||||
Returns:
|
||||
dict: Summary of the deletion
|
||||
|
||||
Raises:
|
||||
ValueError: If verification fails
|
||||
"""
|
||||
try:
|
||||
deletion_request = UserDeletionRequest.objects.get(
|
||||
verification_code=verification_code
|
||||
)
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Check if request is still valid
|
||||
if not deletion_request.is_valid():
|
||||
if deletion_request.is_expired():
|
||||
raise ValueError("Verification code has expired")
|
||||
elif deletion_request.is_used:
|
||||
raise ValueError("Verification code has already been used")
|
||||
elif deletion_request.attempts >= deletion_request.max_attempts:
|
||||
raise ValueError("Too many verification attempts")
|
||||
else:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Increment attempts
|
||||
deletion_request.increment_attempts()
|
||||
|
||||
# Mark as used
|
||||
deletion_request.mark_as_used()
|
||||
|
||||
# Delete the user
|
||||
user = deletion_request.user
|
||||
result = cls.delete_user_preserve_submissions(user)
|
||||
|
||||
# Add deletion request info to result
|
||||
result["deletion_request"] = {
|
||||
"verification_code": verification_code,
|
||||
"created_at": deletion_request.created_at,
|
||||
"verified_at": timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def cancel_deletion_request(cls, user: User) -> bool:
|
||||
"""
|
||||
Cancel a pending deletion request.
|
||||
|
||||
Args:
|
||||
user: The user whose deletion request to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if a request was cancelled, False if no request existed
|
||||
"""
|
||||
try:
|
||||
deletion_request = getattr(user, "deletion_request", None)
|
||||
if deletion_request:
|
||||
deletion_request.delete()
|
||||
return True
|
||||
return False
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_deletion_requests(cls) -> int:
|
||||
"""
|
||||
Clean up expired deletion requests.
|
||||
|
||||
Returns:
|
||||
int: Number of expired requests cleaned up
|
||||
"""
|
||||
return UserDeletionRequest.cleanup_expired()
|
||||
11
backend/apps/accounts/services/__init__.py
Normal file
11
backend/apps/accounts/services/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Accounts Services Package
|
||||
|
||||
This package contains business logic services for account management,
|
||||
including social provider management, user authentication, and profile services.
|
||||
"""
|
||||
|
||||
from .social_provider_service import SocialProviderService
|
||||
from .user_deletion_service import UserDeletionService
|
||||
|
||||
__all__ = ['SocialProviderService', 'UserDeletionService']
|
||||
351
backend/apps/accounts/services/notification_service.py
Normal file
351
backend/apps/accounts/services/notification_service.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Notification service for creating and managing user notifications.
|
||||
|
||||
This service handles the creation, delivery, and management of notifications
|
||||
for various events including submission approvals/rejections.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from apps.accounts.models import User, UserNotification, NotificationPreference
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for creating and managing user notifications."""
|
||||
|
||||
@staticmethod
|
||||
def create_notification(
|
||||
user: User,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
related_object: Optional[Any] = None,
|
||||
priority: str = UserNotification.Priority.NORMAL,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a new notification for a user.
|
||||
|
||||
Args:
|
||||
user: The user to notify
|
||||
notification_type: Type of notification (from UserNotification.NotificationType)
|
||||
title: Notification title
|
||||
message: Notification message
|
||||
related_object: Optional related object (submission, review, etc.)
|
||||
priority: Notification priority
|
||||
extra_data: Additional data to store with notification
|
||||
expires_at: When the notification expires
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
# Get content type and object ID if related object provided
|
||||
content_type = None
|
||||
object_id = None
|
||||
if related_object:
|
||||
content_type = ContentType.objects.get_for_model(related_object)
|
||||
object_id = related_object.pk
|
||||
|
||||
# Create the notification
|
||||
notification = UserNotification.objects.create(
|
||||
user=user,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
priority=priority,
|
||||
extra_data=extra_data or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# Send notification through appropriate channels
|
||||
NotificationService._send_notification(notification)
|
||||
|
||||
return notification
|
||||
|
||||
@staticmethod
|
||||
def create_submission_approved_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission approval.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The approved submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} has been approved!"
|
||||
message = f"Great news! Your {submission_type} submission has been approved and is now live on ThrillWiki."
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"moderator_message": additional_message,
|
||||
"approved_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_APPROVED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.NORMAL,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_rejected_notification(
|
||||
user: User,
|
||||
submission_object: Any,
|
||||
submission_type: str,
|
||||
rejection_reason: str,
|
||||
additional_message: str = "",
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission rejection.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The rejected submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
rejection_reason: Reason for rejection
|
||||
additional_message: Additional message from moderator
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} needs attention"
|
||||
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
|
||||
message += f"\n\nReason: {rejection_reason}"
|
||||
|
||||
if additional_message:
|
||||
message += f"\n\nModerator note: {additional_message}"
|
||||
|
||||
message += "\n\nYou can edit and resubmit your content from your profile page."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"rejection_reason": rejection_reason,
|
||||
"moderator_message": additional_message,
|
||||
"rejected_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_REJECTED,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.HIGH,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_submission_pending_notification(
|
||||
user: User, submission_object: Any, submission_type: str
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for submission pending review.
|
||||
|
||||
Args:
|
||||
user: User who submitted the content
|
||||
submission_object: The pending submission object
|
||||
submission_type: Type of submission (e.g., "park photo", "ride review")
|
||||
|
||||
Returns:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} is under review"
|
||||
message = f"Thanks for your {submission_type} submission! It's now under review by our moderation team."
|
||||
message += "\n\nWe'll notify you once it's been reviewed. This usually takes 1-2 business days."
|
||||
|
||||
extra_data = {
|
||||
"submission_type": submission_type,
|
||||
"submitted_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
return NotificationService.create_notification(
|
||||
user=user,
|
||||
notification_type=UserNotification.NotificationType.SUBMISSION_PENDING,
|
||||
title=title,
|
||||
message=message,
|
||||
related_object=submission_object,
|
||||
priority=UserNotification.Priority.LOW,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send notification through appropriate channels based on user preferences.
|
||||
|
||||
Args:
|
||||
notification: The notification to send
|
||||
"""
|
||||
user = notification.user
|
||||
|
||||
# Get user's notification preferences
|
||||
try:
|
||||
preferences = user.notification_preference
|
||||
except NotificationPreference.DoesNotExist:
|
||||
# Create default preferences if they don't exist
|
||||
preferences = NotificationPreference.objects.create(user=user)
|
||||
|
||||
# Send email notification if enabled
|
||||
if preferences.should_send_notification(
|
||||
notification.notification_type, "email"
|
||||
):
|
||||
NotificationService._send_email_notification(notification)
|
||||
|
||||
# Toast notifications are always created (the notification object itself)
|
||||
# The frontend will display them as toast notifications based on preferences
|
||||
|
||||
@staticmethod
|
||||
def _send_email_notification(notification: UserNotification) -> None:
|
||||
"""
|
||||
Send email notification to user using the custom ForwardEmail service.
|
||||
|
||||
Args:
|
||||
notification: The notification to send via email
|
||||
"""
|
||||
try:
|
||||
user = notification.user
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"notification": notification,
|
||||
"site_name": "ThrillWiki",
|
||||
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
|
||||
}
|
||||
|
||||
# Render email templates
|
||||
subject = f"ThrillWiki: {notification.title}"
|
||||
html_message = render_to_string("emails/notification.html", context)
|
||||
plain_message = render_to_string("emails/notification.txt", context)
|
||||
|
||||
# Send email using custom ForwardEmail service
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject=subject,
|
||||
text=plain_message,
|
||||
html=html_message,
|
||||
)
|
||||
|
||||
# Mark as sent
|
||||
notification.email_sent = True
|
||||
notification.email_sent_at = timezone.now()
|
||||
notification.save(update_fields=["email_sent", "email_sent_at"])
|
||||
|
||||
logger.info(
|
||||
f"Email notification sent to {user.email} for notification {notification.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send email notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_notifications(
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
notification_types: Optional[List[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[UserNotification]:
|
||||
"""
|
||||
Get notifications for a user.
|
||||
|
||||
Args:
|
||||
user: User to get notifications for
|
||||
unread_only: Only return unread notifications
|
||||
notification_types: Filter by notification types
|
||||
limit: Limit number of results
|
||||
|
||||
Returns:
|
||||
List[UserNotification]: List of notifications
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user)
|
||||
|
||||
if unread_only:
|
||||
queryset = queryset.filter(is_read=False)
|
||||
|
||||
if notification_types:
|
||||
queryset = queryset.filter(notification_type__in=notification_types)
|
||||
|
||||
# Exclude expired notifications
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||||
)
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def mark_notifications_read(
|
||||
user: User, notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Mark notifications as read for a user.
|
||||
|
||||
Args:
|
||||
user: User whose notifications to mark as read
|
||||
notification_ids: Specific notification IDs to mark as read (if None, marks all)
|
||||
|
||||
Returns:
|
||||
int: Number of notifications marked as read
|
||||
"""
|
||||
queryset = UserNotification.objects.filter(user=user, is_read=False)
|
||||
|
||||
if notification_ids:
|
||||
queryset = queryset.filter(id__in=notification_ids)
|
||||
|
||||
return queryset.update(is_read=True, read_at=timezone.now())
|
||||
|
||||
@staticmethod
|
||||
def cleanup_old_notifications(days: int = 90) -> int:
|
||||
"""
|
||||
Clean up old read notifications.
|
||||
|
||||
Args:
|
||||
days: Number of days to keep read notifications
|
||||
|
||||
Returns:
|
||||
int: Number of notifications deleted
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
old_notifications = UserNotification.objects.filter(
|
||||
is_read=True, read_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
count = old_notifications.count()
|
||||
old_notifications.delete()
|
||||
|
||||
logger.info(f"Cleaned up {count} old notifications")
|
||||
return count
|
||||
257
backend/apps/accounts/services/social_provider_service.py
Normal file
257
backend/apps/accounts/services/social_provider_service.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Social Provider Management Service
|
||||
|
||||
This service handles the business logic for connecting and disconnecting
|
||||
social authentication providers while ensuring users never lock themselves
|
||||
out of their accounts.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, TYPE_CHECKING
|
||||
from django.contrib.auth import get_user_model
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from allauth.socialaccount.providers import registry
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.http import HttpRequest
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from apps.accounts.models import User
|
||||
else:
|
||||
User = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocialProviderService:
|
||||
"""Service for managing social provider connections."""
|
||||
|
||||
@staticmethod
|
||||
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if a user can safely disconnect a social provider.
|
||||
|
||||
Args:
|
||||
user: The user attempting to disconnect
|
||||
provider: The provider to disconnect (e.g., 'google', 'discord')
|
||||
|
||||
Returns:
|
||||
Tuple of (can_disconnect: bool, reason: str)
|
||||
"""
|
||||
try:
|
||||
# Count remaining social accounts after disconnection
|
||||
remaining_social_accounts = user.socialaccount_set.exclude(
|
||||
provider=provider
|
||||
).count()
|
||||
|
||||
# Check if user has email/password auth
|
||||
has_password_auth = (
|
||||
user.email and
|
||||
user.has_usable_password() and
|
||||
bool(user.password) # Not empty/unusable
|
||||
)
|
||||
|
||||
# Allow disconnection only if alternative auth exists
|
||||
can_disconnect = remaining_social_accounts > 0 or has_password_auth
|
||||
|
||||
if not can_disconnect:
|
||||
if remaining_social_accounts == 0 and not has_password_auth:
|
||||
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
|
||||
elif not has_password_auth:
|
||||
return False, "Please set up email/password authentication before disconnecting this provider."
|
||||
else:
|
||||
return False, "Cannot disconnect this provider at this time."
|
||||
|
||||
return True, "Provider can be safely disconnected."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
|
||||
return False, "Unable to verify disconnection safety. Please try again."
|
||||
|
||||
@staticmethod
|
||||
def get_connected_providers(user: "User") -> List[Dict]:
|
||||
"""
|
||||
Get all social providers connected to a user's account.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
List of connected provider information
|
||||
"""
|
||||
try:
|
||||
connected_providers = []
|
||||
|
||||
for social_account in user.socialaccount_set.all():
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||
user, social_account.provider
|
||||
)
|
||||
|
||||
provider_info = {
|
||||
'provider': social_account.provider,
|
||||
'provider_name': social_account.get_provider().name,
|
||||
'uid': social_account.uid,
|
||||
'date_joined': social_account.date_joined,
|
||||
'can_disconnect': can_disconnect,
|
||||
'disconnect_reason': reason if not can_disconnect else None,
|
||||
'extra_data': social_account.extra_data
|
||||
}
|
||||
|
||||
connected_providers.append(provider_info)
|
||||
|
||||
return connected_providers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting connected providers for user {user.id}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_available_providers(request: HttpRequest) -> List[Dict]:
|
||||
"""
|
||||
Get all available social providers for the current site.
|
||||
|
||||
Args:
|
||||
request: The HTTP request
|
||||
|
||||
Returns:
|
||||
List of available provider information
|
||||
"""
|
||||
try:
|
||||
site = get_current_site(request)
|
||||
available_providers = []
|
||||
|
||||
# Get all social apps configured for this site
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
provider = registry.by_id(social_app.provider)
|
||||
|
||||
provider_info = {
|
||||
'id': social_app.provider,
|
||||
'name': provider.name,
|
||||
'auth_url': request.build_absolute_uri(
|
||||
f'/accounts/{social_app.provider}/login/'
|
||||
),
|
||||
'connect_url': request.build_absolute_uri(
|
||||
f'/api/v1/auth/social/connect/{social_app.provider}/'
|
||||
)
|
||||
}
|
||||
|
||||
available_providers.append(provider_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error processing provider {social_app.provider}: {e}")
|
||||
continue
|
||||
|
||||
return available_providers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available providers: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Disconnect a social provider from a user's account.
|
||||
|
||||
Args:
|
||||
user: The user to disconnect from
|
||||
provider: The provider to disconnect
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# First check if disconnection is allowed
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||
user, provider)
|
||||
|
||||
if not can_disconnect:
|
||||
return False, reason
|
||||
|
||||
# Find and delete the social account
|
||||
social_accounts = user.socialaccount_set.filter(provider=provider)
|
||||
|
||||
if not social_accounts.exists():
|
||||
return False, f"No {provider} account found to disconnect."
|
||||
|
||||
# Delete all social accounts for this provider (in case of duplicates)
|
||||
deleted_count = social_accounts.count()
|
||||
social_accounts.delete()
|
||||
|
||||
logger.info(
|
||||
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
||||
|
||||
return True, f"{provider.title()} account disconnected successfully."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
|
||||
return False, f"Failed to disconnect {provider} account. Please try again."
|
||||
|
||||
@staticmethod
|
||||
def get_auth_status(user: "User") -> Dict:
|
||||
"""
|
||||
Get comprehensive authentication status for a user.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
Dictionary with authentication status information
|
||||
"""
|
||||
try:
|
||||
connected_providers = SocialProviderService.get_connected_providers(user)
|
||||
|
||||
has_password_auth = (
|
||||
user.email and
|
||||
user.has_usable_password() and
|
||||
bool(user.password)
|
||||
)
|
||||
|
||||
auth_methods_count = len(connected_providers) + \
|
||||
(1 if has_password_auth else 0)
|
||||
|
||||
return {
|
||||
'user_id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'has_password_auth': has_password_auth,
|
||||
'connected_providers': connected_providers,
|
||||
'total_auth_methods': auth_methods_count,
|
||||
'can_disconnect_any': auth_methods_count > 1,
|
||||
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting auth status for user {user.id}: {e}")
|
||||
return {
|
||||
'error': 'Unable to retrieve authentication status'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate that a social provider is configured and available.
|
||||
|
||||
Args:
|
||||
provider: The provider ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# Check if provider is registered with allauth
|
||||
if provider not in registry.provider_map:
|
||||
return False, f"Provider '{provider}' is not supported."
|
||||
|
||||
# Check if provider has a social app configured
|
||||
if not SocialApp.objects.filter(provider=provider).exists():
|
||||
return False, f"Provider '{provider}' is not configured on this site."
|
||||
|
||||
return True, f"Provider '{provider}' is valid and available."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating provider {provider}: {e}")
|
||||
return False, "Unable to validate provider."
|
||||
309
backend/apps/accounts/services/user_deletion_service.py
Normal file
309
backend/apps/accounts/services/user_deletion_service.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
User Deletion Service
|
||||
|
||||
This service handles user account deletion while preserving submissions
|
||||
and maintaining data integrity across the platform.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserDeletionRequest:
|
||||
"""Model for tracking user deletion requests."""
|
||||
|
||||
def __init__(self, user: User, verification_code: str, expires_at: datetime):
|
||||
self.user = user
|
||||
self.verification_code = verification_code
|
||||
self.expires_at = expires_at
|
||||
self.created_at = timezone.now()
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
"""Service for handling user account deletion with submission preservation."""
|
||||
|
||||
# In-memory storage for deletion requests (in production, use Redis or database)
|
||||
_deletion_requests = {}
|
||||
|
||||
@staticmethod
|
||||
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
Args:
|
||||
user: User to check for deletion eligibility
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
|
||||
"""
|
||||
# Prevent deletion of superusers
|
||||
if user.is_superuser:
|
||||
return False, "Cannot delete superuser accounts"
|
||||
|
||||
# Prevent deletion of staff/admin users
|
||||
if user.is_staff:
|
||||
return False, "Cannot delete staff accounts"
|
||||
|
||||
# Check for system users (if you have any special system accounts)
|
||||
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
|
||||
return False, "Cannot delete admin or moderator accounts"
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def request_user_deletion(user: User) -> UserDeletionRequest:
|
||||
"""
|
||||
Create a deletion request for a user and send verification email.
|
||||
|
||||
Args:
|
||||
user: User requesting deletion
|
||||
|
||||
Returns:
|
||||
UserDeletionRequest: The deletion request object
|
||||
|
||||
Raises:
|
||||
ValueError: If user cannot be deleted
|
||||
"""
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise ValueError(reason)
|
||||
|
||||
# Generate verification code
|
||||
verification_code = ''.join(secrets.choice(
|
||||
string.ascii_uppercase + string.digits) for _ in range(8))
|
||||
|
||||
# Set expiration (24 hours from now)
|
||||
expires_at = timezone.now() + timezone.timedelta(hours=24)
|
||||
|
||||
# Create deletion request
|
||||
deletion_request = UserDeletionRequest(user, verification_code, expires_at)
|
||||
|
||||
# Store request (in production, use Redis or database)
|
||||
UserDeletionService._deletion_requests[verification_code] = deletion_request
|
||||
|
||||
# Send verification email
|
||||
UserDeletionService._send_deletion_verification_email(
|
||||
user, verification_code, expires_at)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@staticmethod
|
||||
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify deletion code and delete user account.
|
||||
|
||||
Args:
|
||||
verification_code: Verification code from email
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Deletion result information
|
||||
|
||||
Raises:
|
||||
ValueError: If verification code is invalid or expired
|
||||
"""
|
||||
# Find deletion request
|
||||
deletion_request = UserDeletionService._deletion_requests.get(verification_code)
|
||||
if not deletion_request:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Check if expired
|
||||
if timezone.now() > deletion_request.expires_at:
|
||||
# Clean up expired request
|
||||
del UserDeletionService._deletion_requests[verification_code]
|
||||
raise ValueError("Verification code has expired")
|
||||
|
||||
user = deletion_request.user
|
||||
|
||||
# Perform deletion
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
# Clean up deletion request
|
||||
del UserDeletionService._deletion_requests[verification_code]
|
||||
|
||||
# Add verification info to result
|
||||
result['deletion_request'] = {
|
||||
'verification_code': verification_code,
|
||||
'created_at': deletion_request.created_at,
|
||||
'verified_at': timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def cancel_deletion_request(user: User) -> bool:
|
||||
"""
|
||||
Cancel a pending deletion request for a user.
|
||||
|
||||
Args:
|
||||
user: User whose deletion request to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if request was found and cancelled, False if no request found
|
||||
"""
|
||||
# Find and remove any deletion requests for this user
|
||||
to_remove = []
|
||||
for code, request in UserDeletionService._deletion_requests.items():
|
||||
if request.user.id == user.id:
|
||||
to_remove.append(code)
|
||||
|
||||
for code in to_remove:
|
||||
del UserDeletionService._deletion_requests[code]
|
||||
|
||||
return len(to_remove) > 0
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a user account while preserving all their submissions.
|
||||
|
||||
Args:
|
||||
user: User to delete
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Information about the deletion and preserved submissions
|
||||
"""
|
||||
# Get or create the "deleted_user" placeholder
|
||||
deleted_user_placeholder, created = User.objects.get_or_create(
|
||||
username='deleted_user',
|
||||
defaults={
|
||||
'email': 'deleted@thrillwiki.com',
|
||||
'first_name': 'Deleted',
|
||||
'last_name': 'User',
|
||||
'is_active': False,
|
||||
}
|
||||
)
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = UserDeletionService._count_user_submissions(user)
|
||||
|
||||
# Transfer submissions to placeholder user
|
||||
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder)
|
||||
|
||||
# Store user info before deletion
|
||||
deleted_user_info = {
|
||||
'username': user.username,
|
||||
'user_id': getattr(user, 'user_id', user.id),
|
||||
'email': user.email,
|
||||
'date_joined': user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user account
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
'deleted_user': deleted_user_info,
|
||||
'preserved_submissions': submission_counts,
|
||||
'transferred_to': {
|
||||
'username': deleted_user_placeholder.username,
|
||||
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _count_user_submissions(user: User) -> Dict[str, int]:
|
||||
"""Count all submissions for a user."""
|
||||
counts = {}
|
||||
|
||||
# Count different types of submissions
|
||||
# Note: These are placeholder counts - adjust based on your actual models
|
||||
counts['park_reviews'] = getattr(
|
||||
user, 'park_reviews', user.__class__.objects.none()).count()
|
||||
counts['ride_reviews'] = getattr(
|
||||
user, 'ride_reviews', user.__class__.objects.none()).count()
|
||||
counts['uploaded_park_photos'] = getattr(
|
||||
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
|
||||
counts['uploaded_ride_photos'] = getattr(
|
||||
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
|
||||
counts['top_lists'] = getattr(
|
||||
user, 'top_lists', user.__class__.objects.none()).count()
|
||||
counts['edit_submissions'] = getattr(
|
||||
user, 'edit_submissions', user.__class__.objects.none()).count()
|
||||
counts['photo_submissions'] = getattr(
|
||||
user, 'photo_submissions', user.__class__.objects.none()).count()
|
||||
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _transfer_user_submissions(user: User, placeholder_user: User) -> None:
|
||||
"""Transfer all user submissions to placeholder user."""
|
||||
|
||||
# Transfer different types of submissions
|
||||
# Note: Adjust these based on your actual model relationships
|
||||
|
||||
# Park reviews
|
||||
if hasattr(user, 'park_reviews'):
|
||||
user.park_reviews.all().update(user=placeholder_user)
|
||||
|
||||
# Ride reviews
|
||||
if hasattr(user, 'ride_reviews'):
|
||||
user.ride_reviews.all().update(user=placeholder_user)
|
||||
|
||||
# Uploaded photos
|
||||
if hasattr(user, 'uploaded_park_photos'):
|
||||
user.uploaded_park_photos.all().update(user=placeholder_user)
|
||||
|
||||
if hasattr(user, 'uploaded_ride_photos'):
|
||||
user.uploaded_ride_photos.all().update(user=placeholder_user)
|
||||
|
||||
# Top lists
|
||||
if hasattr(user, 'top_lists'):
|
||||
user.top_lists.all().update(user=placeholder_user)
|
||||
|
||||
# Edit submissions
|
||||
if hasattr(user, 'edit_submissions'):
|
||||
user.edit_submissions.all().update(user=placeholder_user)
|
||||
|
||||
# Photo submissions
|
||||
if hasattr(user, 'photo_submissions'):
|
||||
user.photo_submissions.all().update(user=placeholder_user)
|
||||
|
||||
@staticmethod
|
||||
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
|
||||
"""Send verification email for account deletion."""
|
||||
try:
|
||||
context = {
|
||||
'user': user,
|
||||
'verification_code': verification_code,
|
||||
'expires_at': expires_at,
|
||||
'site_name': 'ThrillWiki',
|
||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||
}
|
||||
|
||||
subject = 'ThrillWiki: Confirm Account Deletion'
|
||||
html_message = render_to_string(
|
||||
'emails/account_deletion_verification.html', context)
|
||||
plain_message = render_to_string(
|
||||
'emails/account_deletion_verification.txt', context)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
html_message=html_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Deletion verification email sent to {user.email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to send deletion verification email to {user.email}: {str(e)}")
|
||||
raise
|
||||
@@ -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):
|
||||
@@ -21,13 +22,13 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
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:
|
||||
@@ -38,26 +39,32 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
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)
|
||||
}"
|
||||
)
|
||||
except Exception as 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"""
|
||||
@@ -83,22 +90,36 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
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,31 +128,45 @@ 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
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user