mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 10:07:03 -05:00
Compare commits
18 Commits
clean-hist
...
133dcabb58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
133dcabb58 | ||
|
|
b627aed65d | ||
|
|
e4e36c7899 | ||
|
|
831be6a2ee | ||
|
|
bf7e0c0f40 | ||
|
|
dcf890a55c | ||
|
|
937eee19e4 | ||
|
|
e62646bcf9 | ||
|
|
92f4104d7a | ||
|
|
02c7cbd1cd | ||
|
|
d504d41de2 | ||
|
|
b0e0678590 | ||
|
|
652ea149bd | ||
|
|
66ed4347a9 | ||
|
|
69c07d1381 | ||
|
|
bead0654df | ||
|
|
37a20f83ba | ||
|
|
2304085c32 |
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.
|
||||||
55
.clinerules
55
.clinerules
@@ -1,55 +0,0 @@
|
|||||||
# Project Startup Rules
|
|
||||||
|
|
||||||
## Development Server
|
|
||||||
IMPORTANT: Always follow these instructions exactly when starting the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
|
|
||||||
|
|
||||||
## Package Management
|
|
||||||
IMPORTANT: When a Python package is needed, only use UV to add it:
|
|
||||||
```bash
|
|
||||||
uv add <package>
|
|
||||||
```
|
|
||||||
Do not attempt to install packages using any other method.
|
|
||||||
|
|
||||||
## Django Management Commands
|
|
||||||
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
|
|
||||||
```bash
|
|
||||||
uv run manage.py <command>
|
|
||||||
```
|
|
||||||
This applies to all management commands including but not limited to:
|
|
||||||
- Making migrations: `uv run manage.py makemigrations`
|
|
||||||
- Applying migrations: `uv run manage.py migrate`
|
|
||||||
- Creating superuser: `uv run manage.py createsuperuser`
|
|
||||||
- Starting shell: `uv run manage.py shell`
|
|
||||||
|
|
||||||
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
|
|
||||||
|
|
||||||
## Entity Relationship Rules
|
|
||||||
IMPORTANT: Follow these entity relationship patterns consistently:
|
|
||||||
|
|
||||||
# Park Relationships
|
|
||||||
- Parks MUST have an Operator (required relationship)
|
|
||||||
- Parks MAY have a PropertyOwner (optional, usually same as Operator)
|
|
||||||
- Parks CANNOT directly reference Company entities
|
|
||||||
|
|
||||||
# Ride Relationships
|
|
||||||
- Rides MUST belong to a Park (required relationship)
|
|
||||||
- Rides MAY have a Manufacturer (optional relationship)
|
|
||||||
- Rides MAY have a Designer (optional relationship)
|
|
||||||
- Rides CANNOT directly reference Company entities
|
|
||||||
|
|
||||||
# Entity Definitions
|
|
||||||
- Operators: Companies that operate theme parks (replaces Company.owner)
|
|
||||||
- PropertyOwners: Companies that own park property (new concept, optional)
|
|
||||||
- Manufacturers: Companies that manufacture rides (replaces Company for rides)
|
|
||||||
- Designers: Companies/individuals that design rides (existing concept)
|
|
||||||
|
|
||||||
# Relationship Constraints
|
|
||||||
- Operator and PropertyOwner are usually the same entity but CAN be different
|
|
||||||
- Manufacturers and Designers are distinct concepts and should not be conflated
|
|
||||||
- All entity relationships should use proper foreign keys with appropriate null/blank settings
|
|
||||||
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
|
||||||
442
.gitignore
vendored
442
.gitignore
vendored
@@ -1,198 +1,8 @@
|
|||||||
/.vscode
|
# Python
|
||||||
/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
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
@@ -212,186 +22,98 @@ share/python-wheels/
|
|||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
|
||||||
# PyInstaller
|
# Django
|
||||||
# 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:
|
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
|
/backend/staticfiles/
|
||||||
|
/backend/media/
|
||||||
|
|
||||||
# Flask stuff:
|
# UV
|
||||||
instance/
|
.uv/
|
||||||
.webassets-cache
|
backend/.uv/
|
||||||
|
|
||||||
# Scrapy stuff:
|
# Node.js
|
||||||
.scrapy
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# Sphinx documentation
|
# Vue.js / Vite
|
||||||
docs/_build/
|
/frontend/dist/
|
||||||
|
/frontend/dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
# PyBuilder
|
# Environment variables
|
||||||
.pybuilder/
|
.env
|
||||||
target/
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
backend/.env
|
||||||
|
frontend/.env
|
||||||
|
|
||||||
# Jupyter Notebook
|
# IDEs
|
||||||
.ipynb_checkpoints
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
# IPython
|
# OS
|
||||||
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/
|
|
||||||
|
|
||||||
# Pixi package manager
|
|
||||||
.pixi/
|
|
||||||
|
|
||||||
# Django Tailwind CLI
|
|
||||||
.django_tailwind_cli/
|
|
||||||
|
|
||||||
# General
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.AppleDouble
|
Thumbs.db
|
||||||
.LSOverride
|
Desktop.ini
|
||||||
|
|
||||||
# Icon must end with two \r
|
# Logs
|
||||||
Icon
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
._*
|
|
||||||
|
|
||||||
# Files that might appear in the root of a volume
|
|
||||||
.DocumentRevisions-V100
|
|
||||||
.fseventsd
|
|
||||||
.Spotlight-V100
|
|
||||||
.TemporaryItems
|
|
||||||
.Trashes
|
|
||||||
.VolumeIcon.icns
|
|
||||||
.com.apple.timemachine.donotpresent
|
|
||||||
|
|
||||||
# Directories potentially created on remote AFP share
|
|
||||||
.AppleDB
|
|
||||||
.AppleDesktop
|
|
||||||
Network Trash Folder
|
|
||||||
Temporary Items
|
|
||||||
.apdisk
|
|
||||||
|
|
||||||
|
|
||||||
# ThrillWiki CI/CD Configuration
|
|
||||||
.thrillwiki-config
|
|
||||||
***REMOVED***.unraid
|
|
||||||
***REMOVED***.webhook
|
|
||||||
.github-token
|
|
||||||
logs/
|
logs/
|
||||||
profiles
|
*.log
|
||||||
.thrillwiki-github-token
|
|
||||||
.thrillwiki-template-config
|
|
||||||
|
|
||||||
# Environment files with potential secrets
|
# Coverage
|
||||||
scripts/systemd/thrillwiki-automation***REMOVED***
|
coverage/
|
||||||
scripts/systemd/thrillwiki-deployment***REMOVED***
|
*.lcov
|
||||||
scripts/systemd/****REMOVED***
|
.nyc_output
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# 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
|
||||||
116
.roo/rules/api_architecture_enforcement
Normal file
116
.roo/rules/api_architecture_enforcement
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# API Architecture Enforcement Rules
|
||||||
|
|
||||||
|
## CRITICAL: Centralized API Structure
|
||||||
|
All API endpoints MUST be centralized under the `backend/api/v1/` structure. This is NON-NEGOTIABLE.
|
||||||
|
|
||||||
|
### Mandatory API Directory Structure
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── api/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── urls.py # Main API router
|
||||||
|
│ └── v1/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── urls.py # V1 API routes
|
||||||
|
│ ├── rides/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── urls.py # Ride-specific routes
|
||||||
|
│ │ ├── views.py # Ride API views
|
||||||
|
│ │ └── serializers.py
|
||||||
|
│ ├── parks/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── urls.py
|
||||||
|
│ │ ├── views.py
|
||||||
|
│ │ └── serializers.py
|
||||||
|
│ └── auth/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── urls.py
|
||||||
|
│ ├── views.py
|
||||||
|
│ └── serializers.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### FORBIDDEN: App-Level API Endpoints
|
||||||
|
**ABSOLUTELY PROHIBITED:**
|
||||||
|
- `backend/apps/{app_name}/api_urls.py`
|
||||||
|
- `backend/apps/{app_name}/api_views.py`
|
||||||
|
- Any API endpoints defined within individual app directories
|
||||||
|
- Direct URL routing from apps that bypass the central API structure
|
||||||
|
|
||||||
|
### Required URL Pattern
|
||||||
|
- **Frontend requests:** `/api/{endpoint}`
|
||||||
|
- **Vite proxy rewrites to:** `/api/v1/{endpoint}`
|
||||||
|
- **Django serves from:** `backend/apps/api/v1/{endpoint}`
|
||||||
|
|
||||||
|
### Migration Requirements
|
||||||
|
When consolidating rogue API endpoints:
|
||||||
|
|
||||||
|
1. **BEFORE REMOVAL:** Ensure ALL functionality exists in `backend/apps/api/v1/`
|
||||||
|
2. **Move views:** Transfer all API views to appropriate `backend/apps/api/v1/{domain}/views.py`
|
||||||
|
3. **Move serializers:** Transfer to `backend/apps/api/v1/{domain}/serializers.py`
|
||||||
|
4. **Update URLs:** Consolidate routes in `backend/apps/api/v1/{domain}/urls.py`
|
||||||
|
5. **Test thoroughly:** Verify all endpoints work via central API
|
||||||
|
6. **Only then remove:** Delete the rogue `api_urls.py` and `api_views.py` files
|
||||||
|
|
||||||
|
### Enforcement Actions
|
||||||
|
If rogue API files are discovered:
|
||||||
|
|
||||||
|
1. **STOP all other work**
|
||||||
|
2. **Create the proper API structure first**
|
||||||
|
3. **Migrate ALL functionality**
|
||||||
|
4. **Test extensively**
|
||||||
|
5. **Remove rogue files only after verification**
|
||||||
|
|
||||||
|
### URL Routing Rules
|
||||||
|
- **Main API router:** `backend/apps/api/urls.py` includes `apps/api/v1/urls.py`
|
||||||
|
- **Version router:** `backend/apps/api/v1/urls.py` includes domain-specific routes
|
||||||
|
- **Domain routers:** `backend/api/v1/{domain}/urls.py` defines actual endpoints
|
||||||
|
- **No direct app routing:** Apps CANNOT define their own API URLs
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
- **API client:** `frontend/src/services/api.ts` uses `/api/` prefix
|
||||||
|
- **Vite proxy:** Automatically rewrites `/api/` to `/api/v1/`
|
||||||
|
- **URL consistency:** All frontend API calls follow this pattern
|
||||||
|
|
||||||
|
### Quality Assurance
|
||||||
|
- **No API endpoints** may exist outside `backend/apps/api/v1/`
|
||||||
|
- **All API responses** must use proper DRF serializers
|
||||||
|
- **Consistent error handling** across all endpoints
|
||||||
|
- **Proper authentication** and permissions on all routes
|
||||||
|
|
||||||
|
### Examples of Proper Structure
|
||||||
|
```python
|
||||||
|
# backend/apps/api/urls.py
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('v1/', include('api.v1.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
# backend/apps/api/v1/urls.py
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('rides/', include('api.v1.rides.urls')),
|
||||||
|
path('parks/', include('api.v1.parks.urls')),
|
||||||
|
path('auth/', include('api.v1.auth.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
# backend/apps/api/v1/rides/urls.py
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.RideListAPIView.as_view(), name='ride_list'),
|
||||||
|
path('filter-options/', views.FilterOptionsAPIView.as_view(), name='filter_options'),
|
||||||
|
path('search/companies/', views.CompanySearchAPIView.as_view(), name='search_companies'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### CRITICAL FAILURE MODES TO PREVENT
|
||||||
|
- **Split API responsibility** between apps and central API
|
||||||
|
- **Inconsistent URL patterns** breaking frontend routing
|
||||||
|
- **Vite proxy bypass** causing 404 errors
|
||||||
|
- **Missing functionality** during migration
|
||||||
|
- **Breaking changes** without proper testing
|
||||||
|
|
||||||
|
This rule ensures clean, maintainable, and predictable API architecture that supports the frontend proxy system and prevents the exact issues we discovered in the rides filtering system.
|
||||||
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."
|
||||||
599
README.md
599
README.md
@@ -1,391 +1,344 @@
|
|||||||
# ThrillWiki Development Environment Setup
|
# ThrillWiki Django + Vue.js Monorepo
|
||||||
|
|
||||||
ThrillWiki is a modern Django web application for theme park and roller coaster enthusiasts, featuring a sophisticated dark theme design with purple-to-blue gradients, HTMX interactivity, and comprehensive park/ride information management.
|
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
|
||||||
|
|
||||||
## 🏗️ Technology Stack
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
- **Backend**: Django 5.0+ with GeoDjango (PostGIS)
|
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
|
||||||
- **Frontend**: HTMX + Alpine.js + Tailwind CSS
|
|
||||||
- **Database**: PostgreSQL with PostGIS extension
|
|
||||||
- **Package Management**: UV (Python package manager)
|
|
||||||
- **Authentication**: Django Allauth with Google/Discord OAuth
|
|
||||||
- **Styling**: Tailwind CSS with custom dark theme
|
|
||||||
- **History Tracking**: django-pghistory for audit trails
|
|
||||||
- **Testing**: Pytest + Playwright for E2E testing
|
|
||||||
|
|
||||||
## 📋 Prerequisites
|
```
|
||||||
|
thrillwiki-monorepo/
|
||||||
### Required Software
|
├── backend/ # Django REST API (Port 8000)
|
||||||
|
│ ├── apps/ # Modular Django applications
|
||||||
1. **Python 3.11+**
|
│ ├── config/ # Django settings and configuration
|
||||||
```bash
|
│ ├── templates/ # Django templates
|
||||||
python --version # Should be 3.11 or higher
|
│ └── static/ # Static assets
|
||||||
```
|
├── frontend/ # Vue.js SPA (Port 5174)
|
||||||
|
│ ├── src/ # Vue.js source code
|
||||||
2. **UV Package Manager**
|
│ ├── public/ # Static assets
|
||||||
```bash
|
│ └── dist/ # Build output
|
||||||
# Install UV if not already installed
|
├── shared/ # Shared resources and documentation
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
│ ├── docs/ # Comprehensive documentation
|
||||||
# or
|
│ ├── scripts/ # Development and deployment scripts
|
||||||
pip install uv
|
│ ├── config/ # Shared configuration
|
||||||
```
|
│ └── media/ # Shared media files
|
||||||
|
├── architecture/ # Architecture documentation
|
||||||
3. **PostgreSQL with PostGIS**
|
└── profiles/ # Development profiles
|
||||||
```bash
|
```
|
||||||
# macOS (Homebrew)
|
|
||||||
brew install postgresql postgis
|
|
||||||
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt-get install postgresql postgresql-contrib postgis
|
|
||||||
|
|
||||||
# Start PostgreSQL service
|
|
||||||
brew services start postgresql # macOS
|
|
||||||
sudo systemctl start postgresql # Linux
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **GDAL/GEOS Libraries** (for GeoDjango)
|
|
||||||
```bash
|
|
||||||
# macOS (Homebrew)
|
|
||||||
brew install gdal geos
|
|
||||||
|
|
||||||
# Ubuntu/Debian
|
|
||||||
sudo apt-get install gdal-bin libgdal-dev libgeos-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Node.js** (for Tailwind CSS)
|
|
||||||
```bash
|
|
||||||
# Install Node.js 18+ for Tailwind CSS compilation
|
|
||||||
node --version # Should be 18 or higher
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### 1. Clone and Setup Project
|
### Prerequisites
|
||||||
|
|
||||||
```bash
|
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
|
||||||
# Clone the repository
|
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
|
||||||
git clone <repository-url>
|
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
|
||||||
cd thrillwiki_django_no_react
|
- **Redis 6+** (optional, for caching and sessions)
|
||||||
|
|
||||||
# Install Python dependencies using UV
|
### Development Setup
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Database Setup
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd thrillwiki-monorepo
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
2. **Install dependencies**
|
||||||
# Create PostgreSQL database and user
|
```bash
|
||||||
createdb thrillwiki
|
# Install frontend dependencies
|
||||||
createuser wiki
|
pnpm install
|
||||||
|
|
||||||
# Connect to PostgreSQL and setup
|
# Install backend dependencies
|
||||||
psql postgres
|
cd backend && uv sync && cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
In the PostgreSQL shell:
|
3. **Environment configuration**
|
||||||
```sql
|
```bash
|
||||||
-- Set password for wiki user
|
# Copy environment files
|
||||||
ALTER USER wiki WITH PASSWORD 'thrillwiki';
|
cp .env.example .env
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
cp frontend/.env.development frontend/.env.local
|
||||||
|
|
||||||
-- Grant privileges
|
# Edit .env files with your settings
|
||||||
GRANT ALL PRIVILEGES ON DATABASE thrillwiki TO wiki;
|
```
|
||||||
|
|
||||||
-- Enable PostGIS extension
|
4. **Database setup**
|
||||||
\c thrillwiki
|
```bash
|
||||||
CREATE EXTENSION postgis;
|
cd backend
|
||||||
\q
|
uv run manage.py migrate
|
||||||
```
|
uv run manage.py createsuperuser
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
### 3. Environment Configuration
|
5. **Start development servers**
|
||||||
|
```bash
|
||||||
|
# Start both servers concurrently
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
The project uses these database settings (configured in [`thrillwiki/settings.py`](thrillwiki/settings.py)):
|
# Or start individually
|
||||||
```python
|
pnpm run dev:frontend # Vue.js on :5174
|
||||||
DATABASES = {
|
pnpm run dev:backend # Django on :8000
|
||||||
"default": {
|
```
|
||||||
"ENGINE": "django.contrib.gis.db.backends.postgis",
|
|
||||||
"NAME": "thrillwiki",
|
|
||||||
"USER": "wiki",
|
|
||||||
"PASSWORD": "thrillwiki",
|
|
||||||
"HOST": "192.168.86.3", # Update to your PostgreSQL host
|
|
||||||
"PORT": "5432",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: Update the `HOST` setting in [`thrillwiki/settings.py`](thrillwiki/settings.py) to match your PostgreSQL server location:
|
## 📁 Project Structure Details
|
||||||
- Use `"localhost"` or `"127.0.0.1"` for local development
|
|
||||||
- Current setting is `"192.168.86.3"` - update this to your PostgreSQL server IP
|
|
||||||
- For local development, change to `"localhost"` in settings.py
|
|
||||||
|
|
||||||
### 4. Database Migration
|
### 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
|
||||||
|
|
||||||
```bash
|
### Frontend (`/frontend`)
|
||||||
# Run database migrations
|
- **Vue 3** with Composition API and `<script setup>` syntax
|
||||||
uv run manage.py migrate
|
- **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
|
||||||
|
|
||||||
# Create a superuser account
|
### Shared Resources (`/shared`)
|
||||||
uv run manage.py createsuperuser
|
- **Documentation** - Comprehensive guides and API documentation
|
||||||
```
|
- **Development scripts** - Automated setup, build, and deployment scripts
|
||||||
|
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
|
||||||
**Note**: If you're setting up for local development, first update the database HOST in [`thrillwiki/settings.py`](thrillwiki/settings.py) from `"192.168.86.3"` to `"localhost"` before running migrations.
|
- **Media management** - Centralized media file handling and optimization
|
||||||
|
|
||||||
### 5. Start Development Server
|
|
||||||
|
|
||||||
**CRITICAL**: Always use this exact command sequence for starting the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
This command:
|
|
||||||
- Kills any existing processes on port 8000
|
|
||||||
- Cleans Python cache files
|
|
||||||
- Starts Tailwind CSS compilation
|
|
||||||
- Runs the Django development server
|
|
||||||
|
|
||||||
The application will be available at: http://localhost:8000
|
|
||||||
|
|
||||||
## 🛠️ Development Workflow
|
## 🛠️ Development Workflow
|
||||||
|
|
||||||
### Package Management
|
### Available Scripts
|
||||||
|
|
||||||
**ALWAYS use UV for package management**:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add new Python packages
|
# Development
|
||||||
uv add <package-name>
|
pnpm run dev # Start both servers concurrently
|
||||||
|
pnpm run dev:frontend # Frontend only (:5174)
|
||||||
|
pnpm run dev:backend # Backend only (:8000)
|
||||||
|
|
||||||
# Add development dependencies
|
# Building
|
||||||
uv add --dev <package-name>
|
pnpm run build # Build frontend for production
|
||||||
|
pnpm run build:staging # Build for staging environment
|
||||||
|
pnpm run build:production # Build for production environment
|
||||||
|
|
||||||
# Never use pip install - always use UV
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Django Management Commands
|
### Backend Development
|
||||||
|
|
||||||
**ALWAYS use UV for Django commands**:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Correct way to run Django commands
|
cd backend
|
||||||
uv run manage.py <command>
|
|
||||||
|
|
||||||
# Examples:
|
# Django management commands
|
||||||
uv run manage.py makemigrations
|
|
||||||
uv run manage.py migrate
|
uv run manage.py migrate
|
||||||
uv run manage.py shell
|
uv run manage.py makemigrations
|
||||||
uv run manage.py createsuperuser
|
uv run manage.py createsuperuser
|
||||||
uv run manage.py collectstatic
|
uv run manage.py collectstatic
|
||||||
|
|
||||||
# NEVER use these patterns:
|
# Testing and quality
|
||||||
# python manage.py <command> ❌ Wrong
|
uv run manage.py test
|
||||||
# uv run python manage.py <command> ❌ Wrong
|
uv run black . # Format code
|
||||||
|
uv run flake8 . # Lint code
|
||||||
|
uv run isort . # Sort imports
|
||||||
```
|
```
|
||||||
|
|
||||||
### CSS Development
|
### Frontend Development
|
||||||
|
|
||||||
The project uses **Tailwind CSS v4** with a custom dark theme. CSS files are located in:
|
|
||||||
- Source: [`static/css/src/input.css`](static/css/src/input.css)
|
|
||||||
- Compiled: [`static/css/`](static/css/) (auto-generated)
|
|
||||||
|
|
||||||
Tailwind automatically compiles when using the `tailwind runserver` command.
|
|
||||||
|
|
||||||
#### Tailwind CSS v4 Migration
|
|
||||||
|
|
||||||
This project has been migrated from Tailwind CSS v3 to v4. For complete migration details:
|
|
||||||
|
|
||||||
- **📖 Full Migration Documentation**: [`TAILWIND_V4_MIGRATION.md`](TAILWIND_V4_MIGRATION.md)
|
|
||||||
- **⚡ Quick Reference Guide**: [`TAILWIND_V4_QUICK_REFERENCE.md`](TAILWIND_V4_QUICK_REFERENCE.md)
|
|
||||||
|
|
||||||
**Key v4 Changes**:
|
|
||||||
- New CSS-first approach with `@theme` blocks
|
|
||||||
- Updated utility class names (e.g., `outline-none` → `outline-hidden`)
|
|
||||||
- New opacity syntax (e.g., `bg-blue-500/50` instead of `bg-blue-500 bg-opacity-50`)
|
|
||||||
- Enhanced performance and smaller bundle sizes
|
|
||||||
|
|
||||||
**Custom Theme Variables** (available in CSS):
|
|
||||||
```css
|
|
||||||
var(--color-primary) /* #4f46e5 - Indigo-600 */
|
|
||||||
var(--color-secondary) /* #e11d48 - Rose-600 */
|
|
||||||
var(--color-accent) /* #8b5cf6 - Violet-500 */
|
|
||||||
var(--font-family-sans) /* Poppins, sans-serif */
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
thrillwiki_django_no_react/
|
|
||||||
├── accounts/ # User account management
|
|
||||||
├── analytics/ # Analytics and tracking
|
|
||||||
├── companies/ # Theme park companies
|
|
||||||
├── core/ # Core application logic
|
|
||||||
├── designers/ # Ride designers
|
|
||||||
├── history/ # History timeline features
|
|
||||||
├── location/ # Geographic location handling
|
|
||||||
├── media/ # Media file management
|
|
||||||
├── moderation/ # Content moderation
|
|
||||||
├── parks/ # Theme park management
|
|
||||||
├── reviews/ # User reviews
|
|
||||||
├── rides/ # Roller coaster/ride management
|
|
||||||
├── search/ # Search functionality
|
|
||||||
├── static/ # Static assets (CSS, JS, images)
|
|
||||||
├── templates/ # Django templates
|
|
||||||
├── thrillwiki/ # Main Django project settings
|
|
||||||
├── memory-bank/ # Development documentation
|
|
||||||
└── .clinerules # Project development rules
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Key Features
|
|
||||||
|
|
||||||
### Authentication System
|
|
||||||
- Django Allauth integration
|
|
||||||
- Google OAuth authentication
|
|
||||||
- Discord OAuth authentication
|
|
||||||
- Custom user profiles with avatars
|
|
||||||
|
|
||||||
### Geographic Features
|
|
||||||
- PostGIS integration for location data
|
|
||||||
- Interactive park maps
|
|
||||||
- Location-based search and filtering
|
|
||||||
|
|
||||||
### Content Management
|
|
||||||
- Park and ride information management
|
|
||||||
- Photo galleries with upload capabilities
|
|
||||||
- User-generated reviews and ratings
|
|
||||||
- Content moderation system
|
|
||||||
|
|
||||||
### Modern Frontend
|
|
||||||
- HTMX for dynamic interactions
|
|
||||||
- Alpine.js for client-side behavior
|
|
||||||
- Tailwind CSS with custom dark theme
|
|
||||||
- Responsive design (mobile-first)
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run Python tests
|
cd frontend
|
||||||
uv run pytest
|
|
||||||
|
|
||||||
# Run with coverage
|
# Vue.js development
|
||||||
uv run coverage run -m pytest
|
pnpm run dev # Start dev server
|
||||||
uv run coverage report
|
pnpm run build # Production build
|
||||||
|
pnpm run preview # Preview production build
|
||||||
# Run E2E tests with Playwright
|
pnpm run test:unit # Vitest unit tests
|
||||||
uv run pytest tests/e2e/
|
pnpm run test:e2e # Playwright E2E tests
|
||||||
|
pnpm run lint # ESLint
|
||||||
|
pnpm run type-check # TypeScript checking
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Structure
|
## 🔧 Configuration
|
||||||
- Unit tests: Located within each app's `tests/` directory
|
|
||||||
- E2E tests: [`tests/e2e/`](tests/e2e/)
|
|
||||||
- Test fixtures: [`tests/fixtures/`](tests/fixtures/)
|
|
||||||
|
|
||||||
## 📚 Documentation
|
### Environment Variables
|
||||||
|
|
||||||
### Memory Bank System
|
#### Root `.env`
|
||||||
The project uses a comprehensive documentation system in [`memory-bank/`](memory-bank/):
|
```bash
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
- [`memory-bank/activeContext.md`](memory-bank/activeContext.md) - Current development context
|
# Security
|
||||||
- [`memory-bank/documentation/design-system.md`](memory-bank/documentation/design-system.md) - Design system documentation
|
SECRET_KEY=your-secret-key
|
||||||
- [`memory-bank/features/`](memory-bank/features/) - Feature-specific documentation
|
DEBUG=True
|
||||||
- [`memory-bank/testing/`](memory-bank/testing/) - Testing documentation and results
|
|
||||||
|
|
||||||
### Key Documentation Files
|
# API Configuration
|
||||||
- [Design System](memory-bank/documentation/design-system.md) - UI/UX guidelines and patterns
|
API_BASE_URL=http://localhost:8000/api
|
||||||
- [Authentication System](memory-bank/features/auth/) - OAuth and user management
|
```
|
||||||
- [Layout Optimization](memory-bank/projects/) - Responsive design implementations
|
|
||||||
|
|
||||||
## 🚨 Important Development Rules
|
#### Backend `.env`
|
||||||
|
```bash
|
||||||
|
# Django Settings
|
||||||
|
DJANGO_SETTINGS_MODULE=config.django.local
|
||||||
|
DEBUG=True
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
### Critical Commands
|
# Database
|
||||||
1. **Server Startup**: Always use the full command sequence:
|
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||||
```bash
|
|
||||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Package Management**: Only use UV:
|
# Redis
|
||||||
```bash
|
REDIS_URL=redis://localhost:6379
|
||||||
uv add <package> # ✅ Correct
|
|
||||||
pip install <package> # ❌ Wrong
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Django Commands**: Always prefix with `uv run`:
|
# Email (optional)
|
||||||
```bash
|
EMAIL_HOST=smtp.gmail.com
|
||||||
uv run manage.py <command> # ✅ Correct
|
EMAIL_PORT=587
|
||||||
python manage.py <command> # ❌ Wrong
|
EMAIL_USE_TLS=True
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Configuration
|
#### Frontend `.env.local`
|
||||||
- Ensure PostgreSQL is running before starting development
|
```bash
|
||||||
- PostGIS extension must be enabled
|
# API Configuration
|
||||||
- Update database host settings for your environment
|
VITE_API_BASE_URL=http://localhost:8000/api
|
||||||
|
|
||||||
### GeoDjango Requirements
|
# Development
|
||||||
- GDAL and GEOS libraries must be properly installed
|
VITE_APP_TITLE=ThrillWiki (Development)
|
||||||
- Library paths are configured in [`thrillwiki/settings.py`](thrillwiki/settings.py) for macOS Homebrew
|
|
||||||
- Current paths: `/opt/homebrew/lib/libgdal.dylib` and `/opt/homebrew/lib/libgeos_c.dylib`
|
|
||||||
- May need adjustment based on your system's library locations (Linux users will need different paths)
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
# Feature Flags
|
||||||
|
VITE_ENABLE_DEBUG=true
|
||||||
|
```
|
||||||
|
|
||||||
### Common Issues
|
## 📊 Key Features
|
||||||
|
|
||||||
1. **PostGIS Extension Error**
|
### Backend Features
|
||||||
```bash
|
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
|
||||||
# Connect to database and enable PostGIS
|
- **Extensive Ride Database** - Complete roller coaster and ride information
|
||||||
psql thrillwiki
|
- **User Management** - Authentication, profiles, and permissions
|
||||||
CREATE EXTENSION postgis;
|
- **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
|
||||||
|
|
||||||
2. **GDAL/GEOS Library Not Found**
|
### Frontend Features
|
||||||
```bash
|
- **Responsive Design** - Mobile-first approach with Tailwind CSS
|
||||||
# macOS (Homebrew): Current paths in settings.py
|
- **Dark Mode Support** - Complete dark/light theme system
|
||||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
- **Real-time Search** - Instant search with debouncing and highlighting
|
||||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
- **Interactive Maps** - Park and ride location visualization
|
||||||
|
- **Photo Galleries** - High-quality image management
|
||||||
# Linux: Update paths in settings.py to something like:
|
- **User Dashboard** - Personalized content and contributions
|
||||||
# GDAL_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgdal.so"
|
- **Progressive Web App** - PWA capabilities for mobile experience
|
||||||
# GEOS_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgeos_c.so"
|
- **Accessibility** - WCAG 2.1 AA compliance
|
||||||
|
|
||||||
# Find your library locations
|
|
||||||
find /usr -name "libgdal*" 2>/dev/null
|
|
||||||
find /usr -name "libgeos*" 2>/dev/null
|
|
||||||
find /opt -name "libgdal*" 2>/dev/null
|
|
||||||
find /opt -name "libgeos*" 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Port 8000 Already in Use**
|
## 📖 Documentation
|
||||||
```bash
|
|
||||||
# Kill existing processes
|
|
||||||
lsof -ti :8000 | xargs kill -9
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Tailwind CSS Not Compiling**
|
### Core Documentation
|
||||||
```bash
|
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
|
||||||
# Ensure Node.js is installed and use the full server command
|
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
|
||||||
node --version
|
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
|
||||||
uv run manage.py tailwind runserver
|
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
|
||||||
```
|
|
||||||
|
|
||||||
### Getting Help
|
### Architecture & Deployment
|
||||||
|
- **[Architecture Overview](./architecture/)** - System design and decisions
|
||||||
|
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
|
||||||
|
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
|
||||||
|
|
||||||
1. Check the [`memory-bank/`](memory-bank/) documentation for detailed feature information
|
### Additional Resources
|
||||||
2. Review [`memory-bank/testing/`](memory-bank/testing/) for known issues and solutions
|
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
|
||||||
3. Ensure all prerequisites are properly installed
|
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
|
||||||
4. Verify database connection and PostGIS extension
|
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
|
||||||
|
|
||||||
## 🎯 Next Steps
|
## 🚀 Deployment
|
||||||
|
|
||||||
After successful setup:
|
### Development Environment
|
||||||
|
```bash
|
||||||
|
# Quick start with all services
|
||||||
|
./shared/scripts/dev/start-all.sh
|
||||||
|
|
||||||
1. **Explore the Admin Interface**: http://localhost:8000/admin/
|
# Full development setup
|
||||||
2. **Browse the Application**: http://localhost:8000/
|
./shared/scripts/dev/setup-dev.sh
|
||||||
3. **Review Documentation**: Check [`memory-bank/`](memory-bank/) for detailed feature docs
|
```
|
||||||
4. **Run Tests**: Ensure everything works with `uv run pytest`
|
|
||||||
5. **Start Development**: Follow the development workflow guidelines above
|
### 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Happy Coding!** 🎢✨
|
**Built with ❤️ for the theme park and roller coaster community**
|
||||||
|
|
||||||
For detailed feature documentation and development context, see the [`memory-bank/`](memory-bank/) directory.
|
|
||||||
|
|||||||
@@ -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,226 +0,0 @@
|
|||||||
"""
|
|
||||||
Selectors for user and account-related data retrieval.
|
|
||||||
Following Django styleguide pattern for separating data access from business logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
from django.db.models import QuerySet, Q, F, Count, Avg, Prefetch
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
def user_profile_optimized(*, user_id: int) -> Any:
|
|
||||||
"""
|
|
||||||
Get a user with optimized queries for profile display.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User instance with prefetched related data
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
User.DoesNotExist: If user doesn't exist
|
|
||||||
"""
|
|
||||||
return User.objects.prefetch_related(
|
|
||||||
'park_reviews',
|
|
||||||
'ride_reviews',
|
|
||||||
'socialaccount_set'
|
|
||||||
).annotate(
|
|
||||||
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
|
|
||||||
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
|
|
||||||
total_review_count=F('park_review_count') + F('ride_review_count')
|
|
||||||
).get(id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
def active_users_with_stats() -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get active users with review statistics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of active users with review counts
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
is_active=True
|
|
||||||
).annotate(
|
|
||||||
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
|
|
||||||
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
|
|
||||||
total_review_count=F('park_review_count') + F('ride_review_count')
|
|
||||||
).order_by('-total_review_count')
|
|
||||||
|
|
||||||
|
|
||||||
def users_with_recent_activity(*, days: int = 30) -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users who have been active in the last N days.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: Number of days to look back for activity
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of recently active users
|
|
||||||
"""
|
|
||||||
cutoff_date = timezone.now() - timedelta(days=days)
|
|
||||||
|
|
||||||
return User.objects.filter(
|
|
||||||
Q(last_login__gte=cutoff_date) |
|
|
||||||
Q(park_reviews__created_at__gte=cutoff_date) |
|
|
||||||
Q(ride_reviews__created_at__gte=cutoff_date)
|
|
||||||
).annotate(
|
|
||||||
recent_park_reviews=Count('park_reviews', filter=Q(park_reviews__created_at__gte=cutoff_date)),
|
|
||||||
recent_ride_reviews=Count('ride_reviews', filter=Q(ride_reviews__created_at__gte=cutoff_date)),
|
|
||||||
recent_total_reviews=F('recent_park_reviews') + F('recent_ride_reviews')
|
|
||||||
).order_by('-last_login').distinct()
|
|
||||||
|
|
||||||
|
|
||||||
def top_reviewers(*, limit: int = 10) -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get top users by review count.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum number of users to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of top reviewers
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
is_active=True
|
|
||||||
).annotate(
|
|
||||||
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
|
|
||||||
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
|
|
||||||
total_review_count=F('park_review_count') + F('ride_review_count')
|
|
||||||
).filter(
|
|
||||||
total_review_count__gt=0
|
|
||||||
).order_by('-total_review_count')[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def moderator_users() -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users with moderation permissions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of users who can moderate content
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
Q(is_staff=True) |
|
|
||||||
Q(groups__name='Moderators') |
|
|
||||||
Q(user_permissions__codename__in=['change_parkreview', 'change_ridereview'])
|
|
||||||
).distinct().order_by('username')
|
|
||||||
|
|
||||||
|
|
||||||
def users_by_registration_date(*, start_date, end_date) -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users who registered within a date range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_date: Start of date range
|
|
||||||
end_date: End of date range
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of users registered in the date range
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
date_joined__date__gte=start_date,
|
|
||||||
date_joined__date__lte=end_date
|
|
||||||
).order_by('-date_joined')
|
|
||||||
|
|
||||||
|
|
||||||
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users matching a search query for autocomplete functionality.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search string
|
|
||||||
limit: Maximum number of results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of matching users for autocomplete
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
Q(username__icontains=query) |
|
|
||||||
Q(first_name__icontains=query) |
|
|
||||||
Q(last_name__icontains=query),
|
|
||||||
is_active=True
|
|
||||||
).order_by('username')[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def users_with_social_accounts() -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users who have connected social accounts.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of users with social account connections
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
socialaccount__isnull=False
|
|
||||||
).prefetch_related(
|
|
||||||
'socialaccount_set'
|
|
||||||
).distinct().order_by('username')
|
|
||||||
|
|
||||||
|
|
||||||
def user_statistics_summary() -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get overall user statistics for dashboard/analytics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing user statistics
|
|
||||||
"""
|
|
||||||
total_users = User.objects.count()
|
|
||||||
active_users = User.objects.filter(is_active=True).count()
|
|
||||||
staff_users = User.objects.filter(is_staff=True).count()
|
|
||||||
|
|
||||||
# Users with reviews
|
|
||||||
users_with_reviews = User.objects.filter(
|
|
||||||
Q(park_reviews__isnull=False) |
|
|
||||||
Q(ride_reviews__isnull=False)
|
|
||||||
).distinct().count()
|
|
||||||
|
|
||||||
# Recent registrations (last 30 days)
|
|
||||||
cutoff_date = timezone.now() - timedelta(days=30)
|
|
||||||
recent_registrations = User.objects.filter(
|
|
||||||
date_joined__gte=cutoff_date
|
|
||||||
).count()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_users': total_users,
|
|
||||||
'active_users': active_users,
|
|
||||||
'inactive_users': total_users - active_users,
|
|
||||||
'staff_users': staff_users,
|
|
||||||
'users_with_reviews': users_with_reviews,
|
|
||||||
'recent_registrations': recent_registrations,
|
|
||||||
'review_participation_rate': (users_with_reviews / total_users * 100) if total_users > 0 else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def users_needing_email_verification() -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users who haven't verified their email addresses.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of users with unverified emails
|
|
||||||
"""
|
|
||||||
return User.objects.filter(
|
|
||||||
is_active=True,
|
|
||||||
emailaddress__verified=False
|
|
||||||
).distinct().order_by('date_joined')
|
|
||||||
|
|
||||||
|
|
||||||
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
|
||||||
"""
|
|
||||||
Get users who have written at least a minimum number of reviews.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
min_reviews: Minimum number of reviews required
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QuerySet of users with sufficient review activity
|
|
||||||
"""
|
|
||||||
return User.objects.annotate(
|
|
||||||
park_review_count=Count('park_reviews', filter=Q(park_reviews__is_published=True)),
|
|
||||||
ride_review_count=Count('ride_reviews', filter=Q(ride_reviews__is_published=True)),
|
|
||||||
total_review_count=F('park_review_count') + F('ride_review_count')
|
|
||||||
).filter(
|
|
||||||
total_review_count__gte=min_reviews
|
|
||||||
).order_by('-total_review_count')
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.contrib.auth.models import Group, Permission
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from .models import User, UserProfile
|
|
||||||
from .signals import create_default_groups
|
|
||||||
|
|
||||||
class SignalsTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(
|
|
||||||
username='testuser',
|
|
||||||
email='testuser@example.com',
|
|
||||||
password='password'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_create_user_profile(self):
|
|
||||||
self.assertTrue(hasattr(self.user, 'profile'))
|
|
||||||
self.assertIsInstance(self.user.profile, UserProfile)
|
|
||||||
|
|
||||||
@patch('accounts.signals.requests.get')
|
|
||||||
def test_create_user_profile_with_social_avatar(self, mock_get):
|
|
||||||
# Mock the response from requests.get
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.content = b'fake-image-content'
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
|
|
||||||
# Create a social account for the user
|
|
||||||
social_account = self.user.socialaccount_set.create(
|
|
||||||
provider='google',
|
|
||||||
extra_data={'picture': 'http://example.com/avatar.png'}
|
|
||||||
)
|
|
||||||
|
|
||||||
# The signal should have been triggered when the user was created,
|
|
||||||
# but we can trigger it again to test the avatar download
|
|
||||||
from .signals import create_user_profile
|
|
||||||
create_user_profile(sender=User, instance=self.user, created=True)
|
|
||||||
|
|
||||||
self.user.profile.refresh_from_db()
|
|
||||||
self.assertTrue(self.user.profile.avatar.name.startswith('avatars/avatar_testuser'))
|
|
||||||
|
|
||||||
def test_save_user_profile(self):
|
|
||||||
self.user.profile.delete()
|
|
||||||
self.assertFalse(hasattr(self.user, 'profile'))
|
|
||||||
self.user.save()
|
|
||||||
self.assertTrue(hasattr(self.user, 'profile'))
|
|
||||||
self.assertIsInstance(self.user.profile, UserProfile)
|
|
||||||
|
|
||||||
def test_sync_user_role_with_groups(self):
|
|
||||||
self.user.role = User.Roles.MODERATOR
|
|
||||||
self.user.save()
|
|
||||||
self.assertTrue(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
|
|
||||||
self.assertTrue(self.user.is_staff)
|
|
||||||
|
|
||||||
self.user.role = User.Roles.ADMIN
|
|
||||||
self.user.save()
|
|
||||||
self.assertFalse(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
|
|
||||||
self.assertTrue(self.user.groups.filter(name=User.Roles.ADMIN).exists())
|
|
||||||
self.assertTrue(self.user.is_staff)
|
|
||||||
|
|
||||||
self.user.role = User.Roles.SUPERUSER
|
|
||||||
self.user.save()
|
|
||||||
self.assertFalse(self.user.groups.filter(name=User.Roles.ADMIN).exists())
|
|
||||||
self.assertTrue(self.user.groups.filter(name=User.Roles.SUPERUSER).exists())
|
|
||||||
self.assertTrue(self.user.is_superuser)
|
|
||||||
self.assertTrue(self.user.is_staff)
|
|
||||||
|
|
||||||
self.user.role = User.Roles.USER
|
|
||||||
self.user.save()
|
|
||||||
self.assertFalse(self.user.groups.exists())
|
|
||||||
self.assertFalse(self.user.is_superuser)
|
|
||||||
self.assertFalse(self.user.is_staff)
|
|
||||||
|
|
||||||
def test_create_default_groups(self):
|
|
||||||
# Create some permissions for testing
|
|
||||||
content_type = ContentType.objects.get_for_model(User)
|
|
||||||
Permission.objects.create(codename='change_review', name='Can change review', content_type=content_type)
|
|
||||||
Permission.objects.create(codename='delete_review', name='Can delete review', content_type=content_type)
|
|
||||||
Permission.objects.create(codename='change_user', name='Can change user', content_type=content_type)
|
|
||||||
|
|
||||||
create_default_groups()
|
|
||||||
|
|
||||||
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
|
||||||
self.assertIsNotNone(moderator_group)
|
|
||||||
self.assertTrue(moderator_group.permissions.filter(codename='change_review').exists())
|
|
||||||
self.assertFalse(moderator_group.permissions.filter(codename='change_user').exists())
|
|
||||||
|
|
||||||
admin_group = Group.objects.get(name=User.Roles.ADMIN)
|
|
||||||
self.assertIsNotNone(admin_group)
|
|
||||||
self.assertTrue(admin_group.permissions.filter(codename='change_review').exists())
|
|
||||||
self.assertTrue(admin_group.permissions.filter(codename='change_user').exists())
|
|
||||||
@@ -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,391 +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 email_service.services import EmailService
|
|
||||||
from parks.models import ParkReview
|
|
||||||
from rides.models import RideReview
|
|
||||||
from allauth.account.views import LoginView, SignupView
|
|
||||||
from .mixins import TurnstileMixin
|
|
||||||
from typing import Dict, Any, Optional, Union, cast, TYPE_CHECKING
|
|
||||||
from django_htmx.http import HttpResponseClientRefresh
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from django.contrib.sites.requests import RequestSite
|
|
||||||
from contextlib import suppress
|
|
||||||
import re
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from django.contrib.sites.requests import RequestSite
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
|
||||||
|
|
||||||
class CustomLoginView(TurnstileMixin, LoginView):
|
|
||||||
def form_valid(self, form):
|
|
||||||
try:
|
|
||||||
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['park_reviews'] = self._get_user_park_reviews(user)
|
|
||||||
context['ride_reviews'] = self._get_user_ride_reviews(user)
|
|
||||||
context['top_lists'] = self._get_user_top_lists(user)
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def _get_user_park_reviews(self, user: User) -> QuerySet[ParkReview]:
|
|
||||||
return ParkReview.objects.filter(
|
|
||||||
user=user,
|
|
||||||
is_published=True
|
|
||||||
).select_related(
|
|
||||||
'user',
|
|
||||||
'user__profile',
|
|
||||||
'park'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
def _get_user_ride_reviews(self, user: User) -> QuerySet[RideReview]:
|
|
||||||
return RideReview.objects.filter(
|
|
||||||
user=user,
|
|
||||||
is_published=True
|
|
||||||
).select_related(
|
|
||||||
'user',
|
|
||||||
'user__profile',
|
|
||||||
'ride'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
|
|
||||||
return TopList.objects.filter(
|
|
||||||
user=user
|
|
||||||
).select_related(
|
|
||||||
'user',
|
|
||||||
'user__profile'
|
|
||||||
).prefetch_related(
|
|
||||||
'items'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
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')
|
|
||||||
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.
|
||||||
31
backend/.env.example
Normal file
31
backend/.env.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
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/api/__init__.py
Normal file
6
backend/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Centralized API package for ThrillWiki.
|
||||||
|
|
||||||
|
This package contains all API endpoints organized by version.
|
||||||
|
All API routes must be routed through this centralized structure.
|
||||||
|
"""
|
||||||
12
backend/api/urls.py
Normal file
12
backend/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Main API router for ThrillWiki.
|
||||||
|
|
||||||
|
This module routes all API requests to the appropriate version.
|
||||||
|
Currently supports v1 API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("v1/", include("api.v1.urls")),
|
||||||
|
]
|
||||||
6
backend/api/v1/__init__.py
Normal file
6
backend/api/v1/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Version 1 API package for ThrillWiki.
|
||||||
|
|
||||||
|
This package contains all v1 API endpoints organized by domain.
|
||||||
|
Domain-specific endpoints are in their respective subdirectories.
|
||||||
|
"""
|
||||||
6
backend/api/v1/auth/__init__.py
Normal file
6
backend/api/v1/auth/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Authentication API endpoints for ThrillWiki v1.
|
||||||
|
|
||||||
|
This package contains all authentication and authorization-related
|
||||||
|
API functionality including login, logout, user management, and permissions.
|
||||||
|
"""
|
||||||
510
backend/api/v1/auth/serializers.py
Normal file
510
backend/api/v1/auth/serializers.py
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
"""
|
||||||
|
Auth domain serializers for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains all serializers related to authentication, user accounts,
|
||||||
|
profiles, top lists, and user statistics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from drf_spectacular.utils import (
|
||||||
|
extend_schema_serializer,
|
||||||
|
extend_schema_field,
|
||||||
|
OpenApiExample,
|
||||||
|
)
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
# Import shared utilities
|
||||||
|
|
||||||
|
|
||||||
|
class ModelChoices:
|
||||||
|
"""Model choices utility class."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_top_list_categories():
|
||||||
|
"""Get top list category choices."""
|
||||||
|
return [
|
||||||
|
("RC", "Roller Coasters"),
|
||||||
|
("DR", "Dark Rides"),
|
||||||
|
("FR", "Flat Rides"),
|
||||||
|
("WR", "Water Rides"),
|
||||||
|
("PK", "Parks"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# === AUTHENTICATION SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_serializer(
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"User Example",
|
||||||
|
summary="Example user response",
|
||||||
|
description="A typical user object",
|
||||||
|
value={
|
||||||
|
"id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"date_joined": "2024-01-01T12:00:00Z",
|
||||||
|
"is_active": True,
|
||||||
|
"avatar_url": "https://example.com/avatars/john.jpg",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class UserOutputSerializer(serializers.ModelSerializer):
|
||||||
|
"""User serializer for API responses."""
|
||||||
|
|
||||||
|
avatar_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserModel
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"date_joined",
|
||||||
|
"is_active",
|
||||||
|
"avatar_url",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "date_joined", "is_active"]
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class LoginInputSerializer(serializers.Serializer):
|
||||||
|
"""Input 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 LoginOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for successful login."""
|
||||||
|
|
||||||
|
token = serializers.CharField()
|
||||||
|
user = UserOutputSerializer()
|
||||||
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SignupInputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Input 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 = UserModel
|
||||||
|
fields = [
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"password",
|
||||||
|
"password_confirm",
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
"password": {"write_only": True},
|
||||||
|
"email": {"required": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
"""Validate email is unique."""
|
||||||
|
if UserModel.objects.filter(email=value).exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Use type: ignore for Django's create_user method which isn't properly typed
|
||||||
|
user = UserModel.objects.create_user( # type: ignore[attr-defined]
|
||||||
|
password=password, **validated_data
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class SignupOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for successful signup."""
|
||||||
|
|
||||||
|
token = serializers.CharField()
|
||||||
|
user = UserOutputSerializer()
|
||||||
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for password reset request."""
|
||||||
|
|
||||||
|
email = serializers.EmailField()
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
"""Validate email exists."""
|
||||||
|
try:
|
||||||
|
user = UserModel.objects.get(email=value)
|
||||||
|
self.user = user
|
||||||
|
return value
|
||||||
|
except UserModel.DoesNotExist:
|
||||||
|
# Don't reveal if email exists or not for security
|
||||||
|
return value
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
"""Send password reset email if user exists."""
|
||||||
|
if hasattr(self, "user"):
|
||||||
|
# Create password reset token
|
||||||
|
token = get_random_string(64)
|
||||||
|
# Note: PasswordReset model would need to be imported
|
||||||
|
# PasswordReset.objects.update_or_create(...)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for password reset request."""
|
||||||
|
|
||||||
|
detail = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeInputSerializer(serializers.Serializer):
|
||||||
|
"""Input 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
|
||||||
|
# validated_data is guaranteed to exist after is_valid() is called
|
||||||
|
new_password = self.validated_data["new_password"] # type: ignore[index]
|
||||||
|
|
||||||
|
user.set_password(new_password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for password change."""
|
||||||
|
|
||||||
|
detail = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for logout."""
|
||||||
|
|
||||||
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SocialProviderOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for social authentication providers."""
|
||||||
|
|
||||||
|
id = serializers.CharField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
authUrl = serializers.URLField()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatusOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for authentication status check."""
|
||||||
|
|
||||||
|
authenticated = serializers.BooleanField()
|
||||||
|
user = UserOutputSerializer(allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
# === USER PROFILE SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_serializer(
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"User Profile Example",
|
||||||
|
summary="Example user profile response",
|
||||||
|
description="A user's profile information",
|
||||||
|
value={
|
||||||
|
"id": 1,
|
||||||
|
"profile_id": "1234",
|
||||||
|
"display_name": "Coaster Enthusiast",
|
||||||
|
"bio": "Love visiting theme parks around the world!",
|
||||||
|
"pronouns": "they/them",
|
||||||
|
"avatar_url": "/media/avatars/user1.jpg",
|
||||||
|
"coaster_credits": 150,
|
||||||
|
"dark_ride_credits": 45,
|
||||||
|
"flat_ride_credits": 80,
|
||||||
|
"water_ride_credits": 25,
|
||||||
|
"user": {
|
||||||
|
"username": "coaster_fan",
|
||||||
|
"date_joined": "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class UserProfileOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for user profiles."""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
profile_id = serializers.CharField()
|
||||||
|
display_name = serializers.CharField()
|
||||||
|
bio = serializers.CharField()
|
||||||
|
pronouns = serializers.CharField()
|
||||||
|
avatar_url = serializers.SerializerMethodField()
|
||||||
|
twitter = serializers.URLField()
|
||||||
|
instagram = serializers.URLField()
|
||||||
|
youtube = serializers.URLField()
|
||||||
|
discord = serializers.CharField()
|
||||||
|
|
||||||
|
# Ride statistics
|
||||||
|
coaster_credits = serializers.IntegerField()
|
||||||
|
dark_ride_credits = serializers.IntegerField()
|
||||||
|
flat_ride_credits = serializers.IntegerField()
|
||||||
|
water_ride_credits = serializers.IntegerField()
|
||||||
|
|
||||||
|
# User info (limited)
|
||||||
|
user = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||||
|
def get_avatar_url(self, obj) -> str | None:
|
||||||
|
return obj.get_avatar()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.DictField())
|
||||||
|
def get_user(self, obj) -> dict:
|
||||||
|
return {
|
||||||
|
"username": obj.user.username,
|
||||||
|
"date_joined": obj.user.date_joined,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileCreateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for creating user profiles."""
|
||||||
|
|
||||||
|
display_name = serializers.CharField(max_length=50)
|
||||||
|
bio = serializers.CharField(max_length=500, allow_blank=True, default="")
|
||||||
|
pronouns = serializers.CharField(max_length=50, allow_blank=True, default="")
|
||||||
|
twitter = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
instagram = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
youtube = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
discord = serializers.CharField(max_length=100, allow_blank=True, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for updating user profiles."""
|
||||||
|
|
||||||
|
display_name = serializers.CharField(max_length=50, required=False)
|
||||||
|
bio = serializers.CharField(max_length=500, allow_blank=True, required=False)
|
||||||
|
pronouns = serializers.CharField(max_length=50, allow_blank=True, required=False)
|
||||||
|
twitter = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
instagram = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
youtube = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
discord = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||||
|
coaster_credits = serializers.IntegerField(required=False)
|
||||||
|
dark_ride_credits = serializers.IntegerField(required=False)
|
||||||
|
flat_ride_credits = serializers.IntegerField(required=False)
|
||||||
|
water_ride_credits = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
# === TOP LIST SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_serializer(
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Top List Example",
|
||||||
|
summary="Example top list response",
|
||||||
|
description="A user's top list of rides or parks",
|
||||||
|
value={
|
||||||
|
"id": 1,
|
||||||
|
"title": "My Top 10 Roller Coasters",
|
||||||
|
"category": "RC",
|
||||||
|
"description": "My favorite roller coasters ranked",
|
||||||
|
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
|
||||||
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2024-08-15T12:00:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class TopListOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for top lists."""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
title = serializers.CharField()
|
||||||
|
category = serializers.CharField()
|
||||||
|
description = serializers.CharField()
|
||||||
|
created_at = serializers.DateTimeField()
|
||||||
|
updated_at = serializers.DateTimeField()
|
||||||
|
|
||||||
|
# User info
|
||||||
|
user = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.DictField())
|
||||||
|
def get_user(self, obj) -> dict:
|
||||||
|
return {
|
||||||
|
"username": obj.user.username,
|
||||||
|
"display_name": obj.user.get_display_name(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TopListCreateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for creating top lists."""
|
||||||
|
|
||||||
|
title = serializers.CharField(max_length=100)
|
||||||
|
category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories())
|
||||||
|
description = serializers.CharField(allow_blank=True, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class TopListUpdateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for updating top lists."""
|
||||||
|
|
||||||
|
title = serializers.CharField(max_length=100, required=False)
|
||||||
|
category = serializers.ChoiceField(
|
||||||
|
choices=ModelChoices.get_top_list_categories(), required=False
|
||||||
|
)
|
||||||
|
description = serializers.CharField(allow_blank=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
# === TOP LIST ITEM SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_serializer(
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Top List Item Example",
|
||||||
|
summary="Example top list item response",
|
||||||
|
description="An item in a user's top list",
|
||||||
|
value={
|
||||||
|
"id": 1,
|
||||||
|
"rank": 1,
|
||||||
|
"notes": "Amazing airtime and smooth ride",
|
||||||
|
"object_name": "Steel Vengeance",
|
||||||
|
"object_type": "Ride",
|
||||||
|
"top_list": {"id": 1, "title": "My Top 10 Roller Coasters"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class TopListItemOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for top list items."""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
rank = serializers.IntegerField()
|
||||||
|
notes = serializers.CharField()
|
||||||
|
object_name = serializers.SerializerMethodField()
|
||||||
|
object_type = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Top list info
|
||||||
|
top_list = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField())
|
||||||
|
def get_object_name(self, obj) -> str:
|
||||||
|
"""Get the name of the referenced object."""
|
||||||
|
# This would need to be implemented based on the generic foreign key
|
||||||
|
return "Object Name" # Placeholder
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField())
|
||||||
|
def get_object_type(self, obj) -> str:
|
||||||
|
"""Get the type of the referenced object."""
|
||||||
|
return obj.content_type.model_class().__name__
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.DictField())
|
||||||
|
def get_top_list(self, obj) -> dict:
|
||||||
|
return {
|
||||||
|
"id": obj.top_list.id,
|
||||||
|
"title": obj.top_list.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TopListItemCreateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for creating top list items."""
|
||||||
|
|
||||||
|
top_list_id = serializers.IntegerField()
|
||||||
|
content_type_id = serializers.IntegerField()
|
||||||
|
object_id = serializers.IntegerField()
|
||||||
|
rank = serializers.IntegerField(min_value=1)
|
||||||
|
notes = serializers.CharField(allow_blank=True, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class TopListItemUpdateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for updating top list items."""
|
||||||
|
|
||||||
|
rank = serializers.IntegerField(min_value=1, required=False)
|
||||||
|
notes = serializers.CharField(allow_blank=True, required=False)
|
||||||
42
backend/api/v1/auth/urls.py
Normal file
42
backend/api/v1/auth/urls.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Auth domain URL Configuration for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains all URL patterns for authentication, user accounts,
|
||||||
|
profiles, and top lists functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
# Create router and register ViewSets
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
|
||||||
|
router.register(r"toplists", views.TopListViewSet, basename="top-list")
|
||||||
|
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Authentication endpoints
|
||||||
|
path("login/", views.LoginAPIView.as_view(), name="auth-login"),
|
||||||
|
path("signup/", views.SignupAPIView.as_view(), name="auth-signup"),
|
||||||
|
path("logout/", views.LogoutAPIView.as_view(), name="auth-logout"),
|
||||||
|
path("user/", views.CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||||
|
path(
|
||||||
|
"password/reset/",
|
||||||
|
views.PasswordResetAPIView.as_view(),
|
||||||
|
name="auth-password-reset",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"password/change/",
|
||||||
|
views.PasswordChangeAPIView.as_view(),
|
||||||
|
name="auth-password-change",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"social/providers/",
|
||||||
|
views.SocialProvidersAPIView.as_view(),
|
||||||
|
name="auth-social-providers",
|
||||||
|
),
|
||||||
|
path("status/", views.AuthStatusAPIView.as_view(), name="auth-status"),
|
||||||
|
# Include router URLs for ViewSets (profiles, top lists)
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
624
backend/api/v1/auth/views.py
Normal file
624
backend/api/v1/auth/views.py
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
"""
|
||||||
|
Auth domain views for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains all authentication-related API endpoints including
|
||||||
|
login, signup, logout, password management, social authentication,
|
||||||
|
user profiles, and top lists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, login, logout, get_user_model
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Q
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
|
||||||
|
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||||
|
from .serializers import (
|
||||||
|
# Authentication serializers
|
||||||
|
LoginInputSerializer,
|
||||||
|
LoginOutputSerializer,
|
||||||
|
SignupInputSerializer,
|
||||||
|
SignupOutputSerializer,
|
||||||
|
LogoutOutputSerializer,
|
||||||
|
UserOutputSerializer,
|
||||||
|
PasswordResetInputSerializer,
|
||||||
|
PasswordResetOutputSerializer,
|
||||||
|
PasswordChangeInputSerializer,
|
||||||
|
PasswordChangeOutputSerializer,
|
||||||
|
SocialProviderOutputSerializer,
|
||||||
|
AuthStatusOutputSerializer,
|
||||||
|
# User profile serializers
|
||||||
|
UserProfileCreateInputSerializer,
|
||||||
|
UserProfileUpdateInputSerializer,
|
||||||
|
UserProfileOutputSerializer,
|
||||||
|
# Top list serializers
|
||||||
|
TopListCreateInputSerializer,
|
||||||
|
TopListUpdateInputSerializer,
|
||||||
|
TopListOutputSerializer,
|
||||||
|
TopListItemCreateInputSerializer,
|
||||||
|
TopListItemUpdateInputSerializer,
|
||||||
|
TopListItemOutputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle optional dependencies with fallback classes
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackTurnstileMixin:
|
||||||
|
"""Fallback mixin if TurnstileMixin is not available."""
|
||||||
|
|
||||||
|
def validate_turnstile(self, request):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Try to import the real class, use fallback if not available
|
||||||
|
try:
|
||||||
|
from apps.accounts.mixins import TurnstileMixin
|
||||||
|
except ImportError:
|
||||||
|
TurnstileMixin = FallbackTurnstileMixin
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# === AUTHENTICATION API VIEWS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="User login",
|
||||||
|
description="Authenticate user with username/email and password.",
|
||||||
|
request=LoginInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: LoginOutputSerializer,
|
||||||
|
400: "Bad Request",
|
||||||
|
},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class LoginAPIView(TurnstileMixin, APIView):
|
||||||
|
"""API endpoint for user login."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
serializer_class = LoginInputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
# Validate Turnstile if configured
|
||||||
|
self.validate_turnstile(request)
|
||||||
|
except ValidationError as e:
|
||||||
|
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
serializer = LoginInputSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
# type: ignore[index]
|
||||||
|
email_or_username = serializer.validated_data["username"]
|
||||||
|
password = serializer.validated_data["password"] # type: ignore[index]
|
||||||
|
|
||||||
|
# Optimized user lookup: single query using Q objects
|
||||||
|
user = None
|
||||||
|
|
||||||
|
# Single query to find user by email OR username
|
||||||
|
try:
|
||||||
|
if "@" in email_or_username:
|
||||||
|
# Email-like input: try email first, then username as fallback
|
||||||
|
user_obj = (
|
||||||
|
UserModel.objects.select_related()
|
||||||
|
.filter(
|
||||||
|
Q(email=email_or_username) | Q(username=email_or_username)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Username-like input: try username first, then email as fallback
|
||||||
|
user_obj = (
|
||||||
|
UserModel.objects.select_related()
|
||||||
|
.filter(
|
||||||
|
Q(username=email_or_username) | Q(email=email_or_username)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_obj:
|
||||||
|
user = authenticate(
|
||||||
|
# type: ignore[attr-defined]
|
||||||
|
request._request,
|
||||||
|
username=user_obj.username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to original behavior
|
||||||
|
user = authenticate(
|
||||||
|
# type: ignore[attr-defined]
|
||||||
|
request._request,
|
||||||
|
username=email_or_username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
if user.is_active:
|
||||||
|
login(request._request, user) # type: ignore[attr-defined]
|
||||||
|
# Optimized token creation - get_or_create is atomic
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
token, created = Token.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
response_serializer = LoginOutputSerializer(
|
||||||
|
{
|
||||||
|
"token": token.key,
|
||||||
|
"user": user,
|
||||||
|
"message": "Login successful",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{"error": "Account is disabled"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid credentials"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="User registration",
|
||||||
|
description="Register a new user account.",
|
||||||
|
request=SignupInputSerializer,
|
||||||
|
responses={
|
||||||
|
201: SignupOutputSerializer,
|
||||||
|
400: "Bad Request",
|
||||||
|
},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class SignupAPIView(TurnstileMixin, APIView):
|
||||||
|
"""API endpoint for user registration."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
serializer_class = SignupInputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
# Validate Turnstile if configured
|
||||||
|
self.validate_turnstile(request)
|
||||||
|
except ValidationError as e:
|
||||||
|
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
serializer = SignupInputSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
user = serializer.save()
|
||||||
|
login(request._request, user) # type: ignore[attr-defined]
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
token, created = Token.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
response_serializer = SignupOutputSerializer(
|
||||||
|
{
|
||||||
|
"token": token.key,
|
||||||
|
"user": user,
|
||||||
|
"message": "Registration successful",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="User logout",
|
||||||
|
description="Logout the current user and invalidate their token.",
|
||||||
|
responses={
|
||||||
|
200: LogoutOutputSerializer,
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class LogoutAPIView(APIView):
|
||||||
|
"""API endpoint for user logout."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = LogoutOutputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
# Delete the token for token-based auth
|
||||||
|
if hasattr(request.user, "auth_token"):
|
||||||
|
request.user.auth_token.delete()
|
||||||
|
|
||||||
|
# Logout from session
|
||||||
|
logout(request._request) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
response_serializer = LogoutOutputSerializer(
|
||||||
|
{"message": "Logout successful"}
|
||||||
|
)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
except Exception:
|
||||||
|
return Response(
|
||||||
|
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
get=extend_schema(
|
||||||
|
summary="Get current user",
|
||||||
|
description="Retrieve information about the currently authenticated user.",
|
||||||
|
responses={
|
||||||
|
200: UserOutputSerializer,
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class CurrentUserAPIView(APIView):
|
||||||
|
"""API endpoint to get current user information."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = UserOutputSerializer
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
serializer = UserOutputSerializer(request.user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Request password reset",
|
||||||
|
description="Send a password reset email to the user.",
|
||||||
|
request=PasswordResetInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: PasswordResetOutputSerializer,
|
||||||
|
400: "Bad Request",
|
||||||
|
},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class PasswordResetAPIView(APIView):
|
||||||
|
"""API endpoint to request password reset."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
serializer_class = PasswordResetInputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
serializer = PasswordResetInputSerializer(
|
||||||
|
data=request.data, context={"request": request}
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
response_serializer = PasswordResetOutputSerializer(
|
||||||
|
{"detail": "Password reset email sent"}
|
||||||
|
)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Change password",
|
||||||
|
description="Change the current user's password.",
|
||||||
|
request=PasswordChangeInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: PasswordChangeOutputSerializer,
|
||||||
|
400: "Bad Request",
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class PasswordChangeAPIView(APIView):
|
||||||
|
"""API endpoint to change password."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = PasswordChangeInputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
serializer = PasswordChangeInputSerializer(
|
||||||
|
data=request.data, context={"request": request}
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
response_serializer = PasswordChangeOutputSerializer(
|
||||||
|
{"detail": "Password changed successfully"}
|
||||||
|
)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
get=extend_schema(
|
||||||
|
summary="Get social providers",
|
||||||
|
description="Retrieve available social authentication providers.",
|
||||||
|
responses={200: "List of social providers"},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class SocialProvidersAPIView(APIView):
|
||||||
|
"""API endpoint to get available social authentication providers."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
serializer_class = SocialProviderOutputSerializer
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
site = get_current_site(request._request) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# Cache key based on site and request host
|
||||||
|
cache_key = (
|
||||||
|
f"social_providers:{getattr(site, 'id', site.pk)}:{request.get_host()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to get from cache first (cache for 15 minutes)
|
||||||
|
cached_providers = cache.get(cache_key)
|
||||||
|
if cached_providers is not None:
|
||||||
|
return Response(cached_providers)
|
||||||
|
|
||||||
|
providers_list = []
|
||||||
|
|
||||||
|
# Optimized query: filter by site and order by provider name
|
||||||
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
|
||||||
|
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
|
||||||
|
|
||||||
|
for social_app in social_apps:
|
||||||
|
try:
|
||||||
|
# Simplified provider name resolution - avoid expensive provider class loading
|
||||||
|
provider_name = social_app.name or social_app.provider.title()
|
||||||
|
|
||||||
|
# Build auth URL efficiently
|
||||||
|
auth_url = request.build_absolute_uri(
|
||||||
|
f"/accounts/{social_app.provider}/login/"
|
||||||
|
)
|
||||||
|
|
||||||
|
providers_list.append(
|
||||||
|
{
|
||||||
|
"id": social_app.provider,
|
||||||
|
"name": provider_name,
|
||||||
|
"authUrl": auth_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Skip if provider can't be loaded
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Serialize and cache the result
|
||||||
|
serializer = SocialProviderOutputSerializer(providers_list, many=True)
|
||||||
|
response_data = serializer.data
|
||||||
|
|
||||||
|
# Cache for 15 minutes (900 seconds)
|
||||||
|
cache.set(cache_key, response_data, 900)
|
||||||
|
|
||||||
|
return Response(response_data)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Check authentication status",
|
||||||
|
description="Check if user is authenticated and return user data.",
|
||||||
|
responses={200: AuthStatusOutputSerializer},
|
||||||
|
tags=["Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class AuthStatusAPIView(APIView):
|
||||||
|
"""API endpoint to check authentication status."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
serializer_class = AuthStatusOutputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
response_data = {
|
||||||
|
"authenticated": True,
|
||||||
|
"user": request.user,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
response_data = {
|
||||||
|
"authenticated": False,
|
||||||
|
"user": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = AuthStatusOutputSerializer(response_data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
# === USER PROFILE API VIEWS ===
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing user profiles."""
|
||||||
|
|
||||||
|
queryset = UserProfile.objects.select_related("user").all()
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "create":
|
||||||
|
return UserProfileCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return UserProfileUpdateInputSerializer
|
||||||
|
return UserProfileOutputSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter profiles based on user permissions."""
|
||||||
|
if self.request.user.is_staff:
|
||||||
|
return self.queryset
|
||||||
|
return self.queryset.filter(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def me(self, request):
|
||||||
|
"""Get current user's profile."""
|
||||||
|
try:
|
||||||
|
profile = UserProfile.objects.get(user=request.user)
|
||||||
|
serializer = self.get_serializer(profile)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Profile not found"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === TOP LIST API VIEWS ===
|
||||||
|
|
||||||
|
|
||||||
|
class TopListViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing user top lists."""
|
||||||
|
|
||||||
|
queryset = (
|
||||||
|
TopList.objects.select_related("user").prefetch_related("items__ride").all()
|
||||||
|
)
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "create":
|
||||||
|
return TopListCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return TopListUpdateInputSerializer
|
||||||
|
return TopListOutputSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter lists based on user permissions and visibility."""
|
||||||
|
queryset = self.queryset
|
||||||
|
|
||||||
|
if not self.request.user.is_staff:
|
||||||
|
# Non-staff users can only see their own lists and public lists
|
||||||
|
queryset = queryset.filter(Q(user=self.request.user) | Q(is_public=True))
|
||||||
|
|
||||||
|
return queryset.order_by("-created_at")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Set the user when creating a top list."""
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def my_lists(self, request):
|
||||||
|
"""Get current user's top lists."""
|
||||||
|
lists = self.get_queryset().filter(user=request.user)
|
||||||
|
serializer = self.get_serializer(lists, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def duplicate(self, request, pk=None):
|
||||||
|
"""Duplicate a top list for the current user."""
|
||||||
|
original_list = self.get_object()
|
||||||
|
|
||||||
|
# Create new list
|
||||||
|
new_list = TopList.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
name=f"Copy of {original_list.name}",
|
||||||
|
description=original_list.description,
|
||||||
|
is_public=False, # Duplicated lists are private by default
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy all items
|
||||||
|
for item in original_list.items.all():
|
||||||
|
TopListItem.objects.create(
|
||||||
|
top_list=new_list,
|
||||||
|
ride=item.ride,
|
||||||
|
position=item.position,
|
||||||
|
notes=item.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(new_list)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class TopListItemViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing top list items."""
|
||||||
|
|
||||||
|
queryset = TopListItem.objects.select_related("top_list__user", "ride").all()
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "create":
|
||||||
|
return TopListItemCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return TopListItemUpdateInputSerializer
|
||||||
|
return TopListItemOutputSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter items based on user permissions."""
|
||||||
|
queryset = self.queryset
|
||||||
|
|
||||||
|
if not self.request.user.is_staff:
|
||||||
|
# Non-staff users can only see items from their own lists or public lists
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(top_list__user=self.request.user) | Q(top_list__is_public=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.order_by("top_list_id", "position")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Validate user can add items to the list."""
|
||||||
|
top_list = serializer.validated_data["top_list"]
|
||||||
|
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||||
|
raise PermissionError("You can only add items to your own lists")
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
"""Validate user can update items in the list."""
|
||||||
|
top_list = serializer.instance.top_list
|
||||||
|
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||||
|
raise PermissionError("You can only update items in your own lists")
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Validate user can delete items from the list."""
|
||||||
|
if (
|
||||||
|
instance.top_list.user != self.request.user
|
||||||
|
and not self.request.user.is_staff
|
||||||
|
):
|
||||||
|
raise PermissionError("You can only delete items from your own lists")
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def reorder(self, request):
|
||||||
|
"""Reorder items in a top list."""
|
||||||
|
top_list_id = request.data.get("top_list_id")
|
||||||
|
item_ids = request.data.get("item_ids", [])
|
||||||
|
|
||||||
|
if not top_list_id or not item_ids:
|
||||||
|
return Response(
|
||||||
|
{"error": "top_list_id and item_ids are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
top_list = TopList.objects.get(id=top_list_id)
|
||||||
|
if top_list.user != request.user and not request.user.is_staff:
|
||||||
|
return Response(
|
||||||
|
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update positions
|
||||||
|
for position, item_id in enumerate(item_ids, 1):
|
||||||
|
TopListItem.objects.filter(id=item_id, top_list=top_list).update(
|
||||||
|
position=position
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"success": True})
|
||||||
|
|
||||||
|
except TopList.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Top list not found"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
6
backend/api/v1/media/__init__.py
Normal file
6
backend/api/v1/media/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Media API endpoints for ThrillWiki v1.
|
||||||
|
|
||||||
|
This package contains all media-related API functionality including
|
||||||
|
photo uploads, media management, and media-specific operations.
|
||||||
|
"""
|
||||||
222
backend/api/v1/media/serializers.py
Normal file
222
backend/api/v1/media/serializers.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Media domain serializers for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains serializers for photo uploads, media management,
|
||||||
|
and related media functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from drf_spectacular.utils import (
|
||||||
|
extend_schema_serializer,
|
||||||
|
extend_schema_field,
|
||||||
|
OpenApiExample,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === MEDIA UPLOAD SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_serializer(
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Photo Upload Example",
|
||||||
|
summary="Example photo upload request",
|
||||||
|
description="Upload a photo for a park or ride",
|
||||||
|
value={
|
||||||
|
"photo": "file_upload",
|
||||||
|
"app_label": "parks",
|
||||||
|
"model": "park",
|
||||||
|
"object_id": 123,
|
||||||
|
"caption": "Beautiful view of the park entrance",
|
||||||
|
"alt_text": "Park entrance with landscaping",
|
||||||
|
"is_primary": True,
|
||||||
|
"photo_type": "general",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class PhotoUploadInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for photo uploads."""
|
||||||
|
|
||||||
|
photo = serializers.ImageField(
|
||||||
|
help_text="The image file to upload"
|
||||||
|
)
|
||||||
|
app_label = serializers.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text="App label of the content object (e.g., 'parks', 'rides')"
|
||||||
|
)
|
||||||
|
model = serializers.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text="Model name of the content object (e.g., 'park', 'ride')"
|
||||||
|
)
|
||||||
|
object_id = serializers.IntegerField(
|
||||||
|
help_text="ID of the content object"
|
||||||
|
)
|
||||||
|
caption = serializers.CharField(
|
||||||
|
max_length=500,
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
help_text="Optional caption for the photo"
|
||||||
|
)
|
||||||
|
alt_text = serializers.CharField(
|
||||||
|
max_length=255,
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
help_text="Optional alt text for accessibility"
|
||||||
|
)
|
||||||
|
is_primary = serializers.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether this should be the primary photo"
|
||||||
|
)
|
||||||
|
photo_type = serializers.CharField(
|
||||||
|
max_length=50,
|
||||||
|
default="general",
|
||||||
|
required=False,
|
||||||
|
help_text="Type of photo (for rides: 'general', 'on_ride', 'construction', etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoUploadOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for photo uploads."""
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
url = serializers.CharField()
|
||||||
|
caption = serializers.CharField()
|
||||||
|
alt_text = serializers.CharField()
|
||||||
|
is_primary = serializers.BooleanField()
|
||||||
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
# === PHOTO DETAIL SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_serializer(
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Photo Detail Example",
|
||||||
|
summary="Example photo detail response",
|
||||||
|
description="A photo with full details",
|
||||||
|
value={
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://example.com/media/photos/ride123.jpg",
|
||||||
|
"thumbnail_url": "https://example.com/media/thumbnails/ride123_thumb.jpg",
|
||||||
|
"caption": "Amazing view of Steel Vengeance",
|
||||||
|
"alt_text": "Steel Vengeance roller coaster with blue sky",
|
||||||
|
"is_primary": True,
|
||||||
|
"uploaded_at": "2024-08-15T10:30:00Z",
|
||||||
|
"uploaded_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "coaster_photographer",
|
||||||
|
"display_name": "Coaster Photographer",
|
||||||
|
},
|
||||||
|
"content_type": "Ride",
|
||||||
|
"object_id": 123,
|
||||||
|
"file_size": 2048576,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"format": "JPEG",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class PhotoDetailOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for photo details."""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
url = serializers.URLField()
|
||||||
|
thumbnail_url = serializers.URLField(required=False)
|
||||||
|
caption = serializers.CharField()
|
||||||
|
alt_text = serializers.CharField()
|
||||||
|
is_primary = serializers.BooleanField()
|
||||||
|
uploaded_at = serializers.DateTimeField()
|
||||||
|
content_type = serializers.CharField()
|
||||||
|
object_id = serializers.IntegerField()
|
||||||
|
|
||||||
|
# File metadata
|
||||||
|
file_size = serializers.IntegerField()
|
||||||
|
width = serializers.IntegerField()
|
||||||
|
height = serializers.IntegerField()
|
||||||
|
format = serializers.CharField()
|
||||||
|
|
||||||
|
# Uploader info
|
||||||
|
uploaded_by = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.DictField())
|
||||||
|
def get_uploaded_by(self, obj) -> dict:
|
||||||
|
"""Get uploader information."""
|
||||||
|
return {
|
||||||
|
"id": obj.uploaded_by.id,
|
||||||
|
"username": obj.uploaded_by.username,
|
||||||
|
"display_name": getattr(
|
||||||
|
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
|
||||||
|
)(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoListOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for photo list view."""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
url = serializers.URLField()
|
||||||
|
thumbnail_url = serializers.URLField(required=False)
|
||||||
|
caption = serializers.CharField()
|
||||||
|
is_primary = serializers.BooleanField()
|
||||||
|
uploaded_at = serializers.DateTimeField()
|
||||||
|
uploaded_by = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.DictField())
|
||||||
|
def get_uploaded_by(self, obj) -> dict:
|
||||||
|
"""Get uploader information."""
|
||||||
|
return {
|
||||||
|
"id": obj.uploaded_by.id,
|
||||||
|
"username": obj.uploaded_by.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoUpdateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for updating photos."""
|
||||||
|
|
||||||
|
caption = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
||||||
|
alt_text = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||||
|
is_primary = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
# === MEDIA STATS SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
class MediaStatsOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for media statistics."""
|
||||||
|
|
||||||
|
total_photos = serializers.IntegerField()
|
||||||
|
photos_by_content_type = serializers.DictField()
|
||||||
|
recent_uploads = serializers.IntegerField()
|
||||||
|
top_uploaders = serializers.ListField()
|
||||||
|
storage_usage = serializers.DictField()
|
||||||
|
|
||||||
|
|
||||||
|
# === BULK OPERATIONS SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPhotoActionInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for bulk photo actions."""
|
||||||
|
|
||||||
|
photo_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
help_text="List of photo IDs to perform action on"
|
||||||
|
)
|
||||||
|
action = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
('delete', 'Delete'),
|
||||||
|
('approve', 'Approve'),
|
||||||
|
('reject', 'Reject'),
|
||||||
|
],
|
||||||
|
help_text="Action to perform on selected photos"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPhotoActionOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for bulk photo actions."""
|
||||||
|
|
||||||
|
success_count = serializers.IntegerField()
|
||||||
|
failed_count = serializers.IntegerField()
|
||||||
|
errors = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
|
message = serializers.CharField()
|
||||||
29
backend/api/v1/media/urls.py
Normal file
29
backend/api/v1/media/urls.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Media API URL configuration for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains URL patterns for media management endpoints
|
||||||
|
including photo uploads, CRUD operations, and bulk actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
# Create router for ViewSets
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"photos", views.PhotoViewSet, basename="photo")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Photo upload endpoint
|
||||||
|
path("upload/", views.PhotoUploadAPIView.as_view(), name="photo_upload"),
|
||||||
|
|
||||||
|
# Media statistics endpoint
|
||||||
|
path("stats/", views.MediaStatsAPIView.as_view(), name="media_stats"),
|
||||||
|
|
||||||
|
# Bulk photo operations
|
||||||
|
path("photos/bulk-action/", views.BulkPhotoActionAPIView.as_view(),
|
||||||
|
name="bulk_photo_action"),
|
||||||
|
|
||||||
|
# Include router URLs for photo management (CRUD operations)
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
484
backend/api/v1/media/views.py
Normal file
484
backend/api/v1/media/views.py
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
"""
|
||||||
|
Media API views for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module provides API endpoints for media management including
|
||||||
|
photo uploads, captions, and media operations.
|
||||||
|
Consolidated from apps.media.views with proper domain service integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Union
|
||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import Http404
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
|
|
||||||
|
# Import domain-specific models and services instead of generic Photo model
|
||||||
|
from apps.parks.models import ParkPhoto, Park
|
||||||
|
from apps.rides.models import RidePhoto, Ride
|
||||||
|
from apps.parks.services import ParkMediaService
|
||||||
|
from apps.rides.services import RideMediaService
|
||||||
|
from .serializers import (
|
||||||
|
PhotoUploadInputSerializer,
|
||||||
|
PhotoUploadOutputSerializer,
|
||||||
|
PhotoDetailOutputSerializer,
|
||||||
|
PhotoUpdateInputSerializer,
|
||||||
|
PhotoListOutputSerializer,
|
||||||
|
MediaStatsOutputSerializer,
|
||||||
|
BulkPhotoActionInputSerializer,
|
||||||
|
BulkPhotoActionOutputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Upload photo",
|
||||||
|
description="Upload a photo and associate it with a content object (park, ride, etc.)",
|
||||||
|
request=PhotoUploadInputSerializer,
|
||||||
|
responses={
|
||||||
|
201: PhotoUploadOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class PhotoUploadAPIView(APIView):
|
||||||
|
"""API endpoint for photo uploads."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
"""Upload a photo and associate it with a content object."""
|
||||||
|
try:
|
||||||
|
serializer = PhotoUploadInputSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
|
# Get content object
|
||||||
|
try:
|
||||||
|
content_type = ContentType.objects.get(
|
||||||
|
app_label=validated_data["app_label"], model=validated_data["model"]
|
||||||
|
)
|
||||||
|
content_object = content_type.get_object_for_this_type(
|
||||||
|
pk=validated_data["object_id"]
|
||||||
|
)
|
||||||
|
except ContentType.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": f"Invalid content type: {validated_data['app_label']}.{validated_data['model']}"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except content_type.model_class().DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Content object not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine which domain service to use based on content object
|
||||||
|
if hasattr(content_object, '_meta') and content_object._meta.app_label == 'parks':
|
||||||
|
# Check permissions for park photos
|
||||||
|
if not request.user.has_perm("parks.add_parkphoto"):
|
||||||
|
return Response(
|
||||||
|
{"error": "You do not have permission to upload park photos"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create park photo using park media service
|
||||||
|
photo = ParkMediaService.upload_photo(
|
||||||
|
park=content_object,
|
||||||
|
image_file=validated_data["photo"],
|
||||||
|
user=request.user,
|
||||||
|
caption=validated_data.get("caption", ""),
|
||||||
|
alt_text=validated_data.get("alt_text", ""),
|
||||||
|
is_primary=validated_data.get("is_primary", False),
|
||||||
|
)
|
||||||
|
elif hasattr(content_object, '_meta') and content_object._meta.app_label == 'rides':
|
||||||
|
# Check permissions for ride photos
|
||||||
|
if not request.user.has_perm("rides.add_ridephoto"):
|
||||||
|
return Response(
|
||||||
|
{"error": "You do not have permission to upload ride photos"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create ride photo using ride media service
|
||||||
|
photo = RideMediaService.upload_photo(
|
||||||
|
ride=content_object,
|
||||||
|
image_file=validated_data["photo"],
|
||||||
|
user=request.user,
|
||||||
|
caption=validated_data.get("caption", ""),
|
||||||
|
alt_text=validated_data.get("alt_text", ""),
|
||||||
|
is_primary=validated_data.get("is_primary", False),
|
||||||
|
photo_type=validated_data.get("photo_type", "general"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Unsupported content type for media upload: {content_object._meta.label}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_serializer = PhotoUploadOutputSerializer(
|
||||||
|
{
|
||||||
|
"id": photo.id,
|
||||||
|
"url": photo.image.url,
|
||||||
|
"caption": photo.caption,
|
||||||
|
"alt_text": photo.alt_text,
|
||||||
|
"is_primary": photo.is_primary,
|
||||||
|
"message": "Photo uploaded successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in photo upload: {str(e)}", exc_info=True)
|
||||||
|
return Response(
|
||||||
|
{"error": f"An error occurred while uploading the photo: {str(e)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(
|
||||||
|
summary="List photos",
|
||||||
|
description="Retrieve a list of photos with optional filtering",
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="content_type",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Filter by content type (e.g., 'parks.park', 'rides.ride')",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="object_id",
|
||||||
|
type=OpenApiTypes.INT,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Filter by object ID",
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name="is_primary",
|
||||||
|
type=OpenApiTypes.BOOL,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Filter by primary photos only",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: PhotoListOutputSerializer(many=True)},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
retrieve=extend_schema(
|
||||||
|
summary="Get photo details",
|
||||||
|
description="Retrieve detailed information about a specific photo",
|
||||||
|
responses={
|
||||||
|
200: PhotoDetailOutputSerializer,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
update=extend_schema(
|
||||||
|
summary="Update photo",
|
||||||
|
description="Update photo information (caption, alt text, etc.)",
|
||||||
|
request=PhotoUpdateInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: PhotoDetailOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
destroy=extend_schema(
|
||||||
|
summary="Delete photo",
|
||||||
|
description="Delete a photo (only by owner or admin)",
|
||||||
|
responses={
|
||||||
|
204: None,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
set_primary=extend_schema(
|
||||||
|
summary="Set photo as primary",
|
||||||
|
description="Set this photo as the primary photo for its content object",
|
||||||
|
responses={
|
||||||
|
200: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class PhotoViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing photos across domains."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
lookup_field = "id"
|
||||||
|
|
||||||
|
def get_queryset(self) -> QuerySet:
|
||||||
|
"""Get queryset combining photos from all domains."""
|
||||||
|
# Combine park and ride photos
|
||||||
|
park_photos = ParkPhoto.objects.select_related('uploaded_by', 'park')
|
||||||
|
ride_photos = RidePhoto.objects.select_related('uploaded_by', 'ride')
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
content_type = self.request.query_params.get('content_type')
|
||||||
|
object_id = self.request.query_params.get('object_id')
|
||||||
|
is_primary = self.request.query_params.get('is_primary')
|
||||||
|
|
||||||
|
if content_type == 'parks.park':
|
||||||
|
queryset = park_photos
|
||||||
|
if object_id:
|
||||||
|
queryset = queryset.filter(park_id=object_id)
|
||||||
|
elif content_type == 'rides.ride':
|
||||||
|
queryset = ride_photos
|
||||||
|
if object_id:
|
||||||
|
queryset = queryset.filter(ride_id=object_id)
|
||||||
|
else:
|
||||||
|
# Return combined queryset (this is complex due to different models)
|
||||||
|
# For now, return park photos as default - in production might need Union
|
||||||
|
queryset = park_photos
|
||||||
|
|
||||||
|
if is_primary is not None:
|
||||||
|
is_primary_bool = is_primary.lower() in ('true', '1', 'yes')
|
||||||
|
queryset = queryset.filter(is_primary=is_primary_bool)
|
||||||
|
|
||||||
|
return queryset.order_by('-uploaded_at')
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "list":
|
||||||
|
return PhotoListOutputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return PhotoUpdateInputSerializer
|
||||||
|
return PhotoDetailOutputSerializer
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Get photo object from either domain."""
|
||||||
|
photo_id = self.kwargs.get('id')
|
||||||
|
|
||||||
|
# Try to find in park photos first
|
||||||
|
try:
|
||||||
|
return ParkPhoto.objects.select_related('uploaded_by', 'park').get(id=photo_id)
|
||||||
|
except ParkPhoto.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try ride photos
|
||||||
|
try:
|
||||||
|
return RidePhoto.objects.select_related('uploaded_by', 'ride').get(id=photo_id)
|
||||||
|
except RidePhoto.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise Http404("Photo not found")
|
||||||
|
|
||||||
|
def update(self, request: Request, *args, **kwargs) -> Response:
|
||||||
|
"""Update photo details."""
|
||||||
|
photo = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||||
|
raise PermissionDenied("You can only edit your own photos")
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=True)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for field, value in serializer.validated_data.items():
|
||||||
|
setattr(photo, field, value)
|
||||||
|
|
||||||
|
photo.save()
|
||||||
|
|
||||||
|
# Return updated photo details
|
||||||
|
response_serializer = PhotoDetailOutputSerializer(photo)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
def destroy(self, request: Request, *args, **kwargs) -> Response:
|
||||||
|
"""Delete a photo."""
|
||||||
|
photo = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||||
|
raise PermissionDenied("You can only delete your own photos")
|
||||||
|
|
||||||
|
photo.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def set_primary(self, request: Request, id=None) -> Response:
|
||||||
|
"""Set this photo as primary for its content object."""
|
||||||
|
photo = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||||
|
raise PermissionDenied("You can only modify your own photos")
|
||||||
|
|
||||||
|
# Use appropriate service based on photo type
|
||||||
|
if isinstance(photo, ParkPhoto):
|
||||||
|
ParkMediaService.set_primary_photo(photo.park, photo)
|
||||||
|
elif isinstance(photo, RidePhoto):
|
||||||
|
RideMediaService.set_primary_photo(photo.ride, photo)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
"message": "Photo set as primary successfully",
|
||||||
|
"photo_id": photo.id,
|
||||||
|
"is_primary": True
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
get=extend_schema(
|
||||||
|
summary="Get media statistics",
|
||||||
|
description="Retrieve statistics about photos and media usage",
|
||||||
|
responses={200: MediaStatsOutputSerializer},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class MediaStatsAPIView(APIView):
|
||||||
|
"""API endpoint for media statistics."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
"""Get media statistics."""
|
||||||
|
from django.db.models import Count
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Count photos by type
|
||||||
|
park_photo_count = ParkPhoto.objects.count()
|
||||||
|
ride_photo_count = RidePhoto.objects.count()
|
||||||
|
total_photos = park_photo_count + ride_photo_count
|
||||||
|
|
||||||
|
# Recent uploads (last 30 days)
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
recent_park_uploads = ParkPhoto.objects.filter(
|
||||||
|
uploaded_at__gte=thirty_days_ago).count()
|
||||||
|
recent_ride_uploads = RidePhoto.objects.filter(
|
||||||
|
uploaded_at__gte=thirty_days_ago).count()
|
||||||
|
recent_uploads = recent_park_uploads + recent_ride_uploads
|
||||||
|
|
||||||
|
# Top uploaders
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# This is a simplified version - in production might need more complex aggregation
|
||||||
|
top_uploaders = []
|
||||||
|
|
||||||
|
stats = MediaStatsOutputSerializer({
|
||||||
|
"total_photos": total_photos,
|
||||||
|
"photos_by_content_type": {
|
||||||
|
"parks": park_photo_count,
|
||||||
|
"rides": ride_photo_count,
|
||||||
|
},
|
||||||
|
"recent_uploads": recent_uploads,
|
||||||
|
"top_uploaders": top_uploaders,
|
||||||
|
"storage_usage": {
|
||||||
|
"total_size": 0, # Would need to calculate from file sizes
|
||||||
|
"average_size": 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(stats.data)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Bulk photo actions",
|
||||||
|
description="Perform bulk actions on multiple photos (delete, approve, etc.)",
|
||||||
|
request=BulkPhotoActionInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: BulkPhotoActionOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Media"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class BulkPhotoActionAPIView(APIView):
|
||||||
|
"""API endpoint for bulk photo operations."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request: Request) -> Response:
|
||||||
|
"""Perform bulk action on photos."""
|
||||||
|
serializer = BulkPhotoActionInputSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
photo_ids = serializer.validated_data['photo_ids']
|
||||||
|
action = serializer.validated_data['action']
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for photo_id in photo_ids:
|
||||||
|
try:
|
||||||
|
# Find photo in either domain
|
||||||
|
photo = None
|
||||||
|
try:
|
||||||
|
photo = ParkPhoto.objects.get(id=photo_id)
|
||||||
|
except ParkPhoto.DoesNotExist:
|
||||||
|
try:
|
||||||
|
photo = RidePhoto.objects.get(id=photo_id)
|
||||||
|
except RidePhoto.DoesNotExist:
|
||||||
|
errors.append(f"Photo {photo_id} not found")
|
||||||
|
failed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||||
|
errors.append(f"No permission for photo {photo_id}")
|
||||||
|
failed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Perform action
|
||||||
|
if action == 'delete':
|
||||||
|
photo.delete()
|
||||||
|
success_count += 1
|
||||||
|
elif action == 'approve':
|
||||||
|
if hasattr(photo, 'is_approved'):
|
||||||
|
photo.is_approved = True
|
||||||
|
photo.save()
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
errors.append(f"Photo {photo_id} does not support approval")
|
||||||
|
failed_count += 1
|
||||||
|
elif action == 'reject':
|
||||||
|
if hasattr(photo, 'is_approved'):
|
||||||
|
photo.is_approved = False
|
||||||
|
photo.save()
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
errors.append(f"Photo {photo_id} does not support approval")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error processing photo {photo_id}: {str(e)}")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
response_data = BulkPhotoActionOutputSerializer({
|
||||||
|
"success_count": success_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"errors": errors,
|
||||||
|
"message": f"Bulk {action} completed: {success_count} successful, {failed_count} failed"
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(response_data.data)
|
||||||
6
backend/api/v1/parks/__init__.py
Normal file
6
backend/api/v1/parks/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Parks API endpoints for ThrillWiki v1.
|
||||||
|
|
||||||
|
This package contains all park-related API functionality including
|
||||||
|
park management, park photos, and park-specific operations.
|
||||||
|
"""
|
||||||
116
backend/api/v1/parks/serializers.py
Normal file
116
backend/api/v1/parks/serializers.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Park media serializers for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains serializers for park-specific media functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from apps.parks.models import ParkPhoto
|
||||||
|
|
||||||
|
|
||||||
|
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Output serializer for park photos."""
|
||||||
|
|
||||||
|
uploaded_by_username = serializers.CharField(
|
||||||
|
source="uploaded_by.username", read_only=True
|
||||||
|
)
|
||||||
|
file_size = serializers.ReadOnlyField()
|
||||||
|
dimensions = serializers.ReadOnlyField()
|
||||||
|
park_slug = serializers.CharField(source="park.slug", read_only=True)
|
||||||
|
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ParkPhoto
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"image",
|
||||||
|
"caption",
|
||||||
|
"alt_text",
|
||||||
|
"is_primary",
|
||||||
|
"is_approved",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"date_taken",
|
||||||
|
"uploaded_by_username",
|
||||||
|
"file_size",
|
||||||
|
"dimensions",
|
||||||
|
"park_slug",
|
||||||
|
"park_name",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"uploaded_by_username",
|
||||||
|
"file_size",
|
||||||
|
"dimensions",
|
||||||
|
"park_slug",
|
||||||
|
"park_name",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Input serializer for creating park photos."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ParkPhoto
|
||||||
|
fields = [
|
||||||
|
"image",
|
||||||
|
"caption",
|
||||||
|
"alt_text",
|
||||||
|
"is_primary",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Input serializer for updating park photos."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ParkPhoto
|
||||||
|
fields = [
|
||||||
|
"caption",
|
||||||
|
"alt_text",
|
||||||
|
"is_primary",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Simplified output serializer for park photo lists."""
|
||||||
|
|
||||||
|
uploaded_by_username = serializers.CharField(
|
||||||
|
source="uploaded_by.username", read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ParkPhoto
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"image",
|
||||||
|
"caption",
|
||||||
|
"is_primary",
|
||||||
|
"is_approved",
|
||||||
|
"created_at",
|
||||||
|
"uploaded_by_username",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for photo approval operations."""
|
||||||
|
|
||||||
|
photo_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||||
|
)
|
||||||
|
approve = serializers.BooleanField(
|
||||||
|
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for park photo statistics."""
|
||||||
|
|
||||||
|
total_photos = serializers.IntegerField()
|
||||||
|
approved_photos = serializers.IntegerField()
|
||||||
|
pending_photos = serializers.IntegerField()
|
||||||
|
has_primary = serializers.BooleanField()
|
||||||
|
recent_uploads = serializers.IntegerField()
|
||||||
15
backend/api/v1/parks/urls.py
Normal file
15
backend/api/v1/parks/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Park API URLs for ThrillWiki API v1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import ParkPhotoViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"photos", ParkPhotoViewSet, basename="park-photo")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
274
backend/api/v1/parks/views.py
Normal file
274
backend/api/v1/parks/views.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Park API views for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains consolidated park photo viewset for the centralized API structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
|
from apps.parks.models import ParkPhoto
|
||||||
|
from apps.parks.services import ParkMediaService
|
||||||
|
|
||||||
|
from .serializers import (
|
||||||
|
ParkPhotoOutputSerializer,
|
||||||
|
ParkPhotoCreateInputSerializer,
|
||||||
|
ParkPhotoUpdateInputSerializer,
|
||||||
|
ParkPhotoListOutputSerializer,
|
||||||
|
ParkPhotoApprovalInputSerializer,
|
||||||
|
ParkPhotoStatsOutputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(
|
||||||
|
summary="List park photos",
|
||||||
|
description="Retrieve a paginated list of park photos with filtering capabilities.",
|
||||||
|
responses={200: ParkPhotoListOutputSerializer(many=True)},
|
||||||
|
tags=["Park Media"],
|
||||||
|
),
|
||||||
|
create=extend_schema(
|
||||||
|
summary="Upload park photo",
|
||||||
|
description="Upload a new photo for a park. Requires authentication.",
|
||||||
|
request=ParkPhotoCreateInputSerializer,
|
||||||
|
responses={
|
||||||
|
201: ParkPhotoOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Park Media"],
|
||||||
|
),
|
||||||
|
retrieve=extend_schema(
|
||||||
|
summary="Get park photo details",
|
||||||
|
description="Retrieve detailed information about a specific park photo.",
|
||||||
|
responses={
|
||||||
|
200: ParkPhotoOutputSerializer,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Park Media"],
|
||||||
|
),
|
||||||
|
update=extend_schema(
|
||||||
|
summary="Update park photo",
|
||||||
|
description="Update park photo information. Requires authentication and ownership or admin privileges.",
|
||||||
|
request=ParkPhotoUpdateInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: ParkPhotoOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Park Media"],
|
||||||
|
),
|
||||||
|
partial_update=extend_schema(
|
||||||
|
summary="Partially update park photo",
|
||||||
|
description="Partially update park photo information. Requires authentication and ownership or admin privileges.",
|
||||||
|
request=ParkPhotoUpdateInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: ParkPhotoOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Park Media"],
|
||||||
|
),
|
||||||
|
destroy=extend_schema(
|
||||||
|
summary="Delete park photo",
|
||||||
|
description="Delete a park photo. Requires authentication and ownership or admin privileges.",
|
||||||
|
responses={
|
||||||
|
204: None,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Park Media"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class ParkPhotoViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing park photos.
|
||||||
|
|
||||||
|
Provides CRUD operations for park photos with proper permission checking.
|
||||||
|
Uses ParkMediaService for business logic operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
lookup_field = "id"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Get photos for the current park with optimized queries."""
|
||||||
|
return (
|
||||||
|
ParkPhoto.objects.select_related("park", "park__operator", "uploaded_by")
|
||||||
|
.filter(park_id=self.kwargs.get("park_pk"))
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "list":
|
||||||
|
return ParkPhotoListOutputSerializer
|
||||||
|
elif self.action == "create":
|
||||||
|
return ParkPhotoCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return ParkPhotoUpdateInputSerializer
|
||||||
|
else:
|
||||||
|
return ParkPhotoOutputSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Create a new park photo using ParkMediaService."""
|
||||||
|
park_id = self.kwargs.get("park_pk")
|
||||||
|
if not park_id:
|
||||||
|
raise ValidationError("Park ID is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the service to create the photo with proper business logic
|
||||||
|
photo = ParkMediaService.create_photo(
|
||||||
|
park_id=park_id,
|
||||||
|
uploaded_by=self.request.user,
|
||||||
|
**serializer.validated_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the instance for the serializer response
|
||||||
|
serializer.instance = photo
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating park photo: {e}")
|
||||||
|
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
"""Update park photo with permission checking."""
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (
|
||||||
|
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||||
|
):
|
||||||
|
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||||
|
|
||||||
|
# Handle primary photo logic using service
|
||||||
|
if serializer.validated_data.get("is_primary", False):
|
||||||
|
try:
|
||||||
|
ParkMediaService.set_primary_photo(
|
||||||
|
park_id=instance.park_id, photo_id=instance.id
|
||||||
|
)
|
||||||
|
# Remove is_primary from validated_data since service handles it
|
||||||
|
if "is_primary" in serializer.validated_data:
|
||||||
|
del serializer.validated_data["is_primary"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting primary photo: {e}")
|
||||||
|
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||||
|
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Delete park photo with permission checking."""
|
||||||
|
# Check permissions
|
||||||
|
if not (
|
||||||
|
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||||
|
):
|
||||||
|
raise PermissionDenied(
|
||||||
|
"You can only delete your own photos or be an admin."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ParkMediaService.delete_photo(instance.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting park photo: {e}")
|
||||||
|
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def set_primary(self, request, **kwargs):
|
||||||
|
"""Set this photo as the primary photo for the park."""
|
||||||
|
photo = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||||
|
raise PermissionDenied(
|
||||||
|
"You can only modify your own photos or be an admin."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ParkMediaService.set_primary_photo(park_id=photo.park_id, photo_id=photo.id)
|
||||||
|
|
||||||
|
# Refresh the photo instance
|
||||||
|
photo.refresh_from_db()
|
||||||
|
serializer = self.get_serializer(photo)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"message": "Photo set as primary successfully",
|
||||||
|
"photo": serializer.data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting primary photo: {e}")
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||||
|
def bulk_approve(self, request, **kwargs):
|
||||||
|
"""Bulk approve or reject multiple photos (admin only)."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise PermissionDenied("Only administrators can approve photos.")
|
||||||
|
|
||||||
|
serializer = ParkPhotoApprovalInputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
photo_ids = serializer.validated_data["photo_ids"]
|
||||||
|
approve = serializer.validated_data["approve"]
|
||||||
|
park_id = self.kwargs.get("park_pk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Filter photos to only those belonging to this park
|
||||||
|
photos = ParkPhoto.objects.filter(id__in=photo_ids, park_id=park_id)
|
||||||
|
|
||||||
|
updated_count = photos.update(is_approved=approve)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||||
|
"updated_count": updated_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bulk photo approval: {e}")
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to update photos: {str(e)}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def stats(self, request, **kwargs):
|
||||||
|
"""Get photo statistics for the park."""
|
||||||
|
park_id = self.kwargs.get("park_pk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = ParkMediaService.get_photo_stats(park_id=park_id)
|
||||||
|
serializer = ParkPhotoStatsOutputSerializer(stats)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting park photo stats: {e}")
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
6
backend/api/v1/rides/__init__.py
Normal file
6
backend/api/v1/rides/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Rides API endpoints for ThrillWiki v1.
|
||||||
|
|
||||||
|
This package contains all ride-related API functionality including
|
||||||
|
ride management, ride photos, and ride-specific operations.
|
||||||
|
"""
|
||||||
146
backend/api/v1/rides/serializers.py
Normal file
146
backend/api/v1/rides/serializers.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Ride media serializers for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains serializers for ride-specific media functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from apps.rides.models import RidePhoto
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Output serializer for ride photos."""
|
||||||
|
|
||||||
|
uploaded_by_username = serializers.CharField(
|
||||||
|
source="uploaded_by.username", read_only=True
|
||||||
|
)
|
||||||
|
file_size = serializers.ReadOnlyField()
|
||||||
|
dimensions = serializers.ReadOnlyField()
|
||||||
|
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
|
||||||
|
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||||
|
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
|
||||||
|
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RidePhoto
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"image",
|
||||||
|
"caption",
|
||||||
|
"alt_text",
|
||||||
|
"is_primary",
|
||||||
|
"is_approved",
|
||||||
|
"photo_type",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"date_taken",
|
||||||
|
"uploaded_by_username",
|
||||||
|
"file_size",
|
||||||
|
"dimensions",
|
||||||
|
"ride_slug",
|
||||||
|
"ride_name",
|
||||||
|
"park_slug",
|
||||||
|
"park_name",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"uploaded_by_username",
|
||||||
|
"file_size",
|
||||||
|
"dimensions",
|
||||||
|
"ride_slug",
|
||||||
|
"ride_name",
|
||||||
|
"park_slug",
|
||||||
|
"park_name",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Input serializer for creating ride photos."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RidePhoto
|
||||||
|
fields = [
|
||||||
|
"image",
|
||||||
|
"caption",
|
||||||
|
"alt_text",
|
||||||
|
"photo_type",
|
||||||
|
"is_primary",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Input serializer for updating ride photos."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RidePhoto
|
||||||
|
fields = [
|
||||||
|
"caption",
|
||||||
|
"alt_text",
|
||||||
|
"photo_type",
|
||||||
|
"is_primary",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
|
||||||
|
"""Simplified output serializer for ride photo lists."""
|
||||||
|
|
||||||
|
uploaded_by_username = serializers.CharField(
|
||||||
|
source="uploaded_by.username", read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RidePhoto
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"image",
|
||||||
|
"caption",
|
||||||
|
"photo_type",
|
||||||
|
"is_primary",
|
||||||
|
"is_approved",
|
||||||
|
"created_at",
|
||||||
|
"uploaded_by_username",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoApprovalInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for photo approval operations."""
|
||||||
|
|
||||||
|
photo_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||||
|
)
|
||||||
|
approve = serializers.BooleanField(
|
||||||
|
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoStatsOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for ride photo statistics."""
|
||||||
|
|
||||||
|
total_photos = serializers.IntegerField()
|
||||||
|
approved_photos = serializers.IntegerField()
|
||||||
|
pending_photos = serializers.IntegerField()
|
||||||
|
has_primary = serializers.BooleanField()
|
||||||
|
recent_uploads = serializers.IntegerField()
|
||||||
|
by_type = serializers.DictField(
|
||||||
|
child=serializers.IntegerField(), help_text="Photo counts by type"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RidePhotoTypeFilterSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for filtering photos by type."""
|
||||||
|
|
||||||
|
photo_type = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
("exterior", "Exterior View"),
|
||||||
|
("queue", "Queue Area"),
|
||||||
|
("station", "Station"),
|
||||||
|
("onride", "On-Ride"),
|
||||||
|
("construction", "Construction"),
|
||||||
|
("other", "Other"),
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
help_text="Filter photos by type",
|
||||||
|
)
|
||||||
15
backend/api/v1/rides/urls.py
Normal file
15
backend/api/v1/rides/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Ride API URLs for ThrillWiki API v1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import RidePhotoViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"photos", RidePhotoViewSet, basename="ride-photo")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
274
backend/api/v1/rides/views.py
Normal file
274
backend/api/v1/rides/views.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Ride API views for ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module contains consolidated ride photo viewset for the centralized API structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
|
from apps.rides.models import RidePhoto
|
||||||
|
from apps.rides.services import RideMediaService
|
||||||
|
|
||||||
|
from .serializers import (
|
||||||
|
RidePhotoOutputSerializer,
|
||||||
|
RidePhotoCreateInputSerializer,
|
||||||
|
RidePhotoUpdateInputSerializer,
|
||||||
|
RidePhotoListOutputSerializer,
|
||||||
|
RidePhotoApprovalInputSerializer,
|
||||||
|
RidePhotoStatsOutputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(
|
||||||
|
summary="List ride photos",
|
||||||
|
description="Retrieve a paginated list of ride photos with filtering capabilities.",
|
||||||
|
responses={200: RidePhotoListOutputSerializer(many=True)},
|
||||||
|
tags=["Ride Media"],
|
||||||
|
),
|
||||||
|
create=extend_schema(
|
||||||
|
summary="Upload ride photo",
|
||||||
|
description="Upload a new photo for a ride. Requires authentication.",
|
||||||
|
request=RidePhotoCreateInputSerializer,
|
||||||
|
responses={
|
||||||
|
201: RidePhotoOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Ride Media"],
|
||||||
|
),
|
||||||
|
retrieve=extend_schema(
|
||||||
|
summary="Get ride photo details",
|
||||||
|
description="Retrieve detailed information about a specific ride photo.",
|
||||||
|
responses={
|
||||||
|
200: RidePhotoOutputSerializer,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Ride Media"],
|
||||||
|
),
|
||||||
|
update=extend_schema(
|
||||||
|
summary="Update ride photo",
|
||||||
|
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||||
|
request=RidePhotoUpdateInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: RidePhotoOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Ride Media"],
|
||||||
|
),
|
||||||
|
partial_update=extend_schema(
|
||||||
|
summary="Partially update ride photo",
|
||||||
|
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||||
|
request=RidePhotoUpdateInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: RidePhotoOutputSerializer,
|
||||||
|
400: OpenApiTypes.OBJECT,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Ride Media"],
|
||||||
|
),
|
||||||
|
destroy=extend_schema(
|
||||||
|
summary="Delete ride photo",
|
||||||
|
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
|
||||||
|
responses={
|
||||||
|
204: None,
|
||||||
|
401: OpenApiTypes.OBJECT,
|
||||||
|
403: OpenApiTypes.OBJECT,
|
||||||
|
404: OpenApiTypes.OBJECT,
|
||||||
|
},
|
||||||
|
tags=["Ride Media"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class RidePhotoViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing ride photos.
|
||||||
|
|
||||||
|
Provides CRUD operations for ride photos with proper permission checking.
|
||||||
|
Uses RideMediaService for business logic operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
lookup_field = "id"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Get photos for the current ride with optimized queries."""
|
||||||
|
return (
|
||||||
|
RidePhoto.objects.select_related("ride", "ride__park", "uploaded_by")
|
||||||
|
.filter(ride_id=self.kwargs.get("ride_pk"))
|
||||||
|
.order_by("-created_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "list":
|
||||||
|
return RidePhotoListOutputSerializer
|
||||||
|
elif self.action == "create":
|
||||||
|
return RidePhotoCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return RidePhotoUpdateInputSerializer
|
||||||
|
else:
|
||||||
|
return RidePhotoOutputSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Create a new ride photo using RideMediaService."""
|
||||||
|
ride_id = self.kwargs.get("ride_pk")
|
||||||
|
if not ride_id:
|
||||||
|
raise ValidationError("Ride ID is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the service to create the photo with proper business logic
|
||||||
|
photo = RideMediaService.create_photo(
|
||||||
|
ride_id=ride_id,
|
||||||
|
uploaded_by=self.request.user,
|
||||||
|
**serializer.validated_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the instance for the serializer response
|
||||||
|
serializer.instance = photo
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating ride photo: {e}")
|
||||||
|
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
"""Update ride photo with permission checking."""
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (
|
||||||
|
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||||
|
):
|
||||||
|
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||||
|
|
||||||
|
# Handle primary photo logic using service
|
||||||
|
if serializer.validated_data.get("is_primary", False):
|
||||||
|
try:
|
||||||
|
RideMediaService.set_primary_photo(
|
||||||
|
ride_id=instance.ride_id, photo_id=instance.id
|
||||||
|
)
|
||||||
|
# Remove is_primary from validated_data since service handles it
|
||||||
|
if "is_primary" in serializer.validated_data:
|
||||||
|
del serializer.validated_data["is_primary"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting primary photo: {e}")
|
||||||
|
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||||
|
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Delete ride photo with permission checking."""
|
||||||
|
# Check permissions
|
||||||
|
if not (
|
||||||
|
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||||
|
):
|
||||||
|
raise PermissionDenied(
|
||||||
|
"You can only delete your own photos or be an admin."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
RideMediaService.delete_photo(instance.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting ride photo: {e}")
|
||||||
|
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def set_primary(self, request, **kwargs):
|
||||||
|
"""Set this photo as the primary photo for the ride."""
|
||||||
|
photo = self.get_object()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||||
|
raise PermissionDenied(
|
||||||
|
"You can only modify your own photos or be an admin."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
RideMediaService.set_primary_photo(ride_id=photo.ride_id, photo_id=photo.id)
|
||||||
|
|
||||||
|
# Refresh the photo instance
|
||||||
|
photo.refresh_from_db()
|
||||||
|
serializer = self.get_serializer(photo)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"message": "Photo set as primary successfully",
|
||||||
|
"photo": serializer.data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting primary photo: {e}")
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||||
|
def bulk_approve(self, request, **kwargs):
|
||||||
|
"""Bulk approve or reject multiple photos (admin only)."""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
raise PermissionDenied("Only administrators can approve photos.")
|
||||||
|
|
||||||
|
serializer = RidePhotoApprovalInputSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
photo_ids = serializer.validated_data["photo_ids"]
|
||||||
|
approve = serializer.validated_data["approve"]
|
||||||
|
ride_id = self.kwargs.get("ride_pk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Filter photos to only those belonging to this ride
|
||||||
|
photos = RidePhoto.objects.filter(id__in=photo_ids, ride_id=ride_id)
|
||||||
|
|
||||||
|
updated_count = photos.update(is_approved=approve)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||||
|
"updated_count": updated_count,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bulk photo approval: {e}")
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to update photos: {str(e)}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def stats(self, request, **kwargs):
|
||||||
|
"""Get photo statistics for the ride."""
|
||||||
|
ride_id = self.kwargs.get("ride_pk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = RideMediaService.get_photo_stats(ride_id=ride_id)
|
||||||
|
serializer = RidePhotoStatsOutputSerializer(stats)
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting ride photo stats: {e}")
|
||||||
|
return Response(
|
||||||
|
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
18
backend/api/v1/urls.py
Normal file
18
backend/api/v1/urls.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Version 1 API URL router for ThrillWiki.
|
||||||
|
|
||||||
|
This module routes API requests to domain-specific endpoints.
|
||||||
|
All domain endpoints are organized in their respective subdirectories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Domain-specific API endpoints
|
||||||
|
path("rides/", include("api.v1.rides.urls")),
|
||||||
|
path("parks/", include("api.v1.parks.urls")),
|
||||||
|
path("auth/", include("api.v1.auth.urls")),
|
||||||
|
# Media endpoints (for photo management)
|
||||||
|
# Will be consolidated from the various media implementations
|
||||||
|
path("media/", include("api.v1.media.urls")),
|
||||||
|
]
|
||||||
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()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||||
def is_open_for_signup(self, request):
|
def is_open_for_signup(self, request):
|
||||||
"""
|
"""
|
||||||
Whether to allow sign ups.
|
Whether to allow sign ups.
|
||||||
"""
|
"""
|
||||||
return getattr(settings, 'ACCOUNT_ALLOW_SIGNUPS', True)
|
return True
|
||||||
|
|
||||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||||
"""
|
"""
|
||||||
Constructs the email confirmation (activation) url.
|
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}"
|
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
|
||||||
|
|
||||||
def send_confirmation_mail(self, request, emailconfirmation, signup):
|
def send_confirmation_mail(self, request, emailconfirmation, signup):
|
||||||
@@ -27,30 +28,31 @@ class CustomAccountAdapter(DefaultAccountAdapter):
|
|||||||
current_site = get_current_site(request)
|
current_site = get_current_site(request)
|
||||||
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
||||||
ctx = {
|
ctx = {
|
||||||
'user': emailconfirmation.email_address.user,
|
"user": emailconfirmation.email_address.user,
|
||||||
'activate_url': activate_url,
|
"activate_url": activate_url,
|
||||||
'current_site': current_site,
|
"current_site": current_site,
|
||||||
'key': emailconfirmation.key,
|
"key": emailconfirmation.key,
|
||||||
}
|
}
|
||||||
if signup:
|
if signup:
|
||||||
email_template = 'account/email/email_confirmation_signup'
|
email_template = "account/email/email_confirmation_signup"
|
||||||
else:
|
else:
|
||||||
email_template = 'account/email/email_confirmation'
|
email_template = "account/email/email_confirmation"
|
||||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||||
|
|
||||||
|
|
||||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||||
def is_open_for_signup(self, request, sociallogin):
|
def is_open_for_signup(self, request, sociallogin):
|
||||||
"""
|
"""
|
||||||
Whether to allow social account sign ups.
|
Whether to allow social account sign ups.
|
||||||
"""
|
"""
|
||||||
return getattr(settings, 'SOCIALACCOUNT_ALLOW_SIGNUPS', True)
|
return True
|
||||||
|
|
||||||
def populate_user(self, request, sociallogin, data):
|
def populate_user(self, request, sociallogin, data):
|
||||||
"""
|
"""
|
||||||
Hook that can be used to further populate the user instance.
|
Hook that can be used to further populate the user instance.
|
||||||
"""
|
"""
|
||||||
user = super().populate_user(request, sociallogin, data)
|
user = super().populate_user(request, sociallogin, data)
|
||||||
if sociallogin.account.provider == 'discord':
|
if sociallogin.account.provider == "discord":
|
||||||
user.discord_id = sociallogin.account.uid
|
user.discord_id = sociallogin.account.uid
|
||||||
return user
|
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):
|
class AccountsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "accounts"
|
name = "apps.accounts"
|
||||||
|
|
||||||
def ready(self):
|
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.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Clean up social auth tables and migrations'
|
help = "Clean up social auth tables and migrations"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
@@ -11,12 +12,17 @@ class Command(BaseCommand):
|
|||||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp_sites")
|
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp_sites")
|
||||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialaccount")
|
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialaccount")
|
||||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialtoken")
|
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialtoken")
|
||||||
|
|
||||||
# Remove migration records
|
# Remove migration records
|
||||||
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
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
|
# Reset sequences
|
||||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
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,9 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from apps.parks.models import ParkReview, Park, ParkPhoto
|
||||||
from parks.models import Park, ParkReview as Review
|
from apps.rides.models import Ride, RidePhoto
|
||||||
from rides.models import Ride
|
|
||||||
from media.models import Photo
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -13,25 +11,33 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
# Delete test users
|
# Delete test users
|
||||||
test_users = User.objects.filter(
|
test_users = User.objects.filter(username__in=["testuser", "moderator"])
|
||||||
username__in=["testuser", "moderator"])
|
|
||||||
count = test_users.count()
|
count = test_users.count()
|
||||||
test_users.delete()
|
test_users.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||||
|
|
||||||
# Delete test reviews
|
# Delete test reviews
|
||||||
reviews = Review.objects.filter(
|
reviews = ParkReview.objects.filter(
|
||||||
user__username__in=["testuser", "moderator"])
|
user__username__in=["testuser", "moderator"]
|
||||||
|
)
|
||||||
count = reviews.count()
|
count = reviews.count()
|
||||||
reviews.delete()
|
reviews.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||||
|
|
||||||
# Delete test photos
|
# Delete test photos - both park and ride photos
|
||||||
photos = Photo.objects.filter(uploader__username__in=[
|
park_photos = ParkPhoto.objects.filter(
|
||||||
"testuser", "moderator"])
|
uploader__username__in=["testuser", "moderator"]
|
||||||
count = photos.count()
|
)
|
||||||
photos.delete()
|
park_count = park_photos.count()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
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
|
# Delete test parks
|
||||||
parks = Park.objects.filter(name__startswith="Test Park")
|
parks = Park.objects.filter(name__startswith="Test Park")
|
||||||
@@ -64,7 +70,6 @@ class Command(BaseCommand):
|
|||||||
os.remove(f)
|
os.remove(f)
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.WARNING(
|
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
|
||||||
f"Error deleting {f}: {e}"))
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))
|
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))
|
||||||
@@ -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.core.management.base import BaseCommand
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
from django.contrib.auth.models import Group, Permission
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -11,22 +8,25 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
# Create regular test user
|
# Create regular test user
|
||||||
if not User.objects.filter(username="testuser").exists():
|
if not User.objects.filter(username="testuser").exists():
|
||||||
user = User.objects.create_user(
|
user = User.objects.create(
|
||||||
username="testuser",
|
username="testuser",
|
||||||
email="testuser@example.com",
|
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:
|
else:
|
||||||
self.stdout.write(self.style.WARNING("Test user already exists"))
|
self.stdout.write(self.style.WARNING("Test user already exists"))
|
||||||
|
|
||||||
# Create moderator user
|
|
||||||
if not User.objects.filter(username="moderator").exists():
|
if not User.objects.filter(username="moderator").exists():
|
||||||
moderator = User.objects.create_user(
|
moderator = User.objects.create(
|
||||||
username="moderator",
|
username="moderator",
|
||||||
email="moderator@example.com",
|
email="moderator@example.com",
|
||||||
[PASSWORD-REMOVED]",
|
|
||||||
)
|
)
|
||||||
|
moderator.set_password("modpass123")
|
||||||
|
moderator.save()
|
||||||
|
|
||||||
# Create moderator group if it doesn't exist
|
# Create moderator group if it doesn't exist
|
||||||
moderator_group, created = Group.objects.get_or_create(name="Moderators")
|
moderator_group, created = Group.objects.get_or_create(name="Moderators")
|
||||||
@@ -48,7 +48,9 @@ class Command(BaseCommand):
|
|||||||
moderator.groups.add(moderator_group)
|
moderator.groups.add(moderator_group)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
|
self.style.SUCCESS(
|
||||||
|
f"Created moderator user: {moderator.get_username()}"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
||||||
@@ -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
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
def generate_avatar(letter):
|
def generate_avatar(letter):
|
||||||
"""Generate an avatar for a given letter or number"""
|
"""Generate an avatar for a given letter or number"""
|
||||||
avatar_size = (100, 100)
|
avatar_size = (100, 100)
|
||||||
@@ -10,7 +11,7 @@ def generate_avatar(letter):
|
|||||||
font_size = 100
|
font_size = 100
|
||||||
|
|
||||||
# Create a blank image with background color
|
# 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)
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
# Load a font
|
# Load a font
|
||||||
@@ -19,8 +20,14 @@ def generate_avatar(letter):
|
|||||||
|
|
||||||
# Calculate text size and position using textbbox
|
# Calculate text size and position using textbbox
|
||||||
text_bbox = draw.textbbox((0, 0), letter, font=font)
|
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_width, text_height = (
|
||||||
text_position = ((avatar_size[0] - text_width) / 2, (avatar_size[1] - text_height) / 2)
|
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 the text on the image
|
||||||
draw.text(text_position, letter, font=font, fill=text_color)
|
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")
|
avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png")
|
||||||
image.save(avatar_path)
|
image.save(avatar_path)
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
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:
|
for char in characters:
|
||||||
generate_avatar(char)
|
generate_avatar(char)
|
||||||
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {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,66 +3,87 @@ from django.db import connection
|
|||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Reset database and create admin user'
|
help = "Reset database and create admin user"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write('Resetting database...')
|
self.stdout.write("Resetting database...")
|
||||||
|
|
||||||
# Drop all tables
|
# Drop all tables
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
DO $$ DECLARE
|
DO $$ DECLARE
|
||||||
r RECORD;
|
r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP
|
FOR r IN (
|
||||||
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
SELECT tablename FROM pg_tables
|
||||||
|
WHERE schemaname = current_schema()
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
||||||
|
quote_ident(r.tablename) || ' CASCADE';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Reset sequences
|
# Reset sequences
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
DO $$ DECLARE
|
DO $$ DECLARE
|
||||||
r RECORD;
|
r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN (SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema()) LOOP
|
FOR r IN (
|
||||||
EXECUTE 'ALTER SEQUENCE ' || quote_ident(r.sequencename) || ' RESTART WITH 1';
|
SELECT sequencename FROM pg_sequences
|
||||||
|
WHERE schemaname = current_schema()
|
||||||
|
) LOOP
|
||||||
|
EXECUTE 'ALTER SEQUENCE ' || \
|
||||||
|
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
self.stdout.write('All tables dropped and sequences reset.')
|
self.stdout.write("All tables dropped and sequences reset.")
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
from django.core.management import call_command
|
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
|
# Create superuser using raw SQL
|
||||||
try:
|
try:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
# Create user
|
# Create user
|
||||||
user_id = str(uuid.uuid4())[:10]
|
user_id = str(uuid.uuid4())[:10]
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
INSERT INTO accounts_user (
|
INSERT INTO accounts_user (
|
||||||
username, password, email, is_superuser, is_staff,
|
username, password, email, is_superuser, is_staff,
|
||||||
is_active, date_joined, user_id, first_name,
|
is_active, date_joined, user_id, first_name,
|
||||||
last_name, role, is_banned, ban_reason,
|
last_name, role, is_banned, ban_reason,
|
||||||
theme_preference
|
theme_preference
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'admin', %s, 'admin@thrillwiki.com', true, true,
|
'admin', %s, 'admin@thrillwiki.com', true, true,
|
||||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
||||||
'light'
|
'light'
|
||||||
) RETURNING id;
|
) 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
|
# Create profile
|
||||||
profile_id = str(uuid.uuid4())[:10]
|
profile_id = str(uuid.uuid4())[:10]
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
INSERT INTO accounts_userprofile (
|
INSERT INTO accounts_userprofile (
|
||||||
profile_id, display_name, pronouns, bio,
|
profile_id, display_name, pronouns, bio,
|
||||||
twitter, instagram, youtube, discord,
|
twitter, instagram, youtube, discord,
|
||||||
@@ -75,11 +96,13 @@ class Command(BaseCommand):
|
|||||||
0, 0, 0, 0,
|
0, 0, 0, 0,
|
||||||
%s, ''
|
%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:
|
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
|
raise
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS('Database reset complete.'))
|
self.stdout.write(self.style.SUCCESS("Database reset complete."))
|
||||||
@@ -3,34 +3,37 @@ from allauth.socialaccount.models import SocialApp
|
|||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Reset social apps configuration'
|
help = "Reset social apps configuration"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Delete all social apps using raw SQL to bypass Django's ORM
|
# Delete all social apps using raw SQL to bypass Django's ORM
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||||
cursor.execute("DELETE FROM socialaccount_socialapp")
|
cursor.execute("DELETE FROM socialaccount_socialapp")
|
||||||
|
|
||||||
# Get the default site
|
# Get the default site
|
||||||
site = Site.objects.get(id=1)
|
site = Site.objects.get(id=1)
|
||||||
|
|
||||||
# Create Discord app
|
# Create Discord app
|
||||||
discord_app = SocialApp.objects.create(
|
discord_app = SocialApp.objects.create(
|
||||||
provider='discord',
|
provider="discord",
|
||||||
name='Discord',
|
name="Discord",
|
||||||
client_id='1299112802274902047',
|
client_id="1299112802274902047",
|
||||||
secret='ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
secret="ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11",
|
||||||
)
|
)
|
||||||
discord_app.sites.add(site)
|
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
|
# Create Google app
|
||||||
google_app = SocialApp.objects.create(
|
google_app = SocialApp.objects.create(
|
||||||
provider='google',
|
provider="google",
|
||||||
name='Google',
|
name="Google",
|
||||||
client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
client_id=(
|
||||||
secret='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',
|
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
|
||||||
|
),
|
||||||
|
secret="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",
|
||||||
)
|
)
|
||||||
google_app.sites.add(site)
|
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,26 +1,26 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from apps.accounts.models import User
|
||||||
from accounts.models import User
|
from apps.accounts.signals import create_default_groups
|
||||||
from accounts.signals import create_default_groups
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write('Creating default groups and permissions...')
|
self.stdout.write("Creating default groups and permissions...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create default groups with permissions
|
# Create default groups with permissions
|
||||||
create_default_groups()
|
create_default_groups()
|
||||||
|
|
||||||
# Sync existing users with groups based on their roles
|
# Sync existing users with groups based on their roles
|
||||||
users = User.objects.exclude(role=User.Roles.USER)
|
users = User.objects.exclude(role=User.Roles.USER)
|
||||||
for user in users:
|
for user in users:
|
||||||
group = Group.objects.filter(name=user.role).first()
|
group = Group.objects.filter(name=user.role).first()
|
||||||
if group:
|
if group:
|
||||||
user.groups.add(group)
|
user.groups.add(group)
|
||||||
|
|
||||||
# Update staff/superuser status based on role
|
# Update staff/superuser status based on role
|
||||||
if user.role == User.Roles.SUPERUSER:
|
if user.role == User.Roles.SUPERUSER:
|
||||||
user.is_superuser = True
|
user.is_superuser = True
|
||||||
@@ -28,15 +28,17 @@ class Command(BaseCommand):
|
|||||||
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||||
user.is_staff = True
|
user.is_staff = True
|
||||||
user.save()
|
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
|
# Print summary
|
||||||
for group in Group.objects.all():
|
for group in Group.objects.all():
|
||||||
self.stdout.write(f'\nGroup: {group.name}')
|
self.stdout.write(f"\nGroup: {group.name}")
|
||||||
self.stdout.write('Permissions:')
|
self.stdout.write("Permissions:")
|
||||||
for perm in group.permissions.all():
|
for perm in group.permissions.all():
|
||||||
self.stdout.write(f' - {perm.codename}')
|
self.stdout.write(f" - {perm.codename}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))
|
self.stdout.write(self.style.ERROR(f"Error setting up groups: {str(e)}"))
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Set up default site'
|
help = "Set up default site"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Delete any existing sites
|
# Delete any existing sites
|
||||||
Site.objects.all().delete()
|
Site.objects.all().delete()
|
||||||
|
|
||||||
# Create default site
|
# Create default site
|
||||||
site = Site.objects.create(
|
site = Site.objects.create(
|
||||||
id=1,
|
id=1, domain="localhost:8000", name="ThrillWiki Development"
|
||||||
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.core.management.base import BaseCommand
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.contrib.auth import get_user_model
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Set up social authentication through admin interface'
|
help = "Set up social authentication through admin interface"
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Get or create the default site
|
# Get or create the default site
|
||||||
site, _ = Site.objects.get_or_create(
|
site, _ = Site.objects.get_or_create(
|
||||||
id=1,
|
id=1,
|
||||||
defaults={
|
defaults={
|
||||||
'domain': 'localhost:8000',
|
"domain": "localhost:8000",
|
||||||
'name': 'ThrillWiki Development'
|
"name": "ThrillWiki Development",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
if not _:
|
if not _:
|
||||||
site.domain = 'localhost:8000'
|
site.domain = "localhost:8000"
|
||||||
site.name = 'ThrillWiki Development'
|
site.name = "ThrillWiki Development"
|
||||||
site.save()
|
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
|
# Create superuser if it doesn't exist
|
||||||
if not User.objects.filter(username='admin').exists():
|
if not User.objects.filter(username="admin").exists():
|
||||||
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
admin_user = User.objects.create(
|
||||||
self.stdout.write('Created superuser: admin/admin')
|
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:
|
Social auth setup instructions:
|
||||||
|
|
||||||
1. Run the development server:
|
1. Run the development server:
|
||||||
@@ -57,4 +65,6 @@ Social auth setup instructions:
|
|||||||
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
|
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
|
||||||
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
|
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
|
||||||
Sites: Add "localhost:8000"
|
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,19 +2,22 @@ from django.core.management.base import BaseCommand
|
|||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
# Get all sites
|
# Get all sites
|
||||||
sites = Site.objects.all()
|
sites = Site.objects.all()
|
||||||
|
|
||||||
# Update each social app
|
# Update each social app
|
||||||
for app in SocialApp.objects.all():
|
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
|
# Clear existing sites
|
||||||
app.sites.clear()
|
app.sites.clear()
|
||||||
# Add all sites
|
# Add all sites
|
||||||
for site in sites:
|
for site in sites:
|
||||||
app.sites.add(site)
|
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"))
|
||||||
@@ -11,7 +11,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -33,7 +32,10 @@ class Migration(migrations.Migration):
|
|||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
(
|
||||||
|
"password",
|
||||||
|
models.CharField(max_length=128, verbose_name="password"),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"last_login",
|
"last_login",
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
@@ -78,7 +80,9 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"email",
|
"email",
|
||||||
models.EmailField(
|
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",
|
"date_joined",
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
default=django.utils.timezone.now, verbose_name="date joined"
|
default=django.utils.timezone.now,
|
||||||
|
verbose_name="date joined",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -274,7 +279,10 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="TopListEvent",
|
name="TopListEvent",
|
||||||
fields=[
|
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_created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
("pgh_label", models.TextField(help_text="The event label.")),
|
||||||
("id", models.BigIntegerField()),
|
("id", models.BigIntegerField()),
|
||||||
@@ -369,7 +377,10 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="TopListItemEvent",
|
name="TopListItemEvent",
|
||||||
fields=[
|
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_created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("pgh_label", models.TextField(help_text="The event label.")),
|
("pgh_label", models.TextField(help_text="The event label.")),
|
||||||
("id", models.BigIntegerField()),
|
("id", models.BigIntegerField()),
|
||||||
@@ -451,7 +462,10 @@ class Migration(migrations.Migration):
|
|||||||
unique=True,
|
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)),
|
("pronouns", models.CharField(blank=True, max_length=50)),
|
||||||
("bio", models.TextField(blank=True, max_length=500)),
|
("bio", models.TextField(blank=True, max_length=500)),
|
||||||
("twitter", models.URLField(blank=True)),
|
("twitter", models.URLField(blank=True)),
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,11 +2,13 @@ import requests
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class TurnstileMixin:
|
class TurnstileMixin:
|
||||||
"""
|
"""
|
||||||
Mixin to handle Cloudflare Turnstile validation.
|
Mixin to handle Cloudflare Turnstile validation.
|
||||||
Bypasses validation when DEBUG is True.
|
Bypasses validation when DEBUG is True.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def validate_turnstile(self, request):
|
def validate_turnstile(self, request):
|
||||||
"""
|
"""
|
||||||
Validate the Turnstile response token.
|
Validate the Turnstile response token.
|
||||||
@@ -14,20 +16,20 @@ class TurnstileMixin:
|
|||||||
"""
|
"""
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
return
|
return
|
||||||
|
|
||||||
token = request.POST.get('cf-turnstile-response')
|
token = request.POST.get("cf-turnstile-response")
|
||||||
if not token:
|
if not token:
|
||||||
raise ValidationError('Please complete the Turnstile challenge.')
|
raise ValidationError("Please complete the Turnstile challenge.")
|
||||||
|
|
||||||
# Verify the token with Cloudflare
|
# Verify the token with Cloudflare
|
||||||
data = {
|
data = {
|
||||||
'secret': settings.TURNSTILE_SECRET_KEY,
|
"secret": settings.TURNSTILE_SECRET_KEY,
|
||||||
'response': token,
|
"response": token,
|
||||||
'remoteip': request.META.get('REMOTE_ADDR'),
|
"remoteip": request.META.get("REMOTE_ADDR"),
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
if not result.get('success'):
|
if not result.get("success"):
|
||||||
raise ValidationError('Turnstile validation failed. Please try again.')
|
raise ValidationError("Turnstile validation failed. Please try again.")
|
||||||
@@ -2,13 +2,11 @@ from django.contrib.auth.models import AbstractUser
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from io import BytesIO
|
|
||||||
import base64
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from core.history import TrackedModel
|
from apps.core.history import TrackedModel
|
||||||
# import pghistory
|
import pghistory
|
||||||
|
|
||||||
|
|
||||||
def generate_random_id(model_class, id_field):
|
def generate_random_id(model_class, id_field):
|
||||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||||
@@ -17,29 +15,34 @@ def generate_random_id(model_class, id_field):
|
|||||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
||||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
# If all 4-digit numbers are taken, try 5 digits
|
# If all 4-digit numbers are taken, try 5 digits
|
||||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
||||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
class Roles(models.TextChoices):
|
class Roles(models.TextChoices):
|
||||||
USER = 'USER', _('User')
|
USER = "USER", _("User")
|
||||||
MODERATOR = 'MODERATOR', _('Moderator')
|
MODERATOR = "MODERATOR", _("Moderator")
|
||||||
ADMIN = 'ADMIN', _('Admin')
|
ADMIN = "ADMIN", _("Admin")
|
||||||
SUPERUSER = 'SUPERUSER', _('Superuser')
|
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||||
|
|
||||||
class ThemePreference(models.TextChoices):
|
class ThemePreference(models.TextChoices):
|
||||||
LIGHT = 'light', _('Light')
|
LIGHT = "light", _("Light")
|
||||||
DARK = 'dark', _('Dark')
|
DARK = "dark", _("Dark")
|
||||||
|
|
||||||
# Read-only ID
|
# Read-only ID
|
||||||
user_id = models.CharField(
|
user_id = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
unique=True,
|
unique=True,
|
||||||
editable=False,
|
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(
|
role = models.CharField(
|
||||||
@@ -61,50 +64,48 @@ class User(AbstractUser):
|
|||||||
return self.get_display_name()
|
return self.get_display_name()
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('profile', kwargs={'username': self.username})
|
return reverse("profile", kwargs={"username": self.username})
|
||||||
|
|
||||||
def get_display_name(self):
|
def get_display_name(self):
|
||||||
"""Get the user's display name, falling back to username if not set"""
|
"""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:
|
if profile and profile.display_name:
|
||||||
return profile.display_name
|
return profile.display_name
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.user_id:
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
class UserProfile(models.Model):
|
class UserProfile(models.Model):
|
||||||
# Read-only ID
|
# Read-only ID
|
||||||
profile_id = models.CharField(
|
profile_id = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
unique=True,
|
unique=True,
|
||||||
editable=False,
|
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 = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='profile'
|
|
||||||
)
|
|
||||||
display_name = models.CharField(
|
display_name = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
unique=True,
|
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)
|
pronouns = models.CharField(max_length=50, blank=True)
|
||||||
|
|
||||||
bio = models.TextField(max_length=500, blank=True)
|
bio = models.TextField(max_length=500, blank=True)
|
||||||
|
|
||||||
# Social media links
|
# Social media links
|
||||||
twitter = models.URLField(blank=True)
|
twitter = models.URLField(blank=True)
|
||||||
instagram = models.URLField(blank=True)
|
instagram = models.URLField(blank=True)
|
||||||
youtube = models.URLField(blank=True)
|
youtube = models.URLField(blank=True)
|
||||||
discord = models.CharField(max_length=100, blank=True)
|
discord = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
# Ride statistics
|
# Ride statistics
|
||||||
coaster_credits = models.IntegerField(default=0)
|
coaster_credits = models.IntegerField(default=0)
|
||||||
dark_ride_credits = models.IntegerField(default=0)
|
dark_ride_credits = models.IntegerField(default=0)
|
||||||
@@ -112,7 +113,10 @@ class UserProfile(models.Model):
|
|||||||
water_ride_credits = models.IntegerField(default=0)
|
water_ride_credits = models.IntegerField(default=0)
|
||||||
|
|
||||||
def get_avatar(self):
|
def get_avatar(self):
|
||||||
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
|
"""
|
||||||
|
Return the avatar URL or serve a pre-generated avatar based on the
|
||||||
|
first letter of the username
|
||||||
|
"""
|
||||||
if self.avatar:
|
if self.avatar:
|
||||||
return self.avatar.url
|
return self.avatar.url
|
||||||
first_letter = self.user.username.upper()
|
first_letter = self.user.username.upper()
|
||||||
@@ -127,12 +131,14 @@ class UserProfile(models.Model):
|
|||||||
self.display_name = self.user.username
|
self.display_name = self.user.username
|
||||||
|
|
||||||
if not self.profile_id:
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.display_name
|
return self.display_name
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
class EmailVerification(models.Model):
|
class EmailVerification(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
token = models.CharField(max_length=64, unique=True)
|
token = models.CharField(max_length=64, unique=True)
|
||||||
@@ -146,6 +152,8 @@ class EmailVerification(models.Model):
|
|||||||
verbose_name = "Email Verification"
|
verbose_name = "Email Verification"
|
||||||
verbose_name_plural = "Email Verifications"
|
verbose_name_plural = "Email Verifications"
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
class PasswordReset(models.Model):
|
class PasswordReset(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
token = models.CharField(max_length=64)
|
token = models.CharField(max_length=64)
|
||||||
@@ -160,53 +168,55 @@ class PasswordReset(models.Model):
|
|||||||
verbose_name = "Password Reset"
|
verbose_name = "Password Reset"
|
||||||
verbose_name_plural = "Password Resets"
|
verbose_name_plural = "Password Resets"
|
||||||
|
|
||||||
|
|
||||||
# @pghistory.track()
|
# @pghistory.track()
|
||||||
|
|
||||||
|
|
||||||
class TopList(TrackedModel):
|
class TopList(TrackedModel):
|
||||||
class Categories(models.TextChoices):
|
class Categories(models.TextChoices):
|
||||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||||
DARK_RIDE = 'DR', _('Dark Ride')
|
DARK_RIDE = "DR", _("Dark Ride")
|
||||||
FLAT_RIDE = 'FR', _('Flat Ride')
|
FLAT_RIDE = "FR", _("Flat Ride")
|
||||||
WATER_RIDE = 'WR', _('Water Ride')
|
WATER_RIDE = "WR", _("Water Ride")
|
||||||
PARK = 'PK', _('Park')
|
PARK = "PK", _("Park")
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
on_delete=models.CASCADE,
|
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)
|
title = models.CharField(max_length=100)
|
||||||
category = models.CharField(
|
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||||
max_length=2,
|
|
||||||
choices=Categories.choices
|
|
||||||
)
|
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta(TrackedModel.Meta):
|
||||||
ordering = ['-updated_at']
|
ordering = ["-updated_at"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
return (
|
||||||
|
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# @pghistory.track()
|
# @pghistory.track()
|
||||||
|
|
||||||
|
|
||||||
class TopListItem(TrackedModel):
|
class TopListItem(TrackedModel):
|
||||||
top_list = models.ForeignKey(
|
top_list = models.ForeignKey(
|
||||||
TopList,
|
TopList, on_delete=models.CASCADE, related_name="items"
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='items'
|
|
||||||
)
|
)
|
||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
'contenttypes.ContentType',
|
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||||
on_delete=models.CASCADE
|
|
||||||
)
|
)
|
||||||
object_id = models.PositiveIntegerField()
|
object_id = models.PositiveIntegerField()
|
||||||
rank = models.PositiveIntegerField()
|
rank = models.PositiveIntegerField()
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta(TrackedModel.Meta):
|
||||||
ordering = ['rank']
|
ordering = ["rank"]
|
||||||
unique_together = [['top_list', 'rank']]
|
unique_together = [["top_list", "rank"]]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"#{self.rank} in {self.top_list.title}"
|
return f"#{self.rank} in {self.top_list.title}"
|
||||||
@@ -2,14 +2,12 @@ from django.contrib.auth.models import AbstractUser
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from io import BytesIO
|
|
||||||
import base64
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from core.history import TrackedModel
|
from apps.core.history import TrackedModel
|
||||||
import pghistory
|
import pghistory
|
||||||
|
|
||||||
|
|
||||||
def generate_random_id(model_class, id_field):
|
def generate_random_id(model_class, id_field):
|
||||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||||
while True:
|
while True:
|
||||||
@@ -17,29 +15,30 @@ def generate_random_id(model_class, id_field):
|
|||||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
||||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
# If all 4-digit numbers are taken, try 5 digits
|
# If all 4-digit numbers are taken, try 5 digits
|
||||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
||||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
class Roles(models.TextChoices):
|
class Roles(models.TextChoices):
|
||||||
USER = 'USER', _('User')
|
USER = "USER", _("User")
|
||||||
MODERATOR = 'MODERATOR', _('Moderator')
|
MODERATOR = "MODERATOR", _("Moderator")
|
||||||
ADMIN = 'ADMIN', _('Admin')
|
ADMIN = "ADMIN", _("Admin")
|
||||||
SUPERUSER = 'SUPERUSER', _('Superuser')
|
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||||
|
|
||||||
class ThemePreference(models.TextChoices):
|
class ThemePreference(models.TextChoices):
|
||||||
LIGHT = 'light', _('Light')
|
LIGHT = "light", _("Light")
|
||||||
DARK = 'dark', _('Dark')
|
DARK = "dark", _("Dark")
|
||||||
|
|
||||||
# Read-only ID
|
# Read-only ID
|
||||||
user_id = models.CharField(
|
user_id = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
unique=True,
|
unique=True,
|
||||||
editable=False,
|
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(
|
role = models.CharField(
|
||||||
@@ -61,50 +60,47 @@ class User(AbstractUser):
|
|||||||
return self.get_display_name()
|
return self.get_display_name()
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('profile', kwargs={'username': self.username})
|
return reverse("profile", kwargs={"username": self.username})
|
||||||
|
|
||||||
def get_display_name(self):
|
def get_display_name(self):
|
||||||
"""Get the user's display name, falling back to username if not set"""
|
"""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:
|
if profile and profile.display_name:
|
||||||
return profile.display_name
|
return profile.display_name
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.user_id:
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
class UserProfile(models.Model):
|
||||||
# Read-only ID
|
# Read-only ID
|
||||||
profile_id = models.CharField(
|
profile_id = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
unique=True,
|
unique=True,
|
||||||
editable=False,
|
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 = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='profile'
|
|
||||||
)
|
|
||||||
display_name = models.CharField(
|
display_name = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
unique=True,
|
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)
|
pronouns = models.CharField(max_length=50, blank=True)
|
||||||
|
|
||||||
bio = models.TextField(max_length=500, blank=True)
|
bio = models.TextField(max_length=500, blank=True)
|
||||||
|
|
||||||
# Social media links
|
# Social media links
|
||||||
twitter = models.URLField(blank=True)
|
twitter = models.URLField(blank=True)
|
||||||
instagram = models.URLField(blank=True)
|
instagram = models.URLField(blank=True)
|
||||||
youtube = models.URLField(blank=True)
|
youtube = models.URLField(blank=True)
|
||||||
discord = models.CharField(max_length=100, blank=True)
|
discord = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
# Ride statistics
|
# Ride statistics
|
||||||
coaster_credits = models.IntegerField(default=0)
|
coaster_credits = models.IntegerField(default=0)
|
||||||
dark_ride_credits = models.IntegerField(default=0)
|
dark_ride_credits = models.IntegerField(default=0)
|
||||||
@@ -127,12 +123,13 @@ class UserProfile(models.Model):
|
|||||||
self.display_name = self.user.username
|
self.display_name = self.user.username
|
||||||
|
|
||||||
if not self.profile_id:
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.display_name
|
return self.display_name
|
||||||
|
|
||||||
|
|
||||||
class EmailVerification(models.Model):
|
class EmailVerification(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
token = models.CharField(max_length=64, unique=True)
|
token = models.CharField(max_length=64, unique=True)
|
||||||
@@ -146,6 +143,7 @@ class EmailVerification(models.Model):
|
|||||||
verbose_name = "Email Verification"
|
verbose_name = "Email Verification"
|
||||||
verbose_name_plural = "Email Verifications"
|
verbose_name_plural = "Email Verifications"
|
||||||
|
|
||||||
|
|
||||||
class PasswordReset(models.Model):
|
class PasswordReset(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
token = models.CharField(max_length=64)
|
token = models.CharField(max_length=64)
|
||||||
@@ -160,53 +158,51 @@ class PasswordReset(models.Model):
|
|||||||
verbose_name = "Password Reset"
|
verbose_name = "Password Reset"
|
||||||
verbose_name_plural = "Password Resets"
|
verbose_name_plural = "Password Resets"
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class TopList(TrackedModel):
|
class TopList(TrackedModel):
|
||||||
class Categories(models.TextChoices):
|
class Categories(models.TextChoices):
|
||||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||||
DARK_RIDE = 'DR', _('Dark Ride')
|
DARK_RIDE = "DR", _("Dark Ride")
|
||||||
FLAT_RIDE = 'FR', _('Flat Ride')
|
FLAT_RIDE = "FR", _("Flat Ride")
|
||||||
WATER_RIDE = 'WR', _('Water Ride')
|
WATER_RIDE = "WR", _("Water Ride")
|
||||||
PARK = 'PK', _('Park')
|
PARK = "PK", _("Park")
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
on_delete=models.CASCADE,
|
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)
|
title = models.CharField(max_length=100)
|
||||||
category = models.CharField(
|
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||||
max_length=2,
|
|
||||||
choices=Categories.choices
|
|
||||||
)
|
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta(TrackedModel.Meta):
|
||||||
ordering = ['-updated_at']
|
ordering = ["-updated_at"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
return (
|
||||||
|
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class TopListItem(TrackedModel):
|
class TopListItem(TrackedModel):
|
||||||
top_list = models.ForeignKey(
|
top_list = models.ForeignKey(
|
||||||
TopList,
|
TopList, on_delete=models.CASCADE, related_name="items"
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='items'
|
|
||||||
)
|
)
|
||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
'contenttypes.ContentType',
|
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||||
on_delete=models.CASCADE
|
|
||||||
)
|
)
|
||||||
object_id = models.PositiveIntegerField()
|
object_id = models.PositiveIntegerField()
|
||||||
rank = models.PositiveIntegerField()
|
rank = models.PositiveIntegerField()
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta(TrackedModel.Meta):
|
||||||
ordering = ['rank']
|
ordering = ["rank"]
|
||||||
unique_together = [['top_list', 'rank']]
|
unique_together = [["top_list", "rank"]]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"#{self.rank} in {self.top_list.title}"
|
return f"#{self.rank} in {self.top_list.title}"
|
||||||
273
backend/apps/accounts/selectors.py
Normal file
273
backend/apps/accounts/selectors.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
Selectors for user and account-related data retrieval.
|
||||||
|
Following Django styleguide pattern for separating data access from business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
from django.db.models import QuerySet, Q, F, Count
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def user_profile_optimized(*, user_id: int) -> Any:
|
||||||
|
"""
|
||||||
|
Get a user with optimized queries for profile display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance with prefetched related data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
User.DoesNotExist: If user doesn't exist
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.prefetch_related(
|
||||||
|
"park_reviews", "ride_reviews", "socialaccount_set"
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
park_review_count=Count(
|
||||||
|
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
ride_review_count=Count(
|
||||||
|
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
|
)
|
||||||
|
.get(id=user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def active_users_with_stats() -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get active users with review statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of active users with review counts
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.filter(is_active=True)
|
||||||
|
.annotate(
|
||||||
|
park_review_count=Count(
|
||||||
|
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
ride_review_count=Count(
|
||||||
|
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
|
)
|
||||||
|
.order_by("-total_review_count")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def users_with_recent_activity(*, days: int = 30) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users who have been active in the last N days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back for activity
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of recently active users
|
||||||
|
"""
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
return (
|
||||||
|
User.objects.filter(
|
||||||
|
Q(last_login__gte=cutoff_date)
|
||||||
|
| Q(park_reviews__created_at__gte=cutoff_date)
|
||||||
|
| Q(ride_reviews__created_at__gte=cutoff_date)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
recent_park_reviews=Count(
|
||||||
|
"park_reviews",
|
||||||
|
filter=Q(park_reviews__created_at__gte=cutoff_date),
|
||||||
|
),
|
||||||
|
recent_ride_reviews=Count(
|
||||||
|
"ride_reviews",
|
||||||
|
filter=Q(ride_reviews__created_at__gte=cutoff_date),
|
||||||
|
),
|
||||||
|
recent_total_reviews=F("recent_park_reviews") + F("recent_ride_reviews"),
|
||||||
|
)
|
||||||
|
.order_by("-last_login")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def top_reviewers(*, limit: int = 10) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get top users by review count.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of users to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of top reviewers
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.filter(is_active=True)
|
||||||
|
.annotate(
|
||||||
|
park_review_count=Count(
|
||||||
|
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
ride_review_count=Count(
|
||||||
|
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
|
)
|
||||||
|
.filter(total_review_count__gt=0)
|
||||||
|
.order_by("-total_review_count")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def moderator_users() -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users with moderation permissions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of users who can moderate content
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.filter(
|
||||||
|
Q(is_staff=True)
|
||||||
|
| Q(groups__name="Moderators")
|
||||||
|
| Q(
|
||||||
|
user_permissions__codename__in=[
|
||||||
|
"change_parkreview",
|
||||||
|
"change_ridereview",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.order_by("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def users_by_registration_date(*, start_date, end_date) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users who registered within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start of date range
|
||||||
|
end_date: End of date range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of users registered in the date range
|
||||||
|
"""
|
||||||
|
return User.objects.filter(
|
||||||
|
date_joined__date__gte=start_date, date_joined__date__lte=end_date
|
||||||
|
).order_by("-date_joined")
|
||||||
|
|
||||||
|
|
||||||
|
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users matching a search query for autocomplete functionality.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search string
|
||||||
|
limit: Maximum number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of matching users for autocomplete
|
||||||
|
"""
|
||||||
|
return User.objects.filter(
|
||||||
|
Q(username__icontains=query)
|
||||||
|
| Q(first_name__icontains=query)
|
||||||
|
| Q(last_name__icontains=query),
|
||||||
|
is_active=True,
|
||||||
|
).order_by("username")[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def users_with_social_accounts() -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users who have connected social accounts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of users with social account connections
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.filter(socialaccount__isnull=False)
|
||||||
|
.prefetch_related("socialaccount_set")
|
||||||
|
.distinct()
|
||||||
|
.order_by("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_statistics_summary() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get overall user statistics for dashboard/analytics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing user statistics
|
||||||
|
"""
|
||||||
|
total_users = User.objects.count()
|
||||||
|
active_users = User.objects.filter(is_active=True).count()
|
||||||
|
staff_users = User.objects.filter(is_staff=True).count()
|
||||||
|
|
||||||
|
# Users with reviews
|
||||||
|
users_with_reviews = (
|
||||||
|
User.objects.filter(
|
||||||
|
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recent registrations (last 30 days)
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=30)
|
||||||
|
recent_registrations = User.objects.filter(date_joined__gte=cutoff_date).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"inactive_users": total_users - active_users,
|
||||||
|
"staff_users": staff_users,
|
||||||
|
"users_with_reviews": users_with_reviews,
|
||||||
|
"recent_registrations": recent_registrations,
|
||||||
|
"review_participation_rate": (
|
||||||
|
(users_with_reviews / total_users * 100) if total_users > 0 else 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def users_needing_email_verification() -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users who haven't verified their email addresses.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of users with unverified emails
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.filter(is_active=True, emailaddress__verified=False)
|
||||||
|
.distinct()
|
||||||
|
.order_by("date_joined")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Get users who have written at least a minimum number of reviews.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_reviews: Minimum number of reviews required
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet of users with sufficient review activity
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
User.objects.annotate(
|
||||||
|
park_review_count=Count(
|
||||||
|
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
ride_review_count=Count(
|
||||||
|
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||||
|
),
|
||||||
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
|
)
|
||||||
|
.filter(total_review_count__gte=min_reviews)
|
||||||
|
.order_by("-total_review_count")
|
||||||
|
)
|
||||||
245
backend/apps/accounts/serializers.py
Normal file
245
backend/apps/accounts/serializers.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
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 apps.email_service.services import EmailService
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
User serializer for API responses
|
||||||
|
"""
|
||||||
|
|
||||||
|
avatar_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"date_joined",
|
||||||
|
"is_active",
|
||||||
|
"avatar_url",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "date_joined", "is_active"]
|
||||||
|
|
||||||
|
def get_avatar_url(self, obj):
|
||||||
|
"""Get user avatar URL"""
|
||||||
|
if hasattr(obj, "profile") and obj.profile.avatar:
|
||||||
|
return obj.profile.avatar.url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"password",
|
||||||
|
"password_confirm",
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
"password": {"write_only": True},
|
||||||
|
"email": {"required": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
"""Validate email is unique"""
|
||||||
|
if UserModel.objects.filter(email=value).exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Validate email exists"""
|
||||||
|
try:
|
||||||
|
user = UserModel.objects.get(email=value)
|
||||||
|
self.user = user
|
||||||
|
return value
|
||||||
|
except UserModel.DoesNotExist:
|
||||||
|
# Don't reveal if email exists or not for security
|
||||||
|
return value
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
EmailService.send_email(
|
||||||
|
to=getattr(self.user, "email", None),
|
||||||
|
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
|
||||||
|
new_password = (
|
||||||
|
self.initial_data.get("new_password") if self.initial_data else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_password is None:
|
||||||
|
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()
|
||||||
@@ -5,7 +5,8 @@ from django.db import transaction
|
|||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.files.temp import NamedTemporaryFile
|
from django.core.files.temp import NamedTemporaryFile
|
||||||
import requests
|
import requests
|
||||||
from .models import User, UserProfile, EmailVerification
|
from .models import User, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_user_profile(sender, instance, created, **kwargs):
|
def create_user_profile(sender, instance, created, **kwargs):
|
||||||
@@ -14,21 +15,21 @@ def create_user_profile(sender, instance, created, **kwargs):
|
|||||||
if created:
|
if created:
|
||||||
# Create profile
|
# Create profile
|
||||||
profile = UserProfile.objects.create(user=instance)
|
profile = UserProfile.objects.create(user=instance)
|
||||||
|
|
||||||
# If user has a social account with avatar, download it
|
# If user has a social account with avatar, download it
|
||||||
social_account = instance.socialaccount_set.first()
|
social_account = instance.socialaccount_set.first()
|
||||||
if social_account:
|
if social_account:
|
||||||
extra_data = social_account.extra_data
|
extra_data = social_account.extra_data
|
||||||
avatar_url = None
|
avatar_url = None
|
||||||
|
|
||||||
if social_account.provider == 'google':
|
if social_account.provider == "google":
|
||||||
avatar_url = extra_data.get('picture')
|
avatar_url = extra_data.get("picture")
|
||||||
elif social_account.provider == 'discord':
|
elif social_account.provider == "discord":
|
||||||
avatar = extra_data.get('avatar')
|
avatar = extra_data.get("avatar")
|
||||||
discord_id = extra_data.get('id')
|
discord_id = extra_data.get("id")
|
||||||
if avatar:
|
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:
|
if avatar_url:
|
||||||
try:
|
try:
|
||||||
response = requests.get(avatar_url, timeout=60)
|
response = requests.get(avatar_url, timeout=60)
|
||||||
@@ -36,28 +37,34 @@ def create_user_profile(sender, instance, created, **kwargs):
|
|||||||
img_temp = NamedTemporaryFile(delete=True)
|
img_temp = NamedTemporaryFile(delete=True)
|
||||||
img_temp.write(response.content)
|
img_temp.write(response.content)
|
||||||
img_temp.flush()
|
img_temp.flush()
|
||||||
|
|
||||||
file_name = f"avatar_{instance.username}.png"
|
file_name = f"avatar_{instance.username}.png"
|
||||||
profile.avatar.save(
|
profile.avatar.save(file_name, File(img_temp), save=True)
|
||||||
file_name,
|
|
||||||
File(img_temp),
|
|
||||||
save=True
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
|
print(
|
||||||
|
f"Error downloading avatar for user {instance.username}: {
|
||||||
|
str(e)
|
||||||
|
}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def save_user_profile(sender, instance, **kwargs):
|
def save_user_profile(sender, instance, **kwargs):
|
||||||
"""Ensure UserProfile exists and is saved"""
|
"""Ensure UserProfile exists and is saved"""
|
||||||
try:
|
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)
|
UserProfile.objects.create(user=instance)
|
||||||
instance.profile.save()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=User)
|
@receiver(pre_save, sender=User)
|
||||||
def sync_user_role_with_groups(sender, instance, **kwargs):
|
def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||||
"""Sync user role with Django groups"""
|
"""Sync user role with Django groups"""
|
||||||
@@ -72,33 +79,47 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
|||||||
old_group = Group.objects.filter(name=old_instance.role).first()
|
old_group = Group.objects.filter(name=old_instance.role).first()
|
||||||
if old_group:
|
if old_group:
|
||||||
instance.groups.remove(old_group)
|
instance.groups.remove(old_group)
|
||||||
|
|
||||||
# Add to new role group
|
# Add to new role group
|
||||||
if instance.role != User.Roles.USER:
|
if instance.role != User.Roles.USER:
|
||||||
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
||||||
instance.groups.add(new_group)
|
instance.groups.add(new_group)
|
||||||
|
|
||||||
# Special handling for superuser role
|
# Special handling for superuser role
|
||||||
if instance.role == User.Roles.SUPERUSER:
|
if instance.role == User.Roles.SUPERUSER:
|
||||||
instance.is_superuser = True
|
instance.is_superuser = True
|
||||||
instance.is_staff = True
|
instance.is_staff = True
|
||||||
elif old_instance.role == User.Roles.SUPERUSER:
|
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
|
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
|
instance.is_staff = False
|
||||||
|
|
||||||
# Handle staff status for admin and moderator roles
|
# 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
|
instance.is_staff = True
|
||||||
elif old_instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
elif old_instance.role in [
|
||||||
# If removing admin/moderator role, remove staff status
|
User.Roles.ADMIN,
|
||||||
|
User.Roles.MODERATOR,
|
||||||
|
]:
|
||||||
|
# If removing admin/moderator role, remove staff
|
||||||
|
# status
|
||||||
if instance.role not in [User.Roles.SUPERUSER]:
|
if instance.role not in [User.Roles.SUPERUSER]:
|
||||||
instance.is_staff = False
|
instance.is_staff = False
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
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():
|
def create_default_groups():
|
||||||
"""
|
"""
|
||||||
@@ -107,33 +128,47 @@ def create_default_groups():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
|
|
||||||
# Create Moderator group
|
# Create Moderator group
|
||||||
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
||||||
moderator_permissions = [
|
moderator_permissions = [
|
||||||
# Review moderation permissions
|
# Review moderation permissions
|
||||||
'change_review', 'delete_review',
|
"change_review",
|
||||||
'change_reviewreport', 'delete_reviewreport',
|
"delete_review",
|
||||||
|
"change_reviewreport",
|
||||||
|
"delete_reviewreport",
|
||||||
# Edit moderation permissions
|
# Edit moderation permissions
|
||||||
'change_parkedit', 'delete_parkedit',
|
"change_parkedit",
|
||||||
'change_rideedit', 'delete_rideedit',
|
"delete_parkedit",
|
||||||
'change_companyedit', 'delete_companyedit',
|
"change_rideedit",
|
||||||
'change_manufactureredit', 'delete_manufactureredit',
|
"delete_rideedit",
|
||||||
|
"change_companyedit",
|
||||||
|
"delete_companyedit",
|
||||||
|
"change_manufactureredit",
|
||||||
|
"delete_manufactureredit",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create Admin group
|
# Create Admin group
|
||||||
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
||||||
admin_permissions = moderator_permissions + [
|
admin_permissions = moderator_permissions + [
|
||||||
# User management permissions
|
# User management permissions
|
||||||
'change_user', 'delete_user',
|
"change_user",
|
||||||
|
"delete_user",
|
||||||
# Content management permissions
|
# Content management permissions
|
||||||
'add_park', 'change_park', 'delete_park',
|
"add_park",
|
||||||
'add_ride', 'change_ride', 'delete_ride',
|
"change_park",
|
||||||
'add_company', 'change_company', 'delete_company',
|
"delete_park",
|
||||||
'add_manufacturer', 'change_manufacturer', 'delete_manufacturer',
|
"add_ride",
|
||||||
|
"change_ride",
|
||||||
|
"delete_ride",
|
||||||
|
"add_company",
|
||||||
|
"change_company",
|
||||||
|
"delete_company",
|
||||||
|
"add_manufacturer",
|
||||||
|
"change_manufacturer",
|
||||||
|
"delete_manufacturer",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Assign permissions to groups
|
# Assign permissions to groups
|
||||||
for codename in moderator_permissions:
|
for codename in moderator_permissions:
|
||||||
try:
|
try:
|
||||||
@@ -141,7 +176,7 @@ def create_default_groups():
|
|||||||
moderator_group.permissions.add(perm)
|
moderator_group.permissions.add(perm)
|
||||||
except Permission.DoesNotExist:
|
except Permission.DoesNotExist:
|
||||||
print(f"Permission not found: {codename}")
|
print(f"Permission not found: {codename}")
|
||||||
|
|
||||||
for codename in admin_permissions:
|
for codename in admin_permissions:
|
||||||
try:
|
try:
|
||||||
perm = Permission.objects.get(codename=codename)
|
perm = Permission.objects.get(codename=codename)
|
||||||
@@ -4,6 +4,7 @@ from django.template.loader import render_to_string
|
|||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def turnstile_widget():
|
def turnstile_widget():
|
||||||
"""
|
"""
|
||||||
@@ -13,12 +14,10 @@ def turnstile_widget():
|
|||||||
Usage: {% load turnstile_tags %}{% turnstile_widget %}
|
Usage: {% load turnstile_tags %}{% turnstile_widget %}
|
||||||
"""
|
"""
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
template_name = 'accounts/turnstile_widget_empty.html'
|
template_name = "accounts/turnstile_widget_empty.html"
|
||||||
context = {}
|
context = {}
|
||||||
else:
|
else:
|
||||||
template_name = 'accounts/turnstile_widget.html'
|
template_name = "accounts/turnstile_widget.html"
|
||||||
context = {
|
context = {"site_key": settings.TURNSTILE_SITE_KEY}
|
||||||
'site_key': settings.TURNSTILE_SITE_KEY
|
|
||||||
}
|
|
||||||
|
|
||||||
return render_to_string(template_name, context)
|
return render_to_string(template_name, context)
|
||||||
126
backend/apps/accounts/tests.py
Normal file
126
backend/apps/accounts/tests.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from .models import User, UserProfile
|
||||||
|
from .signals import create_default_groups
|
||||||
|
|
||||||
|
|
||||||
|
class SignalsTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="testuser",
|
||||||
|
email="testuser@example.com",
|
||||||
|
password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_user_profile(self):
|
||||||
|
# Refresh user from database to ensure signals have been processed
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
# Check if profile exists in database first
|
||||||
|
profile_exists = UserProfile.objects.filter(user=self.user).exists()
|
||||||
|
self.assertTrue(profile_exists, "UserProfile should be created by signals")
|
||||||
|
|
||||||
|
# Now safely access the profile
|
||||||
|
profile = UserProfile.objects.get(user=self.user)
|
||||||
|
self.assertIsInstance(profile, UserProfile)
|
||||||
|
|
||||||
|
# Test the reverse relationship
|
||||||
|
self.assertTrue(hasattr(self.user, "profile"))
|
||||||
|
# Test that we can access the profile through the user relationship
|
||||||
|
user_profile = getattr(self.user, "profile", None)
|
||||||
|
self.assertEqual(user_profile, profile)
|
||||||
|
|
||||||
|
@patch("accounts.signals.requests.get")
|
||||||
|
def test_create_user_profile_with_social_avatar(self, mock_get):
|
||||||
|
# Mock the response from requests.get
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.content = b"fake-image-content"
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
# Create a social account for the user (we'll skip this test since socialaccount_set requires allauth setup)
|
||||||
|
# This test would need proper allauth configuration to work
|
||||||
|
self.skipTest("Requires proper allauth socialaccount setup")
|
||||||
|
|
||||||
|
def test_save_user_profile(self):
|
||||||
|
# Get the profile safely first
|
||||||
|
profile = UserProfile.objects.get(user=self.user)
|
||||||
|
profile.delete()
|
||||||
|
|
||||||
|
# Refresh user to clear cached profile relationship
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
|
# Check that profile no longer exists
|
||||||
|
self.assertFalse(UserProfile.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
# Trigger save to recreate profile via signal
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
# Verify profile was recreated
|
||||||
|
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
|
||||||
|
new_profile = UserProfile.objects.get(user=self.user)
|
||||||
|
self.assertIsInstance(new_profile, UserProfile)
|
||||||
|
|
||||||
|
def test_sync_user_role_with_groups(self):
|
||||||
|
self.user.role = User.Roles.MODERATOR
|
||||||
|
self.user.save()
|
||||||
|
self.assertTrue(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
|
||||||
|
self.assertTrue(self.user.is_staff)
|
||||||
|
|
||||||
|
self.user.role = User.Roles.ADMIN
|
||||||
|
self.user.save()
|
||||||
|
self.assertFalse(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
|
||||||
|
self.assertTrue(self.user.groups.filter(name=User.Roles.ADMIN).exists())
|
||||||
|
self.assertTrue(self.user.is_staff)
|
||||||
|
|
||||||
|
self.user.role = User.Roles.SUPERUSER
|
||||||
|
self.user.save()
|
||||||
|
self.assertFalse(self.user.groups.filter(name=User.Roles.ADMIN).exists())
|
||||||
|
self.assertTrue(self.user.groups.filter(name=User.Roles.SUPERUSER).exists())
|
||||||
|
self.assertTrue(self.user.is_superuser)
|
||||||
|
self.assertTrue(self.user.is_staff)
|
||||||
|
|
||||||
|
self.user.role = User.Roles.USER
|
||||||
|
self.user.save()
|
||||||
|
self.assertFalse(self.user.groups.exists())
|
||||||
|
self.assertFalse(self.user.is_superuser)
|
||||||
|
self.assertFalse(self.user.is_staff)
|
||||||
|
|
||||||
|
def test_create_default_groups(self):
|
||||||
|
# Create some permissions for testing
|
||||||
|
content_type = ContentType.objects.get_for_model(User)
|
||||||
|
Permission.objects.create(
|
||||||
|
codename="change_review",
|
||||||
|
name="Can change review",
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
Permission.objects.create(
|
||||||
|
codename="delete_review",
|
||||||
|
name="Can delete review",
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
Permission.objects.create(
|
||||||
|
codename="change_user",
|
||||||
|
name="Can change user",
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_default_groups()
|
||||||
|
|
||||||
|
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
||||||
|
self.assertIsNotNone(moderator_group)
|
||||||
|
self.assertTrue(
|
||||||
|
moderator_group.permissions.filter(codename="change_review").exists()
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
moderator_group.permissions.filter(codename="change_user").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_group = Group.objects.get(name=User.Roles.ADMIN)
|
||||||
|
self.assertIsNotNone(admin_group)
|
||||||
|
self.assertTrue(
|
||||||
|
admin_group.permissions.filter(codename="change_review").exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())
|
||||||
48
backend/apps/accounts/urls.py
Normal file
48
backend/apps/accounts/urls.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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"),
|
||||||
|
]
|
||||||
426
backend/apps/accounts/views.py
Normal file
426
backend/apps/accounts/views.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
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 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.contrib.sites.models import Site
|
||||||
|
from django.contrib.sites.requests import RequestSite
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
|
from apps.accounts.models import (
|
||||||
|
User,
|
||||||
|
PasswordReset,
|
||||||
|
TopList,
|
||||||
|
EmailVerification,
|
||||||
|
UserProfile,
|
||||||
|
)
|
||||||
|
from apps.email_service.services import EmailService
|
||||||
|
from apps.parks.models import ParkReview
|
||||||
|
from apps.rides.models import RideReview
|
||||||
|
from allauth.account.views import LoginView, SignupView
|
||||||
|
from .mixins import TurnstileMixin
|
||||||
|
from typing import Dict, Any, Optional, Union, cast
|
||||||
|
from django_htmx.http import HttpResponseClientRefresh
|
||||||
|
from contextlib import suppress
|
||||||
|
import re
|
||||||
|
|
||||||
|
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["park_reviews"] = self._get_user_park_reviews(user)
|
||||||
|
context["ride_reviews"] = self._get_user_ride_reviews(user)
|
||||||
|
context["top_lists"] = self._get_user_top_lists(user)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _get_user_park_reviews(self, user: User) -> QuerySet[ParkReview]:
|
||||||
|
return (
|
||||||
|
ParkReview.objects.filter(user=user, is_published=True)
|
||||||
|
.select_related("user", "user__profile", "park")
|
||||||
|
.order_by("-created_at")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_user_ride_reviews(self, user: User) -> QuerySet[RideReview]:
|
||||||
|
return (
|
||||||
|
RideReview.objects.filter(user=user, is_published=True)
|
||||||
|
.select_related("user", "user__profile", "ride")
|
||||||
|
.order_by("-created_at")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
|
||||||
|
return (
|
||||||
|
TopList.objects.filter(user=user)
|
||||||
|
.select_related("user", "user__profile")
|
||||||
|
.prefetch_related("items")
|
||||||
|
.order_by("-created_at")[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
6
backend/apps/api/__init__.py
Normal file
6
backend/apps/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Centralized API package for ThrillWiki
|
||||||
|
|
||||||
|
All API endpoints MUST be defined here under the /api/v1/ structure.
|
||||||
|
This enforces consistent API architecture and prevents rogue endpoint creation.
|
||||||
|
"""
|
||||||
19
backend/apps/api/apps.py
Normal file
19
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
ThrillWiki API App Configuration
|
||||||
|
|
||||||
|
This module contains the Django app configuration for the centralized API application.
|
||||||
|
All API endpoints are routed through this app following the pattern:
|
||||||
|
- Frontend: /api/{endpoint}
|
||||||
|
- Vite Proxy: /api/ -> /api/v1/
|
||||||
|
- Django: backend/api/v1/{endpoint}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
"""Configuration for the centralized API app."""
|
||||||
|
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "api"
|
||||||
|
verbose_name = "ThrillWiki API"
|
||||||
5
backend/apps/api/urls.py
Normal file
5
backend/apps/api/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("v1/", include("apps.api.v1.urls")),
|
||||||
|
]
|
||||||
6
backend/apps/api/v1/__init__.py
Normal file
6
backend/apps/api/v1/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
ThrillWiki API v1.
|
||||||
|
|
||||||
|
This module provides the version 1 REST API for ThrillWiki, consolidating
|
||||||
|
all endpoints under a unified, well-documented API structure.
|
||||||
|
"""
|
||||||
3
backend/apps/api/v1/accounts/__init__.py
Normal file
3
backend/apps/api/v1/accounts/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Accounts API module for user profile and top list management.
|
||||||
|
"""
|
||||||
18
backend/apps/api/v1/accounts/urls.py
Normal file
18
backend/apps/api/v1/accounts/urls.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Accounts API URL Configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
# Create router and register ViewSets
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
|
||||||
|
router.register(r"toplists", views.TopListViewSet, basename="top-list")
|
||||||
|
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Include router URLs for ViewSets
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
204
backend/apps/api/v1/accounts/views.py
Normal file
204
backend/apps/api/v1/accounts/views.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Accounts API ViewSets for user profiles and top lists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||||
|
from ..serializers import (
|
||||||
|
UserProfileCreateInputSerializer,
|
||||||
|
UserProfileUpdateInputSerializer,
|
||||||
|
UserProfileOutputSerializer,
|
||||||
|
TopListCreateInputSerializer,
|
||||||
|
TopListUpdateInputSerializer,
|
||||||
|
TopListOutputSerializer,
|
||||||
|
TopListItemCreateInputSerializer,
|
||||||
|
TopListItemUpdateInputSerializer,
|
||||||
|
TopListItemOutputSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing user profiles."""
|
||||||
|
|
||||||
|
queryset = UserProfile.objects.select_related("user").all()
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "create":
|
||||||
|
return UserProfileCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return UserProfileUpdateInputSerializer
|
||||||
|
return UserProfileOutputSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter profiles based on user permissions."""
|
||||||
|
if self.request.user.is_staff:
|
||||||
|
return self.queryset
|
||||||
|
return self.queryset.filter(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def me(self, request):
|
||||||
|
"""Get current user's profile."""
|
||||||
|
try:
|
||||||
|
profile = UserProfile.objects.get(user=request.user)
|
||||||
|
serializer = self.get_serializer(profile)
|
||||||
|
return Response(serializer.data)
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Profile not found"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TopListViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing user top lists."""
|
||||||
|
|
||||||
|
queryset = (
|
||||||
|
TopList.objects.select_related("user").prefetch_related("items__ride").all()
|
||||||
|
)
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "create":
|
||||||
|
return TopListCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return TopListUpdateInputSerializer
|
||||||
|
return TopListOutputSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter lists based on user permissions and visibility."""
|
||||||
|
queryset = self.queryset
|
||||||
|
|
||||||
|
if not self.request.user.is_staff:
|
||||||
|
# Non-staff users can only see their own lists and public lists
|
||||||
|
queryset = queryset.filter(Q(user=self.request.user) | Q(is_public=True))
|
||||||
|
|
||||||
|
return queryset.order_by("-created_at")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Set the user when creating a top list."""
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def my_lists(self, request):
|
||||||
|
"""Get current user's top lists."""
|
||||||
|
lists = self.get_queryset().filter(user=request.user)
|
||||||
|
serializer = self.get_serializer(lists, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def duplicate(self, request, pk=None):
|
||||||
|
"""Duplicate a top list for the current user."""
|
||||||
|
original_list = self.get_object()
|
||||||
|
|
||||||
|
# Create new list
|
||||||
|
new_list = TopList.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
name=f"Copy of {original_list.name}",
|
||||||
|
description=original_list.description,
|
||||||
|
is_public=False, # Duplicated lists are private by default
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy all items
|
||||||
|
for item in original_list.items.all():
|
||||||
|
TopListItem.objects.create(
|
||||||
|
top_list=new_list,
|
||||||
|
ride=item.ride,
|
||||||
|
position=item.position,
|
||||||
|
notes=item.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(new_list)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class TopListItemViewSet(ModelViewSet):
|
||||||
|
"""ViewSet for managing top list items."""
|
||||||
|
|
||||||
|
queryset = TopListItem.objects.select_related("top_list__user", "ride").all()
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Return appropriate serializer based on action."""
|
||||||
|
if self.action == "create":
|
||||||
|
return TopListItemCreateInputSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return TopListItemUpdateInputSerializer
|
||||||
|
return TopListItemOutputSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter items based on user permissions."""
|
||||||
|
queryset = self.queryset
|
||||||
|
|
||||||
|
if not self.request.user.is_staff:
|
||||||
|
# Non-staff users can only see items from their own lists or public lists
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(top_list__user=self.request.user) | Q(top_list__is_public=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.order_by("top_list_id", "position")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Validate user can add items to the list."""
|
||||||
|
top_list = serializer.validated_data["top_list"]
|
||||||
|
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||||
|
raise PermissionError("You can only add items to your own lists")
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
"""Validate user can update items in the list."""
|
||||||
|
top_list = serializer.instance.top_list
|
||||||
|
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||||
|
raise PermissionError("You can only update items in your own lists")
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Validate user can delete items from the list."""
|
||||||
|
if (
|
||||||
|
instance.top_list.user != self.request.user
|
||||||
|
and not self.request.user.is_staff
|
||||||
|
):
|
||||||
|
raise PermissionError("You can only delete items from your own lists")
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def reorder(self, request):
|
||||||
|
"""Reorder items in a top list."""
|
||||||
|
top_list_id = request.data.get("top_list_id")
|
||||||
|
item_ids = request.data.get("item_ids", [])
|
||||||
|
|
||||||
|
if not top_list_id or not item_ids:
|
||||||
|
return Response(
|
||||||
|
{"error": "top_list_id and item_ids are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
top_list = TopList.objects.get(id=top_list_id)
|
||||||
|
if top_list.user != request.user and not request.user.is_staff:
|
||||||
|
return Response(
|
||||||
|
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update positions
|
||||||
|
for position, item_id in enumerate(item_ids, 1):
|
||||||
|
TopListItem.objects.filter(id=item_id, top_list=top_list).update(
|
||||||
|
position=position
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({"success": True})
|
||||||
|
|
||||||
|
except TopList.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Top list not found"}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
26
backend/apps/api/v1/core/urls.py
Normal file
26
backend/apps/api/v1/core/urls.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
Core API URL configuration.
|
||||||
|
Centralized from apps.core.urls
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
# Entity search endpoints - migrated from apps.core.urls
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"entities/search/",
|
||||||
|
views.EntityFuzzySearchView.as_view(),
|
||||||
|
name="entity_fuzzy_search",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"entities/not-found/",
|
||||||
|
views.EntityNotFoundView.as_view(),
|
||||||
|
name="entity_not_found",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"entities/suggestions/",
|
||||||
|
views.QuickEntitySuggestionView.as_view(),
|
||||||
|
name="entity_suggestions",
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user