mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 20:47:00 -05:00
Compare commits
25 Commits
clean-hist
...
02ac587216
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02ac587216 | ||
|
|
67db0aa46e | ||
|
|
715e284b3e | ||
|
|
08a4a2d034 | ||
|
|
6125c4ee44 | ||
|
|
53b63d5f09 | ||
|
|
97892e4fc9 | ||
|
|
133dcabb58 | ||
|
|
b627aed65d | ||
|
|
e4e36c7899 | ||
|
|
831be6a2ee | ||
|
|
bf7e0c0f40 | ||
|
|
dcf890a55c | ||
|
|
937eee19e4 | ||
|
|
e62646bcf9 | ||
|
|
92f4104d7a | ||
|
|
02c7cbd1cd | ||
|
|
d504d41de2 | ||
|
|
b0e0678590 | ||
|
|
652ea149bd | ||
|
|
66ed4347a9 | ||
|
|
69c07d1381 | ||
|
|
bead0654df | ||
|
|
37a20f83ba | ||
|
|
2304085c32 |
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
|
||||
/dev.sh
|
||||
/flake.nix
|
||||
venv
|
||||
/venv
|
||||
./venv
|
||||
venv/sour
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
accounts/__pycache__/
|
||||
__pycache__
|
||||
thrillwiki/__pycache__
|
||||
reviews/__pycache__
|
||||
parks/__pycache__
|
||||
media/__pycache__
|
||||
email_service/__pycache__
|
||||
core/__pycache__
|
||||
companies/__pycache__
|
||||
accounts/__pycache__
|
||||
venv
|
||||
accounts/__pycache__
|
||||
thrillwiki/__pycache__/settings.cpython-311.pyc
|
||||
accounts/migrations/__pycache__/__init__.cpython-311.pyc
|
||||
accounts/migrations/__pycache__/0001_initial.cpython-311.pyc
|
||||
companies/migrations/__pycache__
|
||||
moderation/__pycache__
|
||||
rides/__pycache__
|
||||
ssh_tools.jsonc
|
||||
thrillwiki/__pycache__/settings.cpython-312.pyc
|
||||
parks/__pycache__/views.cpython-312.pyc
|
||||
.venv/lib/python3.12/site-packages
|
||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||
.pytest_cache.github
|
||||
static/css/tailwind.css
|
||||
static/css/tailwind.css
|
||||
.venv
|
||||
location/__pycache__
|
||||
analytics/__pycache__
|
||||
designers/__pycache__
|
||||
history_tracking/__pycache__
|
||||
media/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
accounts/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/__pycache__/adapters.cpython-312.pyc
|
||||
accounts/__pycache__/admin.cpython-312.pyc
|
||||
accounts/__pycache__/apps.cpython-312.pyc
|
||||
accounts/__pycache__/models.cpython-312.pyc
|
||||
accounts/__pycache__/signals.cpython-312.pyc
|
||||
accounts/__pycache__/urls.cpython-312.pyc
|
||||
accounts/__pycache__/views.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
companies/__pycache__/__init__.cpython-312.pyc
|
||||
companies/__pycache__/admin.cpython-312.pyc
|
||||
companies/__pycache__/apps.cpython-312.pyc
|
||||
companies/__pycache__/models.cpython-312.pyc
|
||||
companies/__pycache__/signals.cpython-312.pyc
|
||||
companies/__pycache__/urls.cpython-312.pyc
|
||||
companies/__pycache__/views.cpython-312.pyc
|
||||
companies/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
companies/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
core/__pycache__/__init__.cpython-312.pyc
|
||||
core/__pycache__/admin.cpython-312.pyc
|
||||
core/__pycache__/apps.cpython-312.pyc
|
||||
core/__pycache__/models.cpython-312.pyc
|
||||
core/__pycache__/views.cpython-312.pyc
|
||||
core/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
core/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
email_service/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/__pycache__/admin.cpython-312.pyc
|
||||
email_service/__pycache__/apps.cpython-312.pyc
|
||||
email_service/__pycache__/models.cpython-312.pyc
|
||||
email_service/__pycache__/services.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
media/__pycache__/__init__.cpython-312.pyc
|
||||
media/__pycache__/admin.cpython-312.pyc
|
||||
media/__pycache__/apps.cpython-312.pyc
|
||||
media/__pycache__/models.cpython-312.pyc
|
||||
media/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
media/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
parks/__pycache__/__init__.cpython-312.pyc
|
||||
parks/__pycache__/admin.cpython-312.pyc
|
||||
parks/__pycache__/apps.cpython-312.pyc
|
||||
parks/__pycache__/models.cpython-312.pyc
|
||||
parks/__pycache__/signals.cpython-312.pyc
|
||||
parks/__pycache__/urls.cpython-312.pyc
|
||||
parks/__pycache__/views.cpython-312.pyc
|
||||
parks/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
parks/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
reviews/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/__pycache__/admin.cpython-312.pyc
|
||||
reviews/__pycache__/apps.cpython-312.pyc
|
||||
reviews/__pycache__/models.cpython-312.pyc
|
||||
reviews/__pycache__/signals.cpython-312.pyc
|
||||
reviews/__pycache__/urls.cpython-312.pyc
|
||||
reviews/__pycache__/views.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
rides/__pycache__/__init__.cpython-312.pyc
|
||||
rides/__pycache__/admin.cpython-312.pyc
|
||||
rides/__pycache__/apps.cpython-312.pyc
|
||||
rides/__pycache__/models.cpython-312.pyc
|
||||
rides/__pycache__/signals.cpython-312.pyc
|
||||
rides/__pycache__/urls.cpython-312.pyc
|
||||
rides/__pycache__/views.cpython-312.pyc
|
||||
rides/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
rides/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
thrillwiki/__pycache__/__init__.cpython-312.pyc
|
||||
thrillwiki/__pycache__/settings.cpython-312.pyc
|
||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||
thrillwiki/__pycache__/wsgi.cpython-312.pyc
|
||||
accounts/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/__pycache__/adapters.cpython-312.pyc
|
||||
accounts/__pycache__/admin.cpython-312.pyc
|
||||
accounts/__pycache__/apps.cpython-312.pyc
|
||||
accounts/__pycache__/models.cpython-312.pyc
|
||||
accounts/__pycache__/signals.cpython-312.pyc
|
||||
accounts/__pycache__/urls.cpython-312.pyc
|
||||
accounts/__pycache__/views.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
companies/__pycache__/__init__.cpython-312.pyc
|
||||
companies/__pycache__/admin.cpython-312.pyc
|
||||
companies/__pycache__/apps.cpython-312.pyc
|
||||
companies/__pycache__/models.cpython-312.pyc
|
||||
companies/__pycache__/signals.cpython-312.pyc
|
||||
companies/__pycache__/urls.cpython-312.pyc
|
||||
companies/__pycache__/views.cpython-312.pyc
|
||||
companies/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
companies/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
core/__pycache__/__init__.cpython-312.pyc
|
||||
core/__pycache__/admin.cpython-312.pyc
|
||||
core/__pycache__/apps.cpython-312.pyc
|
||||
core/__pycache__/models.cpython-312.pyc
|
||||
core/__pycache__/views.cpython-312.pyc
|
||||
core/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
core/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
email_service/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/__pycache__/admin.cpython-312.pyc
|
||||
email_service/__pycache__/apps.cpython-312.pyc
|
||||
email_service/__pycache__/models.cpython-312.pyc
|
||||
email_service/__pycache__/services.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
email_service/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
media/__pycache__/__init__.cpython-312.pyc
|
||||
media/__pycache__/admin.cpython-312.pyc
|
||||
media/__pycache__/apps.cpython-312.pyc
|
||||
media/__pycache__/models.cpython-312.pyc
|
||||
media/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
media/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
parks/__pycache__/__init__.cpython-312.pyc
|
||||
parks/__pycache__/admin.cpython-312.pyc
|
||||
parks/__pycache__/apps.cpython-312.pyc
|
||||
parks/__pycache__/models.cpython-312.pyc
|
||||
parks/__pycache__/signals.cpython-312.pyc
|
||||
parks/__pycache__/urls.cpython-312.pyc
|
||||
parks/__pycache__/views.cpython-312.pyc
|
||||
parks/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
parks/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
reviews/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/__pycache__/admin.cpython-312.pyc
|
||||
reviews/__pycache__/apps.cpython-312.pyc
|
||||
reviews/__pycache__/models.cpython-312.pyc
|
||||
reviews/__pycache__/signals.cpython-312.pyc
|
||||
reviews/__pycache__/urls.cpython-312.pyc
|
||||
reviews/__pycache__/views.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
reviews/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
rides/__pycache__/__init__.cpython-312.pyc
|
||||
rides/__pycache__/admin.cpython-312.pyc
|
||||
rides/__pycache__/apps.cpython-312.pyc
|
||||
rides/__pycache__/models.cpython-312.pyc
|
||||
rides/__pycache__/signals.cpython-312.pyc
|
||||
rides/__pycache__/urls.cpython-312.pyc
|
||||
rides/__pycache__/views.cpython-312.pyc
|
||||
rides/migrations/__pycache__/__init__.cpython-312.pyc
|
||||
rides/migrations/__pycache__/0001_initial.cpython-312.pyc
|
||||
thrillwiki/__pycache__/__init__.cpython-312.pyc
|
||||
thrillwiki/__pycache__/settings.cpython-312.pyc
|
||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||
thrillwiki/__pycache__/wsgi.cpython-312.pyc
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
@@ -212,186 +22,98 @@ share/python-wheels/
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/backend/staticfiles/
|
||||
/backend/media/
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
# UV
|
||||
.uv/
|
||||
backend/.uv/
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
# Vue.js / Vite
|
||||
/frontend/dist/
|
||||
/frontend/dist-ssr/
|
||||
*.local
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
***REMOVED***
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.[AWS-SECRET-REMOVED]tBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Pixi package manager
|
||||
.pixi/
|
||||
|
||||
# Django Tailwind CLI
|
||||
.django_tailwind_cli/
|
||||
|
||||
# General
|
||||
# OS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Icon must end with two \r
|
||||
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
|
||||
.thrillwiki-github-token
|
||||
.thrillwiki-template-config
|
||||
*.log
|
||||
|
||||
# Environment files with potential secrets
|
||||
scripts/systemd/thrillwiki-automation***REMOVED***
|
||||
scripts/systemd/thrillwiki-deployment***REMOVED***
|
||||
scripts/systemd/****REMOVED***
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.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
|
||||
2
.roo/rules/api_architecture_enforcement
Normal file
2
.roo/rules/api_architecture_enforcement
Normal file
@@ -0,0 +1,2 @@
|
||||
## CRITICAL: Centralized API Structure
|
||||
All API endpoints MUST be centralized under the `backend/apps/api/v1/` structure. This is NON-NEGOTIABLE.
|
||||
49
.roo/rules/critical_rules
Normal file
49
.roo/rules/critical_rules
Normal file
@@ -0,0 +1,49 @@
|
||||
# Project Startup & Development Rules
|
||||
|
||||
## Server & Package Management
|
||||
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
|
||||
```bash
|
||||
$PROJECT_ROOT/shared/scripts/start-servers.sh
|
||||
```
|
||||
- **Python Packages:** Only use UV to add packages:
|
||||
```bash
|
||||
cd $PROJECT_ROOT/backend && uv add <package>
|
||||
```
|
||||
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
|
||||
|
||||
## CRITICAL Frontend design rules
|
||||
- EVERYTHING must support both dark and light mode.
|
||||
- Make sure the light/dark mode toggle works with the Vue components and pages.
|
||||
- Leverage Tailwind CSS 4 and Shadcn UI components.
|
||||
|
||||
## Frontend API URL Rules
|
||||
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
|
||||
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
|
||||
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
|
||||
- **Common Mistake:** Don’t assume frontend URLs are wrong due to proxy configuration.
|
||||
|
||||
## Entity Relationship Patterns
|
||||
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
|
||||
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
|
||||
- **Entities:**
|
||||
- Operators: Operate parks.
|
||||
- PropertyOwners: Own park property (optional).
|
||||
- Manufacturers: Make rides.
|
||||
- Designers: Design rides.
|
||||
- All entities can have locations.
|
||||
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
|
||||
|
||||
## General Best Practices
|
||||
- Never assume blank output means success—always verify changes by testing.
|
||||
- Use context7 for documentation when troubleshooting.
|
||||
- Document changes with conport and reasoning.
|
||||
- Include relevant context and information in all changes.
|
||||
- Test and validate code before deployment.
|
||||
- Communicate changes clearly with your team.
|
||||
- Be open to feedback and continuous improvement.
|
||||
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
|
||||
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
|
||||
- Log important events/errors for troubleshooting.
|
||||
- Prefer existing modules/packages over new code.
|
||||
- Keep documentation up to date.
|
||||
- Consider security vulnerabilities and performance bottlenecks in all changes.
|
||||
390
.roo/rules/roo_code_conport_strategy
Normal file
390
.roo/rules/roo_code_conport_strategy
Normal file
@@ -0,0 +1,390 @@
|
||||
# --- ConPort Memory Strategy ---
|
||||
conport_memory_strategy:
|
||||
# CRITICAL: At the beginning of every session, the agent MUST execute the 'initialization' sequence
|
||||
# to determine the ConPort status and load relevant context.
|
||||
workspace_id_source: "The agent must obtain the absolute path to the current workspace to use as `workspace_id` for all ConPort tool calls. This might be available as `${workspaceFolder}` or require asking the user."
|
||||
|
||||
initialization:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
action: "Determine `ACTUAL_WORKSPACE_ID`."
|
||||
- step: 2
|
||||
action: "Invoke `list_files` for `ACTUAL_WORKSPACE_ID + \"/context_portal/\"`."
|
||||
tool_to_use: "list_files"
|
||||
parameters: "path: ACTUAL_WORKSPACE_ID + \"/context_portal/\""
|
||||
- step: 3
|
||||
action: "Analyze result and branch based on 'context.db' existence."
|
||||
conditions:
|
||||
- if: "'context.db' is found"
|
||||
then_sequence: "load_existing_conport_context"
|
||||
- else: "'context.db' NOT found"
|
||||
then_sequence: "handle_new_conport_setup"
|
||||
|
||||
load_existing_conport_context:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
description: "Attempt to load initial contexts from ConPort."
|
||||
actions:
|
||||
- "Invoke `get_product_context`... Store result."
|
||||
- "Invoke `get_active_context`... Store result."
|
||||
- "Invoke `get_decisions` (limit 5 for a better overview)... Store result."
|
||||
- "Invoke `get_progress` (limit 5)... Store result."
|
||||
- "Invoke `get_system_patterns` (limit 5)... Store result."
|
||||
- "Invoke `get_custom_data` (category: \"critical_settings\")... Store result."
|
||||
- "Invoke `get_custom_data` (category: \"ProjectGlossary\")... Store result."
|
||||
- "Invoke `get_recent_activity_summary` (default params, e.g., last 24h, limit 3 per type) for a quick catch-up. Store result."
|
||||
- step: 2
|
||||
description: "Analyze loaded context."
|
||||
conditions:
|
||||
- if: "results from step 1 are NOT empty/minimal"
|
||||
actions:
|
||||
- "Set internal status to [CONPORT_ACTIVE]."
|
||||
- "Inform user: \"ConPort memory initialized. Existing contexts and recent activity loaded.\""
|
||||
- "Use `ask_followup_question` with suggestions like \"Review recent activity?\", \"Continue previous task?\", \"What would you like to work on?\"."
|
||||
- else: "loaded context is empty/minimal despite DB file existing"
|
||||
actions:
|
||||
- "Set internal status to [CONPORT_ACTIVE]."
|
||||
- "Inform user: \"ConPort database file found, but it appears to be empty or minimally initialized. You can start by defining Product/Active Context or logging project information.\""
|
||||
- "Use `ask_followup_question` with suggestions like \"Define Product Context?\", \"Log a new decision?\"."
|
||||
- step: 3
|
||||
description: "Handle Load Failure (if step 1's `get_*` calls failed)."
|
||||
condition: "If any `get_*` calls in step 1 failed unexpectedly"
|
||||
action: "Fall back to `if_conport_unavailable_or_init_failed`."
|
||||
|
||||
handle_new_conport_setup:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
action: "Inform user: \"No existing ConPort database found at `ACTUAL_WORKSPACE_ID + \"/context_portal/context.db\"`.\""
|
||||
- step: 2
|
||||
action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "Would you like to initialize a new ConPort database for this workspace? The database will be created automatically when ConPort tools are first used."
|
||||
suggestions:
|
||||
- "Yes, initialize a new ConPort database."
|
||||
- "No, do not use ConPort for this session."
|
||||
- step: 3
|
||||
description: "Process user response."
|
||||
conditions:
|
||||
- if_user_response_is: "Yes, initialize a new ConPort database."
|
||||
actions:
|
||||
- "Inform user: \"Okay, a new ConPort database will be created.\""
|
||||
- description: "Attempt to bootstrap Product Context from projectBrief.md (this happens only on new setup)."
|
||||
thinking_preamble: |
|
||||
|
||||
sub_steps:
|
||||
- "Invoke `list_files` with `path: ACTUAL_WORKSPACE_ID` (non-recursive, just to check root)."
|
||||
- description: "Analyze `list_files` result for 'projectBrief.md'."
|
||||
conditions:
|
||||
- if: "'projectBrief.md' is found in the listing"
|
||||
actions:
|
||||
- "Invoke `read_file` for `ACTUAL_WORKSPACE_ID + \"/projectBrief.md\"`."
|
||||
- action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "Found projectBrief.md in your workspace. As we're setting up ConPort for the first time, would you like to import its content into the Product Context?"
|
||||
suggestions:
|
||||
- "Yes, import its content now."
|
||||
- "No, skip importing it for now."
|
||||
- description: "Process user response to import projectBrief.md."
|
||||
conditions:
|
||||
- if_user_response_is: "Yes, import its content now."
|
||||
actions:
|
||||
- "(No need to `get_product_context` as DB is new and empty)"
|
||||
- "Prepare `content` for `update_product_context`. For example: `{\"initial_product_brief\": \"[content from projectBrief.md]\"}`."
|
||||
- "Invoke `update_product_context` with the prepared content."
|
||||
- "Inform user of the import result (success or failure)."
|
||||
- else: "'projectBrief.md' NOT found"
|
||||
actions:
|
||||
- action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "`projectBrief.md` was not found in the workspace root. Would you like to define the initial Product Context manually now?"
|
||||
suggestions:
|
||||
- "Define Product Context manually."
|
||||
- "Skip for now."
|
||||
- "(If \"Define manually\", guide user through `update_product_context`)."
|
||||
- "Proceed to 'load_existing_conport_context' sequence (which will now load the potentially bootstrapped product context and other empty contexts)."
|
||||
- if_user_response_is: "No, do not use ConPort for this session."
|
||||
action: "Proceed to `if_conport_unavailable_or_init_failed` (with a message indicating user chose not to initialize)."
|
||||
|
||||
if_conport_unavailable_or_init_failed:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action: "Inform user: \"ConPort memory will not be used for this session. Status: [CONPORT_INACTIVE].\""
|
||||
|
||||
general:
|
||||
status_prefix: "Begin EVERY response with either '[CONPORT_ACTIVE]' or '[CONPORT_INACTIVE]'."
|
||||
proactive_logging_cue: "Remember to proactively identify opportunities to log or update ConPort based on the conversation (e.g., if user outlines a new plan, consider logging decisions or progress). Confirm with the user before logging."
|
||||
proactive_error_handling: "When encountering errors (e.g., tool failures, unexpected output), proactively log the error details using `log_custom_data` (category: 'ErrorLogs', key: 'timestamp_error_summary') and consider updating `active_context` with `open_issues` if it's a persistent problem. Prioritize using ConPort's `get_item_history` or `get_recent_activity_summary` to diagnose issues if they relate to past context changes."
|
||||
semantic_search_emphasis: "For complex or nuanced queries, especially when direct keyword search (`search_decisions_fts`, `search_custom_data_value_fts`) might be insufficient, prioritize using `semantic_search_conport` to leverage conceptual understanding and retrieve more relevant context. Explain to the user why semantic search is being used."
|
||||
|
||||
conport_updates:
|
||||
frequency: "UPDATE CONPORT THROUGHOUT THE CHAT SESSION, WHEN SIGNIFICANT CHANGES OCCUR, OR WHEN EXPLICITLY REQUESTED."
|
||||
workspace_id_note: "All ConPort tool calls require the `workspace_id`."
|
||||
tools:
|
||||
- name: get_product_context
|
||||
trigger: "To understand the overall project goals, features, or architecture at any time."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_product_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
|
||||
- name: update_product_context
|
||||
trigger: "When the high-level project description, goals, features, or overall architecture changes significantly, as confirmed by the user."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Product context needs updating.
|
||||
- Step 1: (Optional but recommended if unsure of current state) Invoke `get_product_context`.
|
||||
- Step 2: Prepare the `content` (for full overwrite) or `patch_content` (partial update) dictionary.
|
||||
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
|
||||
- Confirm changes with the user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `update_product_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"key_to_update": "new_value", "key_to_delete": "__DELETE__"}}`).
|
||||
- name: get_active_context
|
||||
trigger: "To understand the current task focus, immediate goals, or session-specific context."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_active_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
|
||||
- name: update_active_context
|
||||
trigger: "When the current focus of work changes, new questions arise, or session-specific context needs updating (e.g., `current_focus`, `open_issues`), as confirmed by the user."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Active context needs updating.
|
||||
- Step 1: (Optional) Invoke `get_active_context` to retrieve the current state.
|
||||
- Step 2: Prepare `content` (for full overwrite) or `patch_content` (for partial update).
|
||||
- Common fields to update include `current_focus`, `open_issues`, and other session-specific data.
|
||||
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
|
||||
- Confirm changes with the user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `update_active_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"current_focus": "new_focus", "open_issues": ["issue1", "issue2"], "key_to_delete": "__DELETE__"}}`).
|
||||
- name: log_decision
|
||||
trigger: "When a significant architectural or implementation decision is made and confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_decision` (`{"workspace_id": "...", "summary": "...", "rationale": "...", "tags": ["optional_tag"]}}`).
|
||||
- name: get_decisions
|
||||
trigger: "To retrieve a list of past decisions, e.g., to review history or find a specific decision."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_decisions` (`{"workspace_id": "...", "limit": N, "tags_filter_include_all": ["tag1"], "tags_filter_include_any": ["tag2"]}}`). Explain optional filters.
|
||||
- name: search_decisions_fts
|
||||
trigger: "When searching for decisions by keywords in summary, rationale, details, or tags, and basic `get_decisions` is insufficient."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_decisions_fts` (`{"workspace_id": "...", "query_term": "search keywords", "limit": N}}`).
|
||||
- name: delete_decision_by_id
|
||||
trigger: "When user explicitly confirms deletion of a specific decision by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_decision_by_id` (`{"workspace_id": "...", "decision_id": ID}}`). Emphasize prior confirmation.
|
||||
- name: log_progress
|
||||
trigger: "When a task begins, its status changes (e.g., TODO, IN_PROGRESS, DONE), or it's completed. Also when a new sub-task is defined."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_progress` (`{"workspace_id": "...", "description": "...", "status": "...", "linked_item_type": "...", "linked_item_id": "..."}}`). Note: 'summary' was changed to 'description' for log_progress.
|
||||
- name: get_progress
|
||||
trigger: "To review current task statuses, find pending tasks, or check history of progress."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_progress` (`{"workspace_id": "...", "status_filter": "...", "parent_id_filter": ID, "limit": N}}`).
|
||||
- name: update_progress
|
||||
trigger: "Updates an existing progress entry."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `update_progress` (`{"workspace_id": "...", "progress_id": ID, "status": "...", "description": "...", "parent_id": ID}}`).
|
||||
- name: delete_progress_by_id
|
||||
trigger: "Deletes a progress entry by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_progress_by_id` (`{"workspace_id": "...", "progress_id": ID}}`).
|
||||
- name: log_system_pattern
|
||||
trigger: "When new architectural patterns are introduced, or existing ones are modified, as confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_system_pattern` (`{"workspace_id": "...", "name": "...", "description": "...", "tags": ["optional_tag"]}}`).
|
||||
- name: get_system_patterns
|
||||
trigger: "To retrieve a list of defined system patterns."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_system_patterns` (`{"workspace_id": "...", "tags_filter_include_all": ["tag1"], "limit": N}}`). Note: limit was not in original example, added for consistency.
|
||||
- name: delete_system_pattern_by_id
|
||||
trigger: "When user explicitly confirms deletion of a specific system pattern by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_system_pattern_by_id` (`{"workspace_id": "...", "pattern_id": ID}}`). Emphasize prior confirmation.
|
||||
- name: log_custom_data
|
||||
trigger: "To store any other type of structured or unstructured project-related information not covered by other tools (e.g., glossary terms, technical specs, meeting notes), as confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_custom_data` (`{"workspace_id": "...", "category": "...", "key": "...", "value": {... or "string"}}`). Note: 'metadata' field is not part of log_custom_data args.
|
||||
- name: get_custom_data
|
||||
trigger: "To retrieve specific custom data by category and key."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`).
|
||||
- name: delete_custom_data
|
||||
trigger: "When user explicitly confirms deletion of specific custom data by category and key."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`). Emphasize prior confirmation.
|
||||
- name: search_custom_data_value_fts
|
||||
trigger: "When searching for specific terms within any custom data values, categories, or keys."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_custom_data_value_fts` (`{"workspace_id": "...", "query_term": "...", "category_filter": "...", "limit": N}}`).
|
||||
- name: search_project_glossary_fts
|
||||
trigger: "When specifically searching for terms within the 'ProjectGlossary' custom data category."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_project_glossary_fts` (`{"workspace_id": "...", "query_term": "...", "limit": N}}`).
|
||||
- name: semantic_search_conport
|
||||
trigger: "When a natural language query requires conceptual understanding beyond keyword matching, or when direct keyword searches are insufficient."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `semantic_search_conport` (`{"workspace_id": "...", "query_text": "...", "top_k": N, "filter_item_types": ["decision", "custom_data"]}}`). Explain filters.
|
||||
- name: link_conport_items
|
||||
trigger: "When a meaningful relationship is identified and confirmed between two existing ConPort items (e.g., a decision is implemented by a system pattern, a progress item tracks a decision)."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Need to link two items. Identify source type/ID, target type/ID, and relationship.
|
||||
- Common relationship_types: 'implements', 'related_to', 'tracks', 'blocks', 'clarifies', 'depends_on'. Propose a suitable one or ask user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `link_conport_items` (`{"workspace_id":"...", "source_item_type":"...", "source_item_id":"...", "target_item_type":"...", "target_item_id":"...", "relationship_type":"...", "description":"Optional notes"}`).
|
||||
- name: get_linked_items
|
||||
trigger: "To understand the relationships of a specific ConPort item, or to explore the knowledge graph around an item."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_linked_items` (`{"workspace_id":"...", "item_type":"...", "item_id":"...", "relationship_type_filter":"...", "linked_item_type_filter":"...", "limit":N}`).
|
||||
- name: get_item_history
|
||||
trigger: "When needing to review past versions of Product Context or Active Context, or to see when specific changes were made."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_item_history` (`{"workspace_id":"...", "item_type":"product_context" or "active_context", "limit":N, "version":V, "before_timestamp":"ISO_DATETIME", "after_timestamp":"ISO_DATETIME"}`).
|
||||
- name: batch_log_items
|
||||
trigger: "When the user provides a list of multiple items of the SAME type (e.g., several decisions, multiple new glossary terms) to be logged at once."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- User provided multiple items. Verify they are of the same loggable type.
|
||||
- Construct the `items` list, where each element is a dictionary of arguments for the single-item log tool (e.g., for `log_decision`).
|
||||
</thinking>
|
||||
# Agent Action: Invoke `batch_log_items` (`{"workspace_id":"...", "item_type":"decision", "items": [{"summary":"...", "rationale":"..."}, {"summary":"..."}] }`).
|
||||
- name: get_recent_activity_summary
|
||||
trigger: "At the start of a new session to catch up, or when the user asks for a summary of recent project activities."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_recent_activity_summary` (`{"workspace_id":"...", "hours_ago":H, "since_timestamp":"ISO_DATETIME", "limit_per_type":N}`). Explain default if no time args.
|
||||
- name: get_conport_schema
|
||||
trigger: "If there's uncertainty about available ConPort tools or their arguments during a session (internal LLM check), or if an advanced user specifically asks for the server's tool schema."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_conport_schema` (`{"workspace_id":"..."}`). Primarily for internal LLM reference or direct user request.
|
||||
- name: export_conport_to_markdown
|
||||
trigger: "When the user requests to export the current ConPort data to markdown files (e.g., for backup, sharing, or version control)."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `export_conport_to_markdown` (`{"workspace_id":"...", "output_path":"optional/relative/path"}`). Explain default output path if not provided.
|
||||
- name: import_markdown_to_conport
|
||||
trigger: "When the user requests to import ConPort data from a directory of markdown files previously exported by this system."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `import_markdown_to_conport` (`{"workspace_id":"...", "input_path":"optional/relative/path"}`). Explain default input path. Warn about potential overwrites or merges if data already exists.
|
||||
- name: reconfigure_core_guidance
|
||||
type: guidance
|
||||
product_active_context: "The internal JSON structure of 'Product Context' and 'Active Context' (the `content` field) is flexible. Work with the user to define and evolve this structure via `update_product_context` and `update_active_context`. The server stores this `content` as a JSON blob."
|
||||
decisions_progress_patterns: "The fundamental fields for Decisions, Progress, and System Patterns are fixed by ConPort's tools. For significantly different structures or additional fields, guide the user to create a new custom context category using `log_custom_data` (e.g., category: 'project_milestones_detailed')."
|
||||
|
||||
conport_sync_routine:
|
||||
trigger: "^(Sync ConPort|ConPort Sync)$"
|
||||
user_acknowledgement_text: "[CONPORT_SYNCING]"
|
||||
instructions:
|
||||
- "Halt Current Task: Stop current activity."
|
||||
- "Acknowledge Command: Send `[CONPORT_SYNCING]` to the user."
|
||||
- "Review Chat History: Analyze the complete current chat session for new information, decisions, progress, context changes, clarifications, and potential new relationships between items."
|
||||
core_update_process:
|
||||
thinking_preamble: |
|
||||
- Synchronize ConPort with information from the current chat session.
|
||||
- Use appropriate ConPort tools based on identified changes.
|
||||
- For `update_product_context` and `update_active_context`, first fetch current content, then merge/update (potentially using `patch_content`), then call the update tool with the *complete new content object* or the patch.
|
||||
- All tool calls require the `workspace_id`.
|
||||
agent_action_plan_illustrative:
|
||||
- "Log new decisions (use `log_decision`)."
|
||||
- "Log task progress/status changes (use `log_progress`)."
|
||||
- "Update existing progress entries (use `update_progress`)."
|
||||
- "Delete progress entries (use `delete_progress_by_id`)."
|
||||
- "Log new system patterns (use `log_system_pattern`)."
|
||||
- "Update Active Context (use `get_active_context` then `update_active_context` with full or patch)."
|
||||
- "Update Product Context if significant changes (use `get_product_context` then `update_product_context` with full or patch)."
|
||||
- "Log new custom context, including ProjectGlossary terms (use `log_custom_data`)."
|
||||
- "Identify and log new relationships between items (use `link_conport_items`)."
|
||||
- "If many items of the same type were discussed, consider `batch_log_items`."
|
||||
- "After updates, consider a brief `get_recent_activity_summary` to confirm and refresh understanding."
|
||||
post_sync_actions:
|
||||
- "Inform user: ConPort synchronized with session info."
|
||||
- "Resume previous task or await new instructions."
|
||||
|
||||
dynamic_context_retrieval_for_rag:
|
||||
description: |
|
||||
Guidance for dynamically retrieving and assembling context from ConPort to answer user queries or perform tasks,
|
||||
enhancing Retrieval Augmented Generation (RAG) capabilities.
|
||||
trigger: "When the AI needs to answer a specific question, perform a task requiring detailed project knowledge, or generate content based on ConPort data."
|
||||
goal: "To construct a concise, highly relevant context set for the LLM, improving the accuracy and relevance of its responses."
|
||||
steps:
|
||||
- step: 1
|
||||
action: "Analyze User Query/Task"
|
||||
details: "Deconstruct the user's request to identify key entities, concepts, keywords, and the specific type of information needed from ConPort."
|
||||
- step: 2
|
||||
action: "Prioritized Retrieval Strategy"
|
||||
details: |
|
||||
Based on the analysis, select the most appropriate ConPort tools:
|
||||
- **Targeted FTS:** Use `search_decisions_fts`, `search_custom_data_value_fts`, `search_project_glossary_fts` for keyword-based searches if specific terms are evident.
|
||||
- **Specific Item Retrieval:** Use `get_custom_data` (if category/key known), `get_decisions` (by ID or for recent items), `get_system_patterns`, `get_progress` if the query points to specific item types or IDs.
|
||||
- **(Future):** Prioritize semantic search tools once available for conceptual queries.
|
||||
- **Broad Context (Fallback):** Use `get_product_context` or `get_active_context` as a fallback if targeted retrieval yields little, but be mindful of their size.
|
||||
- step: 3
|
||||
action: "Retrieve Initial Set"
|
||||
details: "Execute the chosen tool(s) to retrieve an initial, small set (e.g., top 3-5) of the most relevant items or data snippets."
|
||||
- step: 4
|
||||
action: "Contextual Expansion (Optional)"
|
||||
details: "For the most promising items from Step 3, consider using `get_linked_items` to fetch directly related items (1-hop). This can provide crucial context or disambiguation. Use judiciously to avoid excessive data."
|
||||
- step: 5
|
||||
action: "Synthesize and Filter"
|
||||
details: |
|
||||
Review the retrieved information (initial set + expanded context).
|
||||
- **Filter:** Discard irrelevant items or parts of items.
|
||||
- **Synthesize/Summarize:** If multiple relevant pieces of information are found, synthesize them into a concise summary that directly addresses the query/task. Extract only the most pertinent sentences or facts.
|
||||
- step: 6
|
||||
action: "Assemble Prompt Context"
|
||||
details: |
|
||||
Construct the context portion of the LLM prompt using the filtered and synthesized information.
|
||||
- **Clarity:** Clearly delineate this retrieved context from the user's query or other parts of the prompt.
|
||||
- **Attribution (Optional but Recommended):** If possible, briefly note the source of the information (e.g., "From Decision D-42:", "According to System Pattern SP-5:").
|
||||
- **Brevity:** Strive for relevance and conciseness. Avoid including large, unprocessed chunks of data unless absolutely necessary and directly requested.
|
||||
general_principles:
|
||||
- "Prefer targeted retrieval over broad context dumps."
|
||||
- "Iterate if initial retrieval is insufficient: try different keywords or tools."
|
||||
- "Balance context richness with prompt token limits."
|
||||
|
||||
proactive_knowledge_graph_linking:
|
||||
description: |
|
||||
Guidance for the AI to proactively identify and suggest the creation of links between ConPort items,
|
||||
enriching the project's knowledge graph based on conversational context.
|
||||
trigger: "During ongoing conversation, when the AI observes potential relationships (e.g., causal, implementational, clarifying) between two or more discussed ConPort items or concepts that are likely represented as ConPort items."
|
||||
goal: "To actively build and maintain a rich, interconnected knowledge graph within ConPort by capturing relationships that might otherwise be missed."
|
||||
steps:
|
||||
- step: 1
|
||||
action: "Monitor Conversational Context"
|
||||
details: "Continuously analyze the user's statements and the flow of discussion for mentions of ConPort items (explicitly by ID, or implicitly by well-known names/summaries) and the relationships being described or implied between them."
|
||||
- step: 2
|
||||
action: "Identify Potential Links"
|
||||
details: |
|
||||
Look for patterns such as:
|
||||
- User states "Decision X led to us doing Y (which is Progress item P-3)."
|
||||
- User discusses how System Pattern SP-2 helps address a concern noted in Decision D-5.
|
||||
- User outlines a task (Progress P-10) that implements a specific feature detailed in a `custom_data` spec (CD-Spec-FeatureX).
|
||||
- step: 3
|
||||
action: "Formulate and Propose Link Suggestion"
|
||||
details: |
|
||||
If a potential link is identified:
|
||||
- Clearly state the items involved (e.g., "Decision D-5", "System Pattern SP-2").
|
||||
- Describe the perceived relationship (e.g., "It seems SP-2 addresses a concern in D-5.").
|
||||
- Propose creating a link using `ask_followup_question`.
|
||||
- Example Question: "I noticed we're discussing Decision D-5 and System Pattern SP-2. It sounds like SP-2 might 'address_concern_in' D-5. Would you like me to create this link in ConPort? You can also suggest a different relationship type."
|
||||
- Suggested Answers:
|
||||
- "Yes, link them with 'addresses_concern_in'."
|
||||
- "Yes, but use relationship type: [user types here]."
|
||||
- "No, don't link them now."
|
||||
- Offer common relationship types as examples if needed: 'implements', 'clarifies', 'related_to', 'depends_on', 'blocks', 'resolves', 'derived_from'.
|
||||
- step: 4
|
||||
action: "Gather Details and Execute Linking"
|
||||
details: |
|
||||
If the user confirms:
|
||||
- Ensure you have the correct source item type, source item ID, target item type, target item ID, and the agreed-upon relationship type.
|
||||
- Ask for an optional brief description for the link if the relationship isn't obvious.
|
||||
- Invoke the `link_conport_items` tool.
|
||||
- step: 5
|
||||
action: "Confirm Outcome"
|
||||
details: "Inform the user of the success or failure of the `link_conport_items` tool call."
|
||||
general_principles:
|
||||
- "Be helpful, not intrusive. If the user declines a suggestion, accept and move on."
|
||||
- "Prioritize clear, strong relationships over tenuous ones."
|
||||
- "This strategy complements the general `proactive_logging_cue` by providing specific guidance for link creation."
|
||||
36
.roomodes
36
.roomodes
@@ -1 +1,35 @@
|
||||
customModes: []
|
||||
customModes:
|
||||
- slug: project-research
|
||||
name: 🔍 Project Research
|
||||
roleDefinition: |
|
||||
You are a detailed-oriented research assistant specializing in examining and understanding codebases. Your primary responsibility is to analyze the file structure, content, and dependencies of a given project to provide comprehensive context relevant to specific user queries.
|
||||
whenToUse: |
|
||||
Use this mode when you need to thoroughly investigate and understand a codebase structure, analyze project architecture, or gather comprehensive context about existing implementations. Ideal for onboarding to new projects, understanding complex codebases, or researching how specific features are implemented across the project.
|
||||
description: Investigate and analyze codebase structure
|
||||
groups:
|
||||
- read
|
||||
source: project
|
||||
customInstructions: |
|
||||
Your role is to deeply investigate and summarize the structure and implementation details of the project codebase. To achieve this effectively, you must:
|
||||
|
||||
1. Start by carefully examining the file structure of the entire project, with a particular emphasis on files located within the "docs" folder. These files typically contain crucial context, architectural explanations, and usage guidelines.
|
||||
|
||||
2. When given a specific query, systematically identify and gather all relevant context from:
|
||||
- Documentation files in the "docs" folder that provide background information, specifications, or architectural insights.
|
||||
- Relevant type definitions and interfaces, explicitly citing their exact location (file path and line number) within the source code.
|
||||
- Implementations directly related to the query, clearly noting their file locations and providing concise yet comprehensive summaries of how they function.
|
||||
- Important dependencies, libraries, or modules involved in the implementation, including their usage context and significance to the query.
|
||||
|
||||
3. Deliver a structured, detailed report that clearly outlines:
|
||||
- An overview of relevant documentation insights.
|
||||
- Specific type definitions and their exact locations.
|
||||
- Relevant implementations, including file paths, functions or methods involved, and a brief explanation of their roles.
|
||||
- Critical dependencies and their roles in relation to the query.
|
||||
|
||||
4. Always cite precise file paths, function names, and line numbers to enhance clarity and ease of navigation.
|
||||
|
||||
5. Organize your findings in logical sections, making it straightforward for the user to understand the project's structure and implementation status relevant to their request.
|
||||
|
||||
6. Ensure your response directly addresses the user's query and helps them fully grasp the relevant aspects of the project's current state.
|
||||
|
||||
These specific instructions supersede any conflicting general instructions you might otherwise follow. Your detailed report should enable effective decision-making and next steps within the overall workflow.
|
||||
|
||||
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)
|
||||
- **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
|
||||
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
1. **Python 3.11+**
|
||||
```bash
|
||||
python --version # Should be 3.11 or higher
|
||||
```
|
||||
|
||||
2. **UV Package Manager**
|
||||
```bash
|
||||
# Install UV if not already installed
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# or
|
||||
pip install uv
|
||||
```
|
||||
|
||||
3. **PostgreSQL with PostGIS**
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install postgresql postgis
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install postgresql postgresql-contrib postgis
|
||||
|
||||
# Start PostgreSQL service
|
||||
brew services start postgresql # macOS
|
||||
sudo systemctl start postgresql # Linux
|
||||
```
|
||||
|
||||
4. **GDAL/GEOS Libraries** (for GeoDjango)
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install gdal geos
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install gdal-bin libgdal-dev libgeos-dev
|
||||
```
|
||||
|
||||
5. **Node.js** (for Tailwind CSS)
|
||||
```bash
|
||||
# Install Node.js 18+ for Tailwind CSS compilation
|
||||
node --version # Should be 18 or higher
|
||||
```
|
||||
```
|
||||
thrillwiki-monorepo/
|
||||
├── backend/ # Django REST API (Port 8000)
|
||||
│ ├── apps/ # Modular Django applications
|
||||
│ ├── config/ # Django settings and configuration
|
||||
│ ├── templates/ # Django templates
|
||||
│ └── static/ # Static assets
|
||||
├── frontend/ # Vue.js SPA (Port 5174)
|
||||
│ ├── src/ # Vue.js source code
|
||||
│ ├── public/ # Static assets
|
||||
│ └── dist/ # Build output
|
||||
├── shared/ # Shared resources and documentation
|
||||
│ ├── docs/ # Comprehensive documentation
|
||||
│ ├── scripts/ # Development and deployment scripts
|
||||
│ ├── config/ # Shared configuration
|
||||
│ └── media/ # Shared media files
|
||||
├── architecture/ # Architecture documentation
|
||||
└── profiles/ # Development profiles
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Clone and Setup Project
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd thrillwiki_django_no_react
|
||||
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
|
||||
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
|
||||
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
|
||||
- **Redis 6+** (optional, for caching and sessions)
|
||||
|
||||
# Install Python dependencies using UV
|
||||
uv sync
|
||||
```
|
||||
### Development Setup
|
||||
|
||||
### 2. Database Setup
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd thrillwiki-monorepo
|
||||
```
|
||||
|
||||
```bash
|
||||
# Create PostgreSQL database and user
|
||||
createdb thrillwiki
|
||||
createuser wiki
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
pnpm install
|
||||
|
||||
# Connect to PostgreSQL and setup
|
||||
psql postgres
|
||||
```
|
||||
# Install backend dependencies
|
||||
cd backend && uv sync && cd ..
|
||||
```
|
||||
|
||||
In the PostgreSQL shell:
|
||||
```sql
|
||||
-- Set password for wiki user
|
||||
ALTER USER wiki WITH PASSWORD 'thrillwiki';
|
||||
3. **Environment configuration**
|
||||
```bash
|
||||
# Copy environment files
|
||||
cp .env.example .env
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.development frontend/.env.local
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL PRIVILEGES ON DATABASE thrillwiki TO wiki;
|
||||
# Edit .env files with your settings
|
||||
```
|
||||
|
||||
-- Enable PostGIS extension
|
||||
\c thrillwiki
|
||||
CREATE EXTENSION postgis;
|
||||
\q
|
||||
```
|
||||
4. **Database setup**
|
||||
```bash
|
||||
cd backend
|
||||
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)):
|
||||
```python
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.contrib.gis.db.backends.postgis",
|
||||
"NAME": "thrillwiki",
|
||||
"USER": "wiki",
|
||||
"PASSWORD": "thrillwiki",
|
||||
"HOST": "192.168.86.3", # Update to your PostgreSQL host
|
||||
"PORT": "5432",
|
||||
}
|
||||
}
|
||||
```
|
||||
# Or start individually
|
||||
pnpm run dev:frontend # Vue.js on :5174
|
||||
pnpm run dev:backend # Django on :8000
|
||||
```
|
||||
|
||||
**Important**: Update the `HOST` setting in [`thrillwiki/settings.py`](thrillwiki/settings.py) to match your PostgreSQL server location:
|
||||
- Use `"localhost"` or `"127.0.0.1"` for local development
|
||||
- Current setting is `"192.168.86.3"` - update this to your PostgreSQL server IP
|
||||
- For local development, change to `"localhost"` in settings.py
|
||||
## 📁 Project Structure Details
|
||||
|
||||
### 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
|
||||
# Run database migrations
|
||||
uv run manage.py migrate
|
||||
### Frontend (`/frontend`)
|
||||
- **Vue 3** with Composition API and `<script setup>` syntax
|
||||
- **TypeScript** for type safety and better developer experience
|
||||
- **Vite** for lightning-fast development and optimized production builds
|
||||
- **Tailwind CSS** with custom design system and dark mode support
|
||||
- **Pinia** for state management with modular stores
|
||||
- **Vue Router** for client-side routing
|
||||
- **Comprehensive UI component library** with shadcn-vue components
|
||||
|
||||
# Create a superuser account
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
**Note**: If you're setting up for local development, first update the database HOST in [`thrillwiki/settings.py`](thrillwiki/settings.py) from `"192.168.86.3"` to `"localhost"` before running migrations.
|
||||
|
||||
### 5. Start Development Server
|
||||
|
||||
**CRITICAL**: Always use this exact command sequence for starting the development server:
|
||||
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
This command:
|
||||
- Kills any existing processes on port 8000
|
||||
- Cleans Python cache files
|
||||
- Starts Tailwind CSS compilation
|
||||
- Runs the Django development server
|
||||
|
||||
The application will be available at: http://localhost:8000
|
||||
### Shared Resources (`/shared`)
|
||||
- **Documentation** - Comprehensive guides and API documentation
|
||||
- **Development scripts** - Automated setup, build, and deployment scripts
|
||||
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
|
||||
- **Media management** - Centralized media file handling and optimization
|
||||
|
||||
## 🛠️ Development Workflow
|
||||
|
||||
### Package Management
|
||||
|
||||
**ALWAYS use UV for package management**:
|
||||
### Available Scripts
|
||||
|
||||
```bash
|
||||
# Add new Python packages
|
||||
uv add <package-name>
|
||||
# Development
|
||||
pnpm run dev # Start both servers concurrently
|
||||
pnpm run dev:frontend # Frontend only (:5174)
|
||||
pnpm run dev:backend # Backend only (:8000)
|
||||
|
||||
# Add development dependencies
|
||||
uv add --dev <package-name>
|
||||
# Building
|
||||
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
|
||||
|
||||
**ALWAYS use UV for Django commands**:
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
# Correct way to run Django commands
|
||||
uv run manage.py <command>
|
||||
cd backend
|
||||
|
||||
# Examples:
|
||||
uv run manage.py makemigrations
|
||||
# Django management commands
|
||||
uv run manage.py migrate
|
||||
uv run manage.py shell
|
||||
uv run manage.py makemigrations
|
||||
uv run manage.py createsuperuser
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# NEVER use these patterns:
|
||||
# python manage.py <command> ❌ Wrong
|
||||
# uv run python manage.py <command> ❌ Wrong
|
||||
# Testing and quality
|
||||
uv run manage.py test
|
||||
uv run black . # Format code
|
||||
uv run flake8 . # Lint code
|
||||
uv run isort . # Sort imports
|
||||
```
|
||||
|
||||
### CSS 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
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
# Run Python tests
|
||||
uv run pytest
|
||||
cd frontend
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run -m pytest
|
||||
uv run coverage report
|
||||
|
||||
# Run E2E tests with Playwright
|
||||
uv run pytest tests/e2e/
|
||||
# Vue.js development
|
||||
pnpm run dev # Start dev server
|
||||
pnpm run build # Production build
|
||||
pnpm run preview # Preview production build
|
||||
pnpm run test:unit # Vitest unit tests
|
||||
pnpm run test:e2e # Playwright E2E tests
|
||||
pnpm run lint # ESLint
|
||||
pnpm run type-check # TypeScript checking
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
- Unit tests: Located within each app's `tests/` directory
|
||||
- E2E tests: [`tests/e2e/`](tests/e2e/)
|
||||
- Test fixtures: [`tests/fixtures/`](tests/fixtures/)
|
||||
## 🔧 Configuration
|
||||
|
||||
## 📚 Documentation
|
||||
### Environment Variables
|
||||
|
||||
### Memory Bank System
|
||||
The project uses a comprehensive documentation system in [`memory-bank/`](memory-bank/):
|
||||
#### Root `.env`
|
||||
```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
|
||||
- [`memory-bank/documentation/design-system.md`](memory-bank/documentation/design-system.md) - Design system documentation
|
||||
- [`memory-bank/features/`](memory-bank/features/) - Feature-specific documentation
|
||||
- [`memory-bank/testing/`](memory-bank/testing/) - Testing documentation and results
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
|
||||
### Key Documentation Files
|
||||
- [Design System](memory-bank/documentation/design-system.md) - UI/UX guidelines and patterns
|
||||
- [Authentication System](memory-bank/features/auth/) - OAuth and user management
|
||||
- [Layout Optimization](memory-bank/projects/) - Responsive design implementations
|
||||
# API Configuration
|
||||
API_BASE_URL=http://localhost:8000/api
|
||||
```
|
||||
|
||||
## 🚨 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
|
||||
1. **Server Startup**: Always use the full command sequence:
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
2. **Package Management**: Only use UV:
|
||||
```bash
|
||||
uv add <package> # ✅ Correct
|
||||
pip install <package> # ❌ Wrong
|
||||
```
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
3. **Django Commands**: Always prefix with `uv run`:
|
||||
```bash
|
||||
uv run manage.py <command> # ✅ Correct
|
||||
python manage.py <command> # ❌ Wrong
|
||||
```
|
||||
# Email (optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
```
|
||||
|
||||
### Database Configuration
|
||||
- Ensure PostgreSQL is running before starting development
|
||||
- PostGIS extension must be enabled
|
||||
- Update database host settings for your environment
|
||||
#### Frontend `.env.local`
|
||||
```bash
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
|
||||
### GeoDjango Requirements
|
||||
- GDAL and GEOS libraries must be properly installed
|
||||
- Library paths are configured in [`thrillwiki/settings.py`](thrillwiki/settings.py) for macOS Homebrew
|
||||
- Current paths: `/opt/homebrew/lib/libgdal.dylib` and `/opt/homebrew/lib/libgeos_c.dylib`
|
||||
- May need adjustment based on your system's library locations (Linux users will need different paths)
|
||||
# Development
|
||||
VITE_APP_TITLE=ThrillWiki (Development)
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
# Feature Flags
|
||||
VITE_ENABLE_DEBUG=true
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
## 📊 Key Features
|
||||
|
||||
1. **PostGIS Extension Error**
|
||||
```bash
|
||||
# Connect to database and enable PostGIS
|
||||
psql thrillwiki
|
||||
CREATE EXTENSION postgis;
|
||||
```
|
||||
### Backend Features
|
||||
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
|
||||
- **Extensive Ride Database** - Complete roller coaster and ride information
|
||||
- **User Management** - Authentication, profiles, and permissions
|
||||
- **Content Moderation** - Review and approval workflows
|
||||
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
|
||||
- **Background Tasks** - Celery integration for long-running processes
|
||||
- **Caching Strategy** - Redis-based caching for performance
|
||||
- **Search Functionality** - Full-text search across all content
|
||||
|
||||
2. **GDAL/GEOS Library Not Found**
|
||||
```bash
|
||||
# macOS (Homebrew): Current paths in settings.py
|
||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
||||
|
||||
# Linux: Update paths in settings.py to something like:
|
||||
# GDAL_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgdal.so"
|
||||
# GEOS_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgeos_c.so"
|
||||
|
||||
# Find your library locations
|
||||
find /usr -name "libgdal*" 2>/dev/null
|
||||
find /usr -name "libgeos*" 2>/dev/null
|
||||
find /opt -name "libgdal*" 2>/dev/null
|
||||
find /opt -name "libgeos*" 2>/dev/null
|
||||
```
|
||||
### Frontend Features
|
||||
- **Responsive Design** - Mobile-first approach with Tailwind CSS
|
||||
- **Dark Mode Support** - Complete dark/light theme system
|
||||
- **Real-time Search** - Instant search with debouncing and highlighting
|
||||
- **Interactive Maps** - Park and ride location visualization
|
||||
- **Photo Galleries** - High-quality image management
|
||||
- **User Dashboard** - Personalized content and contributions
|
||||
- **Progressive Web App** - PWA capabilities for mobile experience
|
||||
- **Accessibility** - WCAG 2.1 AA compliance
|
||||
|
||||
3. **Port 8000 Already in Use**
|
||||
```bash
|
||||
# Kill existing processes
|
||||
lsof -ti :8000 | xargs kill -9
|
||||
```
|
||||
## 📖 Documentation
|
||||
|
||||
4. **Tailwind CSS Not Compiling**
|
||||
```bash
|
||||
# Ensure Node.js is installed and use the full server command
|
||||
node --version
|
||||
uv run manage.py tailwind runserver
|
||||
```
|
||||
### Core Documentation
|
||||
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
|
||||
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
|
||||
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
|
||||
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
|
||||
|
||||
### 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
|
||||
2. Review [`memory-bank/testing/`](memory-bank/testing/) for known issues and solutions
|
||||
3. Ensure all prerequisites are properly installed
|
||||
4. Verify database connection and PostGIS extension
|
||||
### Additional Resources
|
||||
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
|
||||
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
|
||||
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
|
||||
|
||||
## 🎯 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/
|
||||
2. **Browse the Application**: http://localhost:8000/
|
||||
3. **Review Documentation**: Check [`memory-bank/`](memory-bank/) for detailed feature docs
|
||||
4. **Run Tests**: Ensure everything works with `uv run pytest`
|
||||
5. **Start Development**: Follow the development workflow guidelines above
|
||||
# Full development setup
|
||||
./shared/scripts/dev/setup-dev.sh
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
```bash
|
||||
# Build all components
|
||||
./shared/scripts/build/build-all.sh
|
||||
|
||||
# Deploy to production
|
||||
./shared/scripts/deploy/deploy.sh
|
||||
```
|
||||
|
||||
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests** - Individual function and method testing
|
||||
- **Integration Tests** - API endpoint and database interaction testing
|
||||
- **E2E Tests** - Full user journey testing with Selenium
|
||||
|
||||
### Frontend Testing
|
||||
- **Unit Tests** - Component and utility function testing with Vitest
|
||||
- **Integration Tests** - Component interaction testing
|
||||
- **E2E Tests** - User journey testing with Playwright
|
||||
|
||||
### Code Quality
|
||||
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
|
||||
- **Type Checking** - TypeScript for frontend, mypy for Python
|
||||
- **Code Formatting** - Prettier for frontend, Black for Python
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
|
||||
|
||||
1. **Development Setup** - Getting your development environment ready
|
||||
2. **Code Standards** - Coding conventions and best practices
|
||||
3. **Pull Request Process** - How to submit your changes
|
||||
4. **Issue Reporting** - How to report bugs and request features
|
||||
|
||||
### Quick Contribution Start
|
||||
```bash
|
||||
# Fork and clone the repository
|
||||
git clone https://github.com/your-username/thrillwiki-monorepo.git
|
||||
cd thrillwiki-monorepo
|
||||
|
||||
# Set up development environment
|
||||
./shared/scripts/dev/setup-dev.sh
|
||||
|
||||
# Create a feature branch
|
||||
git checkout -b feature/your-feature-name
|
||||
|
||||
# Make your changes and test
|
||||
pnpm run test
|
||||
|
||||
# Submit a pull request
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Theme Park Community** - For providing data and inspiration
|
||||
- **Open Source Contributors** - For the amazing tools and libraries
|
||||
- **Vue.js and Django Communities** - For excellent documentation and support
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
|
||||
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
|
||||
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding!** 🎢✨
|
||||
|
||||
For detailed feature documentation and development context, see the [`memory-bank/`](memory-bank/) directory.
|
||||
**Built with ❤️ for the theme park and roller coaster community**
|
||||
|
||||
@@ -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')
|
||||
649
api_endpoints_curl_commands.sh
Executable file
649
api_endpoints_curl_commands.sh
Executable file
@@ -0,0 +1,649 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ThrillWiki API Endpoints - Complete Curl Commands
|
||||
# Generated from comprehensive URL analysis
|
||||
# Base URL - adjust as needed for your environment
|
||||
BASE_URL="http://localhost:8000"
|
||||
|
||||
# Command line options
|
||||
SKIP_AUTH=false
|
||||
ONLY_AUTH=false
|
||||
SKIP_DOCS=false
|
||||
HELP=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-auth)
|
||||
SKIP_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--only-auth)
|
||||
ONLY_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--skip-docs)
|
||||
SKIP_DOCS=true
|
||||
shift
|
||||
;;
|
||||
--base-url)
|
||||
BASE_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
HELP=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Show help
|
||||
if [ "$HELP" = true ]; then
|
||||
echo "ThrillWiki API Endpoints Test Suite"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --skip-auth Skip endpoints that require authentication"
|
||||
echo " --only-auth Only test endpoints that require authentication"
|
||||
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
|
||||
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Test all endpoints"
|
||||
echo " $0 --skip-auth # Test only public endpoints"
|
||||
echo " $0 --only-auth # Test only authenticated endpoints"
|
||||
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
|
||||
echo " $0 --base-url https://api.example.com # Use custom base URL"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate conflicting options
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Error: --skip-auth and --only-auth cannot be used together"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ThrillWiki API Endpoints Test Suite ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
if [ "$SKIP_AUTH" = true ]; then
|
||||
echo "Mode: Public endpoints only (skipping authentication required)"
|
||||
elif [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Mode: Authenticated endpoints only"
|
||||
else
|
||||
echo "Mode: All endpoints"
|
||||
fi
|
||||
if [ "$SKIP_DOCS" = true ]; then
|
||||
echo "Skipping: API documentation endpoints"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Helper function to check if we should run an endpoint
|
||||
should_run_endpoint() {
|
||||
local requires_auth=$1
|
||||
local is_docs=$2
|
||||
|
||||
# Skip docs if requested
|
||||
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Skip auth endpoints if requested
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Only run auth endpoints if requested
|
||||
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Counter for endpoint numbering
|
||||
ENDPOINT_NUM=1
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo "=== AUTHENTICATION ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Login"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/login/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "testuser", "password": "testpass"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Signup"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Logout"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
|
||||
-H "Content-Type: application/json"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Reset"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Social Providers"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/providers/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Auth Status"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/status/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Current User"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/user/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Change"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Health Check"
|
||||
curl -X GET "$BASE_URL/api/v1/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Simple Health"
|
||||
curl -X GET "$BASE_URL/api/v1/health/simple/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
|
||||
curl -X GET "$BASE_URL/api/v1/health/performance/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Trending Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/content/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. New Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/new/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS ENDPOINTS (/api/v1/stats/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
|
||||
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rankings"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking History"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
|
||||
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"category": "RC"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# PARKS API ENDPOINTS (/api/v1/parks/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== PARKS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Parks"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Park Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Park", "location": "Test City"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Park Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RIDES API ENDPOINTS (/api/v1/rides/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RIDES API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rides"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Ride Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test ride photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated ride photo caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List User Profiles"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top Lists"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top List Items"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Update User Profile"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"bio": "Updated bio"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Top List Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"toplist": 1, "ride": 1, "position": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"position": 2}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HISTORY API ENDPOINTS (/api/v1/history/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Park History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# EMAIL API ENDPOINTS (/api/v1/email/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Send Email"
|
||||
curl -X POST "$BASE_URL/api/v1/email/send/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# CORE API ENDPOINTS (/api/v1/core/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== CORE API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
|
||||
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "nonexistent park", "type": "park"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# MAPS API ENDPOINTS (/api/v1/maps/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== MAPS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Map Locations"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Search"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/cache/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
|
||||
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# API DOCUMENTATION ENDPOINTS
|
||||
# ============================================================================
|
||||
if should_run_endpoint false true; then
|
||||
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. OpenAPI Schema"
|
||||
curl -X GET "$BASE_URL/api/schema/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Swagger UI"
|
||||
curl -X GET "$BASE_URL/api/docs/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. ReDoc"
|
||||
curl -X GET "$BASE_URL/api/redoc/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK (Django Health Check)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Django Health Check"
|
||||
curl -X GET "$BASE_URL/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
|
||||
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
|
||||
echo ""
|
||||
echo "Notes:"
|
||||
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
|
||||
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
|
||||
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
|
||||
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
|
||||
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
|
||||
echo ""
|
||||
echo "Authentication required endpoints are marked with Authorization header"
|
||||
echo "File upload endpoints use multipart/form-data (-F flag)"
|
||||
echo "JSON endpoints use application/json content type"
|
||||
372
architecture/architecture-validation.md
Normal file
372
architecture/architecture-validation.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# ThrillWiki Monorepo Architecture Validation
|
||||
|
||||
This document provides a comprehensive review and validation of the proposed monorepo architecture for migrating ThrillWiki from Django-only to Django + Vue.js.
|
||||
|
||||
## Architecture Overview Validation
|
||||
|
||||
### ✅ Core Requirements Met
|
||||
|
||||
1. **Clean Separation of Concerns**
|
||||
- Backend: Django API, business logic, database management
|
||||
- Frontend: Vue.js SPA with modern tooling
|
||||
- Shared: Common resources and media files
|
||||
|
||||
2. **Development Workflow Preservation**
|
||||
- UV package management for Python maintained
|
||||
- pnpm for Node.js package management
|
||||
- Existing development scripts adapted
|
||||
- Hot reloading for both backend and frontend
|
||||
|
||||
3. **Project Structure Compatibility**
|
||||
- Django apps preserved under `backend/apps/`
|
||||
- Configuration maintained under `backend/config/`
|
||||
- Static files strategy clearly defined
|
||||
- Media files centralized in `shared/media/`
|
||||
|
||||
## Technical Architecture Validation
|
||||
|
||||
### Backend Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Django Backend] --> B[Apps Directory]
|
||||
A --> C[Config Directory]
|
||||
A --> D[Static Files]
|
||||
|
||||
B --> E[accounts]
|
||||
B --> F[parks]
|
||||
B --> G[rides]
|
||||
B --> H[moderation]
|
||||
B --> I[location]
|
||||
B --> J[media]
|
||||
B --> K[email_service]
|
||||
B --> L[core]
|
||||
|
||||
C --> M[Django Settings]
|
||||
C --> N[URL Configuration]
|
||||
C --> O[WSGI/ASGI]
|
||||
|
||||
D --> P[Admin Assets]
|
||||
D --> Q[Backend Static]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ All 8 Django apps properly mapped to new structure
|
||||
- ✅ Configuration files maintain their organization
|
||||
- ✅ Static file handling preserves Django admin functionality
|
||||
- ✅ UV package management integration maintained
|
||||
|
||||
### Frontend Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Vue.js Frontend] --> B[Source Code]
|
||||
A --> C[Build System]
|
||||
A --> D[Development Tools]
|
||||
|
||||
B --> E[Components]
|
||||
B --> F[Views/Pages]
|
||||
B --> G[Router]
|
||||
B --> H[State Management]
|
||||
B --> I[API Layer]
|
||||
|
||||
C --> J[Vite]
|
||||
C --> K[TypeScript]
|
||||
C --> L[Tailwind CSS]
|
||||
|
||||
D --> M[Hot Reload]
|
||||
D --> N[Dev Server]
|
||||
D --> O[Build Tools]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ Modern Vue.js 3 + Composition API
|
||||
- ✅ TypeScript for type safety
|
||||
- ✅ Vite for fast development and builds
|
||||
- ✅ Tailwind CSS for styling (matching current setup)
|
||||
- ✅ Pinia for state management
|
||||
- ✅ Vue Router for SPA navigation
|
||||
|
||||
### Integration Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue.js Frontend] --> B[HTTP API Calls]
|
||||
B --> C[Django REST API]
|
||||
C --> D[Database]
|
||||
C --> E[Media Files]
|
||||
E --> F[Shared Media Directory]
|
||||
F --> G[Frontend Access]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ RESTful API integration between frontend and backend
|
||||
- ✅ Media files accessible to both systems
|
||||
- ✅ Authentication handling via API tokens
|
||||
- ✅ CORS configuration for cross-origin requests
|
||||
|
||||
## File Migration Validation
|
||||
|
||||
### Critical File Mappings ✅
|
||||
|
||||
| Component | Current | New Location | Status |
|
||||
|-----------|---------|--------------|--------|
|
||||
| Django Apps | `/apps/` | `/backend/apps/` | ✅ Mapped |
|
||||
| Configuration | `/config/` | `/backend/config/` | ✅ Mapped |
|
||||
| Static Files | `/static/` | `/backend/static/` | ✅ Mapped |
|
||||
| Media Files | `/media/` | `/shared/media/` | ✅ Mapped |
|
||||
| Scripts | `/scripts/` | `/scripts/` | ✅ Preserved |
|
||||
| Dependencies | `/pyproject.toml` | `/backend/pyproject.toml` | ✅ Mapped |
|
||||
|
||||
### Import Path Updates Required ✅
|
||||
|
||||
**Django Settings Updates:**
|
||||
```python
|
||||
# OLD
|
||||
INSTALLED_APPS = [
|
||||
'accounts',
|
||||
'parks',
|
||||
'rides',
|
||||
# ...
|
||||
]
|
||||
|
||||
# NEW
|
||||
INSTALLED_APPS = [
|
||||
'apps.accounts',
|
||||
'apps.parks',
|
||||
'apps.rides',
|
||||
# ...
|
||||
]
|
||||
```
|
||||
|
||||
**Media Path Updates:**
|
||||
```python
|
||||
# NEW
|
||||
MEDIA_ROOT = BASE_DIR.parent / 'shared' / 'media'
|
||||
```
|
||||
|
||||
## Development Workflow Validation
|
||||
|
||||
### Package Management ✅
|
||||
|
||||
**Backend (UV):**
|
||||
- ✅ `uv add <package>` for new dependencies
|
||||
- ✅ `uv run manage.py <command>` for Django commands
|
||||
- ✅ `uv sync` for dependency installation
|
||||
|
||||
**Frontend (pnpm):**
|
||||
- ✅ `pnpm add <package>` for new dependencies
|
||||
- ✅ `pnpm install` for dependency installation
|
||||
- ✅ `pnpm run dev` for development server
|
||||
|
||||
**Root Workspace:**
|
||||
- ✅ `pnpm run dev` starts both servers concurrently
|
||||
- ✅ Individual server commands available
|
||||
- ✅ Build and test scripts coordinated
|
||||
|
||||
### Development Scripts ✅
|
||||
|
||||
```bash
|
||||
# Root level coordination
|
||||
pnpm run dev # Both servers
|
||||
pnpm run backend:dev # Django only
|
||||
pnpm run frontend:dev # Vue.js only
|
||||
pnpm run build # Production build
|
||||
pnpm run test # All tests
|
||||
pnpm run lint # All linting
|
||||
pnpm run format # Code formatting
|
||||
```
|
||||
|
||||
## Deployment Strategy Validation
|
||||
|
||||
### Container Strategy ✅
|
||||
|
||||
**Multi-container Approach:**
|
||||
- ✅ Separate containers for backend and frontend
|
||||
- ✅ Shared volumes for media files
|
||||
- ✅ Database and Redis containers
|
||||
- ✅ Nginx reverse proxy configuration
|
||||
|
||||
**Build Process:**
|
||||
- ✅ Backend: Django static collection + uv dependencies
|
||||
- ✅ Frontend: Vite production build + asset optimization
|
||||
- ✅ Shared: Media file persistence across deployments
|
||||
|
||||
### Platform Compatibility ✅
|
||||
|
||||
**Supported Deployment Platforms:**
|
||||
- ✅ Docker Compose (local and production)
|
||||
- ✅ Vercel (frontend + serverless backend)
|
||||
- ✅ Railway (container deployment)
|
||||
- ✅ DigitalOcean App Platform
|
||||
- ✅ AWS ECS/Fargate
|
||||
- ✅ Google Cloud Run
|
||||
|
||||
## Performance Considerations ✅
|
||||
|
||||
### Backend Optimization
|
||||
- ✅ Database connection pooling
|
||||
- ✅ Redis caching strategy
|
||||
- ✅ Static file CDN integration
|
||||
- ✅ API response optimization
|
||||
|
||||
### Frontend Optimization
|
||||
- ✅ Code splitting and lazy loading
|
||||
- ✅ Asset optimization with Vite
|
||||
- ✅ Tree shaking for minimal bundle size
|
||||
- ✅ Modern build targets
|
||||
|
||||
### Development Performance
|
||||
- ✅ Hot module replacement for Vue.js
|
||||
- ✅ Django auto-reload for backend changes
|
||||
- ✅ Fast dependency installation with UV and pnpm
|
||||
- ✅ Concurrent development servers
|
||||
|
||||
## Security Validation ✅
|
||||
|
||||
### Backend Security
|
||||
- ✅ Django security middleware maintained
|
||||
- ✅ CORS configuration for API access
|
||||
- ✅ Authentication token management
|
||||
- ✅ Input validation and sanitization
|
||||
|
||||
### Frontend Security
|
||||
- ✅ Content Security Policy headers
|
||||
- ✅ XSS protection mechanisms
|
||||
- ✅ Secure API communication (HTTPS)
|
||||
- ✅ Environment variable protection
|
||||
|
||||
### Deployment Security
|
||||
- ✅ SSL/TLS termination
|
||||
- ✅ Security headers configuration
|
||||
- ✅ Secret management strategy
|
||||
- ✅ Container security best practices
|
||||
|
||||
## Risk Assessment and Mitigation
|
||||
|
||||
### Low Risk Items ✅
|
||||
- **File organization**: Clear mapping and systematic approach
|
||||
- **Package management**: Both UV and pnpm are stable and well-supported
|
||||
- **Development workflow**: Incremental changes to existing process
|
||||
|
||||
### Medium Risk Items ⚠️
|
||||
- **Import path updates**: Requires careful testing of all Django apps
|
||||
- **Static file handling**: Need to verify Django admin continues working
|
||||
- **API integration**: New frontend-backend communication layer
|
||||
|
||||
**Mitigation Strategies:**
|
||||
- Comprehensive testing suite for Django apps after migration
|
||||
- Static file serving verification in development and production
|
||||
- API endpoint testing and documentation
|
||||
- Gradual migration approach with rollback capabilities
|
||||
|
||||
### High Risk Items 🔴
|
||||
- **Data migration**: Database changes during restructuring
|
||||
- **Production deployment**: New deployment process requires validation
|
||||
|
||||
**Mitigation Strategies:**
|
||||
- Database backup before any structural changes
|
||||
- Staging environment testing before production deployment
|
||||
- Blue-green deployment strategy for zero-downtime migration
|
||||
- Monitoring and alerting for post-migration issues
|
||||
|
||||
## Testing Strategy Validation
|
||||
|
||||
### Backend Testing ✅
|
||||
```bash
|
||||
# Django tests
|
||||
cd backend
|
||||
uv run manage.py test
|
||||
|
||||
# Code quality
|
||||
uv run flake8 .
|
||||
uv run black --check .
|
||||
```
|
||||
|
||||
### Frontend Testing ✅
|
||||
```bash
|
||||
# Vue.js tests
|
||||
cd frontend
|
||||
pnpm run test
|
||||
pnpm run test:unit
|
||||
pnpm run test:e2e
|
||||
|
||||
# Code quality
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
```
|
||||
|
||||
### Integration Testing ✅
|
||||
- API endpoint testing
|
||||
- Frontend-backend communication testing
|
||||
- Media file access testing
|
||||
- Authentication flow testing
|
||||
|
||||
## Documentation Validation ✅
|
||||
|
||||
### Created Documentation
|
||||
- ✅ **Monorepo Structure Plan**: Complete directory organization
|
||||
- ✅ **Migration Mapping**: File-by-file migration guide
|
||||
- ✅ **Deployment Guide**: Comprehensive deployment strategies
|
||||
- ✅ **Architecture Validation**: This validation document
|
||||
|
||||
### Required Updates
|
||||
- ✅ Root README.md update for monorepo structure
|
||||
- ✅ Development setup instructions
|
||||
- ✅ API documentation for frontend integration
|
||||
- ✅ Deployment runbooks
|
||||
|
||||
## Implementation Readiness Assessment
|
||||
|
||||
### Prerequisites Met ✅
|
||||
- [x] Current Django project analysis complete
|
||||
- [x] Monorepo structure designed
|
||||
- [x] File migration strategy defined
|
||||
- [x] Development workflow planned
|
||||
- [x] Deployment strategy documented
|
||||
- [x] Risk assessment completed
|
||||
|
||||
### Ready for Implementation ✅
|
||||
- [x] Clear step-by-step migration plan
|
||||
- [x] File mapping completeness verified
|
||||
- [x] Package management strategy confirmed
|
||||
- [x] Testing approach defined
|
||||
- [x] Rollback strategy available
|
||||
|
||||
### Success Criteria Defined ✅
|
||||
1. **Functional Requirements**
|
||||
- All existing Django functionality preserved
|
||||
- Modern Vue.js frontend operational
|
||||
- API integration working correctly
|
||||
- Media file handling functional
|
||||
|
||||
2. **Performance Requirements**
|
||||
- Development servers start within reasonable time
|
||||
- Build process completes successfully
|
||||
- Production deployment successful
|
||||
|
||||
3. **Quality Requirements**
|
||||
- All tests passing after migration
|
||||
- Code quality standards maintained
|
||||
- Documentation updated and complete
|
||||
|
||||
## Final Recommendation ✅
|
||||
|
||||
**Approval Status: APPROVED FOR IMPLEMENTATION**
|
||||
|
||||
The proposed monorepo architecture for ThrillWiki is comprehensive, well-planned, and ready for implementation. The plan demonstrates:
|
||||
|
||||
1. **Technical Soundness**: Architecture follows modern best practices
|
||||
2. **Risk Management**: Potential issues identified with mitigation strategies
|
||||
3. **Implementation Clarity**: Clear step-by-step migration process
|
||||
4. **Operational Readiness**: Deployment and maintenance procedures defined
|
||||
|
||||
**Next Steps:**
|
||||
1. Switch to **Code Mode** for implementation
|
||||
2. Begin with directory structure creation
|
||||
3. Migrate backend files systematically
|
||||
4. Create Vue.js frontend application
|
||||
5. Test integration between systems
|
||||
6. Update deployment configurations
|
||||
|
||||
The architecture provides a solid foundation for scaling ThrillWiki with modern frontend technologies while preserving the robust Django backend functionality.
|
||||
628
architecture/deployment-guide.md
Normal file
628
architecture/deployment-guide.md
Normal file
@@ -0,0 +1,628 @@
|
||||
# ThrillWiki Monorepo Deployment Guide
|
||||
|
||||
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + Vue.js monorepo.
|
||||
|
||||
## Build Process Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Source Code] --> B[Backend Build]
|
||||
A --> C[Frontend Build]
|
||||
B --> D[Django Static Collection]
|
||||
C --> E[Vue.js Production Build]
|
||||
D --> F[Backend Container]
|
||||
E --> G[Frontend Assets]
|
||||
F --> H[Production Deployment]
|
||||
G --> H
|
||||
```
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+ with UV package manager
|
||||
- Node.js 18+ with pnpm
|
||||
- PostgreSQL (production) / SQLite (development)
|
||||
- Redis (for caching and sessions)
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd thrillwiki-monorepo
|
||||
|
||||
# Install root dependencies
|
||||
pnpm install
|
||||
|
||||
# Backend setup
|
||||
cd backend
|
||||
uv sync
|
||||
uv run manage.py migrate
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# Frontend setup
|
||||
cd ../frontend
|
||||
pnpm install
|
||||
|
||||
# Start development servers
|
||||
cd ..
|
||||
pnpm run dev # Starts both backend and frontend
|
||||
```
|
||||
|
||||
## Build Strategies
|
||||
|
||||
### 1. Containerized Deployment (Recommended)
|
||||
|
||||
#### Multi-stage Dockerfile for Backend
|
||||
```dockerfile
|
||||
# backend/Dockerfile
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN pip install uv
|
||||
RUN uv sync --no-dev
|
||||
|
||||
FROM python:3.11-slim as runtime
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY . .
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
```
|
||||
|
||||
#### Dockerfile for Frontend
|
||||
```dockerfile
|
||||
# frontend/Dockerfile
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:alpine as runtime
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
#### Docker Compose for Development
|
||||
```yaml
|
||||
# docker-compose.dev.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: thrillwiki
|
||||
POSTGRES_USER: thrillwiki
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./shared/media:/app/media
|
||||
environment:
|
||||
- DEBUG=1
|
||||
- DATABASE_URL=postgresql://thrillwiki:password@db:5432/thrillwiki
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
#### Docker Compose for Production
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DEBUG=0
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ALLOWED_HOSTS=${ALLOWED_HOSTS}
|
||||
volumes:
|
||||
- ./shared/media:/app/media
|
||||
- static_files:/app/staticfiles
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./nginx/ssl:/etc/nginx/ssl
|
||||
- static_files:/usr/share/nginx/html/static
|
||||
- ./shared/media:/usr/share/nginx/html/media
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
static_files:
|
||||
```
|
||||
|
||||
### 2. Static Site Generation (Alternative)
|
||||
|
||||
For sites with mostly static content, consider pre-rendering:
|
||||
|
||||
```bash
|
||||
# Frontend build with pre-rendering
|
||||
cd frontend
|
||||
pnpm run build:prerender
|
||||
|
||||
# Serve static files with minimal backend
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflow
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy ThrillWiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
- name: Backend Tests
|
||||
run: |
|
||||
cd backend
|
||||
uv sync
|
||||
uv run manage.py test
|
||||
uv run flake8 .
|
||||
uv run black --check .
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Frontend Tests
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run test
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push Docker images
|
||||
run: |
|
||||
docker build -t thrillwiki-backend ./backend
|
||||
docker build -t thrillwiki-frontend ./frontend
|
||||
# Push to registry
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
# Deploy using your preferred method
|
||||
# (AWS ECS, GCP Cloud Run, Azure Container Instances, etc.)
|
||||
```
|
||||
|
||||
## Platform-Specific Deployments
|
||||
|
||||
### 1. Vercel Deployment (Frontend + API)
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "frontend/package.json",
|
||||
"use": "@vercel/static-build",
|
||||
"config": {
|
||||
"distDir": "dist"
|
||||
}
|
||||
},
|
||||
{
|
||||
"src": "backend/config/wsgi.py",
|
||||
"use": "@vercel/python"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/api/(.*)",
|
||||
"dest": "backend/config/wsgi.py"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "frontend/dist/$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Railway Deployment
|
||||
|
||||
```toml
|
||||
# railway.toml
|
||||
[environments.production]
|
||||
|
||||
[environments.production.services.backend]
|
||||
dockerfile = "backend/Dockerfile"
|
||||
variables = { DEBUG = "0" }
|
||||
|
||||
[environments.production.services.frontend]
|
||||
dockerfile = "frontend/Dockerfile"
|
||||
|
||||
[environments.production.services.postgres]
|
||||
image = "postgres:15"
|
||||
variables = { POSTGRES_DB = "thrillwiki" }
|
||||
```
|
||||
|
||||
### 3. DigitalOcean App Platform
|
||||
|
||||
```yaml
|
||||
# .do/app.yaml
|
||||
name: thrillwiki
|
||||
services:
|
||||
- name: backend
|
||||
source_dir: backend
|
||||
github:
|
||||
repo: your-username/thrillwiki-monorepo
|
||||
branch: main
|
||||
run_command: gunicorn config.wsgi:application
|
||||
environment_slug: python
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
envs:
|
||||
- key: DEBUG
|
||||
value: "0"
|
||||
|
||||
- name: frontend
|
||||
source_dir: frontend
|
||||
github:
|
||||
repo: your-username/thrillwiki-monorepo
|
||||
branch: main
|
||||
build_command: pnpm run build
|
||||
run_command: pnpm run preview
|
||||
environment_slug: node-js
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
|
||||
databases:
|
||||
- name: thrillwiki-db
|
||||
engine: PG
|
||||
version: "15"
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
```bash
|
||||
# Django Settings
|
||||
DEBUG=0
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@host:port/database
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://host:port/0
|
||||
|
||||
# File Storage
|
||||
MEDIA_ROOT=/app/media
|
||||
STATIC_ROOT=/app/staticfiles
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=smtp.yourmailprovider.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@yourdomain.com
|
||||
EMAIL_HOST_PASSWORD=your-email-password
|
||||
|
||||
# Third-party Services
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
AWS_ACCESS_KEY_ID=your-aws-key
|
||||
AWS_SECRET_ACCESS_KEY=your-aws-secret
|
||||
```
|
||||
|
||||
#### Frontend (.env.production)
|
||||
```bash
|
||||
VITE_API_URL=https://api.yourdomain.com
|
||||
VITE_APP_TITLE=ThrillWiki
|
||||
VITE_SENTRY_DSN=your-frontend-sentry-dsn
|
||||
VITE_GOOGLE_ANALYTICS_ID=your-ga-id
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Backend Optimizations
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
|
||||
# Database optimization
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'CONN_MAX_AGE': 60,
|
||||
'OPTIONS': {
|
||||
'MAX_CONNS': 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Caching
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
},
|
||||
'KEY_PREFIX': 'thrillwiki'
|
||||
}
|
||||
}
|
||||
|
||||
# Static files with CDN
|
||||
AWS_S3_CUSTOM_DOMAIN = 'cdn.yourdomain.com'
|
||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.StaticS3Boto3Storage'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.MediaS3Boto3Storage'
|
||||
```
|
||||
|
||||
### Frontend Optimizations
|
||||
```typescript
|
||||
// frontend/vite.config.ts
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
ui: ['@headlessui/vue', '@heroicons/vue']
|
||||
}
|
||||
}
|
||||
},
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Application Monitoring
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="your-sentry-dsn",
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
send_default_pii=True
|
||||
)
|
||||
|
||||
# Logging configuration
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': '/var/log/django/thrillwiki.log',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['file'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Infrastructure Monitoring
|
||||
- Use Prometheus + Grafana for metrics
|
||||
- Implement health check endpoints
|
||||
- Set up log aggregation (ELK stack or similar)
|
||||
- Monitor database performance
|
||||
- Track API response times
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Security Checklist
|
||||
- [ ] HTTPS enforced with SSL certificates
|
||||
- [ ] Security headers configured (HSTS, CSP, etc.)
|
||||
- [ ] Database credentials secured
|
||||
- [ ] Secret keys rotated regularly
|
||||
- [ ] CORS properly configured
|
||||
- [ ] Rate limiting implemented
|
||||
- [ ] File upload validation
|
||||
- [ ] SQL injection protection
|
||||
- [ ] XSS protection enabled
|
||||
- [ ] CSRF protection active
|
||||
|
||||
### Security Headers
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
|
||||
# CORS for API
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"https://yourdomain.com",
|
||||
"https://www.yourdomain.com",
|
||||
]
|
||||
```
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup Strategy
|
||||
```bash
|
||||
# Automated backup script
|
||||
#!/bin/bash
|
||||
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
||||
```
|
||||
|
||||
### Media Files Backup
|
||||
```bash
|
||||
# Sync media files to S3
|
||||
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
||||
```
|
||||
|
||||
## Scaling Strategies
|
||||
|
||||
### Horizontal Scaling
|
||||
- Load balancer configuration
|
||||
- Database read replicas
|
||||
- CDN for static assets
|
||||
- Redis clustering
|
||||
- Auto-scaling groups
|
||||
|
||||
### Vertical Scaling
|
||||
- Database connection pooling
|
||||
- Application server optimization
|
||||
- Memory usage optimization
|
||||
- CPU-intensive task optimization
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Common Issues
|
||||
1. **Build failures**: Check dependencies and environment variables
|
||||
2. **Database connection errors**: Verify connection strings and firewall rules
|
||||
3. **Static file 404s**: Ensure collectstatic runs and paths are correct
|
||||
4. **CORS errors**: Check CORS configuration and allowed origins
|
||||
5. **Memory issues**: Monitor application memory usage and optimize queries
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Backend debugging
|
||||
cd backend
|
||||
uv run manage.py check --deploy
|
||||
uv run manage.py shell
|
||||
uv run manage.py dbshell
|
||||
|
||||
# Frontend debugging
|
||||
cd frontend
|
||||
pnpm run build --debug
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
This deployment guide provides a comprehensive approach to deploying the ThrillWiki monorepo across various platforms while maintaining security, performance, and scalability.
|
||||
353
architecture/migration-mapping.md
Normal file
353
architecture/migration-mapping.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# ThrillWiki Migration Mapping Document
|
||||
|
||||
This document provides a comprehensive mapping of files from the current Django project to the new monorepo structure.
|
||||
|
||||
## Root Level Files
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `manage.py` | `backend/manage.py` | Core Django management |
|
||||
| `pyproject.toml` | `backend/pyproject.toml` | Python dependencies |
|
||||
| `uv.lock` | `backend/uv.lock` | UV lock file |
|
||||
| `.gitignore` | `.gitignore` (update) | Merge with monorepo patterns |
|
||||
| `README.md` | `README.md` (update) | Update for monorepo |
|
||||
| `.pre-commit-config.yaml` | `.pre-commit-config.yaml` | Root level |
|
||||
|
||||
## Configuration Directory
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `config/django/` | `backend/config/django/` | Django settings |
|
||||
| `config/settings/` | `backend/config/settings/` | Environment settings |
|
||||
| `config/urls.py` | `backend/config/urls.py` | URL configuration |
|
||||
| `config/wsgi.py` | `backend/config/wsgi.py` | WSGI configuration |
|
||||
| `config/asgi.py` | `backend/config/asgi.py` | ASGI configuration |
|
||||
|
||||
## Django Apps
|
||||
|
||||
### Accounts App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `accounts/` | `backend/apps/accounts/` |
|
||||
| `accounts/__init__.py` | `backend/apps/accounts/__init__.py` |
|
||||
| `accounts/models.py` | `backend/apps/accounts/models.py` |
|
||||
| `accounts/views.py` | `backend/apps/accounts/views.py` |
|
||||
| `accounts/admin.py` | `backend/apps/accounts/admin.py` |
|
||||
| `accounts/apps.py` | `backend/apps/accounts/apps.py` |
|
||||
| `accounts/migrations/` | `backend/apps/accounts/migrations/` |
|
||||
| `accounts/tests/` | `backend/apps/accounts/tests/` |
|
||||
|
||||
### Parks App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `parks/` | `backend/apps/parks/` |
|
||||
| `parks/__init__.py` | `backend/apps/parks/__init__.py` |
|
||||
| `parks/models.py` | `backend/apps/parks/models.py` |
|
||||
| `parks/views.py` | `backend/apps/parks/views.py` |
|
||||
| `parks/admin.py` | `backend/apps/parks/admin.py` |
|
||||
| `parks/apps.py` | `backend/apps/parks/apps.py` |
|
||||
| `parks/migrations/` | `backend/apps/parks/migrations/` |
|
||||
| `parks/tests/` | `backend/apps/parks/tests/` |
|
||||
|
||||
### Rides App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `rides/` | `backend/apps/rides/` |
|
||||
| `rides/__init__.py` | `backend/apps/rides/__init__.py` |
|
||||
| `rides/models.py` | `backend/apps/rides/models.py` |
|
||||
| `rides/views.py` | `backend/apps/rides/views.py` |
|
||||
| `rides/admin.py` | `backend/apps/rides/admin.py` |
|
||||
| `rides/apps.py` | `backend/apps/rides/apps.py` |
|
||||
| `rides/migrations/` | `backend/apps/rides/migrations/` |
|
||||
| `rides/tests/` | `backend/apps/rides/tests/` |
|
||||
|
||||
### Moderation App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `moderation/` | `backend/apps/moderation/` |
|
||||
| `moderation/__init__.py` | `backend/apps/moderation/__init__.py` |
|
||||
| `moderation/models.py` | `backend/apps/moderation/models.py` |
|
||||
| `moderation/views.py` | `backend/apps/moderation/views.py` |
|
||||
| `moderation/admin.py` | `backend/apps/moderation/admin.py` |
|
||||
| `moderation/apps.py` | `backend/apps/moderation/apps.py` |
|
||||
| `moderation/migrations/` | `backend/apps/moderation/migrations/` |
|
||||
| `moderation/tests/` | `backend/apps/moderation/tests/` |
|
||||
|
||||
### Location App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `location/` | `backend/apps/location/` |
|
||||
| `location/__init__.py` | `backend/apps/location/__init__.py` |
|
||||
| `location/models.py` | `backend/apps/location/models.py` |
|
||||
| `location/views.py` | `backend/apps/location/views.py` |
|
||||
| `location/admin.py` | `backend/apps/location/admin.py` |
|
||||
| `location/apps.py` | `backend/apps/location/apps.py` |
|
||||
| `location/migrations/` | `backend/apps/location/migrations/` |
|
||||
| `location/tests/` | `backend/apps/location/tests/` |
|
||||
|
||||
### Media App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `media/` | `backend/apps/media/` |
|
||||
| `media/__init__.py` | `backend/apps/media/__init__.py` |
|
||||
| `media/models.py` | `backend/apps/media/models.py` |
|
||||
| `media/views.py` | `backend/apps/media/views.py` |
|
||||
| `media/admin.py` | `backend/apps/media/admin.py` |
|
||||
| `media/apps.py` | `backend/apps/media/apps.py` |
|
||||
| `media/migrations/` | `backend/apps/media/migrations/` |
|
||||
| `media/tests/` | `backend/apps/media/tests/` |
|
||||
|
||||
### Email Service App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `email_service/` | `backend/apps/email_service/` |
|
||||
| `email_service/__init__.py` | `backend/apps/email_service/__init__.py` |
|
||||
| `email_service/models.py` | `backend/apps/email_service/models.py` |
|
||||
| `email_service/views.py` | `backend/apps/email_service/views.py` |
|
||||
| `email_service/admin.py` | `backend/apps/email_service/admin.py` |
|
||||
| `email_service/apps.py` | `backend/apps/email_service/apps.py` |
|
||||
| `email_service/migrations/` | `backend/apps/email_service/migrations/` |
|
||||
| `email_service/tests/` | `backend/apps/email_service/tests/` |
|
||||
|
||||
### Core App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `core/` | `backend/apps/core/` |
|
||||
| `core/__init__.py` | `backend/apps/core/__init__.py` |
|
||||
| `core/models.py` | `backend/apps/core/models.py` |
|
||||
| `core/views.py` | `backend/apps/core/views.py` |
|
||||
| `core/admin.py` | `backend/apps/core/admin.py` |
|
||||
| `core/apps.py` | `backend/apps/core/apps.py` |
|
||||
| `core/migrations/` | `backend/apps/core/migrations/` |
|
||||
| `core/tests/` | `backend/apps/core/tests/` |
|
||||
|
||||
## Static Files and Templates
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `static/` | `backend/static/` | Django admin and backend assets |
|
||||
| `staticfiles/` | `backend/staticfiles/` | Collected static files |
|
||||
| `templates/` | `backend/templates/` | Django templates (if any) |
|
||||
|
||||
## Media Files
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `media/` | `shared/media/` | User uploaded content |
|
||||
|
||||
## Scripts and Development Tools
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `scripts/` | `scripts/` | Root level scripts |
|
||||
| `scripts/dev_server.sh` | `scripts/backend_dev.sh` | Rename for clarity |
|
||||
|
||||
## New Frontend Structure (Created)
|
||||
|
||||
| New Location | Purpose |
|
||||
|--------------|---------|
|
||||
| `frontend/` | Vue.js application root |
|
||||
| `frontend/package.json` | Node.js dependencies |
|
||||
| `frontend/pnpm-lock.yaml` | pnpm lock file |
|
||||
| `frontend/vite.config.ts` | Vite configuration |
|
||||
| `frontend/tsconfig.json` | TypeScript configuration |
|
||||
| `frontend/tailwind.config.js` | Tailwind CSS configuration |
|
||||
| `frontend/src/` | Vue.js source code |
|
||||
| `frontend/src/main.ts` | Application entry point |
|
||||
| `frontend/src/App.vue` | Root component |
|
||||
| `frontend/src/components/` | Vue components |
|
||||
| `frontend/src/views/` | Page components |
|
||||
| `frontend/src/router/` | Vue Router configuration |
|
||||
| `frontend/src/stores/` | Pinia stores |
|
||||
| `frontend/src/composables/` | Vue composables |
|
||||
| `frontend/src/utils/` | Utility functions |
|
||||
| `frontend/src/types/` | TypeScript type definitions |
|
||||
| `frontend/src/assets/` | Static assets |
|
||||
| `frontend/public/` | Public assets |
|
||||
| `frontend/dist/` | Build output |
|
||||
|
||||
## New Shared Resources (Created)
|
||||
|
||||
| New Location | Purpose |
|
||||
|--------------|---------|
|
||||
| `shared/` | Cross-platform resources |
|
||||
| `shared/media/` | User uploaded files |
|
||||
| `shared/docs/` | Documentation |
|
||||
| `shared/types/` | Shared TypeScript types |
|
||||
| `shared/constants/` | Shared constants |
|
||||
|
||||
## Updated Root Files
|
||||
|
||||
### package.json (Root)
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm --filter frontend dev\" \"./scripts/backend_dev.sh\"",
|
||||
"build": "pnpm --filter frontend build",
|
||||
"backend:dev": "./scripts/backend_dev.sh",
|
||||
"frontend:dev": "pnpm --filter frontend dev",
|
||||
"test": "pnpm --filter frontend test && cd backend && uv run manage.py test",
|
||||
"lint": "pnpm --filter frontend lint && cd backend && uv run flake8 .",
|
||||
"format": "pnpm --filter frontend format && cd backend && uv run black ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .gitignore (Updated)
|
||||
```gitignore
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/backend/static/
|
||||
/backend/media/
|
||||
|
||||
# UV
|
||||
.uv/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Vue.js / Vite
|
||||
/frontend/dist/
|
||||
/frontend/dist-ssr/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
```
|
||||
|
||||
## Configuration Updates Required
|
||||
|
||||
### Backend Django Settings
|
||||
Update `INSTALLED_APPS` paths:
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Local apps
|
||||
'apps.accounts',
|
||||
'apps.parks',
|
||||
'apps.rides',
|
||||
'apps.moderation',
|
||||
'apps.location',
|
||||
'apps.media',
|
||||
'apps.email_service',
|
||||
'apps.core',
|
||||
]
|
||||
```
|
||||
|
||||
Update media and static files paths:
|
||||
```python
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR.parent / 'shared' / 'media'
|
||||
```
|
||||
|
||||
### Script Updates
|
||||
Update `scripts/backend_dev.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd backend
|
||||
lsof -ti :8000 | xargs kill -9 2>/dev/null || true
|
||||
find . -type d -name "__pycache__" -exec rm -r {} + 2>/dev/null || true
|
||||
uv run manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
## Migration Steps Summary
|
||||
|
||||
1. **Create new directory structure**
|
||||
2. **Move backend files** to `backend/` directory
|
||||
3. **Update import paths** in Django settings and apps
|
||||
4. **Create frontend** Vue.js application
|
||||
5. **Update scripts** and configuration files
|
||||
6. **Test both backend and frontend** independently
|
||||
7. **Configure API integration** between Django and Vue.js
|
||||
8. **Update deployment** configurations
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] All Django apps moved to `backend/apps/`
|
||||
- [ ] Configuration files updated with new paths
|
||||
- [ ] Static and media file paths configured correctly
|
||||
- [ ] Frontend Vue.js application created and configured
|
||||
- [ ] Root package.json with workspace configuration
|
||||
- [ ] Development scripts updated and tested
|
||||
- [ ] Git configuration updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] CI/CD pipelines updated (if applicable)
|
||||
- [ ] Database migrations work correctly
|
||||
- [ ] Both development servers start successfully
|
||||
- [ ] API endpoints accessible from frontend
|
||||
525
architecture/monorepo-structure-plan.md
Normal file
525
architecture/monorepo-structure-plan.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# ThrillWiki Django + Vue.js Monorepo Architecture Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the optimal monorepo directory structure for migrating the ThrillWiki Django project to a Django + Vue.js architecture. The design separates backend and frontend concerns while maintaining existing Django app organization and supporting modern development workflows.
|
||||
|
||||
## Current Project Analysis
|
||||
|
||||
### Django Apps Structure
|
||||
- **accounts**: User management and authentication
|
||||
- **parks**: Theme park data and operations
|
||||
- **rides**: Ride information and management
|
||||
- **moderation**: Content moderation system
|
||||
- **location**: Geographic data handling
|
||||
- **media**: File and image management
|
||||
- **email_service**: Email functionality
|
||||
- **core**: Core utilities and services
|
||||
|
||||
### Key Infrastructure
|
||||
- **Package Management**: UV-based Python setup
|
||||
- **Configuration**: `config/django/` for settings, `config/settings/` for modular settings
|
||||
- **Development**: `scripts/dev_server.sh` with comprehensive setup
|
||||
- **Static Assets**: Tailwind CSS integration, `static/` and `staticfiles/`
|
||||
- **Media Handling**: Organized `media/` directory with park/ride subdirectories
|
||||
|
||||
## Proposed Monorepo Structure
|
||||
|
||||
```
|
||||
thrillwiki-monorepo/
|
||||
├── README.md
|
||||
├── pyproject.toml # Python dependencies (backend only)
|
||||
├── package.json # Node.js dependencies (monorepo coordination)
|
||||
├── pnpm-workspace.yaml # pnpm workspace configuration
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├──
|
||||
├── backend/ # Django Backend
|
||||
│ ├── manage.py
|
||||
│ ├── pyproject.toml # Backend-specific dependencies
|
||||
│ ├── config/
|
||||
│ │ ├── django/
|
||||
│ │ │ ├── base.py
|
||||
│ │ │ ├── local.py
|
||||
│ │ │ ├── production.py
|
||||
│ │ │ └── test.py
|
||||
│ │ └── settings/
|
||||
│ │ ├── database.py
|
||||
│ │ ├── email.py
|
||||
│ │ └── security.py
|
||||
│ ├── thrillwiki/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── wsgi.py
|
||||
│ │ ├── asgi.py
|
||||
│ │ └── views.py
|
||||
│ ├── apps/ # Django apps
|
||||
│ │ ├── accounts/
|
||||
│ │ ├── parks/
|
||||
│ │ ├── rides/
|
||||
│ │ ├── moderation/
|
||||
│ │ ├── location/
|
||||
│ │ ├── media/
|
||||
│ │ ├── email_service/
|
||||
│ │ └── core/
|
||||
│ ├── templates/ # Django templates (API responses, admin)
|
||||
│ ├── static/ # Backend static files
|
||||
│ │ └── admin/ # Django admin assets
|
||||
│ ├── media/ # User uploads
|
||||
│ │ ├── avatars/
|
||||
│ │ ├── park/
|
||||
│ │ └── submissions/
|
||||
│ └── tests/ # Backend tests
|
||||
│
|
||||
├── frontend/ # Vue.js Frontend
|
||||
│ ├── package.json
|
||||
│ ├── pnpm-lock.yaml
|
||||
│ ├── vite.config.js
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── index.html
|
||||
│ ├── src/
|
||||
│ │ ├── main.js
|
||||
│ │ ├── App.vue
|
||||
│ │ ├── router/
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── stores/ # Pinia/Vuex stores
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── parks.js
|
||||
│ │ │ └── rides.js
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── common/ # Shared components
|
||||
│ │ │ ├── parks/ # Park-specific components
|
||||
│ │ │ ├── rides/ # Ride-specific components
|
||||
│ │ │ └── moderation/ # Moderation components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ │ ├── Home.vue
|
||||
│ │ │ ├── parks/
|
||||
│ │ │ ├── rides/
|
||||
│ │ │ └── auth/
|
||||
│ │ ├── composables/ # Vue 3 composables
|
||||
│ │ │ ├── useAuth.js
|
||||
│ │ │ ├── useApi.js
|
||||
│ │ │ └── useTheme.js
|
||||
│ │ ├── services/ # API service layer
|
||||
│ │ │ ├── api.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── parks.js
|
||||
│ │ │ └── rides.js
|
||||
│ │ ├── assets/
|
||||
│ │ │ ├── images/
|
||||
│ │ │ └── styles/
|
||||
│ │ │ ├── globals.css
|
||||
│ │ │ └── components/
|
||||
│ │ └── utils/
|
||||
│ ├── public/
|
||||
│ │ ├── favicon.ico
|
||||
│ │ └── images/
|
||||
│ ├── dist/ # Build output
|
||||
│ └── tests/ # Frontend tests
|
||||
│ ├── unit/
|
||||
│ └── e2e/
|
||||
│
|
||||
├── shared/ # Shared Resources
|
||||
│ ├── docs/ # Documentation
|
||||
│ │ ├── api/ # API documentation
|
||||
│ │ ├── deployment/ # Deployment guides
|
||||
│ │ └── development/ # Development setup
|
||||
│ ├── scripts/ # Build and deployment scripts
|
||||
│ │ ├── dev/
|
||||
│ │ │ ├── start-backend.sh
|
||||
│ │ │ ├── start-frontend.sh
|
||||
│ │ │ └── start-full-stack.sh
|
||||
│ │ ├── build/
|
||||
│ │ │ ├── build-frontend.sh
|
||||
│ │ │ └── build-production.sh
|
||||
│ │ ├── deploy/
|
||||
│ │ └── utils/
|
||||
│ ├── config/ # Shared configuration
|
||||
│ │ ├── docker/
|
||||
│ │ │ ├── Dockerfile.backend
|
||||
│ │ │ ├── Dockerfile.frontend
|
||||
│ │ │ └── docker-compose.yml
|
||||
│ │ ├── nginx/
|
||||
│ │ └── ci/ # CI/CD configuration
|
||||
│ │ └── github-actions/
|
||||
│ └── types/ # Shared TypeScript types
|
||||
│ ├── api.ts
|
||||
│ ├── parks.ts
|
||||
│ └── rides.ts
|
||||
│
|
||||
├── logs/ # Application logs
|
||||
├── backups/ # Database backups
|
||||
├── uploads/ # Temporary upload directory
|
||||
└── dist/ # Production build output
|
||||
├── backend/ # Django static files
|
||||
└── frontend/ # Vue.js build
|
||||
```
|
||||
|
||||
## Directory Organization Rationale
|
||||
|
||||
### 1. Clear Separation of Concerns
|
||||
- **backend/**: Contains all Django-related code, maintaining existing app structure
|
||||
- **frontend/**: Vue.js application with modern structure (Vite + Vue 3)
|
||||
- **shared/**: Common resources, documentation, and configuration
|
||||
|
||||
### 2. Backend Structure (`backend/`)
|
||||
- Preserves existing Django app organization under `apps/`
|
||||
- Maintains UV-based Python dependency management
|
||||
- Keeps configuration structure with `config/django/` and `config/settings/`
|
||||
- Separates templates for API responses vs. frontend UI
|
||||
|
||||
### 3. Frontend Structure (`frontend/`)
|
||||
- Modern Vue 3 + Vite setup with TypeScript support
|
||||
- Organized by feature areas (parks, rides, auth)
|
||||
- Composables for Vue 3 Composition API patterns
|
||||
- Service layer for API communication with Django backend
|
||||
- Tailwind CSS integration with shared design system
|
||||
|
||||
### 4. Shared Resources (`shared/`)
|
||||
- Centralized documentation and deployment scripts
|
||||
- Docker configuration for containerized deployment
|
||||
- TypeScript type definitions shared between frontend and API
|
||||
- CI/CD pipeline configuration
|
||||
|
||||
## Static File Strategy
|
||||
|
||||
### Development
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue Dev Server :3000] --> B[Vite HMR]
|
||||
C[Django Dev Server :8000] --> D[Django Static Files]
|
||||
E[Tailwind CSS] --> F[Both Frontend & Backend]
|
||||
```
|
||||
|
||||
### Production
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue Build] --> B[dist/frontend/]
|
||||
C[Django Collectstatic] --> D[dist/backend/]
|
||||
E[Nginx] --> F[Serves Both]
|
||||
F --> G[Frontend Assets]
|
||||
F --> H[API Endpoints]
|
||||
F --> I[Media Files]
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
1. **Development Mode**:
|
||||
- Frontend: Vite dev server on port 3000 with HMR
|
||||
- Backend: Django dev server on port 8000
|
||||
- Proxy API calls from frontend to backend
|
||||
|
||||
2. **Production Mode**:
|
||||
- Frontend built to `dist/frontend/`
|
||||
- Django static files collected to `dist/backend/`
|
||||
- Nginx serves static files and proxies API calls
|
||||
|
||||
## Media File Management
|
||||
|
||||
### Current Structure Preservation
|
||||
```
|
||||
media/
|
||||
├── avatars/ # User profile images
|
||||
├── park/ # Park-specific media
|
||||
│ ├── {park-slug}/
|
||||
│ │ └── {ride-slug}/
|
||||
└── submissions/ # User-submitted content
|
||||
└── photos/
|
||||
```
|
||||
|
||||
### Strategy
|
||||
- **Development**: Django serves media files directly
|
||||
- **Production**: CDN or object storage (S3/CloudFlare) integration
|
||||
- **Frontend Access**: Media URLs provided via API responses
|
||||
- **Upload Handling**: Django handles all file uploads, Vue.js provides UI
|
||||
|
||||
## Development Workflow Integration
|
||||
|
||||
### Package Management
|
||||
- **Root**: Node.js dependencies for frontend and tooling (using pnpm)
|
||||
- **Backend**: UV for Python dependencies (existing approach)
|
||||
- **Frontend**: pnpm for Vue.js dependencies
|
||||
|
||||
### Development Scripts
|
||||
```bash
|
||||
# Root level scripts
|
||||
pnpm run dev # Start both backend and frontend
|
||||
pnpm run dev:backend # Start only Django
|
||||
pnpm run dev:frontend # Start only Vue.js
|
||||
pnpm run build # Build for production
|
||||
pnpm run test # Run all tests
|
||||
|
||||
# Backend specific (using UV)
|
||||
cd backend && uv run manage.py runserver
|
||||
cd backend && uv run manage.py test
|
||||
|
||||
# Frontend specific
|
||||
cd frontend && pnpm run dev
|
||||
cd frontend && pnpm run build
|
||||
cd frontend && pnpm run test
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
```bash
|
||||
# Root .env (shared settings)
|
||||
DATABASE_URL=
|
||||
REDIS_URL=
|
||||
SECRET_KEY=
|
||||
|
||||
# Backend .env (Django specific)
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
DEBUG=True
|
||||
|
||||
# Frontend .env (Vue specific)
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_APP_TITLE=ThrillWiki
|
||||
```
|
||||
|
||||
### Package Manager Configuration
|
||||
|
||||
#### Root pnpm-workspace.yaml
|
||||
```yaml
|
||||
packages:
|
||||
- 'frontend'
|
||||
# Backend is managed separately with uv
|
||||
```
|
||||
|
||||
#### Root package.json
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-monorepo",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm run dev:backend\" \"pnpm run dev:frontend\"",
|
||||
"dev:backend": "cd backend && uv run manage.py runserver",
|
||||
"dev:frontend": "cd frontend && pnpm run dev",
|
||||
"build": "pnpm run build:frontend && cd backend && uv run manage.py collectstatic --noinput",
|
||||
"build:frontend": "cd frontend && pnpm run build",
|
||||
"test": "pnpm run test:backend && pnpm run test:frontend",
|
||||
"test:backend": "cd backend && uv run manage.py test",
|
||||
"test:frontend": "cd frontend && pnpm run test",
|
||||
"lint": "cd frontend && pnpm run lint && cd ../backend && uv run flake8 .",
|
||||
"format": "cd frontend && pnpm run format && cd ../backend && uv run black ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend package.json
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"format": "prettier --write src/",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^2.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"prettier": "^3.2.0",
|
||||
"vitest": "^1.3.0",
|
||||
"@playwright/test": "^1.42.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Migration Mapping
|
||||
|
||||
### High-Level Moves
|
||||
```
|
||||
Current → New Location
|
||||
├── manage.py → backend/manage.py
|
||||
├── pyproject.toml → backend/pyproject.toml (+ root package.json)
|
||||
├── config/ → backend/config/
|
||||
├── thrillwiki/ → backend/thrillwiki/
|
||||
├── accounts/ → backend/apps/accounts/
|
||||
├── parks/ → backend/apps/parks/
|
||||
├── rides/ → backend/apps/rides/
|
||||
├── moderation/ → backend/apps/moderation/
|
||||
├── location/ → backend/apps/location/
|
||||
├── media/ → backend/apps/media/
|
||||
├── email_service/ → backend/apps/email_service/
|
||||
├── core/ → backend/apps/core/
|
||||
├── templates/ → backend/templates/ (API) + frontend/src/views/ (UI)
|
||||
├── static/ → backend/static/ (admin) + frontend/src/assets/
|
||||
├── media/ → media/ (shared, accessible to both)
|
||||
├── scripts/ → shared/scripts/
|
||||
├── docs/ → shared/docs/
|
||||
├── tests/ → backend/tests/ + frontend/tests/
|
||||
└── staticfiles/ → dist/backend/ (generated)
|
||||
```
|
||||
|
||||
### Detailed Backend App Moves
|
||||
Each Django app moves to `backend/apps/{app_name}/` with structure preserved:
|
||||
- Models, views, serializers stay the same
|
||||
- Templates for API responses remain in app directories
|
||||
- Static files move to frontend if UI-related
|
||||
- Tests remain with respective apps
|
||||
|
||||
## Build and Deployment Strategy
|
||||
|
||||
### Development Build Process
|
||||
1. **Backend**: No build step, runs directly with Django dev server
|
||||
2. **Frontend**: Vite development server with HMR
|
||||
3. **Shared**: Scripts orchestrate starting both services
|
||||
|
||||
### Production Build Process
|
||||
```mermaid
|
||||
graph TD
|
||||
A[CI/CD Trigger] --> B[Install Dependencies]
|
||||
B --> C[Build Frontend]
|
||||
B --> D[Collect Django Static]
|
||||
C --> E[Generate Frontend Bundle]
|
||||
D --> F[Collect Backend Assets]
|
||||
E --> G[Create Docker Images]
|
||||
F --> G
|
||||
G --> H[Deploy to Production]
|
||||
```
|
||||
|
||||
### Container Strategy
|
||||
- **Multi-stage Docker builds**: Separate backend and frontend images
|
||||
- **Nginx**: Reverse proxy and static file serving
|
||||
- **Volume mounts**: For media files and logs
|
||||
- **Environment-based configuration**: Development vs. production
|
||||
|
||||
## API Integration Strategy
|
||||
|
||||
### Backend API Structure
|
||||
```python
|
||||
# Enhanced DRF setup for SPA
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
],
|
||||
}
|
||||
|
||||
# CORS for development
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # Vue dev server
|
||||
]
|
||||
```
|
||||
|
||||
### Frontend API Service
|
||||
```javascript
|
||||
// API service with auth integration
|
||||
class ApiService {
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Park operations
|
||||
getParks(params = {}) {
|
||||
return this.client.get('/parks/', { params });
|
||||
}
|
||||
|
||||
// Ride operations
|
||||
getRides(parkId, params = {}) {
|
||||
return this.client.get(`/parks/${parkId}/rides/`, { params });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Shared Environment Variables
|
||||
- Database connections
|
||||
- Redis/Cache settings
|
||||
- Secret keys and API keys
|
||||
- Feature flags
|
||||
|
||||
### Application-Specific Settings
|
||||
- **Django**: `backend/config/django/`
|
||||
- **Vue.js**: `frontend/.env` files
|
||||
- **Docker**: `shared/config/docker/`
|
||||
|
||||
### Development vs. Production
|
||||
- Development: Multiple local servers, hot reloading
|
||||
- Production: Containerized deployment, CDN integration
|
||||
|
||||
## Benefits of This Structure
|
||||
|
||||
1. **Clear Separation**: Backend and frontend concerns are clearly separated
|
||||
2. **Scalability**: Each part can be developed, tested, and deployed independently
|
||||
3. **Modern Workflow**: Supports latest Vue 3, Vite, and Django patterns
|
||||
4. **Backward Compatibility**: Preserves existing Django app structure
|
||||
5. **Developer Experience**: Hot reloading, TypeScript support, modern tooling
|
||||
6. **Deployment Flexibility**: Can deploy as SPA + API or traditional Django
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Structure Setup
|
||||
1. Create new directory structure
|
||||
2. Move Django code to `backend/`
|
||||
3. Initialize Vue.js frontend
|
||||
4. Set up basic API integration
|
||||
|
||||
### Phase 2: Frontend Development
|
||||
1. Create Vue.js components for existing Django templates
|
||||
2. Implement routing and state management
|
||||
3. Integrate with Django API endpoints
|
||||
4. Add authentication flow
|
||||
|
||||
### Phase 3: Build & Deploy
|
||||
1. Set up build processes
|
||||
2. Configure CI/CD pipelines
|
||||
3. Implement production deployment
|
||||
4. Performance optimization
|
||||
|
||||
## Considerations and Trade-offs
|
||||
|
||||
### Advantages
|
||||
- Modern development experience
|
||||
- Better code organization
|
||||
- Independent scaling
|
||||
- Rich frontend interactions
|
||||
- API-first architecture
|
||||
|
||||
### Challenges
|
||||
- Increased complexity
|
||||
- Build process coordination
|
||||
- Authentication across services
|
||||
- SEO considerations (if needed)
|
||||
- Development environment setup
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Validate Architecture**: Review with development team
|
||||
2. **Prototype Setup**: Create basic structure with sample components
|
||||
3. **Migration Planning**: Detailed plan for moving existing code
|
||||
4. **Tool Selection**: Finalize Vue.js ecosystem choices (Pinia vs. Vuex, etc.)
|
||||
5. **Implementation**: Begin phase-by-phase migration
|
||||
|
||||
---
|
||||
|
||||
This architecture provides a solid foundation for migrating ThrillWiki to a modern Django + Vue.js monorepo while preserving existing functionality and enabling future growth.
|
||||
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/apps/__init__.py
Normal file
6
backend/apps/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Django apps package.
|
||||
|
||||
This directory contains all Django applications for the ThrillWiki backend.
|
||||
Each app is self-contained and follows Django best practices.
|
||||
"""
|
||||
@@ -6,18 +6,19 @@ from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
return getattr(settings, 'ACCOUNT_ALLOW_SIGNUPS', True)
|
||||
return True
|
||||
|
||||
def get_email_confirmation_url(self, request, emailconfirmation):
|
||||
"""
|
||||
Constructs the email confirmation (activation) url.
|
||||
"""
|
||||
site = get_current_site(request)
|
||||
get_current_site(request)
|
||||
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
|
||||
|
||||
def send_confirmation_mail(self, request, emailconfirmation, signup):
|
||||
@@ -27,30 +28,31 @@ class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
current_site = get_current_site(request)
|
||||
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
||||
ctx = {
|
||||
'user': emailconfirmation.email_address.user,
|
||||
'activate_url': activate_url,
|
||||
'current_site': current_site,
|
||||
'key': emailconfirmation.key,
|
||||
"user": emailconfirmation.email_address.user,
|
||||
"activate_url": activate_url,
|
||||
"current_site": current_site,
|
||||
"key": emailconfirmation.key,
|
||||
}
|
||||
if signup:
|
||||
email_template = 'account/email/email_confirmation_signup'
|
||||
email_template = "account/email/email_confirmation_signup"
|
||||
else:
|
||||
email_template = 'account/email/email_confirmation'
|
||||
email_template = "account/email/email_confirmation"
|
||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||
|
||||
|
||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def is_open_for_signup(self, request, sociallogin):
|
||||
"""
|
||||
Whether to allow social account sign ups.
|
||||
"""
|
||||
return getattr(settings, 'SOCIALACCOUNT_ALLOW_SIGNUPS', True)
|
||||
return True
|
||||
|
||||
def populate_user(self, request, sociallogin, data):
|
||||
"""
|
||||
Hook that can be used to further populate the user instance.
|
||||
"""
|
||||
user = super().populate_user(request, sociallogin, data)
|
||||
if sociallogin.account.provider == 'discord':
|
||||
if sociallogin.account.provider == "discord":
|
||||
user.discord_id = sociallogin.account.uid
|
||||
return user
|
||||
|
||||
360
backend/apps/accounts/admin.py
Normal file
360
backend/apps/accounts/admin.py
Normal file
@@ -0,0 +1,360 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
TopListItem,
|
||||
)
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = "Profile"
|
||||
fieldsets = (
|
||||
(
|
||||
"Personal Info",
|
||||
{"fields": ("display_name", "avatar", "pronouns", "bio")},
|
||||
),
|
||||
(
|
||||
"Social Media",
|
||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
||||
),
|
||||
(
|
||||
"Ride Credits",
|
||||
{
|
||||
"fields": (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TopListItemInline(admin.TabularInline):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
fields = ("content_type", "object_id", "rank", "notes")
|
||||
ordering = ("rank",)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = (
|
||||
"username",
|
||||
"email",
|
||||
"get_avatar",
|
||||
"get_status",
|
||||
"role",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"get_credits",
|
||||
)
|
||||
list_filter = (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"role",
|
||||
"is_banned",
|
||||
"groups",
|
||||
"date_joined",
|
||||
)
|
||||
search_fields = ("username", "email")
|
||||
ordering = ("-date_joined",)
|
||||
actions = [
|
||||
"activate_users",
|
||||
"deactivate_users",
|
||||
"ban_users",
|
||||
"unban_users",
|
||||
]
|
||||
inlines = [UserProfileInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
("Personal info", {"fields": ("email", "pending_email")}),
|
||||
(
|
||||
"Roles and Permissions",
|
||||
{
|
||||
"fields": ("role", "groups", "user_permissions"),
|
||||
"description": (
|
||||
"Role determines group membership. Groups determine permissions."
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Status",
|
||||
{
|
||||
"fields": ("is_active", "is_staff", "is_superuser"),
|
||||
"description": "These are automatically managed based on role.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Ban Status",
|
||||
{
|
||||
"fields": ("is_banned", "ban_reason", "ban_date"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Preferences",
|
||||
{
|
||||
"fields": ("theme_preference",),
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": (
|
||||
"username",
|
||||
"email",
|
||||
"password1",
|
||||
"password2",
|
||||
"role",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Avatar")
|
||||
def get_avatar(self, obj):
|
||||
if obj.profile.avatar:
|
||||
return format_html(
|
||||
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
||||
obj.profile.avatar.url,
|
||||
)
|
||||
return format_html(
|
||||
'<div style="width:30px; height:30px; border-radius:50%; '
|
||||
"background-color:#007bff; color:white; display:flex; "
|
||||
'align-items:center; justify-content:center;">{}</div>',
|
||||
obj.username[0].upper(),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def get_status(self, obj):
|
||||
if obj.is_banned:
|
||||
return format_html('<span style="color: red;">Banned</span>')
|
||||
if not obj.is_active:
|
||||
return format_html('<span style="color: orange;">Inactive</span>')
|
||||
if obj.is_superuser:
|
||||
return format_html('<span style="color: purple;">Superuser</span>')
|
||||
if obj.is_staff:
|
||||
return format_html('<span style="color: blue;">Staff</span>')
|
||||
return format_html('<span style="color: green;">Active</span>')
|
||||
|
||||
@admin.display(description="Ride Credits")
|
||||
def get_credits(self, obj):
|
||||
try:
|
||||
profile = obj.profile
|
||||
return format_html(
|
||||
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
|
||||
profile.coaster_credits,
|
||||
profile.dark_ride_credits,
|
||||
profile.flat_ride_credits,
|
||||
profile.water_ride_credits,
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return "-"
|
||||
|
||||
@admin.action(description="Activate selected users")
|
||||
def activate_users(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
|
||||
@admin.action(description="Deactivate selected users")
|
||||
def deactivate_users(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
|
||||
@admin.action(description="Ban selected users")
|
||||
def ban_users(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
|
||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
||||
|
||||
@admin.action(description="Unban selected users")
|
||||
def unban_users(self, request, queryset):
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
creating = not obj.pk
|
||||
super().save_model(request, obj, form, change)
|
||||
if creating and obj.role != User.Roles.USER:
|
||||
# Ensure new user with role gets added to appropriate group
|
||||
group = Group.objects.filter(name=obj.role).first()
|
||||
if group:
|
||||
obj.groups.add(group)
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"display_name",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
list_filter = (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
search_fields = ("user__username", "user__email", "display_name", "bio")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"User Information",
|
||||
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
|
||||
),
|
||||
(
|
||||
"Social Media",
|
||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
||||
),
|
||||
(
|
||||
"Ride Credits",
|
||||
{
|
||||
"fields": (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "created_at", "last_sent", "is_expired")
|
||||
list_filter = ("created_at", "last_sent")
|
||||
search_fields = ("user__username", "user__email", "token")
|
||||
readonly_fields = ("created_at", "last_sent")
|
||||
|
||||
fieldsets = (
|
||||
("Verification Details", {"fields": ("user", "token")}),
|
||||
("Timing", {"fields": ("created_at", "last_sent")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def is_expired(self, obj):
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if timezone.now() - obj.last_sent > timedelta(days=1):
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
|
||||
|
||||
@admin.register(TopList)
|
||||
class TopListAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "user", "category", "created_at", "updated_at")
|
||||
list_filter = ("category", "created_at", "updated_at")
|
||||
search_fields = ("title", "user__username", "description")
|
||||
inlines = [TopListItemInline]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("user", "title", "category", "description")},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(TopListItem)
|
||||
class TopListItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("top_list", "content_type", "object_id", "rank")
|
||||
list_filter = ("top_list__category", "rank")
|
||||
search_fields = ("top_list__title", "notes")
|
||||
ordering = ("top_list", "rank")
|
||||
|
||||
fieldsets = (
|
||||
("List Information", {"fields": ("top_list", "rank")}),
|
||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PasswordReset)
|
||||
class PasswordResetAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for password reset tokens"""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"is_expired",
|
||||
"used",
|
||||
)
|
||||
list_filter = (
|
||||
"used",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
search_fields = (
|
||||
"user__username",
|
||||
"user__email",
|
||||
"token",
|
||||
)
|
||||
readonly_fields = (
|
||||
"token",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Reset Details",
|
||||
{
|
||||
"fields": (
|
||||
"user",
|
||||
"token",
|
||||
"used",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Status", boolean=True)
|
||||
def is_expired(self, obj):
|
||||
"""Display expiration status with color coding"""
|
||||
from django.utils import timezone
|
||||
|
||||
if obj.used:
|
||||
return format_html('<span style="color: blue;">Used</span>')
|
||||
elif timezone.now() > obj.expires_at:
|
||||
return format_html('<span style="color: red;">Expired</span>')
|
||||
return format_html('<span style="color: green;">Valid</span>')
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of password reset tokens"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Allow viewing but restrict editing of password reset tokens"""
|
||||
return getattr(request.user, "is_superuser", False)
|
||||
@@ -3,7 +3,7 @@ from django.apps import AppConfig
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
name = "apps.accounts"
|
||||
|
||||
def ready(self):
|
||||
import accounts.signals # noqa
|
||||
import apps.accounts.signals # noqa
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check all social auth related tables"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Check SocialApp
|
||||
self.stdout.write("\nChecking SocialApp table:")
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {app.pk}, Provider: {app.provider}, Name: {app.name}, Client ID: {
|
||||
app.client_id
|
||||
}"
|
||||
)
|
||||
self.stdout.write("Sites:")
|
||||
for site in app.sites.all():
|
||||
self.stdout.write(f" - {site.domain}")
|
||||
|
||||
# Check SocialAccount
|
||||
self.stdout.write("\nChecking SocialAccount table:")
|
||||
for account in SocialAccount.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
|
||||
)
|
||||
|
||||
# Check SocialToken
|
||||
self.stdout.write("\nChecking SocialToken table:")
|
||||
for token in SocialToken.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
|
||||
)
|
||||
|
||||
# Check Site
|
||||
self.stdout.write("\nChecking Site table:")
|
||||
for site in Site.objects.all():
|
||||
self.stdout.write(
|
||||
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check social app configurations"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
social_apps = SocialApp.objects.all()
|
||||
|
||||
if not social_apps:
|
||||
self.stdout.write(self.style.ERROR("No social apps found"))
|
||||
return
|
||||
|
||||
for app in social_apps:
|
||||
self.stdout.write(self.style.SUCCESS(f"\nProvider: {app.provider}"))
|
||||
self.stdout.write(f"Name: {app.name}")
|
||||
self.stdout.write(f"Client ID: {app.client_id}")
|
||||
self.stdout.write(f"Secret: {app.secret}")
|
||||
self.stdout.write(
|
||||
f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}"
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up social auth tables and migrations'
|
||||
help = "Clean up social auth tables and migrations"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with connection.cursor() as cursor:
|
||||
@@ -11,12 +12,17 @@ class Command(BaseCommand):
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialapp_sites")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialaccount")
|
||||
cursor.execute("DROP TABLE IF EXISTS socialaccount_socialtoken")
|
||||
|
||||
|
||||
# Remove migration records
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' AND name LIKE '%social%'")
|
||||
|
||||
cursor.execute(
|
||||
"DELETE FROM django_migrations WHERE app='accounts' "
|
||||
"AND name LIKE '%social%'"
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully cleaned up social auth configuration'))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully cleaned up social auth configuration")
|
||||
)
|
||||
@@ -1,9 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from parks.models import Park, ParkReview as Review
|
||||
from rides.models import Ride
|
||||
from media.models import Photo
|
||||
from apps.parks.models import ParkReview, Park, ParkPhoto
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -13,25 +11,33 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Delete test users
|
||||
test_users = User.objects.filter(
|
||||
username__in=["testuser", "moderator"])
|
||||
test_users = User.objects.filter(username__in=["testuser", "moderator"])
|
||||
count = test_users.count()
|
||||
test_users.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||
|
||||
# Delete test reviews
|
||||
reviews = Review.objects.filter(
|
||||
user__username__in=["testuser", "moderator"])
|
||||
reviews = ParkReview.objects.filter(
|
||||
user__username__in=["testuser", "moderator"]
|
||||
)
|
||||
count = reviews.count()
|
||||
reviews.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||
|
||||
# Delete test photos
|
||||
photos = Photo.objects.filter(uploader__username__in=[
|
||||
"testuser", "moderator"])
|
||||
count = photos.count()
|
||||
photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
|
||||
# Delete test photos - both park and ride photos
|
||||
park_photos = ParkPhoto.objects.filter(
|
||||
uploader__username__in=["testuser", "moderator"]
|
||||
)
|
||||
park_count = park_photos.count()
|
||||
park_photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
|
||||
|
||||
ride_photos = RidePhoto.objects.filter(
|
||||
uploader__username__in=["testuser", "moderator"]
|
||||
)
|
||||
ride_count = ride_photos.count()
|
||||
ride_photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
|
||||
|
||||
# Delete test parks
|
||||
parks = Park.objects.filter(name__startswith="Test Park")
|
||||
@@ -64,7 +70,6 @@ class Command(BaseCommand):
|
||||
os.remove(f)
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
|
||||
except OSError as e:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"Error deleting {f}: {e}"))
|
||||
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
|
||||
|
||||
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.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
||||
User = get_user_model()
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -11,22 +8,25 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
# Create regular test user
|
||||
if not User.objects.filter(username="testuser").exists():
|
||||
user = User.objects.create_user(
|
||||
user = User.objects.create(
|
||||
username="testuser",
|
||||
email="testuser@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created test user: {user.get_username()}")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Test user already exists"))
|
||||
|
||||
# Create moderator user
|
||||
if not User.objects.filter(username="moderator").exists():
|
||||
moderator = User.objects.create_user(
|
||||
moderator = User.objects.create(
|
||||
username="moderator",
|
||||
email="moderator@example.com",
|
||||
[PASSWORD-REMOVED]",
|
||||
)
|
||||
moderator.set_password("modpass123")
|
||||
moderator.save()
|
||||
|
||||
# Create moderator group if it doesn't exist
|
||||
moderator_group, created = Group.objects.get_or_create(name="Moderators")
|
||||
@@ -48,7 +48,9 @@ class Command(BaseCommand):
|
||||
moderator.groups.add(moderator_group)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
|
||||
self.style.SUCCESS(
|
||||
f"Created moderator user: {moderator.get_username()}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fix migration history by removing rides.0001_initial"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"DELETE FROM django_migrations WHERE app='rides' "
|
||||
"AND name='0001_initial';"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Successfully removed rides.0001_initial from migration history"
|
||||
)
|
||||
)
|
||||
38
backend/apps/accounts/management/commands/fix_social_apps.py
Normal file
38
backend/apps/accounts/management/commands/fix_social_apps.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fix social app configurations"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete all existing social apps
|
||||
SocialApp.objects.all().delete()
|
||||
self.stdout.write("Deleted all existing social apps")
|
||||
|
||||
# Get the default site
|
||||
site = Site.objects.get(id=1)
|
||||
|
||||
# Create Google provider
|
||||
google_app = SocialApp.objects.create(
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id=os.getenv("GOOGLE_CLIENT_ID"),
|
||||
secret=os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f"Created Google app with client_id: {google_app.client_id}")
|
||||
|
||||
# Create Discord provider
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider="discord",
|
||||
name="Discord",
|
||||
client_id=os.getenv("DISCORD_CLIENT_ID"),
|
||||
secret=os.getenv("DISCORD_CLIENT_SECRET"),
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(
|
||||
f"Created Discord app with client_id: {discord_app.client_id}"
|
||||
)
|
||||
@@ -2,6 +2,7 @@ from django.core.management.base import BaseCommand
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
|
||||
def generate_avatar(letter):
|
||||
"""Generate an avatar for a given letter or number"""
|
||||
avatar_size = (100, 100)
|
||||
@@ -10,7 +11,7 @@ def generate_avatar(letter):
|
||||
font_size = 100
|
||||
|
||||
# Create a blank image with background color
|
||||
image = Image.new('RGB', avatar_size, background_color)
|
||||
image = Image.new("RGB", avatar_size, background_color)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Load a font
|
||||
@@ -19,8 +20,14 @@ def generate_avatar(letter):
|
||||
|
||||
# Calculate text size and position using textbbox
|
||||
text_bbox = draw.textbbox((0, 0), letter, font=font)
|
||||
text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
|
||||
text_position = ((avatar_size[0] - text_width) / 2, (avatar_size[1] - text_height) / 2)
|
||||
text_width, text_height = (
|
||||
text_bbox[2] - text_bbox[0],
|
||||
text_bbox[3] - text_bbox[1],
|
||||
)
|
||||
text_position = (
|
||||
(avatar_size[0] - text_width) / 2,
|
||||
(avatar_size[1] - text_height) / 2,
|
||||
)
|
||||
|
||||
# Draw the text on the image
|
||||
draw.text(text_position, letter, font=font, fill=text_color)
|
||||
@@ -34,11 +41,14 @@ def generate_avatar(letter):
|
||||
avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png")
|
||||
image.save(avatar_path)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Generate avatars for letters A-Z and numbers 0-9'
|
||||
help = "Generate avatars for letters A-Z and numbers 0-9"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
|
||||
characters = [chr(i) for i in range(65, 91)] + [
|
||||
str(i) for i in range(10)
|
||||
] # A-Z and 0-9
|
||||
for char in characters:
|
||||
generate_avatar(char)
|
||||
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.accounts.models import UserProfile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Regenerate default avatars for users without an uploaded avatar"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
profiles = UserProfile.objects.filter(avatar="")
|
||||
for profile in profiles:
|
||||
# This will trigger the avatar generation logic in the save method
|
||||
profile.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}")
|
||||
)
|
||||
@@ -3,66 +3,87 @@ from django.db import connection
|
||||
from django.contrib.auth.hashers import make_password
|
||||
import uuid
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset database and create admin user'
|
||||
help = "Reset database and create admin user"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Resetting database...')
|
||||
self.stdout.write("Resetting database...")
|
||||
|
||||
# Drop all tables
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
FOR r IN (
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
||||
quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema()) LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||
FOR r IN (
|
||||
SELECT sequencename FROM pg_sequences
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || \
|
||||
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
self.stdout.write('All tables dropped and sequences reset.')
|
||||
self.stdout.write("All tables dropped and sequences reset.")
|
||||
|
||||
# Run migrations
|
||||
from django.core.management import call_command
|
||||
call_command('migrate')
|
||||
|
||||
self.stdout.write('Migrations applied.')
|
||||
call_command("migrate")
|
||||
|
||||
self.stdout.write("Migrations applied.")
|
||||
|
||||
# Create superuser using raw SQL
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Create user
|
||||
user_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_user (
|
||||
username, password, email, is_superuser, is_staff,
|
||||
is_active, date_joined, user_id, first_name,
|
||||
last_name, role, is_banned, ban_reason,
|
||||
username, password, email, is_superuser, is_staff,
|
||||
is_active, date_joined, user_id, first_name,
|
||||
last_name, role, is_banned, ban_reason,
|
||||
theme_preference
|
||||
) VALUES (
|
||||
'admin', %s, 'admin@thrillwiki.com', true, true,
|
||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
||||
'light'
|
||||
) RETURNING id;
|
||||
""", [make_password('admin'), user_id])
|
||||
|
||||
user_db_id = cursor.fetchone()[0]
|
||||
""",
|
||||
[make_password("admin"), user_id],
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
raise Exception("Failed to create user - no ID returned")
|
||||
user_db_id = result[0]
|
||||
|
||||
# Create profile
|
||||
profile_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute("""
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_userprofile (
|
||||
profile_id, display_name, pronouns, bio,
|
||||
twitter, instagram, youtube, discord,
|
||||
@@ -75,11 +96,13 @@ class Command(BaseCommand):
|
||||
0, 0, 0, 0,
|
||||
%s, ''
|
||||
);
|
||||
""", [profile_id, user_db_id])
|
||||
""",
|
||||
[profile_id, user_db_id],
|
||||
)
|
||||
|
||||
self.stdout.write('Superuser created.')
|
||||
self.stdout.write("Superuser created.")
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error creating superuser: {str(e)}'))
|
||||
self.stdout.write(self.style.ERROR(f"Error creating superuser: {str(e)}"))
|
||||
raise
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Database reset complete.'))
|
||||
self.stdout.write(self.style.SUCCESS("Database reset complete."))
|
||||
@@ -3,34 +3,37 @@ from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reset social apps configuration'
|
||||
help = "Reset social apps configuration"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete all social apps using raw SQL to bypass Django's ORM
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp")
|
||||
|
||||
|
||||
# Get the default site
|
||||
site = Site.objects.get(id=1)
|
||||
|
||||
|
||||
# Create Discord app
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider='discord',
|
||||
name='Discord',
|
||||
client_id='1299112802274902047',
|
||||
secret='ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
|
||||
provider="discord",
|
||||
name="Discord",
|
||||
client_id="1299112802274902047",
|
||||
secret="ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11",
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f'Created Discord app with ID: {discord_app.id}')
|
||||
|
||||
self.stdout.write(f"Created Discord app with ID: {discord_app.pk}")
|
||||
|
||||
# Create Google app
|
||||
google_app = SocialApp.objects.create(
|
||||
provider='google',
|
||||
name='Google',
|
||||
client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
|
||||
secret='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',
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id=(
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
|
||||
),
|
||||
secret="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",
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(f'Created Google app with ID: {google_app.id}')
|
||||
self.stdout.write(f"Created Google app with ID: {google_app.pk}")
|
||||
@@ -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.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from accounts.models import User
|
||||
from accounts.signals import create_default_groups
|
||||
from django.contrib.auth.models import Group
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.signals import create_default_groups
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up default groups and permissions for user roles'
|
||||
help = "Set up default groups and permissions for user roles"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Creating default groups and permissions...')
|
||||
|
||||
self.stdout.write("Creating default groups and permissions...")
|
||||
|
||||
try:
|
||||
# Create default groups with permissions
|
||||
create_default_groups()
|
||||
|
||||
|
||||
# Sync existing users with groups based on their roles
|
||||
users = User.objects.exclude(role=User.Roles.USER)
|
||||
for user in users:
|
||||
group = Group.objects.filter(name=user.role).first()
|
||||
if group:
|
||||
user.groups.add(group)
|
||||
|
||||
|
||||
# Update staff/superuser status based on role
|
||||
if user.role == User.Roles.SUPERUSER:
|
||||
user.is_superuser = True
|
||||
@@ -28,15 +28,17 @@ class Command(BaseCommand):
|
||||
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully set up groups and permissions'))
|
||||
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully set up groups and permissions")
|
||||
)
|
||||
|
||||
# Print summary
|
||||
for group in Group.objects.all():
|
||||
self.stdout.write(f'\nGroup: {group.name}')
|
||||
self.stdout.write('Permissions:')
|
||||
self.stdout.write(f"\nGroup: {group.name}")
|
||||
self.stdout.write("Permissions:")
|
||||
for perm in group.permissions.all():
|
||||
self.stdout.write(f' - {perm.codename}')
|
||||
|
||||
self.stdout.write(f" - {perm.codename}")
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))
|
||||
self.stdout.write(self.style.ERROR(f"Error setting up groups: {str(e)}"))
|
||||
@@ -1,17 +1,16 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up default site'
|
||||
help = "Set up default site"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Delete any existing sites
|
||||
Site.objects.all().delete()
|
||||
|
||||
|
||||
# Create default site
|
||||
site = Site.objects.create(
|
||||
id=1,
|
||||
domain='localhost:8000',
|
||||
name='ThrillWiki Development'
|
||||
id=1, domain="localhost:8000", name="ThrillWiki Development"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Created site: {site.domain}'))
|
||||
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))
|
||||
129
backend/apps/accounts/management/commands/setup_social_auth.py
Normal file
129
backend/apps/accounts/management/commands/setup_social_auth.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Sets up social authentication apps"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Get environment variables
|
||||
google_client_id = os.getenv("GOOGLE_CLIENT_ID")
|
||||
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||
discord_client_id = os.getenv("DISCORD_CLIENT_ID")
|
||||
discord_client_secret = os.getenv("DISCORD_CLIENT_SECRET")
|
||||
|
||||
# DEBUG: Log environment variable values
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_id type: {type(google_client_id)}, value: {
|
||||
google_client_id
|
||||
}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_secret type: {type(google_client_secret)}, value: {
|
||||
google_client_secret
|
||||
}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_id type: {type(discord_client_id)}, value: {
|
||||
discord_client_id
|
||||
}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_secret type: {type(discord_client_secret)}, value: {
|
||||
discord_client_secret
|
||||
}"
|
||||
)
|
||||
|
||||
if not all(
|
||||
[
|
||||
google_client_id,
|
||||
google_client_secret,
|
||||
discord_client_id,
|
||||
discord_client_secret,
|
||||
]
|
||||
):
|
||||
self.stdout.write(
|
||||
self.style.ERROR("Missing required environment variables")
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_id is None: {google_client_id is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_secret is None: {google_client_secret is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_id is None: {discord_client_id is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_secret is None: {discord_client_secret is None}"
|
||||
)
|
||||
return
|
||||
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
|
||||
)
|
||||
|
||||
# Set up Google
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
provider="google",
|
||||
defaults={
|
||||
"name": "Google",
|
||||
"client_id": google_client_id,
|
||||
"secret": google_client_secret,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
self.stdout.write(
|
||||
f"DEBUG: About to assign google_client_id: {google_client_id} (type: {
|
||||
type(google_client_id)
|
||||
})"
|
||||
)
|
||||
if google_client_id is not None and google_client_secret is not None:
|
||||
google_app.client_id = google_client_id
|
||||
google_app.secret = google_client_secret
|
||||
google_app.save()
|
||||
self.stdout.write("DEBUG: Successfully updated Google app")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Google client_id or secret is None, skipping update."
|
||||
)
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
|
||||
# Set up Discord
|
||||
discord_app, created = SocialApp.objects.get_or_create(
|
||||
provider="discord",
|
||||
defaults={
|
||||
"name": "Discord",
|
||||
"client_id": discord_client_id,
|
||||
"secret": discord_client_secret,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
self.stdout.write(
|
||||
f"DEBUG: About to assign discord_client_id: {discord_client_id} (type: {
|
||||
type(discord_client_id)
|
||||
})"
|
||||
)
|
||||
if discord_client_id is not None and discord_client_secret is not None:
|
||||
discord_app.client_id = discord_client_id
|
||||
discord_app.secret = discord_client_secret
|
||||
discord_app.save()
|
||||
self.stdout.write("DEBUG: Successfully updated Discord app")
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Discord client_id or secret is None, skipping update."
|
||||
)
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))
|
||||
@@ -1,35 +1,43 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Set up social authentication through admin interface'
|
||||
help = "Set up social authentication through admin interface"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'domain': 'localhost:8000',
|
||||
'name': 'ThrillWiki Development'
|
||||
}
|
||||
"domain": "localhost:8000",
|
||||
"name": "ThrillWiki Development",
|
||||
},
|
||||
)
|
||||
if not _:
|
||||
site.domain = 'localhost:8000'
|
||||
site.name = 'ThrillWiki Development'
|
||||
site.domain = "localhost:8000"
|
||||
site.name = "ThrillWiki Development"
|
||||
site.save()
|
||||
self.stdout.write(f'{"Created" if _ else "Updated"} site: {site.domain}')
|
||||
self.stdout.write(f"{'Created' if _ else 'Updated'} site: {site.domain}")
|
||||
|
||||
# Create superuser if it doesn't exist
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
|
||||
self.stdout.write('Created superuser: admin/admin')
|
||||
if not User.objects.filter(username="admin").exists():
|
||||
admin_user = User.objects.create(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
admin_user.set_password("admin")
|
||||
admin_user.save()
|
||||
self.stdout.write("Created superuser: admin/admin")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('''
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"""
|
||||
Social auth setup instructions:
|
||||
|
||||
1. Run the development server:
|
||||
@@ -57,4 +65,6 @@ Social auth setup instructions:
|
||||
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
|
||||
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
|
||||
Sites: Add "localhost:8000"
|
||||
'''))
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Set up social authentication providers for development"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get the current site
|
||||
site = Site.objects.get_current()
|
||||
self.stdout.write(f"Setting up social providers for site: {site}")
|
||||
|
||||
# Clear existing social apps to avoid duplicates
|
||||
deleted_count = SocialApp.objects.all().delete()[0]
|
||||
self.stdout.write(f"Cleared {deleted_count} existing social apps")
|
||||
|
||||
# Create Google social app
|
||||
google_app = SocialApp.objects.create(
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id="demo-google-client-id.apps.googleusercontent.com",
|
||||
secret="demo-google-client-secret",
|
||||
key="",
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Created Google social app"))
|
||||
|
||||
# Create Discord social app
|
||||
discord_app = SocialApp.objects.create(
|
||||
provider="discord",
|
||||
name="Discord",
|
||||
client_id="demo-discord-client-id",
|
||||
secret="demo-discord-client-secret",
|
||||
key="",
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Created Discord social app"))
|
||||
|
||||
# List all social apps
|
||||
self.stdout.write("\nConfigured social apps:")
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}")
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.test import Client
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Test Discord OAuth2 authentication flow"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
client = Client(HTTP_HOST="localhost:8000")
|
||||
|
||||
# Get Discord app
|
||||
try:
|
||||
discord_app = SocialApp.objects.get(provider="discord")
|
||||
self.stdout.write("Found Discord app configuration:")
|
||||
self.stdout.write(f"Client ID: {discord_app.client_id}")
|
||||
|
||||
# Test login URL
|
||||
login_url = "/accounts/discord/login/"
|
||||
response = client.get(login_url, HTTP_HOST="localhost:8000")
|
||||
self.stdout.write(f"\nTesting login URL: {login_url}")
|
||||
self.stdout.write(f"Status code: {response.status_code}")
|
||||
|
||||
if response.status_code == 302:
|
||||
redirect_url = response["Location"]
|
||||
self.stdout.write(f"Redirects to: {redirect_url}")
|
||||
|
||||
# Parse OAuth2 parameters
|
||||
self.stdout.write("\nOAuth2 Parameters:")
|
||||
if "client_id=" in redirect_url:
|
||||
self.stdout.write("✓ client_id parameter present")
|
||||
if "redirect_uri=" in redirect_url:
|
||||
self.stdout.write("✓ redirect_uri parameter present")
|
||||
if "scope=" in redirect_url:
|
||||
self.stdout.write("✓ scope parameter present")
|
||||
if "response_type=" in redirect_url:
|
||||
self.stdout.write("✓ response_type parameter present")
|
||||
if "code_challenge=" in redirect_url:
|
||||
self.stdout.write("✓ PKCE enabled (code_challenge present)")
|
||||
|
||||
# Show callback URL
|
||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||
self.stdout.write(
|
||||
"\nCallback URL to configure in Discord Developer Portal:"
|
||||
)
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
# Show frontend login URL
|
||||
frontend_url = "http://localhost:5173"
|
||||
self.stdout.write("\nFrontend configuration:")
|
||||
self.stdout.write(f"Frontend URL: {frontend_url}")
|
||||
self.stdout.write("Discord login button should use:")
|
||||
self.stdout.write("/accounts/discord/login/?process=login")
|
||||
|
||||
# Show allauth URLs
|
||||
self.stdout.write("\nAllauth URLs:")
|
||||
self.stdout.write("Login URL: /accounts/discord/login/?process=login")
|
||||
self.stdout.write("Callback URL: /accounts/discord/login/callback/")
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR("Discord app not found"))
|
||||
@@ -2,19 +2,22 @@ from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Update social apps to be associated with all sites'
|
||||
help = "Update social apps to be associated with all sites"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Get all sites
|
||||
sites = Site.objects.all()
|
||||
|
||||
|
||||
# Update each social app
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f'Updating {app.provider} app...')
|
||||
self.stdout.write(f"Updating {app.provider} app...")
|
||||
# Clear existing sites
|
||||
app.sites.clear()
|
||||
# Add all sites
|
||||
for site in sites:
|
||||
app.sites.add(site)
|
||||
self.stdout.write(f'Added sites: {", ".join(site.domain for site in sites)}')
|
||||
self.stdout.write(
|
||||
f"Added sites: {', '.join(site.domain for site in sites)}"
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
@@ -33,7 +32,10 @@ class Migration(migrations.Migration):
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"password",
|
||||
models.CharField(max_length=128, verbose_name="password"),
|
||||
),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
@@ -78,7 +80,9 @@ class Migration(migrations.Migration):
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
blank=True,
|
||||
max_length=254,
|
||||
verbose_name="email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -100,7 +104,8 @@ class Migration(migrations.Migration):
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="date joined",
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -274,7 +279,10 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TopListEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
@@ -369,7 +377,10 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name="TopListItemEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"pgh_id",
|
||||
models.AutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
@@ -451,7 +462,10 @@ class Migration(migrations.Migration):
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
|
||||
(
|
||||
"avatar",
|
||||
models.ImageField(blank=True, upload_to="avatars/"),
|
||||
),
|
||||
("pronouns", models.CharField(blank=True, max_length=50)),
|
||||
("bio", models.TextField(blank=True, max_length=500)),
|
||||
("twitter", models.URLField(blank=True)),
|
||||
@@ -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.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class TurnstileMixin:
|
||||
"""
|
||||
Mixin to handle Cloudflare Turnstile validation.
|
||||
Bypasses validation when DEBUG is True.
|
||||
"""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
"""
|
||||
Validate the Turnstile response token.
|
||||
@@ -14,20 +16,20 @@ class TurnstileMixin:
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
return
|
||||
|
||||
token = request.POST.get('cf-turnstile-response')
|
||||
|
||||
token = request.POST.get("cf-turnstile-response")
|
||||
if not token:
|
||||
raise ValidationError('Please complete the Turnstile challenge.')
|
||||
raise ValidationError("Please complete the Turnstile challenge.")
|
||||
|
||||
# Verify the token with Cloudflare
|
||||
data = {
|
||||
'secret': settings.TURNSTILE_SECRET_KEY,
|
||||
'response': token,
|
||||
'remoteip': request.META.get('REMOTE_ADDR'),
|
||||
"secret": settings.TURNSTILE_SECRET_KEY,
|
||||
"response": token,
|
||||
"remoteip": request.META.get("REMOTE_ADDR"),
|
||||
}
|
||||
|
||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||
result = response.json()
|
||||
|
||||
if not result.get('success'):
|
||||
raise ValidationError('Turnstile validation failed. Please try again.')
|
||||
if not result.get("success"):
|
||||
raise ValidationError("Turnstile validation failed. Please try again.")
|
||||
@@ -2,13 +2,11 @@ from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
from core.history import TrackedModel
|
||||
# import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||
@@ -17,29 +15,34 @@ def generate_random_id(model_class, id_field):
|
||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
# If all 4-digit numbers are taken, try 5 digits
|
||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = 'USER', _('User')
|
||||
MODERATOR = 'MODERATOR', _('Moderator')
|
||||
ADMIN = 'ADMIN', _('Admin')
|
||||
SUPERUSER = 'SUPERUSER', _('Superuser')
|
||||
USER = "USER", _("User")
|
||||
MODERATOR = "MODERATOR", _("Moderator")
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = 'light', _('Light')
|
||||
DARK = 'dark', _('Dark')
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this user that remains constant even if the username changes'
|
||||
help_text=(
|
||||
"Unique identifier for this user that remains constant even if the "
|
||||
"username changes"
|
||||
),
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
@@ -61,50 +64,48 @@ class User(AbstractUser):
|
||||
return self.get_display_name()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('profile', kwargs={'username': self.username})
|
||||
return reverse("profile", kwargs={"username": self.username})
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
profile = getattr(self, 'profile', None)
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, 'user_id')
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this profile that remains constant'
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
)
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile'
|
||||
)
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site"
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
)
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
|
||||
# Social media links
|
||||
twitter = models.URLField(blank=True)
|
||||
instagram = models.URLField(blank=True)
|
||||
youtube = models.URLField(blank=True)
|
||||
discord = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = models.IntegerField(default=0)
|
||||
dark_ride_credits = models.IntegerField(default=0)
|
||||
@@ -112,7 +113,10 @@ class UserProfile(models.Model):
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar(self):
|
||||
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
|
||||
"""
|
||||
Return the avatar URL or serve a pre-generated avatar based on the
|
||||
first letter of the username
|
||||
"""
|
||||
if self.avatar:
|
||||
return self.avatar.url
|
||||
first_letter = self.user.username.upper()
|
||||
@@ -127,12 +131,14 @@ class UserProfile(models.Model):
|
||||
self.display_name = self.user.username
|
||||
|
||||
if not self.profile_id:
|
||||
self.profile_id = generate_random_id(UserProfile, 'profile_id')
|
||||
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class EmailVerification(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
@@ -146,6 +152,8 @@ class EmailVerification(models.Model):
|
||||
verbose_name = "Email Verification"
|
||||
verbose_name_plural = "Email Verifications"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class PasswordReset(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64)
|
||||
@@ -160,53 +168,55 @@ class PasswordReset(models.Model):
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
|
||||
# @pghistory.track()
|
||||
|
||||
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
||||
DARK_RIDE = 'DR', _('Dark Ride')
|
||||
FLAT_RIDE = 'FR', _('Flat Ride')
|
||||
WATER_RIDE = 'WR', _('Water Ride')
|
||||
PARK = 'PK', _('Park')
|
||||
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||
DARK_RIDE = "DR", _("Dark Ride")
|
||||
FLAT_RIDE = "FR", _("Flat Ride")
|
||||
WATER_RIDE = "WR", _("Water Ride")
|
||||
PARK = "PK", _("Park")
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='top_lists' # Added related_name for User model access
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=Categories.choices
|
||||
)
|
||||
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
return (
|
||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
)
|
||||
|
||||
|
||||
# @pghistory.track()
|
||||
|
||||
|
||||
class TopListItem(TrackedModel):
|
||||
top_list = models.ForeignKey(
|
||||
TopList,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items'
|
||||
TopList, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
'contenttypes.ContentType',
|
||||
on_delete=models.CASCADE
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['rank']
|
||||
unique_together = [['top_list', 'rank']]
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["rank"]
|
||||
unique_together = [["top_list", "rank"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
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.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
from core.history import TrackedModel
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||
while True:
|
||||
@@ -17,29 +15,30 @@ def generate_random_id(model_class, id_field):
|
||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
# If all 4-digit numbers are taken, try 5 digits
|
||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = 'USER', _('User')
|
||||
MODERATOR = 'MODERATOR', _('Moderator')
|
||||
ADMIN = 'ADMIN', _('Admin')
|
||||
SUPERUSER = 'SUPERUSER', _('Superuser')
|
||||
USER = "USER", _("User")
|
||||
MODERATOR = "MODERATOR", _("Moderator")
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = 'light', _('Light')
|
||||
DARK = 'dark', _('Dark')
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this user that remains constant even if the username changes'
|
||||
help_text="Unique identifier for this user that remains constant even if the username changes",
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
@@ -61,50 +60,47 @@ class User(AbstractUser):
|
||||
return self.get_display_name()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('profile', kwargs={'username': self.username})
|
||||
return reverse("profile", kwargs={"username": self.username})
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
profile = getattr(self, 'profile', None)
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, 'user_id')
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text='Unique identifier for this profile that remains constant'
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
)
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile'
|
||||
)
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site"
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
)
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
|
||||
# Social media links
|
||||
twitter = models.URLField(blank=True)
|
||||
instagram = models.URLField(blank=True)
|
||||
youtube = models.URLField(blank=True)
|
||||
discord = models.CharField(max_length=100, blank=True)
|
||||
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = models.IntegerField(default=0)
|
||||
dark_ride_credits = models.IntegerField(default=0)
|
||||
@@ -127,12 +123,13 @@ class UserProfile(models.Model):
|
||||
self.display_name = self.user.username
|
||||
|
||||
if not self.profile_id:
|
||||
self.profile_id = generate_random_id(UserProfile, 'profile_id')
|
||||
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
|
||||
class EmailVerification(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
@@ -146,6 +143,7 @@ class EmailVerification(models.Model):
|
||||
verbose_name = "Email Verification"
|
||||
verbose_name_plural = "Email Verifications"
|
||||
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64)
|
||||
@@ -160,53 +158,51 @@ class PasswordReset(models.Model):
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = 'RC', _('Roller Coaster')
|
||||
DARK_RIDE = 'DR', _('Dark Ride')
|
||||
FLAT_RIDE = 'FR', _('Flat Ride')
|
||||
WATER_RIDE = 'WR', _('Water Ride')
|
||||
PARK = 'PK', _('Park')
|
||||
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||
DARK_RIDE = "DR", _("Dark Ride")
|
||||
FLAT_RIDE = "FR", _("Flat Ride")
|
||||
WATER_RIDE = "WR", _("Water Ride")
|
||||
PARK = "PK", _("Park")
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='top_lists' # Added related_name for User model access
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(
|
||||
max_length=2,
|
||||
choices=Categories.choices
|
||||
)
|
||||
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
return (
|
||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopListItem(TrackedModel):
|
||||
top_list = models.ForeignKey(
|
||||
TopList,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items'
|
||||
TopList, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
'contenttypes.ContentType',
|
||||
on_delete=models.CASCADE
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['rank']
|
||||
unique_together = [['top_list', 'rank']]
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["rank"]
|
||||
unique_together = [["top_list", "rank"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
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")
|
||||
)
|
||||
265
backend/apps/accounts/serializers.py
Normal file
265
backend/apps/accounts/serializers.py
Normal file
@@ -0,0 +1,265 @@
|
||||
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
|
||||
from typing import cast
|
||||
|
||||
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) -> str | None:
|
||||
"""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 (normalize and check case-insensitively)."""
|
||||
normalized = value.strip().lower() if value is not None else value
|
||||
if UserModel.objects.filter(email__iexact=normalized).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return normalized
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique"""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match"""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data"""
|
||||
validated_data.pop("password_confirm", None)
|
||||
password = validated_data.pop("password")
|
||||
|
||||
user = UserModel.objects.create(**validated_data)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PasswordResetSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password reset request
|
||||
"""
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Normalize email and attach the user to the serializer when found (case-insensitive).
|
||||
|
||||
Returns the normalized email. Does not reveal whether the email exists.
|
||||
"""
|
||||
normalized = value.strip().lower() if value is not None else value
|
||||
try:
|
||||
user = UserModel.objects.get(email__iexact=normalized)
|
||||
self.user = user
|
||||
except UserModel.DoesNotExist:
|
||||
# Do not reveal whether the email exists; keep behavior unchanged.
|
||||
pass
|
||||
return normalized
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists"""
|
||||
if hasattr(self, "user"):
|
||||
# Create password reset token
|
||||
token = get_random_string(64)
|
||||
PasswordReset.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
"token": token,
|
||||
"expires_at": timezone.now() + timedelta(hours=24),
|
||||
"used": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Send reset email
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
site = get_current_site(request)
|
||||
reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/"
|
||||
|
||||
context = {
|
||||
"user": self.user,
|
||||
"reset_url": reset_url,
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_reset.html", context
|
||||
)
|
||||
|
||||
# Narrow and validate email type for the static checker
|
||||
email = getattr(self.user, "email", None)
|
||||
if not email:
|
||||
# No recipient email; skip sending
|
||||
return
|
||||
|
||||
EmailService.send_email(
|
||||
to=cast(str, email),
|
||||
subject="Reset your password",
|
||||
text=f"Click the link to reset your password: {reset_url}",
|
||||
site=site,
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
|
||||
class PasswordChangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password change
|
||||
"""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
max_length=128, validators=[validate_password], style={"input_type": "password"}
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct"""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Old password is incorrect.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate new passwords match"""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Change user password"""
|
||||
user = self.context["request"].user
|
||||
|
||||
# Defensively obtain new_password from validated_data if it's a real dict,
|
||||
# otherwise fall back to initial_data if that's a dict.
|
||||
new_password = None
|
||||
validated = getattr(self, "validated_data", None)
|
||||
if isinstance(validated, dict):
|
||||
new_password = validated.get("new_password")
|
||||
elif isinstance(self.initial_data, dict):
|
||||
new_password = self.initial_data.get("new_password")
|
||||
|
||||
if not new_password:
|
||||
raise serializers.ValidationError("New password is required.")
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class SocialProviderSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for social authentication providers
|
||||
"""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
login_url = serializers.URLField()
|
||||
name = serializers.CharField()
|
||||
login_url = serializers.URLField()
|
||||
@@ -5,7 +5,8 @@ from django.db import transaction
|
||||
from django.core.files import File
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
import requests
|
||||
from .models import User, UserProfile, EmailVerification
|
||||
from .models import User, UserProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
@@ -14,21 +15,21 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
# Create profile
|
||||
profile = UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
# If user has a social account with avatar, download it
|
||||
social_account = instance.socialaccount_set.first()
|
||||
if social_account:
|
||||
extra_data = social_account.extra_data
|
||||
avatar_url = None
|
||||
|
||||
if social_account.provider == 'google':
|
||||
avatar_url = extra_data.get('picture')
|
||||
elif social_account.provider == 'discord':
|
||||
avatar = extra_data.get('avatar')
|
||||
discord_id = extra_data.get('id')
|
||||
|
||||
if social_account.provider == "google":
|
||||
avatar_url = extra_data.get("picture")
|
||||
elif social_account.provider == "discord":
|
||||
avatar = extra_data.get("avatar")
|
||||
discord_id = extra_data.get("id")
|
||||
if avatar:
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png'
|
||||
|
||||
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
|
||||
|
||||
if avatar_url:
|
||||
try:
|
||||
response = requests.get(avatar_url, timeout=60)
|
||||
@@ -36,28 +37,34 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
|
||||
|
||||
file_name = f"avatar_{instance.username}.png"
|
||||
profile.avatar.save(
|
||||
file_name,
|
||||
File(img_temp),
|
||||
save=True
|
||||
)
|
||||
profile.avatar.save(file_name, File(img_temp), save=True)
|
||||
except Exception as e:
|
||||
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
|
||||
print(
|
||||
f"Error downloading avatar for user {instance.username}: {
|
||||
str(e)
|
||||
}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
"""Ensure UserProfile exists and is saved"""
|
||||
try:
|
||||
if not hasattr(instance, 'profile'):
|
||||
# Try to get existing profile first
|
||||
try:
|
||||
profile = instance.profile
|
||||
profile.save()
|
||||
except UserProfile.DoesNotExist:
|
||||
# Profile doesn't exist, create it
|
||||
UserProfile.objects.create(user=instance)
|
||||
instance.profile.save()
|
||||
except Exception as e:
|
||||
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=User)
|
||||
def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
"""Sync user role with Django groups"""
|
||||
@@ -72,33 +79,47 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
old_group = Group.objects.filter(name=old_instance.role).first()
|
||||
if old_group:
|
||||
instance.groups.remove(old_group)
|
||||
|
||||
|
||||
# Add to new role group
|
||||
if instance.role != User.Roles.USER:
|
||||
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
||||
instance.groups.add(new_group)
|
||||
|
||||
|
||||
# Special handling for superuser role
|
||||
if instance.role == User.Roles.SUPERUSER:
|
||||
instance.is_superuser = True
|
||||
instance.is_staff = True
|
||||
elif old_instance.role == User.Roles.SUPERUSER:
|
||||
# If removing superuser role, remove superuser status
|
||||
# If removing superuser role, remove superuser
|
||||
# status
|
||||
instance.is_superuser = False
|
||||
if instance.role not in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
if instance.role not in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
instance.is_staff = False
|
||||
|
||||
|
||||
# Handle staff status for admin and moderator roles
|
||||
if instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
if instance.role in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
instance.is_staff = True
|
||||
elif old_instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
# If removing admin/moderator role, remove staff status
|
||||
elif old_instance.role in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
]:
|
||||
# If removing admin/moderator role, remove staff
|
||||
# status
|
||||
if instance.role not in [User.Roles.SUPERUSER]:
|
||||
instance.is_staff = False
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
|
||||
print(
|
||||
f"Error syncing role with groups for user {instance.username}: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def create_default_groups():
|
||||
"""
|
||||
@@ -107,33 +128,47 @@ def create_default_groups():
|
||||
"""
|
||||
try:
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
# Create Moderator group
|
||||
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
||||
moderator_permissions = [
|
||||
# Review moderation permissions
|
||||
'change_review', 'delete_review',
|
||||
'change_reviewreport', 'delete_reviewreport',
|
||||
"change_review",
|
||||
"delete_review",
|
||||
"change_reviewreport",
|
||||
"delete_reviewreport",
|
||||
# Edit moderation permissions
|
||||
'change_parkedit', 'delete_parkedit',
|
||||
'change_rideedit', 'delete_rideedit',
|
||||
'change_companyedit', 'delete_companyedit',
|
||||
'change_manufactureredit', 'delete_manufactureredit',
|
||||
"change_parkedit",
|
||||
"delete_parkedit",
|
||||
"change_rideedit",
|
||||
"delete_rideedit",
|
||||
"change_companyedit",
|
||||
"delete_companyedit",
|
||||
"change_manufactureredit",
|
||||
"delete_manufactureredit",
|
||||
]
|
||||
|
||||
|
||||
# Create Admin group
|
||||
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
||||
admin_permissions = moderator_permissions + [
|
||||
# User management permissions
|
||||
'change_user', 'delete_user',
|
||||
"change_user",
|
||||
"delete_user",
|
||||
# Content management permissions
|
||||
'add_park', 'change_park', 'delete_park',
|
||||
'add_ride', 'change_ride', 'delete_ride',
|
||||
'add_company', 'change_company', 'delete_company',
|
||||
'add_manufacturer', 'change_manufacturer', 'delete_manufacturer',
|
||||
"add_park",
|
||||
"change_park",
|
||||
"delete_park",
|
||||
"add_ride",
|
||||
"change_ride",
|
||||
"delete_ride",
|
||||
"add_company",
|
||||
"change_company",
|
||||
"delete_company",
|
||||
"add_manufacturer",
|
||||
"change_manufacturer",
|
||||
"delete_manufacturer",
|
||||
]
|
||||
|
||||
|
||||
# Assign permissions to groups
|
||||
for codename in moderator_permissions:
|
||||
try:
|
||||
@@ -141,7 +176,7 @@ def create_default_groups():
|
||||
moderator_group.permissions.add(perm)
|
||||
except Permission.DoesNotExist:
|
||||
print(f"Permission not found: {codename}")
|
||||
|
||||
|
||||
for codename in admin_permissions:
|
||||
try:
|
||||
perm = Permission.objects.get(codename=codename)
|
||||
@@ -4,6 +4,7 @@ from django.template.loader import render_to_string
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def turnstile_widget():
|
||||
"""
|
||||
@@ -13,12 +14,10 @@ def turnstile_widget():
|
||||
Usage: {% load turnstile_tags %}{% turnstile_widget %}
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
template_name = 'accounts/turnstile_widget_empty.html'
|
||||
template_name = "accounts/turnstile_widget_empty.html"
|
||||
context = {}
|
||||
else:
|
||||
template_name = 'accounts/turnstile_widget.html'
|
||||
context = {
|
||||
'site_key': settings.TURNSTILE_SITE_KEY
|
||||
}
|
||||
|
||||
template_name = "accounts/turnstile_widget.html"
|
||||
context = {"site_key": settings.TURNSTILE_SITE_KEY}
|
||||
|
||||
return render_to_string(template_name, context)
|
||||
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.
|
||||
"""
|
||||
23
backend/apps/api/apps.py
Normal file
23
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
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"
|
||||
|
||||
def ready(self):
|
||||
"""Import signals when the app is ready."""
|
||||
import apps.api.v1.signals # noqa: F401
|
||||
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.
|
||||
"""
|
||||
86
backend/apps/api/v1/accounts/serializers.py
Normal file
86
backend/apps/api/v1/accounts/serializers.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
||||
|
||||
|
||||
class UserProfileCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
extra_kwargs = {"user": {"read_only": True}}
|
||||
|
||||
|
||||
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__"
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL"""
|
||||
# Safely try to return an avatar url if present
|
||||
avatar = getattr(obj, "avatar", None)
|
||||
if avatar:
|
||||
return getattr(avatar, "url", None)
|
||||
user_profile = getattr(obj, "user", None)
|
||||
if user_profile and getattr(user_profile, "profile", None):
|
||||
avatar = getattr(user_profile.profile, "avatar", None)
|
||||
if avatar:
|
||||
return getattr(avatar, "url", None)
|
||||
return None
|
||||
|
||||
|
||||
class TopListItemCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListItemUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
# allow updates, adjust as needed
|
||||
extra_kwargs = {"top_list": {"read_only": False}}
|
||||
|
||||
|
||||
class TopListItemOutputSerializer(serializers.ModelSerializer):
|
||||
# Remove the ride field since it doesn't exist on the model
|
||||
# The model likely uses a generic foreign key or different field name
|
||||
|
||||
class Meta:
|
||||
model = TopListItem
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListCreateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TopListUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
# user is set by view's perform_create
|
||||
extra_kwargs = {"user": {"read_only": True}}
|
||||
|
||||
|
||||
class TopListOutputSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
items = TopListItemOutputSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TopList
|
||||
fields = "__all__"
|
||||
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)),
|
||||
]
|
||||
361
backend/apps/api/v1/accounts/views.py
Normal file
361
backend/apps/api/v1/accounts/views.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
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 rest_framework.exceptions import PermissionDenied
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||
from .serializers import (
|
||||
UserProfileCreateInputSerializer,
|
||||
UserProfileUpdateInputSerializer,
|
||||
UserProfileOutputSerializer,
|
||||
TopListCreateInputSerializer,
|
||||
TopListUpdateInputSerializer,
|
||||
TopListOutputSerializer,
|
||||
TopListItemCreateInputSerializer,
|
||||
TopListItemUpdateInputSerializer,
|
||||
TopListItemOutputSerializer,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List user profiles",
|
||||
description="Retrieve a list of user profiles.",
|
||||
responses={200: UserProfileOutputSerializer(many=True)},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create user profile",
|
||||
description="Create a new user profile.",
|
||||
request=UserProfileCreateInputSerializer,
|
||||
responses={201: UserProfileOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get user profile",
|
||||
description="Retrieve a specific user profile by ID.",
|
||||
responses={200: UserProfileOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update user profile",
|
||||
description="Update a user profile.",
|
||||
request=UserProfileUpdateInputSerializer,
|
||||
responses={200: UserProfileOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update user profile",
|
||||
description="Partially update a user profile.",
|
||||
request=UserProfileUpdateInputSerializer,
|
||||
responses={200: UserProfileOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete user profile",
|
||||
description="Delete a user profile.",
|
||||
responses={204: None},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
me=extend_schema(
|
||||
summary="Get current user's profile",
|
||||
description="Retrieve the current authenticated user's profile.",
|
||||
responses={200: UserProfileOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
)
|
||||
class UserProfileViewSet(ModelViewSet):
|
||||
"""ViewSet for managing user profiles."""
|
||||
|
||||
queryset = UserProfile.objects.select_related("user").all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""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): # type: ignore[override]
|
||||
"""Filter profiles based on user permissions."""
|
||||
if getattr(self.request.user, "is_staff", False):
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List top lists",
|
||||
description="Retrieve a list of top lists.",
|
||||
responses={200: TopListOutputSerializer(many=True)},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create top list",
|
||||
description="Create a new top list.",
|
||||
request=TopListCreateInputSerializer,
|
||||
responses={201: TopListOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get top list",
|
||||
description="Retrieve a specific top list by ID.",
|
||||
responses={200: TopListOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update top list",
|
||||
description="Update a top list.",
|
||||
request=TopListUpdateInputSerializer,
|
||||
responses={200: TopListOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update top list",
|
||||
description="Partially update a top list.",
|
||||
request=TopListUpdateInputSerializer,
|
||||
responses={200: TopListOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete top list",
|
||||
description="Delete a top list.",
|
||||
responses={204: None},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
my_lists=extend_schema(
|
||||
summary="Get current user's top lists",
|
||||
description="Retrieve all top lists belonging to the current user.",
|
||||
responses={200: TopListOutputSerializer(many=True)},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
duplicate=extend_schema(
|
||||
summary="Duplicate top list",
|
||||
description="Create a copy of an existing top list for the current user.",
|
||||
responses={201: TopListOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
)
|
||||
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): # type: ignore[override]
|
||||
"""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): # type: ignore[override]
|
||||
"""Filter lists based on user permissions and visibility."""
|
||||
queryset = self.queryset
|
||||
|
||||
if not getattr(self.request.user, "is_staff", False):
|
||||
# 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."""
|
||||
_ = pk # reference pk to avoid unused-variable warnings
|
||||
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)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List top list items",
|
||||
description="Retrieve a list of top list items.",
|
||||
responses={200: TopListItemOutputSerializer(many=True)},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create top list item",
|
||||
description="Add a new item to a top list.",
|
||||
request=TopListItemCreateInputSerializer,
|
||||
responses={201: TopListItemOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get top list item",
|
||||
description="Retrieve a specific top list item by ID.",
|
||||
responses={200: TopListItemOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update top list item",
|
||||
description="Update a top list item.",
|
||||
request=TopListItemUpdateInputSerializer,
|
||||
responses={200: TopListItemOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update top list item",
|
||||
description="Partially update a top list item.",
|
||||
request=TopListItemUpdateInputSerializer,
|
||||
responses={200: TopListItemOutputSerializer},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete top list item",
|
||||
description="Remove an item from a top list.",
|
||||
responses={204: None},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
reorder=extend_schema(
|
||||
summary="Reorder top list items",
|
||||
description="Reorder items within a top list.",
|
||||
responses={
|
||||
200: {"type": "object", "properties": {"success": {"type": "boolean"}}}
|
||||
},
|
||||
tags=["Accounts"],
|
||||
),
|
||||
)
|
||||
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): # type: ignore[override]
|
||||
"""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): # type: ignore[override]
|
||||
"""Filter items based on user permissions."""
|
||||
queryset = self.queryset
|
||||
|
||||
if not getattr(self.request.user, "is_staff", False):
|
||||
# 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 getattr(
|
||||
self.request.user, "is_staff", False
|
||||
):
|
||||
raise PermissionDenied("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 getattr(
|
||||
self.request.user, "is_staff", False
|
||||
):
|
||||
raise PermissionDenied("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 getattr(
|
||||
self.request.user, "is_staff", False
|
||||
):
|
||||
raise PermissionDenied("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 getattr(
|
||||
request.user, "is_staff", False
|
||||
):
|
||||
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/apps/api/v1/auth/__init__.py
Normal file
6
backend/apps/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.
|
||||
"""
|
||||
33
backend/apps/api/v1/auth/models.py
Normal file
33
backend/apps/api/v1/auth/models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
"""Persisted password reset tokens for API-driven password resets."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="password_resets",
|
||||
)
|
||||
token = models.CharField(max_length=128, unique=True, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def mark_used(self) -> None:
|
||||
self.used = True
|
||||
self.save(update_fields=["used"])
|
||||
|
||||
def __str__(self):
|
||||
user_id = getattr(self, "user_id", None)
|
||||
return f"PasswordReset(user={user_id}, token={self.token[:8]}..., used={self.used})"
|
||||
536
backend/apps/api/v1/auth/serializers.py
Normal file
536
backend/apps/api/v1/auth/serializers.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
Auth domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to authentication, user accounts,
|
||||
profiles, top lists, and user statistics.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
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
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import PasswordReset
|
||||
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
def _normalize_email(value: str) -> str:
|
||||
"""Normalize email for consistent lookups (strip + lowercase)."""
|
||||
if value is None:
|
||||
return value
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
# 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 (case-insensitive) and return normalized email."""
|
||||
normalized = _normalize_email(value)
|
||||
if UserModel.objects.filter(email__iexact=normalized).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return normalized
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique."""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match."""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data."""
|
||||
validated_data.pop("password_confirm", None)
|
||||
password = validated_data.pop("password")
|
||||
|
||||
# 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):
|
||||
"""Normalize email and attach user to the serializer when found (case-insensitive).
|
||||
|
||||
Returns the normalized email. Does not reveal whether the email exists.
|
||||
"""
|
||||
normalized = _normalize_email(value)
|
||||
try:
|
||||
user = UserModel.objects.get(email__iexact=normalized)
|
||||
self.user = user
|
||||
except UserModel.DoesNotExist:
|
||||
# Do not reveal whether the email exists; keep behavior unchanged.
|
||||
pass
|
||||
return normalized
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists."""
|
||||
if hasattr(self, "user"):
|
||||
# generate a secure random token and persist it with expiry
|
||||
now = timezone.now()
|
||||
expires = now + timedelta(hours=24) # token valid for 24 hours
|
||||
|
||||
# Persist password reset with generated token (avoid creating an unused local variable).
|
||||
PasswordReset.objects.create(
|
||||
user=self.user,
|
||||
token=get_random_string(64),
|
||||
expires_at=expires,
|
||||
)
|
||||
|
||||
# Optionally: enqueue/send an email with the token-based reset link here.
|
||||
# Keep token out of API responses to avoid leaking it.
|
||||
|
||||
|
||||
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[str, Any]:
|
||||
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[str, Any]:
|
||||
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[str, Any]:
|
||||
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)
|
||||
36
backend/apps/api/v1/auth/urls.py
Normal file
36
backend/apps/api/v1/auth/urls.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Auth domain URL Configuration for ThrillWiki API v1.
|
||||
|
||||
This module contains URL patterns for core authentication functionality only.
|
||||
User profiles and top lists are handled by the dedicated accounts app.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Core 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"),
|
||||
]
|
||||
|
||||
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||
# to maintain clean separation of concerns and avoid duplicate API endpoints.
|
||||
469
backend/apps/api/v1/auth/views.py
Normal file
469
backend/apps/api/v1/auth/views.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
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 typing import Optional, cast # added 'cast'
|
||||
from django.http import HttpRequest # new import
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from .serializers import (
|
||||
# Authentication serializers
|
||||
LoginInputSerializer,
|
||||
LoginOutputSerializer,
|
||||
SignupInputSerializer,
|
||||
SignupOutputSerializer,
|
||||
LogoutOutputSerializer,
|
||||
UserOutputSerializer,
|
||||
PasswordResetInputSerializer,
|
||||
PasswordResetOutputSerializer,
|
||||
PasswordChangeInputSerializer,
|
||||
PasswordChangeOutputSerializer,
|
||||
SocialProviderOutputSerializer,
|
||||
AuthStatusOutputSerializer,
|
||||
)
|
||||
|
||||
# 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 and ensure it's a class/type
|
||||
try:
|
||||
from apps.accounts.mixins import TurnstileMixin as _ImportedTurnstileMixin
|
||||
|
||||
# Ensure the imported object is a class/type that can be used as a base class.
|
||||
# If it's not a type for any reason, fall back to the safe mixin.
|
||||
if isinstance(_ImportedTurnstileMixin, type):
|
||||
TurnstileMixin = _ImportedTurnstileMixin
|
||||
else:
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
except Exception:
|
||||
# Catch any import errors or unexpected exceptions and use the fallback mixin.
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
# Helper: safely obtain underlying HttpRequest (used by Django auth)
|
||||
|
||||
|
||||
def _get_underlying_request(request: Request) -> HttpRequest:
|
||||
"""
|
||||
Return a django HttpRequest for use with Django auth and site utilities.
|
||||
|
||||
DRF's Request wraps the underlying HttpRequest in ._request; cast() tells the
|
||||
typechecker that the returned object is indeed an HttpRequest.
|
||||
"""
|
||||
return cast(HttpRequest, getattr(request, "_request", request))
|
||||
|
||||
|
||||
# Helper: encapsulate user lookup + authenticate to reduce complexity in view
|
||||
def _authenticate_user_by_lookup(
|
||||
email_or_username: str, password: str, request: Request
|
||||
) -> Optional[UserModel]:
|
||||
"""
|
||||
Try a single optimized query to find a user by email OR username then authenticate.
|
||||
Returns authenticated user or None.
|
||||
"""
|
||||
try:
|
||||
# Single query to find user by email OR username
|
||||
if "@" in (email_or_username or ""):
|
||||
user_obj = (
|
||||
UserModel.objects.select_related()
|
||||
.filter(Q(email=email_or_username) | Q(username=email_or_username))
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
user_obj = (
|
||||
UserModel.objects.select_related()
|
||||
.filter(Q(username=email_or_username) | Q(email=email_or_username))
|
||||
.first()
|
||||
)
|
||||
|
||||
if user_obj:
|
||||
username_val = getattr(user_obj, "username", None)
|
||||
return authenticate(
|
||||
# type: ignore[arg-type]
|
||||
_get_underlying_request(request),
|
||||
username=username_val,
|
||||
password=password,
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to authenticate directly with provided identifier
|
||||
return authenticate(
|
||||
# type: ignore[arg-type]
|
||||
_get_underlying_request(request),
|
||||
username=email_or_username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# === 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(APIView):
|
||||
"""API endpoint for user login."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = LoginInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# instantiate mixin before calling to avoid type-mismatch in static analysis
|
||||
TurnstileMixin().validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
# If mixin doesn't do anything, continue
|
||||
pass
|
||||
|
||||
serializer = LoginInputSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
validated = serializer.validated_data
|
||||
# Use .get to satisfy static analyzers
|
||||
email_or_username = validated.get("username") # type: ignore[assignment]
|
||||
password = validated.get("password") # type: ignore[assignment]
|
||||
|
||||
if not email_or_username or not password:
|
||||
return Response(
|
||||
{"error": "username and password are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = _authenticate_user_by_lookup(email_or_username, password, request)
|
||||
|
||||
if user:
|
||||
if getattr(user, "is_active", False):
|
||||
# pass a real HttpRequest to Django login
|
||||
login(_get_underlying_request(request), user)
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
token, _ = 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(APIView):
|
||||
"""API endpoint for user registration."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = SignupInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# instantiate mixin before calling to avoid type-mismatch in static analysis
|
||||
TurnstileMixin().validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
# If mixin doesn't do anything, continue
|
||||
pass
|
||||
|
||||
serializer = SignupInputSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
# pass a real HttpRequest to Django login
|
||||
login(_get_underlying_request(request), user) # type: ignore[arg-type]
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
token, _ = 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 using the underlying HttpRequest
|
||||
logout(_get_underlying_request(request))
|
||||
|
||||
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
|
||||
|
||||
# get_current_site expects a django HttpRequest; _get_underlying_request now returns HttpRequest
|
||||
site = get_current_site(_get_underlying_request(request))
|
||||
|
||||
# Cache key based on site and request host - use getattr to avoid attribute errors
|
||||
site_id = getattr(site, "id", getattr(site, "pk", None))
|
||||
cache_key = f"social_providers:{site_id}:{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:
|
||||
provider_name = (
|
||||
social_app.name or getattr(social_app, "provider", "").title()
|
||||
)
|
||||
|
||||
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:
|
||||
continue
|
||||
|
||||
serializer = SocialProviderOutputSerializer(providers_list, many=True)
|
||||
response_data = serializer.data
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
|
||||
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
|
||||
# to avoid duplication and maintain clean separation of concerns.
|
||||
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",
|
||||
),
|
||||
]
|
||||
370
backend/apps/api/v1/core/views.py
Normal file
370
backend/apps/api/v1/core/views.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Centralized core API views.
|
||||
Migrated from apps.core.views.entity_search
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from typing import Optional, List
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
from apps.core.services.entity_fuzzy_matching import (
|
||||
entity_fuzzy_matcher,
|
||||
EntityType,
|
||||
)
|
||||
|
||||
|
||||
class EntityFuzzySearchView(APIView):
|
||||
"""
|
||||
API endpoint for fuzzy entity search with authentication prompts.
|
||||
|
||||
Handles entity lookup failures by providing intelligent suggestions and
|
||||
authentication prompts for entity creation.
|
||||
|
||||
Migrated from apps.core.views.entity_search.EntityFuzzySearchView
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny] # Allow both authenticated and anonymous users
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Fuzzy entity search",
|
||||
description="Perform fuzzy entity search with authentication prompts for entity creation",
|
||||
)
|
||||
def post(self, request):
|
||||
"""
|
||||
Perform fuzzy entity search.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "entity name to search",
|
||||
"entity_types": ["park", "ride", "company"], // optional
|
||||
"include_suggestions": true // optional, default true
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"query": "original query",
|
||||
"matches": [
|
||||
{
|
||||
"entity_type": "park",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"score": 0.95,
|
||||
"confidence": "high",
|
||||
"match_reason": "Text similarity with 'Cedar Point'",
|
||||
"url": "/parks/cedar-point/",
|
||||
"entity_id": 123
|
||||
}
|
||||
],
|
||||
"suggestion": {
|
||||
"suggested_name": "New Entity Name",
|
||||
"entity_type": "park",
|
||||
"requires_authentication": true,
|
||||
"login_prompt": "Log in to suggest adding...",
|
||||
"signup_prompt": "Sign up to contribute...",
|
||||
"creation_hint": "Help expand ThrillWiki..."
|
||||
},
|
||||
"user_authenticated": false
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse request data
|
||||
query = request.data.get("query", "").strip()
|
||||
entity_types_raw = request.data.get(
|
||||
"entity_types", ["park", "ride", "company"]
|
||||
)
|
||||
include_suggestions = request.data.get("include_suggestions", True)
|
||||
|
||||
# Validate query
|
||||
if not query or len(query) < 2:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Query must be at least 2 characters long",
|
||||
"code": "INVALID_QUERY",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Parse and validate entity types
|
||||
entity_types = []
|
||||
valid_types = {"park", "ride", "company"}
|
||||
|
||||
for entity_type in entity_types_raw:
|
||||
if entity_type in valid_types:
|
||||
entity_types.append(EntityType(entity_type))
|
||||
|
||||
if not entity_types:
|
||||
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
# Perform fuzzy matching
|
||||
matches, suggestion = entity_fuzzy_matcher.find_entity(
|
||||
query=query, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# Format response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"matches": [match.to_dict() for match in matches],
|
||||
"user_authenticated": (
|
||||
request.user.is_authenticated
|
||||
if hasattr(request.user, "is_authenticated")
|
||||
else False
|
||||
),
|
||||
}
|
||||
|
||||
# Include suggestion if requested and available
|
||||
if include_suggestions and suggestion:
|
||||
response_data["suggestion"] = {
|
||||
"suggested_name": suggestion.suggested_name,
|
||||
"entity_type": suggestion.entity_type.value,
|
||||
"requires_authentication": suggestion.requires_authentication,
|
||||
"login_prompt": suggestion.login_prompt,
|
||||
"signup_prompt": suggestion.signup_prompt,
|
||||
"creation_hint": suggestion.creation_hint,
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Internal server error: {str(e)}",
|
||||
"code": "INTERNAL_ERROR",
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class EntityNotFoundView(APIView):
|
||||
"""
|
||||
Endpoint specifically for handling entity not found scenarios.
|
||||
|
||||
This view is called when normal entity lookup fails and provides
|
||||
fuzzy matching suggestions along with authentication prompts.
|
||||
|
||||
Migrated from apps.core.views.entity_search.EntityNotFoundView
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Handle entity not found",
|
||||
description="Handle entity not found scenarios with fuzzy matching suggestions and authentication prompts",
|
||||
)
|
||||
def post(self, request):
|
||||
"""
|
||||
Handle entity not found with suggestions.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"original_query": "what user searched for",
|
||||
"attempted_slug": "slug-that-failed", // optional
|
||||
"entity_type": "park", // optional, inferred from context
|
||||
"context": { // optional context information
|
||||
"park_slug": "park-slug-if-searching-for-ride",
|
||||
"source_page": "page where search originated"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
original_query = request.data.get("original_query", "").strip()
|
||||
attempted_slug = request.data.get("attempted_slug", "")
|
||||
entity_type_hint = request.data.get("entity_type")
|
||||
context = request.data.get("context", {})
|
||||
|
||||
if not original_query:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "original_query is required",
|
||||
"code": "MISSING_QUERY",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Determine entity types to search based on context
|
||||
entity_types = []
|
||||
if entity_type_hint:
|
||||
try:
|
||||
entity_types = [EntityType(entity_type_hint)]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If we have park context, prioritize ride searches
|
||||
if context.get("park_slug") and not entity_types:
|
||||
entity_types = [EntityType.RIDE, EntityType.PARK]
|
||||
|
||||
# Default to all types if not specified
|
||||
if not entity_types:
|
||||
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
# Try fuzzy matching on the original query
|
||||
matches, suggestion = entity_fuzzy_matcher.find_entity(
|
||||
query=original_query, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# If no matches on original query, try the attempted slug
|
||||
if not matches and attempted_slug:
|
||||
# Convert slug back to readable name for fuzzy matching
|
||||
slug_as_name = attempted_slug.replace("-", " ").title()
|
||||
matches, suggestion = entity_fuzzy_matcher.find_entity(
|
||||
query=slug_as_name, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# Prepare response with detailed context
|
||||
response_data = {
|
||||
"success": True,
|
||||
"original_query": original_query,
|
||||
"attempted_slug": attempted_slug,
|
||||
"context": context,
|
||||
"matches": [match.to_dict() for match in matches],
|
||||
"user_authenticated": (
|
||||
request.user.is_authenticated
|
||||
if hasattr(request.user, "is_authenticated")
|
||||
else False
|
||||
),
|
||||
"has_matches": len(matches) > 0,
|
||||
}
|
||||
|
||||
# Always include suggestion for entity not found scenarios
|
||||
if suggestion:
|
||||
response_data["suggestion"] = {
|
||||
"suggested_name": suggestion.suggested_name,
|
||||
"entity_type": suggestion.entity_type.value,
|
||||
"requires_authentication": suggestion.requires_authentication,
|
||||
"login_prompt": suggestion.login_prompt,
|
||||
"signup_prompt": suggestion.signup_prompt,
|
||||
"creation_hint": suggestion.creation_hint,
|
||||
}
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Internal server error: {str(e)}",
|
||||
"code": "INTERNAL_ERROR",
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class QuickEntitySuggestionView(APIView):
|
||||
"""
|
||||
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
|
||||
|
||||
Migrated from apps.core.views.entity_search.QuickEntitySuggestionView
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Quick entity suggestions",
|
||||
description="Lightweight endpoint for quick entity suggestions (e.g., autocomplete)",
|
||||
)
|
||||
def get(self, request):
|
||||
"""
|
||||
Get quick entity suggestions.
|
||||
|
||||
Query parameters:
|
||||
- q: query string
|
||||
- types: comma-separated entity types (park,ride,company)
|
||||
- limit: max results (default 5)
|
||||
"""
|
||||
try:
|
||||
query = request.GET.get("q", "").strip()
|
||||
types_param = request.GET.get("types", "park,ride,company")
|
||||
limit = min(int(request.GET.get("limit", 5)), 10) # Cap at 10
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return Response(
|
||||
{"suggestions": [], "query": query}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Parse entity types
|
||||
entity_types = []
|
||||
for type_str in types_param.split(","):
|
||||
type_str = type_str.strip()
|
||||
if type_str in ["park", "ride", "company"]:
|
||||
entity_types.append(EntityType(type_str))
|
||||
|
||||
if not entity_types:
|
||||
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
# Get fuzzy matches
|
||||
matches, _ = entity_fuzzy_matcher.find_entity(
|
||||
query=query, entity_types=entity_types, user=request.user
|
||||
)
|
||||
|
||||
# Format as simple suggestions
|
||||
suggestions = []
|
||||
for match in matches[:limit]:
|
||||
suggestions.append(
|
||||
{
|
||||
"name": match.name,
|
||||
"type": match.entity_type.value,
|
||||
"slug": match.slug,
|
||||
"url": match.url,
|
||||
"score": match.score,
|
||||
"confidence": match.confidence,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"suggestions": suggestions, "query": query, "count": len(suggestions)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"suggestions": [], "query": request.GET.get("q", ""), "error": str(e)},
|
||||
status=status.HTTP_200_OK,
|
||||
) # Return 200 even on errors for autocomplete
|
||||
|
||||
|
||||
# Utility function for other views to use
|
||||
def get_entity_suggestions(
|
||||
query: str, entity_types: Optional[List[str]] = None, user=None
|
||||
):
|
||||
"""
|
||||
Utility function for other Django views to get entity suggestions.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
entity_types: List of entity type strings
|
||||
user: Django user object
|
||||
|
||||
Returns:
|
||||
Tuple of (matches, suggestion)
|
||||
"""
|
||||
try:
|
||||
# Convert string types to EntityType enums
|
||||
parsed_types = []
|
||||
if entity_types:
|
||||
for entity_type in entity_types:
|
||||
try:
|
||||
parsed_types.append(EntityType(entity_type))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not parsed_types:
|
||||
parsed_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
|
||||
|
||||
return entity_fuzzy_matcher.find_entity(
|
||||
query=query, entity_types=parsed_types, user=user
|
||||
)
|
||||
except Exception:
|
||||
return [], None
|
||||
11
backend/apps/api/v1/email/urls.py
Normal file
11
backend/apps/api/v1/email/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Email service API URL configuration.
|
||||
Centralized from apps.email_service.urls
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("send/", views.SendEmailView.as_view(), name="send_email"),
|
||||
]
|
||||
106
backend/apps/api/v1/email/views.py
Normal file
106
backend/apps/api/v1/email/views.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Centralized email service API views.
|
||||
Migrated from apps.email_service.views
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from apps.email_service.services import EmailService
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="Send email",
|
||||
description="Send an email via the email service.",
|
||||
request={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Recipient email address",
|
||||
},
|
||||
"subject": {"type": "string", "description": "Email subject"},
|
||||
"text": {"type": "string", "description": "Email body text"},
|
||||
"from_email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Sender email address (optional)",
|
||||
},
|
||||
},
|
||||
"required": ["to", "subject", "text"],
|
||||
},
|
||||
responses={
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"response": {"type": "object"},
|
||||
},
|
||||
},
|
||||
400: "Bad Request",
|
||||
500: "Internal Server Error",
|
||||
},
|
||||
tags=["Email"],
|
||||
)
|
||||
class SendEmailView(APIView):
|
||||
"""
|
||||
API endpoint for sending emails.
|
||||
|
||||
Migrated from apps.email_service.views.SendEmailView to centralized API structure.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny] # Allow unauthenticated access
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Send an email via the email service.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Email subject",
|
||||
"text": "Email body text",
|
||||
"from_email": "sender@example.com" // optional
|
||||
}
|
||||
"""
|
||||
data = request.data
|
||||
to = data.get("to")
|
||||
subject = data.get("subject")
|
||||
text = data.get("text")
|
||||
from_email = data.get("from_email") # Optional
|
||||
|
||||
if not all([to, subject, text]):
|
||||
return Response(
|
||||
{
|
||||
"error": "Missing required fields",
|
||||
"required_fields": ["to", "subject", "text"],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the current site
|
||||
site = get_current_site(request)
|
||||
|
||||
# Send email using the site's configuration
|
||||
response = EmailService.send_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
text=text,
|
||||
from_email=from_email, # Will use site's default if None
|
||||
site=site,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "Email sent successfully", "response": response},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
6
backend/apps/api/v1/history/__init__.py
Normal file
6
backend/apps/api/v1/history/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
History API Module
|
||||
|
||||
This module provides API endpoints for accessing historical data and change tracking
|
||||
across all models in the ThrillWiki system.
|
||||
"""
|
||||
45
backend/apps/api/v1/history/urls.py
Normal file
45
backend/apps/api/v1/history/urls.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
History API URLs
|
||||
|
||||
URL patterns for history-related API endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
ParkHistoryViewSet,
|
||||
RideHistoryViewSet,
|
||||
UnifiedHistoryViewSet,
|
||||
)
|
||||
|
||||
# Create router for history ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"timeline", UnifiedHistoryViewSet, basename="unified-history")
|
||||
|
||||
urlpatterns = [
|
||||
# Park history endpoints
|
||||
path(
|
||||
"parks/<str:park_slug>/",
|
||||
ParkHistoryViewSet.as_view({"get": "list"}),
|
||||
name="park-history-list",
|
||||
),
|
||||
path(
|
||||
"parks/<str:park_slug>/detail/",
|
||||
ParkHistoryViewSet.as_view({"get": "retrieve"}),
|
||||
name="park-history-detail",
|
||||
),
|
||||
# Ride history endpoints
|
||||
path(
|
||||
"parks/<str:park_slug>/rides/<str:ride_slug>/",
|
||||
RideHistoryViewSet.as_view({"get": "list"}),
|
||||
name="ride-history-list",
|
||||
),
|
||||
path(
|
||||
"parks/<str:park_slug>/rides/<str:ride_slug>/detail/",
|
||||
RideHistoryViewSet.as_view({"get": "retrieve"}),
|
||||
name="ride-history-detail",
|
||||
),
|
||||
# Include router URLs for unified timeline
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
513
backend/apps/api/v1/history/views.py
Normal file
513
backend/apps/api/v1/history/views.py
Normal file
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
History API Views
|
||||
|
||||
This module provides ViewSets for accessing historical data and change tracking
|
||||
across all models in the ThrillWiki system using django-pghistory.
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rest_framework.request import Request
|
||||
from typing import Optional, cast, Sequence
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Count, QuerySet
|
||||
import pghistory.models
|
||||
from datetime import datetime
|
||||
|
||||
# Import models
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
# Import serializers
|
||||
from .. import serializers as history_serializers
|
||||
from rest_framework import serializers as drf_serializers
|
||||
|
||||
# Minimal fallback serializer used when a specific serializer symbol is missing.
|
||||
|
||||
|
||||
class _FallbackSerializer(drf_serializers.Serializer):
|
||||
def to_representation(self, instance):
|
||||
# return minimal safe representation so responses serialize without errors
|
||||
return {}
|
||||
|
||||
|
||||
ParkHistoryEventSerializer = getattr(
|
||||
history_serializers, "ParkHistoryEventSerializer", _FallbackSerializer
|
||||
)
|
||||
RideHistoryEventSerializer = getattr(
|
||||
history_serializers, "RideHistoryEventSerializer", _FallbackSerializer
|
||||
)
|
||||
ParkHistoryOutputSerializer = getattr(
|
||||
history_serializers, "ParkHistoryOutputSerializer", _FallbackSerializer
|
||||
)
|
||||
RideHistoryOutputSerializer = getattr(
|
||||
history_serializers, "RideHistoryOutputSerializer", _FallbackSerializer
|
||||
)
|
||||
UnifiedHistoryTimelineSerializer = getattr(
|
||||
history_serializers, "UnifiedHistoryTimelineSerializer", _FallbackSerializer
|
||||
)
|
||||
|
||||
# --- Constants for model strings to avoid duplication ---
|
||||
PARK_MODEL = "parks.park"
|
||||
|
||||
RIDE_MODELS: Sequence[str] = [
|
||||
"rides.ride",
|
||||
"rides.ridemodel",
|
||||
"rides.rollercoasterstats",
|
||||
]
|
||||
|
||||
COMPANY_MODELS: Sequence[str] = [
|
||||
"companies.operator",
|
||||
"companies.propertyowner",
|
||||
"companies.manufacturer",
|
||||
"companies.designer",
|
||||
]
|
||||
|
||||
ACCOUNT_MODEL = "accounts.user"
|
||||
|
||||
ALL_TRACKED_MODELS: Sequence[str] = [
|
||||
PARK_MODEL,
|
||||
*RIDE_MODELS,
|
||||
*COMPANY_MODELS,
|
||||
ACCOUNT_MODEL,
|
||||
]
|
||||
|
||||
# --- Helper utilities to reduce duplicated logic / cognitive complexity ---
|
||||
|
||||
|
||||
def _parse_date(date_str: Optional[str]) -> Optional[datetime]:
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_list_filters(
|
||||
queryset: QuerySet,
|
||||
request: Request,
|
||||
*,
|
||||
default_limit: int = 50,
|
||||
max_limit: int = 500,
|
||||
) -> QuerySet:
|
||||
"""
|
||||
Apply common 'list' filters: event_type, start/end date, and limit.
|
||||
Expects request to be a rest_framework.request.Request (cast by caller).
|
||||
"""
|
||||
# event_type
|
||||
event_type = request.query_params.get("event_type")
|
||||
if event_type == "created":
|
||||
queryset = queryset.filter(pgh_label="created")
|
||||
elif event_type == "updated":
|
||||
queryset = queryset.filter(pgh_label="updated")
|
||||
elif event_type == "deleted":
|
||||
queryset = queryset.filter(pgh_label="deleted")
|
||||
|
||||
# date range
|
||||
start_date = _parse_date(request.query_params.get("start_date"))
|
||||
if start_date:
|
||||
queryset = queryset.filter(pgh_created_at__gte=start_date)
|
||||
|
||||
end_date = _parse_date(request.query_params.get("end_date"))
|
||||
if end_date:
|
||||
queryset = queryset.filter(pgh_created_at__lte=end_date)
|
||||
|
||||
# limit (slice the queryset)
|
||||
limit_raw = request.query_params.get("limit", str(default_limit))
|
||||
try:
|
||||
limit_val = min(int(limit_raw), max_limit)
|
||||
queryset = queryset[:limit_val]
|
||||
except (ValueError, TypeError):
|
||||
queryset = queryset[:default_limit]
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Get park history",
|
||||
description="Retrieve history timeline for a specific park including all changes over time.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of history events to return (default: 50, max: 500)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Offset for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="event_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by event type (created, updated, deleted)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="start_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events after this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="end_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events before this date (YYYY-MM-DD)",
|
||||
),
|
||||
],
|
||||
responses={200: ParkHistoryEventSerializer(many=True)},
|
||||
tags=["History", "Parks"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get complete park history",
|
||||
description="Retrieve complete history for a park including current state and timeline.",
|
||||
responses={200: ParkHistoryOutputSerializer},
|
||||
tags=["History", "Parks"],
|
||||
),
|
||||
)
|
||||
class ParkHistoryViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for accessing park history data.
|
||||
|
||||
Provides read-only access to historical changes for parks,
|
||||
including version history and real-world changes.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "park_slug"
|
||||
filter_backends = [OrderingFilter]
|
||||
ordering_fields = ["pgh_created_at"]
|
||||
ordering = ["-pgh_created_at"]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get history events for the specified park."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
if not park_slug:
|
||||
return pghistory.models.Events.objects.none()
|
||||
|
||||
# Get the park to ensure it exists
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
|
||||
# Base queryset for park events
|
||||
queryset = (
|
||||
pghistory.models.Events.objects.filter(
|
||||
pgh_model__in=[PARK_MODEL], pgh_obj_id=getattr(park, "id", None)
|
||||
)
|
||||
.select_related()
|
||||
.order_by("-pgh_created_at")
|
||||
)
|
||||
|
||||
# Apply list filters via helper to reduce complexity
|
||||
if self.action == "list":
|
||||
queryset = _apply_list_filters(
|
||||
queryset, cast(Request, self.request), default_limit=50, max_limit=500
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "retrieve":
|
||||
return ParkHistoryOutputSerializer
|
||||
return ParkHistoryEventSerializer
|
||||
|
||||
def retrieve(self, request, park_slug=None):
|
||||
"""Get complete park history including current state."""
|
||||
park = get_object_or_404(Park, slug=park_slug)
|
||||
|
||||
# Get history events
|
||||
history_events = self.get_queryset()[:100] # Latest 100 events
|
||||
|
||||
# safe attribute access using getattr to avoid static-checker complaints
|
||||
first_recorded = getattr(history_events.last(), "pgh_created_at", None)
|
||||
last_modified = getattr(history_events.first(), "pgh_created_at", None)
|
||||
|
||||
# Prepare data for serializer
|
||||
history_data = {
|
||||
"park": park,
|
||||
"current_state": park,
|
||||
"summary": {
|
||||
"total_events": self.get_queryset().count(),
|
||||
"first_recorded": first_recorded,
|
||||
"last_modified": last_modified,
|
||||
},
|
||||
"events": history_events,
|
||||
}
|
||||
|
||||
serializer = ParkHistoryOutputSerializer(history_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Get ride history",
|
||||
description="Retrieve history timeline for a specific ride including all changes over time.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of history events to return (default: 50, max: 500)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Offset for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="event_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by event type (created, updated, deleted)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="start_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events after this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="end_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events before this date (YYYY-MM-DD)",
|
||||
),
|
||||
],
|
||||
responses={200: RideHistoryEventSerializer(many=True)},
|
||||
tags=["History", "Rides"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get complete ride history",
|
||||
description="Retrieve complete history for a ride including current state and timeline.",
|
||||
responses={200: RideHistoryOutputSerializer},
|
||||
tags=["History", "Rides"],
|
||||
),
|
||||
)
|
||||
class RideHistoryViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for accessing ride history data.
|
||||
|
||||
Provides read-only access to historical changes for rides,
|
||||
including version history and real-world changes.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
lookup_field = "ride_slug"
|
||||
filter_backends = [OrderingFilter]
|
||||
ordering_fields = ["pgh_created_at"]
|
||||
ordering = ["-pgh_created_at"]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get history events for the specified ride."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return pghistory.models.Events.objects.none()
|
||||
|
||||
# Get the ride to ensure it exists
|
||||
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
|
||||
|
||||
# Base queryset for ride events
|
||||
queryset = (
|
||||
pghistory.models.Events.objects.filter(
|
||||
pgh_model__in=RIDE_MODELS, pgh_obj_id=getattr(ride, "id", None)
|
||||
)
|
||||
.select_related()
|
||||
.order_by("-pgh_created_at")
|
||||
)
|
||||
|
||||
# Apply list filters via helper
|
||||
if self.action == "list":
|
||||
queryset = _apply_list_filters(
|
||||
queryset, cast(Request, self.request), default_limit=50, max_limit=500
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "retrieve":
|
||||
return RideHistoryOutputSerializer
|
||||
return RideHistoryEventSerializer
|
||||
|
||||
def retrieve(self, request, park_slug=None, ride_slug=None):
|
||||
"""Get complete ride history including current state."""
|
||||
ride = get_object_or_404(Ride, slug=ride_slug, park__slug=park_slug)
|
||||
|
||||
# Get history events
|
||||
history_events = self.get_queryset()[:100] # Latest 100 events
|
||||
|
||||
# safe attribute access
|
||||
first_recorded = getattr(history_events.last(), "pgh_created_at", None)
|
||||
last_modified = getattr(history_events.first(), "pgh_created_at", None)
|
||||
|
||||
# Prepare data for serializer
|
||||
history_data = {
|
||||
"ride": ride,
|
||||
"current_state": ride,
|
||||
"summary": {
|
||||
"total_events": self.get_queryset().count(),
|
||||
"first_recorded": first_recorded,
|
||||
"last_modified": last_modified,
|
||||
},
|
||||
"events": history_events,
|
||||
}
|
||||
|
||||
serializer = RideHistoryOutputSerializer(history_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="Unified history timeline",
|
||||
description="Retrieve a unified timeline of all changes across parks, rides, and companies.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of history events to return (default: 100, max: 1000)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="offset",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Offset for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="model_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by model type (park, ride, company)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="event_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by event type (created, updated, deleted)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="start_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events after this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="end_date",
|
||||
type=OpenApiTypes.DATE,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter events before this date (YYYY-MM-DD)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="significance",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by change significance (major, minor, routine)",
|
||||
),
|
||||
],
|
||||
responses={200: UnifiedHistoryTimelineSerializer},
|
||||
tags=["History"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get unified history timeline item",
|
||||
description="Retrieve a specific item from the unified history timeline.",
|
||||
responses={200: UnifiedHistoryTimelineSerializer},
|
||||
tags=["History"],
|
||||
),
|
||||
)
|
||||
class UnifiedHistoryViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for unified history timeline across all models.
|
||||
|
||||
Provides a comprehensive view of all changes across
|
||||
parks, rides, and companies in chronological order.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
filter_backends = [OrderingFilter]
|
||||
ordering_fields = ["pgh_created_at"]
|
||||
ordering = ["-pgh_created_at"]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get unified history events across all tracked models."""
|
||||
queryset = (
|
||||
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
|
||||
.select_related()
|
||||
.order_by("-pgh_created_at")
|
||||
)
|
||||
|
||||
# Filter by requested model_type (if provided)
|
||||
model_type = cast(Request, self.request).query_params.get("model_type")
|
||||
if model_type == "park":
|
||||
queryset = queryset.filter(pgh_model=PARK_MODEL)
|
||||
elif model_type == "ride":
|
||||
queryset = queryset.filter(pgh_model__in=RIDE_MODELS)
|
||||
elif model_type == "company":
|
||||
queryset = queryset.filter(pgh_model__in=COMPANY_MODELS)
|
||||
elif model_type == "user":
|
||||
queryset = queryset.filter(pgh_model=ACCOUNT_MODEL)
|
||||
|
||||
# Apply shared list filters when serving the list action
|
||||
if self.action == "list":
|
||||
queryset = _apply_list_filters(
|
||||
queryset, cast(Request, self.request), default_limit=100, max_limit=1000
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self): # type: ignore[override]
|
||||
"""Return unified history timeline serializer."""
|
||||
return UnifiedHistoryTimelineSerializer
|
||||
|
||||
def list(self, request):
|
||||
"""Get unified history timeline with summary statistics."""
|
||||
events = list(self.get_queryset()) # evaluate for counts / earliest/latest use
|
||||
|
||||
# Summary statistics across all tracked models
|
||||
total_events = pghistory.models.Events.objects.filter(
|
||||
pgh_model__in=ALL_TRACKED_MODELS
|
||||
).count()
|
||||
|
||||
event_type_counts = (
|
||||
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
|
||||
.values("pgh_label")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
model_type_counts = (
|
||||
pghistory.models.Events.objects.filter(pgh_model__in=ALL_TRACKED_MODELS)
|
||||
.values("pgh_model")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
timeline_data = {
|
||||
"summary": {
|
||||
"total_events": total_events,
|
||||
"events_returned": len(events),
|
||||
"event_type_breakdown": {
|
||||
item["pgh_label"]: item["count"] for item in event_type_counts
|
||||
},
|
||||
"model_type_breakdown": {
|
||||
item["pgh_model"]: item["count"] for item in model_type_counts
|
||||
},
|
||||
"time_range": {
|
||||
"earliest": events[-1].pgh_created_at if events else None,
|
||||
"latest": events[0].pgh_created_at if events else None,
|
||||
},
|
||||
},
|
||||
"events": events,
|
||||
}
|
||||
|
||||
serializer = UnifiedHistoryTimelineSerializer(timeline_data)
|
||||
return Response(serializer.data)
|
||||
4
backend/apps/api/v1/maps/__init__.py
Normal file
4
backend/apps/api/v1/maps/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Maps API module for centralized API structure.
|
||||
Migrated from apps.core.views.map_views
|
||||
"""
|
||||
32
backend/apps/api/v1/maps/urls.py
Normal file
32
backend/apps/api/v1/maps/urls.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
URL patterns for the unified map service API.
|
||||
Migrated from apps.core.urls.map_urls to centralized API structure.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Map API endpoints - migrated from apps.core.urls.map_urls
|
||||
urlpatterns = [
|
||||
# Main map data endpoint
|
||||
path("locations/", views.MapLocationsAPIView.as_view(), name="map_locations"),
|
||||
# Location detail endpoint
|
||||
path(
|
||||
"locations/<str:location_type>/<int:location_id>/",
|
||||
views.MapLocationDetailAPIView.as_view(),
|
||||
name="map_location_detail",
|
||||
),
|
||||
# Search endpoint
|
||||
path("search/", views.MapSearchAPIView.as_view(), name="map_search"),
|
||||
# Bounds-based query endpoint
|
||||
path("bounds/", views.MapBoundsAPIView.as_view(), name="map_bounds"),
|
||||
# Service statistics endpoint
|
||||
path("stats/", views.MapStatsAPIView.as_view(), name="map_stats"),
|
||||
# Cache management endpoints
|
||||
path("cache/", views.MapCacheAPIView.as_view(), name="map_cache"),
|
||||
path(
|
||||
"cache/invalidate/",
|
||||
views.MapCacheAPIView.as_view(),
|
||||
name="map_cache_invalidate",
|
||||
),
|
||||
]
|
||||
809
backend/apps/api/v1/maps/views.py
Normal file
809
backend/apps/api/v1/maps/views.py
Normal file
@@ -0,0 +1,809 @@
|
||||
"""
|
||||
Centralized map API views.
|
||||
Migrated from apps.core.views.map_views
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import Q
|
||||
from django.core.cache import cache
|
||||
from django.contrib.gis.geos import Polygon
|
||||
from django.contrib.gis.db.models.functions import Distance
|
||||
from django.contrib.gis.geos import Point
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
from apps.parks.models import Park, ParkLocation
|
||||
from apps.rides.models import Ride
|
||||
from ..serializers.maps import (
|
||||
MapLocationSerializer,
|
||||
MapLocationsResponseSerializer,
|
||||
MapSearchResultSerializer,
|
||||
MapSearchResponseSerializer,
|
||||
MapLocationDetailSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get map locations",
|
||||
description="Get map locations with optional clustering and filtering.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"north",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Northern latitude bound (-90 to 90). Used with south, east, west to define geographic bounds.",
|
||||
examples=[OpenApiExample("Example", value=41.5)],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"south",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Southern latitude bound (-90 to 90). Must be less than north bound.",
|
||||
examples=[OpenApiExample("Example", value=41.4)],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"east",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Eastern longitude bound (-180 to 180). Must be greater than west bound.",
|
||||
examples=[OpenApiExample("Example", value=-82.6)],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"west",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Western longitude bound (-180 to 180). Used with other bounds for geographic filtering.",
|
||||
examples=[OpenApiExample("Example", value=-82.8)],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"zoom",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Map zoom level (1-20). Higher values show more detail. Used for clustering decisions.",
|
||||
examples=[OpenApiExample("Example", value=10)],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"types",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Comma-separated location types to include. Valid values: 'park', 'ride'. Default: 'park,ride'",
|
||||
examples=[
|
||||
OpenApiExample("All types", value="park,ride"),
|
||||
OpenApiExample("Parks only", value="park"),
|
||||
OpenApiExample("Rides only", value="ride")
|
||||
],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"cluster",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Enable location clustering for high-density areas. Default: false",
|
||||
examples=[
|
||||
OpenApiExample("Enable clustering", value=True),
|
||||
OpenApiExample("Disable clustering", value=False)
|
||||
],
|
||||
),
|
||||
OpenApiParameter(
|
||||
"q",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Text search query. Searches park/ride names, cities, and states.",
|
||||
examples=[
|
||||
OpenApiExample("Park name", value="Cedar Point"),
|
||||
OpenApiExample("Ride type", value="roller coaster"),
|
||||
OpenApiExample("Location", value="Ohio")
|
||||
],
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: MapLocationsResponseSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Maps"],
|
||||
),
|
||||
)
|
||||
class MapLocationsAPIView(APIView):
|
||||
"""API endpoint for getting map locations with optional clustering."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: HttpRequest) -> Response:
|
||||
"""Get map locations with optional clustering and filtering."""
|
||||
try:
|
||||
# Parse query parameters
|
||||
north = request.GET.get("north")
|
||||
south = request.GET.get("south")
|
||||
east = request.GET.get("east")
|
||||
west = request.GET.get("west")
|
||||
zoom = request.GET.get("zoom", 10)
|
||||
types = request.GET.get("types", "park,ride").split(",")
|
||||
cluster = request.GET.get("cluster", "false").lower() == "true"
|
||||
query = request.GET.get("q", "").strip()
|
||||
|
||||
# Build cache key
|
||||
cache_key = f"map_locations_{north}_{south}_{east}_{west}_{zoom}_{','.join(types)}_{cluster}_{query}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return Response(cached_result)
|
||||
|
||||
locations = []
|
||||
total_count = 0
|
||||
|
||||
# Get parks if requested
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location", "operator").filter(
|
||||
location__point__isnull=False
|
||||
)
|
||||
|
||||
# Apply bounds filtering
|
||||
if all([north, south, east, west]):
|
||||
try:
|
||||
bounds_polygon = Polygon.from_bbox((
|
||||
float(west), float(south), float(east), float(north)
|
||||
))
|
||||
parks_query = parks_query.filter(
|
||||
location__point__within=bounds_polygon)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply text search
|
||||
if query:
|
||||
parks_query = parks_query.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query) |
|
||||
Q(location__state__icontains=query)
|
||||
)
|
||||
|
||||
# Serialize parks
|
||||
for park in parks_query[:100]: # Limit results
|
||||
park_data = {
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"status": park.status,
|
||||
"location": {
|
||||
"city": park.location.city if hasattr(park, 'location') and park.location else "",
|
||||
"state": park.location.state if hasattr(park, 'location') and park.location else "",
|
||||
"country": park.location.country if hasattr(park, 'location') and park.location else "",
|
||||
"formatted_address": park.location.formatted_address if hasattr(park, 'location') and park.location else "",
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": park.coaster_count or 0,
|
||||
"ride_count": park.ride_count or 0,
|
||||
"average_rating": float(park.average_rating) if park.average_rating else None,
|
||||
},
|
||||
}
|
||||
locations.append(park_data)
|
||||
|
||||
# Get rides if requested
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location", "manufacturer").filter(
|
||||
park__location__point__isnull=False
|
||||
)
|
||||
|
||||
# Apply bounds filtering
|
||||
if all([north, south, east, west]):
|
||||
try:
|
||||
bounds_polygon = Polygon.from_bbox((
|
||||
float(west), float(south), float(east), float(north)
|
||||
))
|
||||
rides_query = rides_query.filter(
|
||||
park__location__point__within=bounds_polygon)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply text search
|
||||
if query:
|
||||
rides_query = rides_query.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(park__location__city__icontains=query)
|
||||
)
|
||||
|
||||
# Serialize rides
|
||||
for ride in rides_query[:100]: # Limit results
|
||||
ride_data = {
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"status": ride.status,
|
||||
"location": {
|
||||
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"formatted_address": ride.park.location.formatted_address if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
},
|
||||
"stats": {
|
||||
"category": ride.get_category_display() if ride.category else None,
|
||||
"average_rating": float(ride.average_rating) if ride.average_rating else None,
|
||||
"park_name": ride.park.name,
|
||||
},
|
||||
}
|
||||
locations.append(ride_data)
|
||||
|
||||
total_count = len(locations)
|
||||
|
||||
# Calculate bounds from results
|
||||
bounds = {}
|
||||
if locations:
|
||||
lats = [loc["latitude"] for loc in locations if loc["latitude"]]
|
||||
lngs = [loc["longitude"] for loc in locations if loc["longitude"]]
|
||||
if lats and lngs:
|
||||
bounds = {
|
||||
"north": max(lats),
|
||||
"south": min(lats),
|
||||
"east": max(lngs),
|
||||
"west": min(lngs),
|
||||
}
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"clusters": [], # TODO: Implement clustering
|
||||
"bounds": bounds,
|
||||
"total_count": total_count,
|
||||
"clustered": cluster,
|
||||
}
|
||||
|
||||
# Cache result for 5 minutes
|
||||
cache.set(cache_key, result, 300)
|
||||
|
||||
return Response(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationsAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"status": "error", "message": "Failed to retrieve map locations"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get location details",
|
||||
description="Get detailed information about a specific location.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"location_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
required=True,
|
||||
description="Type of location",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"location_id",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.PATH,
|
||||
required=True,
|
||||
description="ID of the location",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: MapLocationDetailSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Maps"],
|
||||
),
|
||||
)
|
||||
class MapLocationDetailAPIView(APIView):
|
||||
"""API endpoint for getting detailed information about a specific location."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(
|
||||
self, request: HttpRequest, location_type: str, location_id: int
|
||||
) -> Response:
|
||||
"""Get detailed information for a specific location."""
|
||||
try:
|
||||
if location_type == "park":
|
||||
try:
|
||||
obj = Park.objects.select_related(
|
||||
"location", "operator").get(id=location_id)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "error", "message": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
elif location_type == "ride":
|
||||
try:
|
||||
obj = Ride.objects.select_related(
|
||||
"park__location", "manufacturer").get(id=location_id)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"status": "error", "message": "Ride not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"status": "error", "message": "Invalid location type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Serialize the object
|
||||
if location_type == "park":
|
||||
data = {
|
||||
"id": obj.id,
|
||||
"type": "park",
|
||||
"name": obj.name,
|
||||
"slug": obj.slug,
|
||||
"description": obj.description,
|
||||
"latitude": obj.location.latitude if hasattr(obj, 'location') and obj.location else None,
|
||||
"longitude": obj.location.longitude if hasattr(obj, 'location') and obj.location else None,
|
||||
"status": obj.status,
|
||||
"location": {
|
||||
"street_address": obj.location.street_address if hasattr(obj, 'location') and obj.location else "",
|
||||
"city": obj.location.city if hasattr(obj, 'location') and obj.location else "",
|
||||
"state": obj.location.state if hasattr(obj, 'location') and obj.location else "",
|
||||
"country": obj.location.country if hasattr(obj, 'location') and obj.location else "",
|
||||
"postal_code": obj.location.postal_code if hasattr(obj, 'location') and obj.location else "",
|
||||
"formatted_address": obj.location.formatted_address if hasattr(obj, 'location') and obj.location else "",
|
||||
},
|
||||
"stats": {
|
||||
"coaster_count": obj.coaster_count or 0,
|
||||
"ride_count": obj.ride_count or 0,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"size_acres": float(obj.size_acres) if obj.size_acres else None,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
}
|
||||
else: # ride
|
||||
data = {
|
||||
"id": obj.id,
|
||||
"type": "ride",
|
||||
"name": obj.name,
|
||||
"slug": obj.slug,
|
||||
"description": obj.description,
|
||||
"latitude": obj.park.location.latitude if hasattr(obj.park, 'location') and obj.park.location else None,
|
||||
"longitude": obj.park.location.longitude if hasattr(obj.park, 'location') and obj.park.location else None,
|
||||
"status": obj.status,
|
||||
"location": {
|
||||
"street_address": obj.park.location.street_address if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"city": obj.park.location.city if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"state": obj.park.location.state if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"country": obj.park.location.country if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"postal_code": obj.park.location.postal_code if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
"formatted_address": obj.park.location.formatted_address if hasattr(obj.park, 'location') and obj.park.location else "",
|
||||
},
|
||||
"stats": {
|
||||
"category": obj.get_category_display() if obj.category else None,
|
||||
"average_rating": float(obj.average_rating) if obj.average_rating else None,
|
||||
"park_name": obj.park.name,
|
||||
"opening_date": obj.opening_date.isoformat() if obj.opening_date else None,
|
||||
"manufacturer": obj.manufacturer.name if obj.manufacturer else None,
|
||||
},
|
||||
"nearby_locations": [], # TODO: Implement nearby locations
|
||||
}
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"data": data,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapLocationDetailAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"status": "error", "message": "Failed to retrieve location details"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Search map locations",
|
||||
description="Search locations by text query with optional bounds filtering.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"q",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="Search query",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"types",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Comma-separated location types (park,ride)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"page",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Page number",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"page_size",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Results per page",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: MapSearchResponseSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Maps"],
|
||||
),
|
||||
)
|
||||
class MapSearchAPIView(APIView):
|
||||
"""API endpoint for searching locations by text query."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: HttpRequest) -> Response:
|
||||
"""Search locations by text query with pagination."""
|
||||
try:
|
||||
query = request.GET.get("q", "").strip()
|
||||
if not query:
|
||||
return Response(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Search query 'q' parameter is required",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
types = request.GET.get("types", "park,ride").split(",")
|
||||
page = int(request.GET.get("page", 1))
|
||||
page_size = min(int(request.GET.get("page_size", 20)), 100)
|
||||
|
||||
results = []
|
||||
total_count = 0
|
||||
|
||||
# Search parks
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location").filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(location__city__icontains=query) |
|
||||
Q(location__state__icontains=query)
|
||||
).filter(location__point__isnull=False)
|
||||
|
||||
for park in parks_query[:50]: # Limit results
|
||||
results.append({
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"location": {
|
||||
"city": park.location.city if hasattr(park, 'location') and park.location else "",
|
||||
"state": park.location.state if hasattr(park, 'location') and park.location else "",
|
||||
"country": park.location.country if hasattr(park, 'location') and park.location else "",
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
})
|
||||
|
||||
# Search rides
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location").filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(park__name__icontains=query) |
|
||||
Q(park__location__city__icontains=query)
|
||||
).filter(park__location__point__isnull=False)
|
||||
|
||||
for ride in rides_query[:50]: # Limit results
|
||||
results.append({
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"location": {
|
||||
"city": ride.park.location.city if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"state": ride.park.location.state if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
"country": ride.park.location.country if hasattr(ride.park, 'location') and ride.park.location else "",
|
||||
},
|
||||
"relevance_score": 1.0, # TODO: Implement relevance scoring
|
||||
})
|
||||
|
||||
total_count = len(results)
|
||||
|
||||
# Apply pagination
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_results = results[start_idx:end_idx]
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"results": paginated_results,
|
||||
"query": query,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapSearchAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"status": "error", "message": "Search failed due to internal error"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get locations within bounds",
|
||||
description="Get locations within specific geographic bounds.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"north",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="Northern latitude bound",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"south",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="Southern latitude bound",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"east",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="Eastern longitude bound",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"west",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="Western longitude bound",
|
||||
),
|
||||
OpenApiParameter(
|
||||
"types",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
description="Comma-separated location types (park,ride)",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT, 400: OpenApiTypes.OBJECT},
|
||||
tags=["Maps"],
|
||||
),
|
||||
)
|
||||
class MapBoundsAPIView(APIView):
|
||||
"""API endpoint for getting locations within specific bounds."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: HttpRequest) -> Response:
|
||||
"""Get locations within specific geographic bounds."""
|
||||
try:
|
||||
# Parse required bounds parameters
|
||||
try:
|
||||
north = float(request.GET.get("north"))
|
||||
south = float(request.GET.get("south"))
|
||||
east = float(request.GET.get("east"))
|
||||
west = float(request.GET.get("west"))
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{"status": "error", "message": "Invalid bounds parameters"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate bounds
|
||||
if north <= south:
|
||||
return Response(
|
||||
{"status": "error", "message": "North bound must be greater than south bound"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if west >= east:
|
||||
return Response(
|
||||
{"status": "error", "message": "West bound must be less than east bound"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
types = request.GET.get("types", "park,ride").split(",")
|
||||
locations = []
|
||||
|
||||
# Create bounds polygon
|
||||
bounds_polygon = Polygon.from_bbox((west, south, east, north))
|
||||
|
||||
# Get parks within bounds
|
||||
if "park" in types:
|
||||
parks_query = Park.objects.select_related("location").filter(
|
||||
location__point__within=bounds_polygon
|
||||
)
|
||||
|
||||
for park in parks_query[:100]: # Limit results
|
||||
locations.append({
|
||||
"id": park.id,
|
||||
"type": "park",
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"latitude": park.location.latitude if hasattr(park, 'location') and park.location else None,
|
||||
"longitude": park.location.longitude if hasattr(park, 'location') and park.location else None,
|
||||
"status": park.status,
|
||||
})
|
||||
|
||||
# Get rides within bounds
|
||||
if "ride" in types:
|
||||
rides_query = Ride.objects.select_related("park__location").filter(
|
||||
park__location__point__within=bounds_polygon
|
||||
)
|
||||
|
||||
for ride in rides_query[:100]: # Limit results
|
||||
locations.append({
|
||||
"id": ride.id,
|
||||
"type": "ride",
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"latitude": ride.park.location.latitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"longitude": ride.park.location.longitude if hasattr(ride.park, 'location') and ride.park.location else None,
|
||||
"status": ride.status,
|
||||
})
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"locations": locations,
|
||||
"bounds": {
|
||||
"north": north,
|
||||
"south": south,
|
||||
"east": east,
|
||||
"west": west,
|
||||
},
|
||||
"total_count": len(locations),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"status": "error", "message": "Failed to retrieve locations within bounds"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get map service statistics",
|
||||
description="Get map service statistics and performance metrics.",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Maps"],
|
||||
),
|
||||
)
|
||||
class MapStatsAPIView(APIView):
|
||||
"""API endpoint for getting map service statistics and health information."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request: HttpRequest) -> Response:
|
||||
"""Get map service statistics and performance metrics."""
|
||||
try:
|
||||
# Count locations with coordinates
|
||||
parks_with_location = Park.objects.filter(
|
||||
location__point__isnull=False).count()
|
||||
rides_with_location = Ride.objects.filter(
|
||||
park__location__point__isnull=False).count()
|
||||
total_locations = parks_with_location + rides_with_location
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_locations": total_locations,
|
||||
"parks_with_location": parks_with_location,
|
||||
"rides_with_location": rides_with_location,
|
||||
"cache_hits": 0, # TODO: Implement cache statistics
|
||||
"cache_misses": 0, # TODO: Implement cache statistics
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Internal server error: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
delete=extend_schema(
|
||||
summary="Clear map cache",
|
||||
description="Clear all map cache (admin only).",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Maps"],
|
||||
),
|
||||
post=extend_schema(
|
||||
summary="Invalidate specific cache entries",
|
||||
description="Invalidate specific cache entries.",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Maps"],
|
||||
),
|
||||
)
|
||||
class MapCacheAPIView(APIView):
|
||||
"""API endpoint for cache management (admin only)."""
|
||||
|
||||
permission_classes = [AllowAny] # TODO: Add admin permission check
|
||||
|
||||
def delete(self, request: HttpRequest) -> Response:
|
||||
"""Clear all map cache (admin only)."""
|
||||
try:
|
||||
# Clear all map-related cache keys
|
||||
cache_keys = cache.keys("map_*")
|
||||
if cache_keys:
|
||||
cache.delete_many(cache_keys)
|
||||
cleared_count = len(cache_keys)
|
||||
else:
|
||||
cleared_count = 0
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": f"Map cache cleared successfully. Cleared {cleared_count} entries.",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Internal server error: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def post(self, request: HttpRequest) -> Response:
|
||||
"""Invalidate specific cache entries."""
|
||||
try:
|
||||
# Get cache keys to invalidate from request data
|
||||
cache_keys = request.data.get("cache_keys", [])
|
||||
if cache_keys:
|
||||
cache.delete_many(cache_keys)
|
||||
invalidated_count = len(cache_keys)
|
||||
else:
|
||||
invalidated_count = 0
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Internal server error: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
# Legacy compatibility aliases
|
||||
MapLocationsView = MapLocationsAPIView
|
||||
MapLocationDetailView = MapLocationDetailAPIView
|
||||
MapSearchView = MapSearchAPIView
|
||||
MapBoundsView = MapBoundsAPIView
|
||||
MapStatsView = MapStatsAPIView
|
||||
MapCacheView = MapCacheAPIView
|
||||
6
backend/apps/api/v1/parks/__init__.py
Normal file
6
backend/apps/api/v1/parks/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Parks API module for ThrillWiki API v1.
|
||||
|
||||
This module provides API endpoints for park-related functionality including
|
||||
search suggestions, location services, and roadtrip planning.
|
||||
"""
|
||||
466
backend/apps/api/v1/parks/park_views.py
Normal file
466
backend/apps/api/v1/parks/park_views.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Full-featured Parks API views for ThrillWiki API v1.
|
||||
|
||||
This module implements a comprehensive set of endpoints matching the Rides API:
|
||||
- List / Create: GET /parks/ POST /parks/
|
||||
- Retrieve / Update / Delete: GET /parks/{pk}/ PATCH/PUT/DELETE
|
||||
- Filter options: GET /parks/filter-options/
|
||||
- Company search: GET /parks/search/companies/?q=...
|
||||
- Search suggestions: GET /parks/search-suggestions/?q=...
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.decorators import action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
# Attempt to import model-level helpers; fall back gracefully if not present.
|
||||
try:
|
||||
from apps.parks.models import Park, Company as ParkCompany # type: ignore
|
||||
from apps.rides.models import Company as RideCompany # type: ignore
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Park = None # type: ignore
|
||||
ParkCompany = None # type: ignore
|
||||
RideCompany = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
# Attempt to import ModelChoices to return filter options
|
||||
try:
|
||||
from apps.api.v1.serializers.shared import ModelChoices # type: ignore
|
||||
|
||||
HAVE_MODELCHOICES = True
|
||||
except Exception:
|
||||
ModelChoices = None # type: ignore
|
||||
HAVE_MODELCHOICES = False
|
||||
|
||||
# Import serializers - we'll need to create these
|
||||
try:
|
||||
from apps.api.v1.serializers.parks import (
|
||||
ParkListOutputSerializer,
|
||||
ParkDetailOutputSerializer,
|
||||
ParkCreateInputSerializer,
|
||||
ParkUpdateInputSerializer,
|
||||
ParkImageSettingsInputSerializer,
|
||||
)
|
||||
|
||||
SERIALIZERS_AVAILABLE = True
|
||||
except Exception:
|
||||
# Fallback serializers will be created
|
||||
SERIALIZERS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 1000
|
||||
|
||||
|
||||
# --- Park list & create -----------------------------------------------------
|
||||
class ParkListCreateAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List parks with filtering and pagination",
|
||||
description="List parks with basic filtering and pagination.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="country", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="state", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: (
|
||||
"ParkListOutputSerializer(many=True)"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
)
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""List parks with basic filtering and pagination."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Park listing is not available because domain models "
|
||||
"are not imported. Implement apps.parks.models.Park "
|
||||
"(and related managers) to enable listing."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
qs = Park.objects.all().select_related(
|
||||
"operator", "property_owner"
|
||||
) # type: ignore
|
||||
|
||||
# Basic filters
|
||||
q = request.query_params.get("search")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q) # simplistic search
|
||||
|
||||
country = request.query_params.get("country")
|
||||
if country:
|
||||
qs = qs.filter(location__country__icontains=country) # type: ignore
|
||||
|
||||
state = request.query_params.get("state")
|
||||
if state:
|
||||
qs = qs.filter(location__state__icontains=state) # type: ignore
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = ParkListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
else:
|
||||
# Fallback serialization
|
||||
serializer_data = [
|
||||
{
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": getattr(park, "slug", ""),
|
||||
"description": getattr(park, "description", ""),
|
||||
"location": str(getattr(park, "location", "")),
|
||||
"operator": (
|
||||
getattr(park.operator, "name", "")
|
||||
if hasattr(park, "operator")
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for park in page
|
||||
]
|
||||
return paginator.get_paginated_response(serializer_data)
|
||||
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
summary="Create a new park",
|
||||
description="Create a new park.",
|
||||
responses={
|
||||
201: (
|
||||
"ParkDetailOutputSerializer()"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
)
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Create a new park."""
|
||||
if not SERIALIZERS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Park creation serializers not available. "
|
||||
"Implement park serializers to enable creation."
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
serializer_in = ParkCreateInputSerializer(data=request.data)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Park creation is not available because domain models "
|
||||
"are not imported. Implement apps.parks.models.Park "
|
||||
"and necessary create logic."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
validated = serializer_in.validated_data
|
||||
|
||||
# Minimal create logic using model fields if available.
|
||||
park = Park.objects.create( # type: ignore
|
||||
name=validated["name"],
|
||||
description=validated.get("description", ""),
|
||||
# Add other fields as needed based on Park model
|
||||
)
|
||||
|
||||
out_serializer = ParkDetailOutputSerializer(park, context={"request": request})
|
||||
return Response(out_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
# --- Park retrieve / update / delete ---------------------------------------
|
||||
@extend_schema(
|
||||
summary="Retrieve, update or delete a park",
|
||||
responses={
|
||||
200: (
|
||||
"ParkDetailOutputSerializer()"
|
||||
if SERIALIZERS_AVAILABLE
|
||||
else OpenApiTypes.OBJECT
|
||||
)
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
class ParkDetailAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_park_or_404(self, pk: int) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound(
|
||||
(
|
||||
"Park detail is not available because domain models "
|
||||
"are not imported. Implement apps.parks.models.Park "
|
||||
"to enable detail endpoints."
|
||||
)
|
||||
)
|
||||
try:
|
||||
# type: ignore
|
||||
return Park.objects.select_related("operator", "property_owner").get(pk=pk)
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
def get(self, request: Request, pk: int) -> Response:
|
||||
park = self._get_park_or_404(pk)
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = ParkDetailOutputSerializer(park, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
return Response(
|
||||
{
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": getattr(park, "slug", ""),
|
||||
"description": getattr(park, "description", ""),
|
||||
"location": str(getattr(park, "location", "")),
|
||||
"operator": (
|
||||
getattr(park.operator, "name", "")
|
||||
if hasattr(park, "operator")
|
||||
else ""
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def patch(self, request: Request, pk: int) -> Response:
|
||||
park = self._get_park_or_404(pk)
|
||||
if not SERIALIZERS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park update serializers not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
serializer_in = ParkUpdateInputSerializer(data=request.data, partial=True)
|
||||
serializer_in.is_valid(raise_exception=True)
|
||||
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Park update is not available because domain models "
|
||||
"are not imported."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
for key, value in serializer_in.validated_data.items():
|
||||
setattr(park, key, value)
|
||||
park.save()
|
||||
serializer = ParkDetailOutputSerializer(park, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
def put(self, request: Request, pk: int) -> Response:
|
||||
# Full replace - reuse patch behavior for simplicity
|
||||
return self.patch(request, pk)
|
||||
|
||||
def delete(self, request: Request, pk: int) -> Response:
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Park delete is not available because domain models "
|
||||
"are not imported."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
park = self._get_park_or_404(pk)
|
||||
park.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# --- Filter options ---------------------------------------------------------
|
||||
@extend_schema(
|
||||
summary="Get filter options for parks",
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Parks"],
|
||||
)
|
||||
class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return static/dynamic filter options used by the frontend."""
|
||||
# Try to use ModelChoices if available
|
||||
if HAVE_MODELCHOICES and ModelChoices is not None:
|
||||
try:
|
||||
data = {
|
||||
"park_types": ModelChoices.get_park_type_choices(),
|
||||
"countries": ModelChoices.get_country_choices(),
|
||||
"states": ModelChoices.get_state_choices(),
|
||||
"ordering_options": [
|
||||
"name",
|
||||
"-name",
|
||||
"opening_date",
|
||||
"-opening_date",
|
||||
"ride_count",
|
||||
"-ride_count",
|
||||
],
|
||||
}
|
||||
return Response(data)
|
||||
except Exception:
|
||||
# fallthrough to fallback
|
||||
pass
|
||||
|
||||
# Fallback minimal options
|
||||
return Response(
|
||||
{
|
||||
"park_types": ["THEME_PARK", "AMUSEMENT_PARK", "WATER_PARK"],
|
||||
"countries": ["United States", "Canada", "United Kingdom", "Germany"],
|
||||
"ordering_options": ["name", "-name", "opening_date", "-opening_date"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- Company search (autocomplete) -----------------------------------------
|
||||
@extend_schema(
|
||||
summary="Search companies (operators/property owners) for autocomplete",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
)
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Parks"],
|
||||
)
|
||||
class CompanySearchAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
q = request.query_params.get("q", "")
|
||||
if not q:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
if ParkCompany is None:
|
||||
# Provide helpful placeholder structure
|
||||
return Response(
|
||||
[
|
||||
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
|
||||
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
|
||||
{"id": 3, "name": "Disney Parks", "slug": "disney"},
|
||||
]
|
||||
)
|
||||
|
||||
qs = ParkCompany.objects.filter(name__icontains=q)[:20] # type: ignore
|
||||
results = [
|
||||
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
|
||||
]
|
||||
return Response(results)
|
||||
|
||||
|
||||
# --- Search suggestions -----------------------------------------------------
|
||||
@extend_schema(
|
||||
summary="Search suggestions for park search box",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
|
||||
)
|
||||
],
|
||||
tags=["Parks"],
|
||||
)
|
||||
class ParkSearchSuggestionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
q = request.query_params.get("q", "")
|
||||
if not q:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
# Very small suggestion implementation: look in park names if available
|
||||
if MODELS_AVAILABLE and Park is not None:
|
||||
qs = Park.objects.filter(name__icontains=q).values_list("name", flat=True)[
|
||||
:10
|
||||
] # type: ignore
|
||||
return Response([{"suggestion": name} for name in qs])
|
||||
|
||||
# Fallback suggestions
|
||||
fallback = [
|
||||
{"suggestion": f"{q} Park"},
|
||||
{"suggestion": f"{q} Theme Park"},
|
||||
{"suggestion": f"{q} Amusement Park"},
|
||||
]
|
||||
return Response(fallback)
|
||||
|
||||
|
||||
# --- Park image settings ---------------------------------------------------
|
||||
@extend_schema(
|
||||
summary="Set park banner and card images",
|
||||
description="Set banner_image and card_image for a park from existing park photos",
|
||||
request=("ParkImageSettingsInputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
|
||||
responses={
|
||||
200: ("ParkDetailOutputSerializer" if SERIALIZERS_AVAILABLE else OpenApiTypes.OBJECT),
|
||||
400: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
class ParkImageSettingsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def _get_park_or_404(self, pk: int) -> Any:
|
||||
if not MODELS_AVAILABLE:
|
||||
raise NotFound("Park models not available")
|
||||
try:
|
||||
return Park.objects.get(pk=pk) # type: ignore
|
||||
except Park.DoesNotExist: # type: ignore
|
||||
raise NotFound("Park not found")
|
||||
|
||||
def patch(self, request: Request, pk: int) -> Response:
|
||||
"""Set banner and card images for the park."""
|
||||
if not SERIALIZERS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park image settings serializers not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
park = self._get_park_or_404(pk)
|
||||
|
||||
serializer = ParkImageSettingsInputSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Update the park with the validated data
|
||||
for field, value in serializer.validated_data.items():
|
||||
setattr(park, field, value)
|
||||
|
||||
park.save()
|
||||
|
||||
# Return updated park data
|
||||
output_serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request})
|
||||
return Response(output_serializer.data)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user