mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 07:26:59 -05:00
Compare commits
18 Commits
clean-hist
...
133dcabb58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
133dcabb58 | ||
|
|
b627aed65d | ||
|
|
e4e36c7899 | ||
|
|
831be6a2ee | ||
|
|
bf7e0c0f40 | ||
|
|
dcf890a55c | ||
|
|
937eee19e4 | ||
|
|
e62646bcf9 | ||
|
|
92f4104d7a | ||
|
|
02c7cbd1cd | ||
|
|
d504d41de2 | ||
|
|
b0e0678590 | ||
|
|
652ea149bd | ||
|
|
66ed4347a9 | ||
|
|
69c07d1381 | ||
|
|
bead0654df | ||
|
|
37a20f83ba | ||
|
|
2304085c32 |
51
.blackboxrules
Normal file
51
.blackboxrules
Normal file
@@ -0,0 +1,51 @@
|
||||
# Project Startup & Development Rules
|
||||
|
||||
## Server & Package Management
|
||||
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
|
||||
```bash
|
||||
$PROJECT_ROOT/shared/scripts/start-servers.sh
|
||||
```
|
||||
- **Python Packages:** Only use UV to add packages:
|
||||
```bash
|
||||
cd $PROJECT_ROOT/backend && uv add <package>
|
||||
```
|
||||
NEVER use pip or pipenv directly, or uv pip.
|
||||
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
|
||||
- **Node Commands:** Always use 'cd frontend && pnpm add <package>' for all Node.js package installations. NEVER use npm or a different node package manager.
|
||||
|
||||
## CRITICAL Frontend design rules
|
||||
- EVERYTHING must support both dark and light mode.
|
||||
- Make sure the light/dark mode toggle works with the Vue components and pages.
|
||||
- Leverage Tailwind CSS 4 and Shadcn UI components.
|
||||
|
||||
## Frontend API URL Rules
|
||||
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
|
||||
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
|
||||
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
|
||||
- **Common Mistake:** Don’t assume frontend URLs are wrong due to proxy configuration.
|
||||
|
||||
## Entity Relationship Patterns
|
||||
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
|
||||
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
|
||||
- **Entities:**
|
||||
- Operators: Operate parks.
|
||||
- PropertyOwners: Own park property (optional).
|
||||
- Manufacturers: Make rides.
|
||||
- Designers: Design rides.
|
||||
- All entities can have locations.
|
||||
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
|
||||
|
||||
## General Best Practices
|
||||
- Never assume blank output means success—always verify changes by testing.
|
||||
- Use context7 for documentation when troubleshooting.
|
||||
- Document changes with conport and reasoning.
|
||||
- Include relevant context and information in all changes.
|
||||
- Test and validate code before deployment.
|
||||
- Communicate changes clearly with your team.
|
||||
- Be open to feedback and continuous improvement.
|
||||
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
|
||||
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
|
||||
- Log important events/errors for troubleshooting.
|
||||
- Prefer existing modules/packages over new code.
|
||||
- Keep documentation up to date.
|
||||
- Consider security vulnerabilities and performance bottlenecks in all changes.
|
||||
55
.clinerules
55
.clinerules
@@ -1,55 +0,0 @@
|
||||
# Project Startup Rules
|
||||
|
||||
## Development Server
|
||||
IMPORTANT: Always follow these instructions exactly when starting the development server:
|
||||
|
||||
```bash
|
||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
||||
```
|
||||
|
||||
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
|
||||
|
||||
## Package Management
|
||||
IMPORTANT: When a Python package is needed, only use UV to add it:
|
||||
```bash
|
||||
uv add <package>
|
||||
```
|
||||
Do not attempt to install packages using any other method.
|
||||
|
||||
## Django Management Commands
|
||||
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
|
||||
```bash
|
||||
uv run manage.py <command>
|
||||
```
|
||||
This applies to all management commands including but not limited to:
|
||||
- Making migrations: `uv run manage.py makemigrations`
|
||||
- Applying migrations: `uv run manage.py migrate`
|
||||
- Creating superuser: `uv run manage.py createsuperuser`
|
||||
- Starting shell: `uv run manage.py shell`
|
||||
|
||||
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
|
||||
|
||||
## Entity Relationship Rules
|
||||
IMPORTANT: Follow these entity relationship patterns consistently:
|
||||
|
||||
# Park Relationships
|
||||
- Parks MUST have an Operator (required relationship)
|
||||
- Parks MAY have a PropertyOwner (optional, usually same as Operator)
|
||||
- Parks CANNOT directly reference Company entities
|
||||
|
||||
# Ride Relationships
|
||||
- Rides MUST belong to a Park (required relationship)
|
||||
- Rides MAY have a Manufacturer (optional relationship)
|
||||
- Rides MAY have a Designer (optional relationship)
|
||||
- Rides CANNOT directly reference Company entities
|
||||
|
||||
# Entity Definitions
|
||||
- Operators: Companies that operate theme parks (replaces Company.owner)
|
||||
- PropertyOwners: Companies that own park property (new concept, optional)
|
||||
- Manufacturers: Companies that manufacture rides (replaces Company for rides)
|
||||
- Designers: Companies/individuals that design rides (existing concept)
|
||||
|
||||
# Relationship Constraints
|
||||
- Operator and PropertyOwner are usually the same entity but CAN be different
|
||||
- Manufacturers and Designers are distinct concepts and should not be conflated
|
||||
- All entity relationships should use proper foreign keys with appropriate null/blank settings
|
||||
29
.flake8
Normal file
29
.flake8
Normal file
@@ -0,0 +1,29 @@
|
||||
[flake8]
|
||||
# Maximum line length (matches Black formatter)
|
||||
max-line-length = 88
|
||||
|
||||
# Exclude common directories that shouldn't be linted
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.venv,
|
||||
venv,
|
||||
env,
|
||||
.env,
|
||||
migrations,
|
||||
node_modules,
|
||||
.tox,
|
||||
.mypy_cache,
|
||||
.pytest_cache,
|
||||
build,
|
||||
dist,
|
||||
*.egg-info
|
||||
|
||||
# Ignore line break style warnings which are style preferences
|
||||
# W503: line break before binary operator (conflicts with PEP8 W504)
|
||||
# W504: line break after binary operator (conflicts with PEP8 W503)
|
||||
# These warnings contradict each other, so it's best to ignore one or both
|
||||
ignore = W503,W504
|
||||
|
||||
# Maximum complexity for McCabe complexity checker
|
||||
max-complexity = 10
|
||||
442
.gitignore
vendored
442
.gitignore
vendored
@@ -1,198 +1,8 @@
|
||||
/.vscode
|
||||
/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
|
||||
116
.roo/rules/api_architecture_enforcement
Normal file
116
.roo/rules/api_architecture_enforcement
Normal file
@@ -0,0 +1,116 @@
|
||||
# API Architecture Enforcement Rules
|
||||
|
||||
## CRITICAL: Centralized API Structure
|
||||
All API endpoints MUST be centralized under the `backend/api/v1/` structure. This is NON-NEGOTIABLE.
|
||||
|
||||
### Mandatory API Directory Structure
|
||||
```
|
||||
backend/
|
||||
├── api/
|
||||
│ ├── __init__.py
|
||||
│ ├── urls.py # Main API router
|
||||
│ └── v1/
|
||||
│ ├── __init__.py
|
||||
│ ├── urls.py # V1 API routes
|
||||
│ ├── rides/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── urls.py # Ride-specific routes
|
||||
│ │ ├── views.py # Ride API views
|
||||
│ │ └── serializers.py
|
||||
│ ├── parks/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── views.py
|
||||
│ │ └── serializers.py
|
||||
│ └── auth/
|
||||
│ ├── __init__.py
|
||||
│ ├── urls.py
|
||||
│ ├── views.py
|
||||
│ └── serializers.py
|
||||
```
|
||||
|
||||
### FORBIDDEN: App-Level API Endpoints
|
||||
**ABSOLUTELY PROHIBITED:**
|
||||
- `backend/apps/{app_name}/api_urls.py`
|
||||
- `backend/apps/{app_name}/api_views.py`
|
||||
- Any API endpoints defined within individual app directories
|
||||
- Direct URL routing from apps that bypass the central API structure
|
||||
|
||||
### Required URL Pattern
|
||||
- **Frontend requests:** `/api/{endpoint}`
|
||||
- **Vite proxy rewrites to:** `/api/v1/{endpoint}`
|
||||
- **Django serves from:** `backend/apps/api/v1/{endpoint}`
|
||||
|
||||
### Migration Requirements
|
||||
When consolidating rogue API endpoints:
|
||||
|
||||
1. **BEFORE REMOVAL:** Ensure ALL functionality exists in `backend/apps/api/v1/`
|
||||
2. **Move views:** Transfer all API views to appropriate `backend/apps/api/v1/{domain}/views.py`
|
||||
3. **Move serializers:** Transfer to `backend/apps/api/v1/{domain}/serializers.py`
|
||||
4. **Update URLs:** Consolidate routes in `backend/apps/api/v1/{domain}/urls.py`
|
||||
5. **Test thoroughly:** Verify all endpoints work via central API
|
||||
6. **Only then remove:** Delete the rogue `api_urls.py` and `api_views.py` files
|
||||
|
||||
### Enforcement Actions
|
||||
If rogue API files are discovered:
|
||||
|
||||
1. **STOP all other work**
|
||||
2. **Create the proper API structure first**
|
||||
3. **Migrate ALL functionality**
|
||||
4. **Test extensively**
|
||||
5. **Remove rogue files only after verification**
|
||||
|
||||
### URL Routing Rules
|
||||
- **Main API router:** `backend/apps/api/urls.py` includes `apps/api/v1/urls.py`
|
||||
- **Version router:** `backend/apps/api/v1/urls.py` includes domain-specific routes
|
||||
- **Domain routers:** `backend/api/v1/{domain}/urls.py` defines actual endpoints
|
||||
- **No direct app routing:** Apps CANNOT define their own API URLs
|
||||
|
||||
### Frontend Integration
|
||||
- **API client:** `frontend/src/services/api.ts` uses `/api/` prefix
|
||||
- **Vite proxy:** Automatically rewrites `/api/` to `/api/v1/`
|
||||
- **URL consistency:** All frontend API calls follow this pattern
|
||||
|
||||
### Quality Assurance
|
||||
- **No API endpoints** may exist outside `backend/apps/api/v1/`
|
||||
- **All API responses** must use proper DRF serializers
|
||||
- **Consistent error handling** across all endpoints
|
||||
- **Proper authentication** and permissions on all routes
|
||||
|
||||
### Examples of Proper Structure
|
||||
```python
|
||||
# backend/apps/api/urls.py
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('v1/', include('api.v1.urls')),
|
||||
]
|
||||
|
||||
# backend/apps/api/v1/urls.py
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('rides/', include('api.v1.rides.urls')),
|
||||
path('parks/', include('api.v1.parks.urls')),
|
||||
path('auth/', include('api.v1.auth.urls')),
|
||||
]
|
||||
|
||||
# backend/apps/api/v1/rides/urls.py
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.RideListAPIView.as_view(), name='ride_list'),
|
||||
path('filter-options/', views.FilterOptionsAPIView.as_view(), name='filter_options'),
|
||||
path('search/companies/', views.CompanySearchAPIView.as_view(), name='search_companies'),
|
||||
]
|
||||
```
|
||||
|
||||
### CRITICAL FAILURE MODES TO PREVENT
|
||||
- **Split API responsibility** between apps and central API
|
||||
- **Inconsistent URL patterns** breaking frontend routing
|
||||
- **Vite proxy bypass** causing 404 errors
|
||||
- **Missing functionality** during migration
|
||||
- **Breaking changes** without proper testing
|
||||
|
||||
This rule ensures clean, maintainable, and predictable API architecture that supports the frontend proxy system and prevents the exact issues we discovered in the rides filtering system.
|
||||
49
.roo/rules/critical_rules
Normal file
49
.roo/rules/critical_rules
Normal file
@@ -0,0 +1,49 @@
|
||||
# Project Startup & Development Rules
|
||||
|
||||
## Server & Package Management
|
||||
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
|
||||
```bash
|
||||
$PROJECT_ROOT/shared/scripts/start-servers.sh
|
||||
```
|
||||
- **Python Packages:** Only use UV to add packages:
|
||||
```bash
|
||||
cd $PROJECT_ROOT/backend && uv add <package>
|
||||
```
|
||||
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
|
||||
|
||||
## CRITICAL Frontend design rules
|
||||
- EVERYTHING must support both dark and light mode.
|
||||
- Make sure the light/dark mode toggle works with the Vue components and pages.
|
||||
- Leverage Tailwind CSS 4 and Shadcn UI components.
|
||||
|
||||
## Frontend API URL Rules
|
||||
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
|
||||
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
|
||||
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
|
||||
- **Common Mistake:** Don’t assume frontend URLs are wrong due to proxy configuration.
|
||||
|
||||
## Entity Relationship Patterns
|
||||
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
|
||||
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
|
||||
- **Entities:**
|
||||
- Operators: Operate parks.
|
||||
- PropertyOwners: Own park property (optional).
|
||||
- Manufacturers: Make rides.
|
||||
- Designers: Design rides.
|
||||
- All entities can have locations.
|
||||
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
|
||||
|
||||
## General Best Practices
|
||||
- Never assume blank output means success—always verify changes by testing.
|
||||
- Use context7 for documentation when troubleshooting.
|
||||
- Document changes with conport and reasoning.
|
||||
- Include relevant context and information in all changes.
|
||||
- Test and validate code before deployment.
|
||||
- Communicate changes clearly with your team.
|
||||
- Be open to feedback and continuous improvement.
|
||||
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
|
||||
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
|
||||
- Log important events/errors for troubleshooting.
|
||||
- Prefer existing modules/packages over new code.
|
||||
- Keep documentation up to date.
|
||||
- Consider security vulnerabilities and performance bottlenecks in all changes.
|
||||
390
.roo/rules/roo_code_conport_strategy
Normal file
390
.roo/rules/roo_code_conport_strategy
Normal file
@@ -0,0 +1,390 @@
|
||||
# --- ConPort Memory Strategy ---
|
||||
conport_memory_strategy:
|
||||
# CRITICAL: At the beginning of every session, the agent MUST execute the 'initialization' sequence
|
||||
# to determine the ConPort status and load relevant context.
|
||||
workspace_id_source: "The agent must obtain the absolute path to the current workspace to use as `workspace_id` for all ConPort tool calls. This might be available as `${workspaceFolder}` or require asking the user."
|
||||
|
||||
initialization:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
action: "Determine `ACTUAL_WORKSPACE_ID`."
|
||||
- step: 2
|
||||
action: "Invoke `list_files` for `ACTUAL_WORKSPACE_ID + \"/context_portal/\"`."
|
||||
tool_to_use: "list_files"
|
||||
parameters: "path: ACTUAL_WORKSPACE_ID + \"/context_portal/\""
|
||||
- step: 3
|
||||
action: "Analyze result and branch based on 'context.db' existence."
|
||||
conditions:
|
||||
- if: "'context.db' is found"
|
||||
then_sequence: "load_existing_conport_context"
|
||||
- else: "'context.db' NOT found"
|
||||
then_sequence: "handle_new_conport_setup"
|
||||
|
||||
load_existing_conport_context:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
description: "Attempt to load initial contexts from ConPort."
|
||||
actions:
|
||||
- "Invoke `get_product_context`... Store result."
|
||||
- "Invoke `get_active_context`... Store result."
|
||||
- "Invoke `get_decisions` (limit 5 for a better overview)... Store result."
|
||||
- "Invoke `get_progress` (limit 5)... Store result."
|
||||
- "Invoke `get_system_patterns` (limit 5)... Store result."
|
||||
- "Invoke `get_custom_data` (category: \"critical_settings\")... Store result."
|
||||
- "Invoke `get_custom_data` (category: \"ProjectGlossary\")... Store result."
|
||||
- "Invoke `get_recent_activity_summary` (default params, e.g., last 24h, limit 3 per type) for a quick catch-up. Store result."
|
||||
- step: 2
|
||||
description: "Analyze loaded context."
|
||||
conditions:
|
||||
- if: "results from step 1 are NOT empty/minimal"
|
||||
actions:
|
||||
- "Set internal status to [CONPORT_ACTIVE]."
|
||||
- "Inform user: \"ConPort memory initialized. Existing contexts and recent activity loaded.\""
|
||||
- "Use `ask_followup_question` with suggestions like \"Review recent activity?\", \"Continue previous task?\", \"What would you like to work on?\"."
|
||||
- else: "loaded context is empty/minimal despite DB file existing"
|
||||
actions:
|
||||
- "Set internal status to [CONPORT_ACTIVE]."
|
||||
- "Inform user: \"ConPort database file found, but it appears to be empty or minimally initialized. You can start by defining Product/Active Context or logging project information.\""
|
||||
- "Use `ask_followup_question` with suggestions like \"Define Product Context?\", \"Log a new decision?\"."
|
||||
- step: 3
|
||||
description: "Handle Load Failure (if step 1's `get_*` calls failed)."
|
||||
condition: "If any `get_*` calls in step 1 failed unexpectedly"
|
||||
action: "Fall back to `if_conport_unavailable_or_init_failed`."
|
||||
|
||||
handle_new_conport_setup:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action_plan:
|
||||
- step: 1
|
||||
action: "Inform user: \"No existing ConPort database found at `ACTUAL_WORKSPACE_ID + \"/context_portal/context.db\"`.\""
|
||||
- step: 2
|
||||
action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "Would you like to initialize a new ConPort database for this workspace? The database will be created automatically when ConPort tools are first used."
|
||||
suggestions:
|
||||
- "Yes, initialize a new ConPort database."
|
||||
- "No, do not use ConPort for this session."
|
||||
- step: 3
|
||||
description: "Process user response."
|
||||
conditions:
|
||||
- if_user_response_is: "Yes, initialize a new ConPort database."
|
||||
actions:
|
||||
- "Inform user: \"Okay, a new ConPort database will be created.\""
|
||||
- description: "Attempt to bootstrap Product Context from projectBrief.md (this happens only on new setup)."
|
||||
thinking_preamble: |
|
||||
|
||||
sub_steps:
|
||||
- "Invoke `list_files` with `path: ACTUAL_WORKSPACE_ID` (non-recursive, just to check root)."
|
||||
- description: "Analyze `list_files` result for 'projectBrief.md'."
|
||||
conditions:
|
||||
- if: "'projectBrief.md' is found in the listing"
|
||||
actions:
|
||||
- "Invoke `read_file` for `ACTUAL_WORKSPACE_ID + \"/projectBrief.md\"`."
|
||||
- action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "Found projectBrief.md in your workspace. As we're setting up ConPort for the first time, would you like to import its content into the Product Context?"
|
||||
suggestions:
|
||||
- "Yes, import its content now."
|
||||
- "No, skip importing it for now."
|
||||
- description: "Process user response to import projectBrief.md."
|
||||
conditions:
|
||||
- if_user_response_is: "Yes, import its content now."
|
||||
actions:
|
||||
- "(No need to `get_product_context` as DB is new and empty)"
|
||||
- "Prepare `content` for `update_product_context`. For example: `{\"initial_product_brief\": \"[content from projectBrief.md]\"}`."
|
||||
- "Invoke `update_product_context` with the prepared content."
|
||||
- "Inform user of the import result (success or failure)."
|
||||
- else: "'projectBrief.md' NOT found"
|
||||
actions:
|
||||
- action: "Use `ask_followup_question`."
|
||||
tool_to_use: "ask_followup_question"
|
||||
parameters:
|
||||
question: "`projectBrief.md` was not found in the workspace root. Would you like to define the initial Product Context manually now?"
|
||||
suggestions:
|
||||
- "Define Product Context manually."
|
||||
- "Skip for now."
|
||||
- "(If \"Define manually\", guide user through `update_product_context`)."
|
||||
- "Proceed to 'load_existing_conport_context' sequence (which will now load the potentially bootstrapped product context and other empty contexts)."
|
||||
- if_user_response_is: "No, do not use ConPort for this session."
|
||||
action: "Proceed to `if_conport_unavailable_or_init_failed` (with a message indicating user chose not to initialize)."
|
||||
|
||||
if_conport_unavailable_or_init_failed:
|
||||
thinking_preamble: |
|
||||
|
||||
agent_action: "Inform user: \"ConPort memory will not be used for this session. Status: [CONPORT_INACTIVE].\""
|
||||
|
||||
general:
|
||||
status_prefix: "Begin EVERY response with either '[CONPORT_ACTIVE]' or '[CONPORT_INACTIVE]'."
|
||||
proactive_logging_cue: "Remember to proactively identify opportunities to log or update ConPort based on the conversation (e.g., if user outlines a new plan, consider logging decisions or progress). Confirm with the user before logging."
|
||||
proactive_error_handling: "When encountering errors (e.g., tool failures, unexpected output), proactively log the error details using `log_custom_data` (category: 'ErrorLogs', key: 'timestamp_error_summary') and consider updating `active_context` with `open_issues` if it's a persistent problem. Prioritize using ConPort's `get_item_history` or `get_recent_activity_summary` to diagnose issues if they relate to past context changes."
|
||||
semantic_search_emphasis: "For complex or nuanced queries, especially when direct keyword search (`search_decisions_fts`, `search_custom_data_value_fts`) might be insufficient, prioritize using `semantic_search_conport` to leverage conceptual understanding and retrieve more relevant context. Explain to the user why semantic search is being used."
|
||||
|
||||
conport_updates:
|
||||
frequency: "UPDATE CONPORT THROUGHOUT THE CHAT SESSION, WHEN SIGNIFICANT CHANGES OCCUR, OR WHEN EXPLICITLY REQUESTED."
|
||||
workspace_id_note: "All ConPort tool calls require the `workspace_id`."
|
||||
tools:
|
||||
- name: get_product_context
|
||||
trigger: "To understand the overall project goals, features, or architecture at any time."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_product_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
|
||||
- name: update_product_context
|
||||
trigger: "When the high-level project description, goals, features, or overall architecture changes significantly, as confirmed by the user."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Product context needs updating.
|
||||
- Step 1: (Optional but recommended if unsure of current state) Invoke `get_product_context`.
|
||||
- Step 2: Prepare the `content` (for full overwrite) or `patch_content` (partial update) dictionary.
|
||||
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
|
||||
- Confirm changes with the user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `update_product_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"key_to_update": "new_value", "key_to_delete": "__DELETE__"}}`).
|
||||
- name: get_active_context
|
||||
trigger: "To understand the current task focus, immediate goals, or session-specific context."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_active_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
|
||||
- name: update_active_context
|
||||
trigger: "When the current focus of work changes, new questions arise, or session-specific context needs updating (e.g., `current_focus`, `open_issues`), as confirmed by the user."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Active context needs updating.
|
||||
- Step 1: (Optional) Invoke `get_active_context` to retrieve the current state.
|
||||
- Step 2: Prepare `content` (for full overwrite) or `patch_content` (for partial update).
|
||||
- Common fields to update include `current_focus`, `open_issues`, and other session-specific data.
|
||||
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
|
||||
- Confirm changes with the user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `update_active_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"current_focus": "new_focus", "open_issues": ["issue1", "issue2"], "key_to_delete": "__DELETE__"}}`).
|
||||
- name: log_decision
|
||||
trigger: "When a significant architectural or implementation decision is made and confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_decision` (`{"workspace_id": "...", "summary": "...", "rationale": "...", "tags": ["optional_tag"]}}`).
|
||||
- name: get_decisions
|
||||
trigger: "To retrieve a list of past decisions, e.g., to review history or find a specific decision."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_decisions` (`{"workspace_id": "...", "limit": N, "tags_filter_include_all": ["tag1"], "tags_filter_include_any": ["tag2"]}}`). Explain optional filters.
|
||||
- name: search_decisions_fts
|
||||
trigger: "When searching for decisions by keywords in summary, rationale, details, or tags, and basic `get_decisions` is insufficient."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_decisions_fts` (`{"workspace_id": "...", "query_term": "search keywords", "limit": N}}`).
|
||||
- name: delete_decision_by_id
|
||||
trigger: "When user explicitly confirms deletion of a specific decision by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_decision_by_id` (`{"workspace_id": "...", "decision_id": ID}}`). Emphasize prior confirmation.
|
||||
- name: log_progress
|
||||
trigger: "When a task begins, its status changes (e.g., TODO, IN_PROGRESS, DONE), or it's completed. Also when a new sub-task is defined."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_progress` (`{"workspace_id": "...", "description": "...", "status": "...", "linked_item_type": "...", "linked_item_id": "..."}}`). Note: 'summary' was changed to 'description' for log_progress.
|
||||
- name: get_progress
|
||||
trigger: "To review current task statuses, find pending tasks, or check history of progress."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_progress` (`{"workspace_id": "...", "status_filter": "...", "parent_id_filter": ID, "limit": N}}`).
|
||||
- name: update_progress
|
||||
trigger: "Updates an existing progress entry."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `update_progress` (`{"workspace_id": "...", "progress_id": ID, "status": "...", "description": "...", "parent_id": ID}}`).
|
||||
- name: delete_progress_by_id
|
||||
trigger: "Deletes a progress entry by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_progress_by_id` (`{"workspace_id": "...", "progress_id": ID}}`).
|
||||
- name: log_system_pattern
|
||||
trigger: "When new architectural patterns are introduced, or existing ones are modified, as confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_system_pattern` (`{"workspace_id": "...", "name": "...", "description": "...", "tags": ["optional_tag"]}}`).
|
||||
- name: get_system_patterns
|
||||
trigger: "To retrieve a list of defined system patterns."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_system_patterns` (`{"workspace_id": "...", "tags_filter_include_all": ["tag1"], "limit": N}}`). Note: limit was not in original example, added for consistency.
|
||||
- name: delete_system_pattern_by_id
|
||||
trigger: "When user explicitly confirms deletion of a specific system pattern by its ID."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_system_pattern_by_id` (`{"workspace_id": "...", "pattern_id": ID}}`). Emphasize prior confirmation.
|
||||
- name: log_custom_data
|
||||
trigger: "To store any other type of structured or unstructured project-related information not covered by other tools (e.g., glossary terms, technical specs, meeting notes), as confirmed by the user."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `log_custom_data` (`{"workspace_id": "...", "category": "...", "key": "...", "value": {... or "string"}}`). Note: 'metadata' field is not part of log_custom_data args.
|
||||
- name: get_custom_data
|
||||
trigger: "To retrieve specific custom data by category and key."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`).
|
||||
- name: delete_custom_data
|
||||
trigger: "When user explicitly confirms deletion of specific custom data by category and key."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `delete_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`). Emphasize prior confirmation.
|
||||
- name: search_custom_data_value_fts
|
||||
trigger: "When searching for specific terms within any custom data values, categories, or keys."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_custom_data_value_fts` (`{"workspace_id": "...", "query_term": "...", "category_filter": "...", "limit": N}}`).
|
||||
- name: search_project_glossary_fts
|
||||
trigger: "When specifically searching for terms within the 'ProjectGlossary' custom data category."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `search_project_glossary_fts` (`{"workspace_id": "...", "query_term": "...", "limit": N}}`).
|
||||
- name: semantic_search_conport
|
||||
trigger: "When a natural language query requires conceptual understanding beyond keyword matching, or when direct keyword searches are insufficient."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `semantic_search_conport` (`{"workspace_id": "...", "query_text": "...", "top_k": N, "filter_item_types": ["decision", "custom_data"]}}`). Explain filters.
|
||||
- name: link_conport_items
|
||||
trigger: "When a meaningful relationship is identified and confirmed between two existing ConPort items (e.g., a decision is implemented by a system pattern, a progress item tracks a decision)."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- Need to link two items. Identify source type/ID, target type/ID, and relationship.
|
||||
- Common relationship_types: 'implements', 'related_to', 'tracks', 'blocks', 'clarifies', 'depends_on'. Propose a suitable one or ask user.
|
||||
</thinking>
|
||||
# Agent Action: Invoke `link_conport_items` (`{"workspace_id":"...", "source_item_type":"...", "source_item_id":"...", "target_item_type":"...", "target_item_id":"...", "relationship_type":"...", "description":"Optional notes"}`).
|
||||
- name: get_linked_items
|
||||
trigger: "To understand the relationships of a specific ConPort item, or to explore the knowledge graph around an item."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_linked_items` (`{"workspace_id":"...", "item_type":"...", "item_id":"...", "relationship_type_filter":"...", "linked_item_type_filter":"...", "limit":N}`).
|
||||
- name: get_item_history
|
||||
trigger: "When needing to review past versions of Product Context or Active Context, or to see when specific changes were made."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_item_history` (`{"workspace_id":"...", "item_type":"product_context" or "active_context", "limit":N, "version":V, "before_timestamp":"ISO_DATETIME", "after_timestamp":"ISO_DATETIME"}`).
|
||||
- name: batch_log_items
|
||||
trigger: "When the user provides a list of multiple items of the SAME type (e.g., several decisions, multiple new glossary terms) to be logged at once."
|
||||
action_description: |
|
||||
<thinking>
|
||||
- User provided multiple items. Verify they are of the same loggable type.
|
||||
- Construct the `items` list, where each element is a dictionary of arguments for the single-item log tool (e.g., for `log_decision`).
|
||||
</thinking>
|
||||
# Agent Action: Invoke `batch_log_items` (`{"workspace_id":"...", "item_type":"decision", "items": [{"summary":"...", "rationale":"..."}, {"summary":"..."}] }`).
|
||||
- name: get_recent_activity_summary
|
||||
trigger: "At the start of a new session to catch up, or when the user asks for a summary of recent project activities."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_recent_activity_summary` (`{"workspace_id":"...", "hours_ago":H, "since_timestamp":"ISO_DATETIME", "limit_per_type":N}`). Explain default if no time args.
|
||||
- name: get_conport_schema
|
||||
trigger: "If there's uncertainty about available ConPort tools or their arguments during a session (internal LLM check), or if an advanced user specifically asks for the server's tool schema."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `get_conport_schema` (`{"workspace_id":"..."}`). Primarily for internal LLM reference or direct user request.
|
||||
- name: export_conport_to_markdown
|
||||
trigger: "When the user requests to export the current ConPort data to markdown files (e.g., for backup, sharing, or version control)."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `export_conport_to_markdown` (`{"workspace_id":"...", "output_path":"optional/relative/path"}`). Explain default output path if not provided.
|
||||
- name: import_markdown_to_conport
|
||||
trigger: "When the user requests to import ConPort data from a directory of markdown files previously exported by this system."
|
||||
action_description: |
|
||||
# Agent Action: Invoke `import_markdown_to_conport` (`{"workspace_id":"...", "input_path":"optional/relative/path"}`). Explain default input path. Warn about potential overwrites or merges if data already exists.
|
||||
- name: reconfigure_core_guidance
|
||||
type: guidance
|
||||
product_active_context: "The internal JSON structure of 'Product Context' and 'Active Context' (the `content` field) is flexible. Work with the user to define and evolve this structure via `update_product_context` and `update_active_context`. The server stores this `content` as a JSON blob."
|
||||
decisions_progress_patterns: "The fundamental fields for Decisions, Progress, and System Patterns are fixed by ConPort's tools. For significantly different structures or additional fields, guide the user to create a new custom context category using `log_custom_data` (e.g., category: 'project_milestones_detailed')."
|
||||
|
||||
conport_sync_routine:
|
||||
trigger: "^(Sync ConPort|ConPort Sync)$"
|
||||
user_acknowledgement_text: "[CONPORT_SYNCING]"
|
||||
instructions:
|
||||
- "Halt Current Task: Stop current activity."
|
||||
- "Acknowledge Command: Send `[CONPORT_SYNCING]` to the user."
|
||||
- "Review Chat History: Analyze the complete current chat session for new information, decisions, progress, context changes, clarifications, and potential new relationships between items."
|
||||
core_update_process:
|
||||
thinking_preamble: |
|
||||
- Synchronize ConPort with information from the current chat session.
|
||||
- Use appropriate ConPort tools based on identified changes.
|
||||
- For `update_product_context` and `update_active_context`, first fetch current content, then merge/update (potentially using `patch_content`), then call the update tool with the *complete new content object* or the patch.
|
||||
- All tool calls require the `workspace_id`.
|
||||
agent_action_plan_illustrative:
|
||||
- "Log new decisions (use `log_decision`)."
|
||||
- "Log task progress/status changes (use `log_progress`)."
|
||||
- "Update existing progress entries (use `update_progress`)."
|
||||
- "Delete progress entries (use `delete_progress_by_id`)."
|
||||
- "Log new system patterns (use `log_system_pattern`)."
|
||||
- "Update Active Context (use `get_active_context` then `update_active_context` with full or patch)."
|
||||
- "Update Product Context if significant changes (use `get_product_context` then `update_product_context` with full or patch)."
|
||||
- "Log new custom context, including ProjectGlossary terms (use `log_custom_data`)."
|
||||
- "Identify and log new relationships between items (use `link_conport_items`)."
|
||||
- "If many items of the same type were discussed, consider `batch_log_items`."
|
||||
- "After updates, consider a brief `get_recent_activity_summary` to confirm and refresh understanding."
|
||||
post_sync_actions:
|
||||
- "Inform user: ConPort synchronized with session info."
|
||||
- "Resume previous task or await new instructions."
|
||||
|
||||
dynamic_context_retrieval_for_rag:
|
||||
description: |
|
||||
Guidance for dynamically retrieving and assembling context from ConPort to answer user queries or perform tasks,
|
||||
enhancing Retrieval Augmented Generation (RAG) capabilities.
|
||||
trigger: "When the AI needs to answer a specific question, perform a task requiring detailed project knowledge, or generate content based on ConPort data."
|
||||
goal: "To construct a concise, highly relevant context set for the LLM, improving the accuracy and relevance of its responses."
|
||||
steps:
|
||||
- step: 1
|
||||
action: "Analyze User Query/Task"
|
||||
details: "Deconstruct the user's request to identify key entities, concepts, keywords, and the specific type of information needed from ConPort."
|
||||
- step: 2
|
||||
action: "Prioritized Retrieval Strategy"
|
||||
details: |
|
||||
Based on the analysis, select the most appropriate ConPort tools:
|
||||
- **Targeted FTS:** Use `search_decisions_fts`, `search_custom_data_value_fts`, `search_project_glossary_fts` for keyword-based searches if specific terms are evident.
|
||||
- **Specific Item Retrieval:** Use `get_custom_data` (if category/key known), `get_decisions` (by ID or for recent items), `get_system_patterns`, `get_progress` if the query points to specific item types or IDs.
|
||||
- **(Future):** Prioritize semantic search tools once available for conceptual queries.
|
||||
- **Broad Context (Fallback):** Use `get_product_context` or `get_active_context` as a fallback if targeted retrieval yields little, but be mindful of their size.
|
||||
- step: 3
|
||||
action: "Retrieve Initial Set"
|
||||
details: "Execute the chosen tool(s) to retrieve an initial, small set (e.g., top 3-5) of the most relevant items or data snippets."
|
||||
- step: 4
|
||||
action: "Contextual Expansion (Optional)"
|
||||
details: "For the most promising items from Step 3, consider using `get_linked_items` to fetch directly related items (1-hop). This can provide crucial context or disambiguation. Use judiciously to avoid excessive data."
|
||||
- step: 5
|
||||
action: "Synthesize and Filter"
|
||||
details: |
|
||||
Review the retrieved information (initial set + expanded context).
|
||||
- **Filter:** Discard irrelevant items or parts of items.
|
||||
- **Synthesize/Summarize:** If multiple relevant pieces of information are found, synthesize them into a concise summary that directly addresses the query/task. Extract only the most pertinent sentences or facts.
|
||||
- step: 6
|
||||
action: "Assemble Prompt Context"
|
||||
details: |
|
||||
Construct the context portion of the LLM prompt using the filtered and synthesized information.
|
||||
- **Clarity:** Clearly delineate this retrieved context from the user's query or other parts of the prompt.
|
||||
- **Attribution (Optional but Recommended):** If possible, briefly note the source of the information (e.g., "From Decision D-42:", "According to System Pattern SP-5:").
|
||||
- **Brevity:** Strive for relevance and conciseness. Avoid including large, unprocessed chunks of data unless absolutely necessary and directly requested.
|
||||
general_principles:
|
||||
- "Prefer targeted retrieval over broad context dumps."
|
||||
- "Iterate if initial retrieval is insufficient: try different keywords or tools."
|
||||
- "Balance context richness with prompt token limits."
|
||||
|
||||
proactive_knowledge_graph_linking:
|
||||
description: |
|
||||
Guidance for the AI to proactively identify and suggest the creation of links between ConPort items,
|
||||
enriching the project's knowledge graph based on conversational context.
|
||||
trigger: "During ongoing conversation, when the AI observes potential relationships (e.g., causal, implementational, clarifying) between two or more discussed ConPort items or concepts that are likely represented as ConPort items."
|
||||
goal: "To actively build and maintain a rich, interconnected knowledge graph within ConPort by capturing relationships that might otherwise be missed."
|
||||
steps:
|
||||
- step: 1
|
||||
action: "Monitor Conversational Context"
|
||||
details: "Continuously analyze the user's statements and the flow of discussion for mentions of ConPort items (explicitly by ID, or implicitly by well-known names/summaries) and the relationships being described or implied between them."
|
||||
- step: 2
|
||||
action: "Identify Potential Links"
|
||||
details: |
|
||||
Look for patterns such as:
|
||||
- User states "Decision X led to us doing Y (which is Progress item P-3)."
|
||||
- User discusses how System Pattern SP-2 helps address a concern noted in Decision D-5.
|
||||
- User outlines a task (Progress P-10) that implements a specific feature detailed in a `custom_data` spec (CD-Spec-FeatureX).
|
||||
- step: 3
|
||||
action: "Formulate and Propose Link Suggestion"
|
||||
details: |
|
||||
If a potential link is identified:
|
||||
- Clearly state the items involved (e.g., "Decision D-5", "System Pattern SP-2").
|
||||
- Describe the perceived relationship (e.g., "It seems SP-2 addresses a concern in D-5.").
|
||||
- Propose creating a link using `ask_followup_question`.
|
||||
- Example Question: "I noticed we're discussing Decision D-5 and System Pattern SP-2. It sounds like SP-2 might 'address_concern_in' D-5. Would you like me to create this link in ConPort? You can also suggest a different relationship type."
|
||||
- Suggested Answers:
|
||||
- "Yes, link them with 'addresses_concern_in'."
|
||||
- "Yes, but use relationship type: [user types here]."
|
||||
- "No, don't link them now."
|
||||
- Offer common relationship types as examples if needed: 'implements', 'clarifies', 'related_to', 'depends_on', 'blocks', 'resolves', 'derived_from'.
|
||||
- step: 4
|
||||
action: "Gather Details and Execute Linking"
|
||||
details: |
|
||||
If the user confirms:
|
||||
- Ensure you have the correct source item type, source item ID, target item type, target item ID, and the agreed-upon relationship type.
|
||||
- Ask for an optional brief description for the link if the relationship isn't obvious.
|
||||
- Invoke the `link_conport_items` tool.
|
||||
- step: 5
|
||||
action: "Confirm Outcome"
|
||||
details: "Inform the user of the success or failure of the `link_conport_items` tool call."
|
||||
general_principles:
|
||||
- "Be helpful, not intrusive. If the user declines a suggestion, accept and move on."
|
||||
- "Prioritize clear, strong relationships over tenuous ones."
|
||||
- "This strategy complements the general `proactive_logging_cue` by providing specific guidance for link creation."
|
||||
599
README.md
599
README.md
@@ -1,391 +1,344 @@
|
||||
# ThrillWiki Development Environment Setup
|
||||
# ThrillWiki Django + Vue.js Monorepo
|
||||
|
||||
ThrillWiki is a modern Django web application for theme park and roller coaster enthusiasts, featuring a sophisticated dark theme design with purple-to-blue gradients, HTMX interactivity, and comprehensive park/ride information management.
|
||||
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
|
||||
|
||||
## 🏗️ Technology Stack
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
- **Backend**: Django 5.0+ with GeoDjango (PostGIS)
|
||||
- **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')
|
||||
372
architecture/architecture-validation.md
Normal file
372
architecture/architecture-validation.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# ThrillWiki Monorepo Architecture Validation
|
||||
|
||||
This document provides a comprehensive review and validation of the proposed monorepo architecture for migrating ThrillWiki from Django-only to Django + Vue.js.
|
||||
|
||||
## Architecture Overview Validation
|
||||
|
||||
### ✅ Core Requirements Met
|
||||
|
||||
1. **Clean Separation of Concerns**
|
||||
- Backend: Django API, business logic, database management
|
||||
- Frontend: Vue.js SPA with modern tooling
|
||||
- Shared: Common resources and media files
|
||||
|
||||
2. **Development Workflow Preservation**
|
||||
- UV package management for Python maintained
|
||||
- pnpm for Node.js package management
|
||||
- Existing development scripts adapted
|
||||
- Hot reloading for both backend and frontend
|
||||
|
||||
3. **Project Structure Compatibility**
|
||||
- Django apps preserved under `backend/apps/`
|
||||
- Configuration maintained under `backend/config/`
|
||||
- Static files strategy clearly defined
|
||||
- Media files centralized in `shared/media/`
|
||||
|
||||
## Technical Architecture Validation
|
||||
|
||||
### Backend Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Django Backend] --> B[Apps Directory]
|
||||
A --> C[Config Directory]
|
||||
A --> D[Static Files]
|
||||
|
||||
B --> E[accounts]
|
||||
B --> F[parks]
|
||||
B --> G[rides]
|
||||
B --> H[moderation]
|
||||
B --> I[location]
|
||||
B --> J[media]
|
||||
B --> K[email_service]
|
||||
B --> L[core]
|
||||
|
||||
C --> M[Django Settings]
|
||||
C --> N[URL Configuration]
|
||||
C --> O[WSGI/ASGI]
|
||||
|
||||
D --> P[Admin Assets]
|
||||
D --> Q[Backend Static]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ All 8 Django apps properly mapped to new structure
|
||||
- ✅ Configuration files maintain their organization
|
||||
- ✅ Static file handling preserves Django admin functionality
|
||||
- ✅ UV package management integration maintained
|
||||
|
||||
### Frontend Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Vue.js Frontend] --> B[Source Code]
|
||||
A --> C[Build System]
|
||||
A --> D[Development Tools]
|
||||
|
||||
B --> E[Components]
|
||||
B --> F[Views/Pages]
|
||||
B --> G[Router]
|
||||
B --> H[State Management]
|
||||
B --> I[API Layer]
|
||||
|
||||
C --> J[Vite]
|
||||
C --> K[TypeScript]
|
||||
C --> L[Tailwind CSS]
|
||||
|
||||
D --> M[Hot Reload]
|
||||
D --> N[Dev Server]
|
||||
D --> O[Build Tools]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ Modern Vue.js 3 + Composition API
|
||||
- ✅ TypeScript for type safety
|
||||
- ✅ Vite for fast development and builds
|
||||
- ✅ Tailwind CSS for styling (matching current setup)
|
||||
- ✅ Pinia for state management
|
||||
- ✅ Vue Router for SPA navigation
|
||||
|
||||
### Integration Architecture ✅
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue.js Frontend] --> B[HTTP API Calls]
|
||||
B --> C[Django REST API]
|
||||
C --> D[Database]
|
||||
C --> E[Media Files]
|
||||
E --> F[Shared Media Directory]
|
||||
F --> G[Frontend Access]
|
||||
```
|
||||
|
||||
**Validation Points:**
|
||||
- ✅ RESTful API integration between frontend and backend
|
||||
- ✅ Media files accessible to both systems
|
||||
- ✅ Authentication handling via API tokens
|
||||
- ✅ CORS configuration for cross-origin requests
|
||||
|
||||
## File Migration Validation
|
||||
|
||||
### Critical File Mappings ✅
|
||||
|
||||
| Component | Current | New Location | Status |
|
||||
|-----------|---------|--------------|--------|
|
||||
| Django Apps | `/apps/` | `/backend/apps/` | ✅ Mapped |
|
||||
| Configuration | `/config/` | `/backend/config/` | ✅ Mapped |
|
||||
| Static Files | `/static/` | `/backend/static/` | ✅ Mapped |
|
||||
| Media Files | `/media/` | `/shared/media/` | ✅ Mapped |
|
||||
| Scripts | `/scripts/` | `/scripts/` | ✅ Preserved |
|
||||
| Dependencies | `/pyproject.toml` | `/backend/pyproject.toml` | ✅ Mapped |
|
||||
|
||||
### Import Path Updates Required ✅
|
||||
|
||||
**Django Settings Updates:**
|
||||
```python
|
||||
# OLD
|
||||
INSTALLED_APPS = [
|
||||
'accounts',
|
||||
'parks',
|
||||
'rides',
|
||||
# ...
|
||||
]
|
||||
|
||||
# NEW
|
||||
INSTALLED_APPS = [
|
||||
'apps.accounts',
|
||||
'apps.parks',
|
||||
'apps.rides',
|
||||
# ...
|
||||
]
|
||||
```
|
||||
|
||||
**Media Path Updates:**
|
||||
```python
|
||||
# NEW
|
||||
MEDIA_ROOT = BASE_DIR.parent / 'shared' / 'media'
|
||||
```
|
||||
|
||||
## Development Workflow Validation
|
||||
|
||||
### Package Management ✅
|
||||
|
||||
**Backend (UV):**
|
||||
- ✅ `uv add <package>` for new dependencies
|
||||
- ✅ `uv run manage.py <command>` for Django commands
|
||||
- ✅ `uv sync` for dependency installation
|
||||
|
||||
**Frontend (pnpm):**
|
||||
- ✅ `pnpm add <package>` for new dependencies
|
||||
- ✅ `pnpm install` for dependency installation
|
||||
- ✅ `pnpm run dev` for development server
|
||||
|
||||
**Root Workspace:**
|
||||
- ✅ `pnpm run dev` starts both servers concurrently
|
||||
- ✅ Individual server commands available
|
||||
- ✅ Build and test scripts coordinated
|
||||
|
||||
### Development Scripts ✅
|
||||
|
||||
```bash
|
||||
# Root level coordination
|
||||
pnpm run dev # Both servers
|
||||
pnpm run backend:dev # Django only
|
||||
pnpm run frontend:dev # Vue.js only
|
||||
pnpm run build # Production build
|
||||
pnpm run test # All tests
|
||||
pnpm run lint # All linting
|
||||
pnpm run format # Code formatting
|
||||
```
|
||||
|
||||
## Deployment Strategy Validation
|
||||
|
||||
### Container Strategy ✅
|
||||
|
||||
**Multi-container Approach:**
|
||||
- ✅ Separate containers for backend and frontend
|
||||
- ✅ Shared volumes for media files
|
||||
- ✅ Database and Redis containers
|
||||
- ✅ Nginx reverse proxy configuration
|
||||
|
||||
**Build Process:**
|
||||
- ✅ Backend: Django static collection + uv dependencies
|
||||
- ✅ Frontend: Vite production build + asset optimization
|
||||
- ✅ Shared: Media file persistence across deployments
|
||||
|
||||
### Platform Compatibility ✅
|
||||
|
||||
**Supported Deployment Platforms:**
|
||||
- ✅ Docker Compose (local and production)
|
||||
- ✅ Vercel (frontend + serverless backend)
|
||||
- ✅ Railway (container deployment)
|
||||
- ✅ DigitalOcean App Platform
|
||||
- ✅ AWS ECS/Fargate
|
||||
- ✅ Google Cloud Run
|
||||
|
||||
## Performance Considerations ✅
|
||||
|
||||
### Backend Optimization
|
||||
- ✅ Database connection pooling
|
||||
- ✅ Redis caching strategy
|
||||
- ✅ Static file CDN integration
|
||||
- ✅ API response optimization
|
||||
|
||||
### Frontend Optimization
|
||||
- ✅ Code splitting and lazy loading
|
||||
- ✅ Asset optimization with Vite
|
||||
- ✅ Tree shaking for minimal bundle size
|
||||
- ✅ Modern build targets
|
||||
|
||||
### Development Performance
|
||||
- ✅ Hot module replacement for Vue.js
|
||||
- ✅ Django auto-reload for backend changes
|
||||
- ✅ Fast dependency installation with UV and pnpm
|
||||
- ✅ Concurrent development servers
|
||||
|
||||
## Security Validation ✅
|
||||
|
||||
### Backend Security
|
||||
- ✅ Django security middleware maintained
|
||||
- ✅ CORS configuration for API access
|
||||
- ✅ Authentication token management
|
||||
- ✅ Input validation and sanitization
|
||||
|
||||
### Frontend Security
|
||||
- ✅ Content Security Policy headers
|
||||
- ✅ XSS protection mechanisms
|
||||
- ✅ Secure API communication (HTTPS)
|
||||
- ✅ Environment variable protection
|
||||
|
||||
### Deployment Security
|
||||
- ✅ SSL/TLS termination
|
||||
- ✅ Security headers configuration
|
||||
- ✅ Secret management strategy
|
||||
- ✅ Container security best practices
|
||||
|
||||
## Risk Assessment and Mitigation
|
||||
|
||||
### Low Risk Items ✅
|
||||
- **File organization**: Clear mapping and systematic approach
|
||||
- **Package management**: Both UV and pnpm are stable and well-supported
|
||||
- **Development workflow**: Incremental changes to existing process
|
||||
|
||||
### Medium Risk Items ⚠️
|
||||
- **Import path updates**: Requires careful testing of all Django apps
|
||||
- **Static file handling**: Need to verify Django admin continues working
|
||||
- **API integration**: New frontend-backend communication layer
|
||||
|
||||
**Mitigation Strategies:**
|
||||
- Comprehensive testing suite for Django apps after migration
|
||||
- Static file serving verification in development and production
|
||||
- API endpoint testing and documentation
|
||||
- Gradual migration approach with rollback capabilities
|
||||
|
||||
### High Risk Items 🔴
|
||||
- **Data migration**: Database changes during restructuring
|
||||
- **Production deployment**: New deployment process requires validation
|
||||
|
||||
**Mitigation Strategies:**
|
||||
- Database backup before any structural changes
|
||||
- Staging environment testing before production deployment
|
||||
- Blue-green deployment strategy for zero-downtime migration
|
||||
- Monitoring and alerting for post-migration issues
|
||||
|
||||
## Testing Strategy Validation
|
||||
|
||||
### Backend Testing ✅
|
||||
```bash
|
||||
# Django tests
|
||||
cd backend
|
||||
uv run manage.py test
|
||||
|
||||
# Code quality
|
||||
uv run flake8 .
|
||||
uv run black --check .
|
||||
```
|
||||
|
||||
### Frontend Testing ✅
|
||||
```bash
|
||||
# Vue.js tests
|
||||
cd frontend
|
||||
pnpm run test
|
||||
pnpm run test:unit
|
||||
pnpm run test:e2e
|
||||
|
||||
# Code quality
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
```
|
||||
|
||||
### Integration Testing ✅
|
||||
- API endpoint testing
|
||||
- Frontend-backend communication testing
|
||||
- Media file access testing
|
||||
- Authentication flow testing
|
||||
|
||||
## Documentation Validation ✅
|
||||
|
||||
### Created Documentation
|
||||
- ✅ **Monorepo Structure Plan**: Complete directory organization
|
||||
- ✅ **Migration Mapping**: File-by-file migration guide
|
||||
- ✅ **Deployment Guide**: Comprehensive deployment strategies
|
||||
- ✅ **Architecture Validation**: This validation document
|
||||
|
||||
### Required Updates
|
||||
- ✅ Root README.md update for monorepo structure
|
||||
- ✅ Development setup instructions
|
||||
- ✅ API documentation for frontend integration
|
||||
- ✅ Deployment runbooks
|
||||
|
||||
## Implementation Readiness Assessment
|
||||
|
||||
### Prerequisites Met ✅
|
||||
- [x] Current Django project analysis complete
|
||||
- [x] Monorepo structure designed
|
||||
- [x] File migration strategy defined
|
||||
- [x] Development workflow planned
|
||||
- [x] Deployment strategy documented
|
||||
- [x] Risk assessment completed
|
||||
|
||||
### Ready for Implementation ✅
|
||||
- [x] Clear step-by-step migration plan
|
||||
- [x] File mapping completeness verified
|
||||
- [x] Package management strategy confirmed
|
||||
- [x] Testing approach defined
|
||||
- [x] Rollback strategy available
|
||||
|
||||
### Success Criteria Defined ✅
|
||||
1. **Functional Requirements**
|
||||
- All existing Django functionality preserved
|
||||
- Modern Vue.js frontend operational
|
||||
- API integration working correctly
|
||||
- Media file handling functional
|
||||
|
||||
2. **Performance Requirements**
|
||||
- Development servers start within reasonable time
|
||||
- Build process completes successfully
|
||||
- Production deployment successful
|
||||
|
||||
3. **Quality Requirements**
|
||||
- All tests passing after migration
|
||||
- Code quality standards maintained
|
||||
- Documentation updated and complete
|
||||
|
||||
## Final Recommendation ✅
|
||||
|
||||
**Approval Status: APPROVED FOR IMPLEMENTATION**
|
||||
|
||||
The proposed monorepo architecture for ThrillWiki is comprehensive, well-planned, and ready for implementation. The plan demonstrates:
|
||||
|
||||
1. **Technical Soundness**: Architecture follows modern best practices
|
||||
2. **Risk Management**: Potential issues identified with mitigation strategies
|
||||
3. **Implementation Clarity**: Clear step-by-step migration process
|
||||
4. **Operational Readiness**: Deployment and maintenance procedures defined
|
||||
|
||||
**Next Steps:**
|
||||
1. Switch to **Code Mode** for implementation
|
||||
2. Begin with directory structure creation
|
||||
3. Migrate backend files systematically
|
||||
4. Create Vue.js frontend application
|
||||
5. Test integration between systems
|
||||
6. Update deployment configurations
|
||||
|
||||
The architecture provides a solid foundation for scaling ThrillWiki with modern frontend technologies while preserving the robust Django backend functionality.
|
||||
628
architecture/deployment-guide.md
Normal file
628
architecture/deployment-guide.md
Normal file
@@ -0,0 +1,628 @@
|
||||
# ThrillWiki Monorepo Deployment Guide
|
||||
|
||||
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + Vue.js monorepo.
|
||||
|
||||
## Build Process Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Source Code] --> B[Backend Build]
|
||||
A --> C[Frontend Build]
|
||||
B --> D[Django Static Collection]
|
||||
C --> E[Vue.js Production Build]
|
||||
D --> F[Backend Container]
|
||||
E --> G[Frontend Assets]
|
||||
F --> H[Production Deployment]
|
||||
G --> H
|
||||
```
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+ with UV package manager
|
||||
- Node.js 18+ with pnpm
|
||||
- PostgreSQL (production) / SQLite (development)
|
||||
- Redis (for caching and sessions)
|
||||
|
||||
### Local Development Setup
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd thrillwiki-monorepo
|
||||
|
||||
# Install root dependencies
|
||||
pnpm install
|
||||
|
||||
# Backend setup
|
||||
cd backend
|
||||
uv sync
|
||||
uv run manage.py migrate
|
||||
uv run manage.py collectstatic
|
||||
|
||||
# Frontend setup
|
||||
cd ../frontend
|
||||
pnpm install
|
||||
|
||||
# Start development servers
|
||||
cd ..
|
||||
pnpm run dev # Starts both backend and frontend
|
||||
```
|
||||
|
||||
## Build Strategies
|
||||
|
||||
### 1. Containerized Deployment (Recommended)
|
||||
|
||||
#### Multi-stage Dockerfile for Backend
|
||||
```dockerfile
|
||||
# backend/Dockerfile
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN pip install uv
|
||||
RUN uv sync --no-dev
|
||||
|
||||
FROM python:3.11-slim as runtime
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY . .
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
```
|
||||
|
||||
#### Dockerfile for Frontend
|
||||
```dockerfile
|
||||
# frontend/Dockerfile
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM nginx:alpine as runtime
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
#### Docker Compose for Development
|
||||
```yaml
|
||||
# docker-compose.dev.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: thrillwiki
|
||||
POSTGRES_USER: thrillwiki
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./shared/media:/app/media
|
||||
environment:
|
||||
- DEBUG=1
|
||||
- DATABASE_URL=postgresql://thrillwiki:password@db:5432/thrillwiki
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8000
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
#### Docker Compose for Production
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DEBUG=0
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ALLOWED_HOSTS=${ALLOWED_HOSTS}
|
||||
volumes:
|
||||
- ./shared/media:/app/media
|
||||
- static_files:/app/staticfiles
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./nginx/ssl:/etc/nginx/ssl
|
||||
- static_files:/usr/share/nginx/html/static
|
||||
- ./shared/media:/usr/share/nginx/html/media
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
static_files:
|
||||
```
|
||||
|
||||
### 2. Static Site Generation (Alternative)
|
||||
|
||||
For sites with mostly static content, consider pre-rendering:
|
||||
|
||||
```bash
|
||||
# Frontend build with pre-rendering
|
||||
cd frontend
|
||||
pnpm run build:prerender
|
||||
|
||||
# Serve static files with minimal backend
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflow
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy ThrillWiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
- name: Backend Tests
|
||||
run: |
|
||||
cd backend
|
||||
uv sync
|
||||
uv run manage.py test
|
||||
uv run flake8 .
|
||||
uv run black --check .
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Frontend Tests
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run test
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push Docker images
|
||||
run: |
|
||||
docker build -t thrillwiki-backend ./backend
|
||||
docker build -t thrillwiki-frontend ./frontend
|
||||
# Push to registry
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
# Deploy using your preferred method
|
||||
# (AWS ECS, GCP Cloud Run, Azure Container Instances, etc.)
|
||||
```
|
||||
|
||||
## Platform-Specific Deployments
|
||||
|
||||
### 1. Vercel Deployment (Frontend + API)
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "frontend/package.json",
|
||||
"use": "@vercel/static-build",
|
||||
"config": {
|
||||
"distDir": "dist"
|
||||
}
|
||||
},
|
||||
{
|
||||
"src": "backend/config/wsgi.py",
|
||||
"use": "@vercel/python"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/api/(.*)",
|
||||
"dest": "backend/config/wsgi.py"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "frontend/dist/$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Railway Deployment
|
||||
|
||||
```toml
|
||||
# railway.toml
|
||||
[environments.production]
|
||||
|
||||
[environments.production.services.backend]
|
||||
dockerfile = "backend/Dockerfile"
|
||||
variables = { DEBUG = "0" }
|
||||
|
||||
[environments.production.services.frontend]
|
||||
dockerfile = "frontend/Dockerfile"
|
||||
|
||||
[environments.production.services.postgres]
|
||||
image = "postgres:15"
|
||||
variables = { POSTGRES_DB = "thrillwiki" }
|
||||
```
|
||||
|
||||
### 3. DigitalOcean App Platform
|
||||
|
||||
```yaml
|
||||
# .do/app.yaml
|
||||
name: thrillwiki
|
||||
services:
|
||||
- name: backend
|
||||
source_dir: backend
|
||||
github:
|
||||
repo: your-username/thrillwiki-monorepo
|
||||
branch: main
|
||||
run_command: gunicorn config.wsgi:application
|
||||
environment_slug: python
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
envs:
|
||||
- key: DEBUG
|
||||
value: "0"
|
||||
|
||||
- name: frontend
|
||||
source_dir: frontend
|
||||
github:
|
||||
repo: your-username/thrillwiki-monorepo
|
||||
branch: main
|
||||
build_command: pnpm run build
|
||||
run_command: pnpm run preview
|
||||
environment_slug: node-js
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
|
||||
databases:
|
||||
- name: thrillwiki-db
|
||||
engine: PG
|
||||
version: "15"
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
```bash
|
||||
# Django Settings
|
||||
DEBUG=0
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@host:port/database
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://host:port/0
|
||||
|
||||
# File Storage
|
||||
MEDIA_ROOT=/app/media
|
||||
STATIC_ROOT=/app/staticfiles
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||
EMAIL_HOST=smtp.yourmailprovider.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@yourdomain.com
|
||||
EMAIL_HOST_PASSWORD=your-email-password
|
||||
|
||||
# Third-party Services
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
AWS_ACCESS_KEY_ID=your-aws-key
|
||||
AWS_SECRET_ACCESS_KEY=your-aws-secret
|
||||
```
|
||||
|
||||
#### Frontend (.env.production)
|
||||
```bash
|
||||
VITE_API_URL=https://api.yourdomain.com
|
||||
VITE_APP_TITLE=ThrillWiki
|
||||
VITE_SENTRY_DSN=your-frontend-sentry-dsn
|
||||
VITE_GOOGLE_ANALYTICS_ID=your-ga-id
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Backend Optimizations
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
|
||||
# Database optimization
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'CONN_MAX_AGE': 60,
|
||||
'OPTIONS': {
|
||||
'MAX_CONNS': 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Caching
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
},
|
||||
'KEY_PREFIX': 'thrillwiki'
|
||||
}
|
||||
}
|
||||
|
||||
# Static files with CDN
|
||||
AWS_S3_CUSTOM_DOMAIN = 'cdn.yourdomain.com'
|
||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.StaticS3Boto3Storage'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.MediaS3Boto3Storage'
|
||||
```
|
||||
|
||||
### Frontend Optimizations
|
||||
```typescript
|
||||
// frontend/vite.config.ts
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
ui: ['@headlessui/vue', '@heroicons/vue']
|
||||
}
|
||||
}
|
||||
},
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Application Monitoring
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="your-sentry-dsn",
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
send_default_pii=True
|
||||
)
|
||||
|
||||
# Logging configuration
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'INFO',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': '/var/log/django/thrillwiki.log',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['file'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Infrastructure Monitoring
|
||||
- Use Prometheus + Grafana for metrics
|
||||
- Implement health check endpoints
|
||||
- Set up log aggregation (ELK stack or similar)
|
||||
- Monitor database performance
|
||||
- Track API response times
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Security Checklist
|
||||
- [ ] HTTPS enforced with SSL certificates
|
||||
- [ ] Security headers configured (HSTS, CSP, etc.)
|
||||
- [ ] Database credentials secured
|
||||
- [ ] Secret keys rotated regularly
|
||||
- [ ] CORS properly configured
|
||||
- [ ] Rate limiting implemented
|
||||
- [ ] File upload validation
|
||||
- [ ] SQL injection protection
|
||||
- [ ] XSS protection enabled
|
||||
- [ ] CSRF protection active
|
||||
|
||||
### Security Headers
|
||||
```python
|
||||
# backend/config/settings/production.py
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
|
||||
# CORS for API
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"https://yourdomain.com",
|
||||
"https://www.yourdomain.com",
|
||||
]
|
||||
```
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup Strategy
|
||||
```bash
|
||||
# Automated backup script
|
||||
#!/bin/bash
|
||||
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
||||
```
|
||||
|
||||
### Media Files Backup
|
||||
```bash
|
||||
# Sync media files to S3
|
||||
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
||||
```
|
||||
|
||||
## Scaling Strategies
|
||||
|
||||
### Horizontal Scaling
|
||||
- Load balancer configuration
|
||||
- Database read replicas
|
||||
- CDN for static assets
|
||||
- Redis clustering
|
||||
- Auto-scaling groups
|
||||
|
||||
### Vertical Scaling
|
||||
- Database connection pooling
|
||||
- Application server optimization
|
||||
- Memory usage optimization
|
||||
- CPU-intensive task optimization
|
||||
|
||||
## Troubleshooting Guide
|
||||
|
||||
### Common Issues
|
||||
1. **Build failures**: Check dependencies and environment variables
|
||||
2. **Database connection errors**: Verify connection strings and firewall rules
|
||||
3. **Static file 404s**: Ensure collectstatic runs and paths are correct
|
||||
4. **CORS errors**: Check CORS configuration and allowed origins
|
||||
5. **Memory issues**: Monitor application memory usage and optimize queries
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Backend debugging
|
||||
cd backend
|
||||
uv run manage.py check --deploy
|
||||
uv run manage.py shell
|
||||
uv run manage.py dbshell
|
||||
|
||||
# Frontend debugging
|
||||
cd frontend
|
||||
pnpm run build --debug
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
This deployment guide provides a comprehensive approach to deploying the ThrillWiki monorepo across various platforms while maintaining security, performance, and scalability.
|
||||
353
architecture/migration-mapping.md
Normal file
353
architecture/migration-mapping.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# ThrillWiki Migration Mapping Document
|
||||
|
||||
This document provides a comprehensive mapping of files from the current Django project to the new monorepo structure.
|
||||
|
||||
## Root Level Files
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `manage.py` | `backend/manage.py` | Core Django management |
|
||||
| `pyproject.toml` | `backend/pyproject.toml` | Python dependencies |
|
||||
| `uv.lock` | `backend/uv.lock` | UV lock file |
|
||||
| `.gitignore` | `.gitignore` (update) | Merge with monorepo patterns |
|
||||
| `README.md` | `README.md` (update) | Update for monorepo |
|
||||
| `.pre-commit-config.yaml` | `.pre-commit-config.yaml` | Root level |
|
||||
|
||||
## Configuration Directory
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `config/django/` | `backend/config/django/` | Django settings |
|
||||
| `config/settings/` | `backend/config/settings/` | Environment settings |
|
||||
| `config/urls.py` | `backend/config/urls.py` | URL configuration |
|
||||
| `config/wsgi.py` | `backend/config/wsgi.py` | WSGI configuration |
|
||||
| `config/asgi.py` | `backend/config/asgi.py` | ASGI configuration |
|
||||
|
||||
## Django Apps
|
||||
|
||||
### Accounts App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `accounts/` | `backend/apps/accounts/` |
|
||||
| `accounts/__init__.py` | `backend/apps/accounts/__init__.py` |
|
||||
| `accounts/models.py` | `backend/apps/accounts/models.py` |
|
||||
| `accounts/views.py` | `backend/apps/accounts/views.py` |
|
||||
| `accounts/admin.py` | `backend/apps/accounts/admin.py` |
|
||||
| `accounts/apps.py` | `backend/apps/accounts/apps.py` |
|
||||
| `accounts/migrations/` | `backend/apps/accounts/migrations/` |
|
||||
| `accounts/tests/` | `backend/apps/accounts/tests/` |
|
||||
|
||||
### Parks App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `parks/` | `backend/apps/parks/` |
|
||||
| `parks/__init__.py` | `backend/apps/parks/__init__.py` |
|
||||
| `parks/models.py` | `backend/apps/parks/models.py` |
|
||||
| `parks/views.py` | `backend/apps/parks/views.py` |
|
||||
| `parks/admin.py` | `backend/apps/parks/admin.py` |
|
||||
| `parks/apps.py` | `backend/apps/parks/apps.py` |
|
||||
| `parks/migrations/` | `backend/apps/parks/migrations/` |
|
||||
| `parks/tests/` | `backend/apps/parks/tests/` |
|
||||
|
||||
### Rides App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `rides/` | `backend/apps/rides/` |
|
||||
| `rides/__init__.py` | `backend/apps/rides/__init__.py` |
|
||||
| `rides/models.py` | `backend/apps/rides/models.py` |
|
||||
| `rides/views.py` | `backend/apps/rides/views.py` |
|
||||
| `rides/admin.py` | `backend/apps/rides/admin.py` |
|
||||
| `rides/apps.py` | `backend/apps/rides/apps.py` |
|
||||
| `rides/migrations/` | `backend/apps/rides/migrations/` |
|
||||
| `rides/tests/` | `backend/apps/rides/tests/` |
|
||||
|
||||
### Moderation App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `moderation/` | `backend/apps/moderation/` |
|
||||
| `moderation/__init__.py` | `backend/apps/moderation/__init__.py` |
|
||||
| `moderation/models.py` | `backend/apps/moderation/models.py` |
|
||||
| `moderation/views.py` | `backend/apps/moderation/views.py` |
|
||||
| `moderation/admin.py` | `backend/apps/moderation/admin.py` |
|
||||
| `moderation/apps.py` | `backend/apps/moderation/apps.py` |
|
||||
| `moderation/migrations/` | `backend/apps/moderation/migrations/` |
|
||||
| `moderation/tests/` | `backend/apps/moderation/tests/` |
|
||||
|
||||
### Location App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `location/` | `backend/apps/location/` |
|
||||
| `location/__init__.py` | `backend/apps/location/__init__.py` |
|
||||
| `location/models.py` | `backend/apps/location/models.py` |
|
||||
| `location/views.py` | `backend/apps/location/views.py` |
|
||||
| `location/admin.py` | `backend/apps/location/admin.py` |
|
||||
| `location/apps.py` | `backend/apps/location/apps.py` |
|
||||
| `location/migrations/` | `backend/apps/location/migrations/` |
|
||||
| `location/tests/` | `backend/apps/location/tests/` |
|
||||
|
||||
### Media App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `media/` | `backend/apps/media/` |
|
||||
| `media/__init__.py` | `backend/apps/media/__init__.py` |
|
||||
| `media/models.py` | `backend/apps/media/models.py` |
|
||||
| `media/views.py` | `backend/apps/media/views.py` |
|
||||
| `media/admin.py` | `backend/apps/media/admin.py` |
|
||||
| `media/apps.py` | `backend/apps/media/apps.py` |
|
||||
| `media/migrations/` | `backend/apps/media/migrations/` |
|
||||
| `media/tests/` | `backend/apps/media/tests/` |
|
||||
|
||||
### Email Service App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `email_service/` | `backend/apps/email_service/` |
|
||||
| `email_service/__init__.py` | `backend/apps/email_service/__init__.py` |
|
||||
| `email_service/models.py` | `backend/apps/email_service/models.py` |
|
||||
| `email_service/views.py` | `backend/apps/email_service/views.py` |
|
||||
| `email_service/admin.py` | `backend/apps/email_service/admin.py` |
|
||||
| `email_service/apps.py` | `backend/apps/email_service/apps.py` |
|
||||
| `email_service/migrations/` | `backend/apps/email_service/migrations/` |
|
||||
| `email_service/tests/` | `backend/apps/email_service/tests/` |
|
||||
|
||||
### Core App
|
||||
| Current Location | New Location |
|
||||
|------------------|--------------|
|
||||
| `core/` | `backend/apps/core/` |
|
||||
| `core/__init__.py` | `backend/apps/core/__init__.py` |
|
||||
| `core/models.py` | `backend/apps/core/models.py` |
|
||||
| `core/views.py` | `backend/apps/core/views.py` |
|
||||
| `core/admin.py` | `backend/apps/core/admin.py` |
|
||||
| `core/apps.py` | `backend/apps/core/apps.py` |
|
||||
| `core/migrations/` | `backend/apps/core/migrations/` |
|
||||
| `core/tests/` | `backend/apps/core/tests/` |
|
||||
|
||||
## Static Files and Templates
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `static/` | `backend/static/` | Django admin and backend assets |
|
||||
| `staticfiles/` | `backend/staticfiles/` | Collected static files |
|
||||
| `templates/` | `backend/templates/` | Django templates (if any) |
|
||||
|
||||
## Media Files
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `media/` | `shared/media/` | User uploaded content |
|
||||
|
||||
## Scripts and Development Tools
|
||||
|
||||
| Current Location | New Location | Notes |
|
||||
|------------------|--------------|-------|
|
||||
| `scripts/` | `scripts/` | Root level scripts |
|
||||
| `scripts/dev_server.sh` | `scripts/backend_dev.sh` | Rename for clarity |
|
||||
|
||||
## New Frontend Structure (Created)
|
||||
|
||||
| New Location | Purpose |
|
||||
|--------------|---------|
|
||||
| `frontend/` | Vue.js application root |
|
||||
| `frontend/package.json` | Node.js dependencies |
|
||||
| `frontend/pnpm-lock.yaml` | pnpm lock file |
|
||||
| `frontend/vite.config.ts` | Vite configuration |
|
||||
| `frontend/tsconfig.json` | TypeScript configuration |
|
||||
| `frontend/tailwind.config.js` | Tailwind CSS configuration |
|
||||
| `frontend/src/` | Vue.js source code |
|
||||
| `frontend/src/main.ts` | Application entry point |
|
||||
| `frontend/src/App.vue` | Root component |
|
||||
| `frontend/src/components/` | Vue components |
|
||||
| `frontend/src/views/` | Page components |
|
||||
| `frontend/src/router/` | Vue Router configuration |
|
||||
| `frontend/src/stores/` | Pinia stores |
|
||||
| `frontend/src/composables/` | Vue composables |
|
||||
| `frontend/src/utils/` | Utility functions |
|
||||
| `frontend/src/types/` | TypeScript type definitions |
|
||||
| `frontend/src/assets/` | Static assets |
|
||||
| `frontend/public/` | Public assets |
|
||||
| `frontend/dist/` | Build output |
|
||||
|
||||
## New Shared Resources (Created)
|
||||
|
||||
| New Location | Purpose |
|
||||
|--------------|---------|
|
||||
| `shared/` | Cross-platform resources |
|
||||
| `shared/media/` | User uploaded files |
|
||||
| `shared/docs/` | Documentation |
|
||||
| `shared/types/` | Shared TypeScript types |
|
||||
| `shared/constants/` | Shared constants |
|
||||
|
||||
## Updated Root Files
|
||||
|
||||
### package.json (Root)
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm --filter frontend dev\" \"./scripts/backend_dev.sh\"",
|
||||
"build": "pnpm --filter frontend build",
|
||||
"backend:dev": "./scripts/backend_dev.sh",
|
||||
"frontend:dev": "pnpm --filter frontend dev",
|
||||
"test": "pnpm --filter frontend test && cd backend && uv run manage.py test",
|
||||
"lint": "pnpm --filter frontend lint && cd backend && uv run flake8 .",
|
||||
"format": "pnpm --filter frontend format && cd backend && uv run black ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### .gitignore (Updated)
|
||||
```gitignore
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/backend/static/
|
||||
/backend/media/
|
||||
|
||||
# UV
|
||||
.uv/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Vue.js / Vite
|
||||
/frontend/dist/
|
||||
/frontend/dist-ssr/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
```
|
||||
|
||||
## Configuration Updates Required
|
||||
|
||||
### Backend Django Settings
|
||||
Update `INSTALLED_APPS` paths:
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Local apps
|
||||
'apps.accounts',
|
||||
'apps.parks',
|
||||
'apps.rides',
|
||||
'apps.moderation',
|
||||
'apps.location',
|
||||
'apps.media',
|
||||
'apps.email_service',
|
||||
'apps.core',
|
||||
]
|
||||
```
|
||||
|
||||
Update media and static files paths:
|
||||
```python
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR.parent / 'shared' / 'media'
|
||||
```
|
||||
|
||||
### Script Updates
|
||||
Update `scripts/backend_dev.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
cd backend
|
||||
lsof -ti :8000 | xargs kill -9 2>/dev/null || true
|
||||
find . -type d -name "__pycache__" -exec rm -r {} + 2>/dev/null || true
|
||||
uv run manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
## Migration Steps Summary
|
||||
|
||||
1. **Create new directory structure**
|
||||
2. **Move backend files** to `backend/` directory
|
||||
3. **Update import paths** in Django settings and apps
|
||||
4. **Create frontend** Vue.js application
|
||||
5. **Update scripts** and configuration files
|
||||
6. **Test both backend and frontend** independently
|
||||
7. **Configure API integration** between Django and Vue.js
|
||||
8. **Update deployment** configurations
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- [ ] All Django apps moved to `backend/apps/`
|
||||
- [ ] Configuration files updated with new paths
|
||||
- [ ] Static and media file paths configured correctly
|
||||
- [ ] Frontend Vue.js application created and configured
|
||||
- [ ] Root package.json with workspace configuration
|
||||
- [ ] Development scripts updated and tested
|
||||
- [ ] Git configuration updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] CI/CD pipelines updated (if applicable)
|
||||
- [ ] Database migrations work correctly
|
||||
- [ ] Both development servers start successfully
|
||||
- [ ] API endpoints accessible from frontend
|
||||
525
architecture/monorepo-structure-plan.md
Normal file
525
architecture/monorepo-structure-plan.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# ThrillWiki Django + Vue.js Monorepo Architecture Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the optimal monorepo directory structure for migrating the ThrillWiki Django project to a Django + Vue.js architecture. The design separates backend and frontend concerns while maintaining existing Django app organization and supporting modern development workflows.
|
||||
|
||||
## Current Project Analysis
|
||||
|
||||
### Django Apps Structure
|
||||
- **accounts**: User management and authentication
|
||||
- **parks**: Theme park data and operations
|
||||
- **rides**: Ride information and management
|
||||
- **moderation**: Content moderation system
|
||||
- **location**: Geographic data handling
|
||||
- **media**: File and image management
|
||||
- **email_service**: Email functionality
|
||||
- **core**: Core utilities and services
|
||||
|
||||
### Key Infrastructure
|
||||
- **Package Management**: UV-based Python setup
|
||||
- **Configuration**: `config/django/` for settings, `config/settings/` for modular settings
|
||||
- **Development**: `scripts/dev_server.sh` with comprehensive setup
|
||||
- **Static Assets**: Tailwind CSS integration, `static/` and `staticfiles/`
|
||||
- **Media Handling**: Organized `media/` directory with park/ride subdirectories
|
||||
|
||||
## Proposed Monorepo Structure
|
||||
|
||||
```
|
||||
thrillwiki-monorepo/
|
||||
├── README.md
|
||||
├── pyproject.toml # Python dependencies (backend only)
|
||||
├── package.json # Node.js dependencies (monorepo coordination)
|
||||
├── pnpm-workspace.yaml # pnpm workspace configuration
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├──
|
||||
├── backend/ # Django Backend
|
||||
│ ├── manage.py
|
||||
│ ├── pyproject.toml # Backend-specific dependencies
|
||||
│ ├── config/
|
||||
│ │ ├── django/
|
||||
│ │ │ ├── base.py
|
||||
│ │ │ ├── local.py
|
||||
│ │ │ ├── production.py
|
||||
│ │ │ └── test.py
|
||||
│ │ └── settings/
|
||||
│ │ ├── database.py
|
||||
│ │ ├── email.py
|
||||
│ │ └── security.py
|
||||
│ ├── thrillwiki/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── wsgi.py
|
||||
│ │ ├── asgi.py
|
||||
│ │ └── views.py
|
||||
│ ├── apps/ # Django apps
|
||||
│ │ ├── accounts/
|
||||
│ │ ├── parks/
|
||||
│ │ ├── rides/
|
||||
│ │ ├── moderation/
|
||||
│ │ ├── location/
|
||||
│ │ ├── media/
|
||||
│ │ ├── email_service/
|
||||
│ │ └── core/
|
||||
│ ├── templates/ # Django templates (API responses, admin)
|
||||
│ ├── static/ # Backend static files
|
||||
│ │ └── admin/ # Django admin assets
|
||||
│ ├── media/ # User uploads
|
||||
│ │ ├── avatars/
|
||||
│ │ ├── park/
|
||||
│ │ └── submissions/
|
||||
│ └── tests/ # Backend tests
|
||||
│
|
||||
├── frontend/ # Vue.js Frontend
|
||||
│ ├── package.json
|
||||
│ ├── pnpm-lock.yaml
|
||||
│ ├── vite.config.js
|
||||
│ ├── tailwind.config.js
|
||||
│ ├── index.html
|
||||
│ ├── src/
|
||||
│ │ ├── main.js
|
||||
│ │ ├── App.vue
|
||||
│ │ ├── router/
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── stores/ # Pinia/Vuex stores
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── parks.js
|
||||
│ │ │ └── rides.js
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── common/ # Shared components
|
||||
│ │ │ ├── parks/ # Park-specific components
|
||||
│ │ │ ├── rides/ # Ride-specific components
|
||||
│ │ │ └── moderation/ # Moderation components
|
||||
│ │ ├── views/ # Page components
|
||||
│ │ │ ├── Home.vue
|
||||
│ │ │ ├── parks/
|
||||
│ │ │ ├── rides/
|
||||
│ │ │ └── auth/
|
||||
│ │ ├── composables/ # Vue 3 composables
|
||||
│ │ │ ├── useAuth.js
|
||||
│ │ │ ├── useApi.js
|
||||
│ │ │ └── useTheme.js
|
||||
│ │ ├── services/ # API service layer
|
||||
│ │ │ ├── api.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ ├── parks.js
|
||||
│ │ │ └── rides.js
|
||||
│ │ ├── assets/
|
||||
│ │ │ ├── images/
|
||||
│ │ │ └── styles/
|
||||
│ │ │ ├── globals.css
|
||||
│ │ │ └── components/
|
||||
│ │ └── utils/
|
||||
│ ├── public/
|
||||
│ │ ├── favicon.ico
|
||||
│ │ └── images/
|
||||
│ ├── dist/ # Build output
|
||||
│ └── tests/ # Frontend tests
|
||||
│ ├── unit/
|
||||
│ └── e2e/
|
||||
│
|
||||
├── shared/ # Shared Resources
|
||||
│ ├── docs/ # Documentation
|
||||
│ │ ├── api/ # API documentation
|
||||
│ │ ├── deployment/ # Deployment guides
|
||||
│ │ └── development/ # Development setup
|
||||
│ ├── scripts/ # Build and deployment scripts
|
||||
│ │ ├── dev/
|
||||
│ │ │ ├── start-backend.sh
|
||||
│ │ │ ├── start-frontend.sh
|
||||
│ │ │ └── start-full-stack.sh
|
||||
│ │ ├── build/
|
||||
│ │ │ ├── build-frontend.sh
|
||||
│ │ │ └── build-production.sh
|
||||
│ │ ├── deploy/
|
||||
│ │ └── utils/
|
||||
│ ├── config/ # Shared configuration
|
||||
│ │ ├── docker/
|
||||
│ │ │ ├── Dockerfile.backend
|
||||
│ │ │ ├── Dockerfile.frontend
|
||||
│ │ │ └── docker-compose.yml
|
||||
│ │ ├── nginx/
|
||||
│ │ └── ci/ # CI/CD configuration
|
||||
│ │ └── github-actions/
|
||||
│ └── types/ # Shared TypeScript types
|
||||
│ ├── api.ts
|
||||
│ ├── parks.ts
|
||||
│ └── rides.ts
|
||||
│
|
||||
├── logs/ # Application logs
|
||||
├── backups/ # Database backups
|
||||
├── uploads/ # Temporary upload directory
|
||||
└── dist/ # Production build output
|
||||
├── backend/ # Django static files
|
||||
└── frontend/ # Vue.js build
|
||||
```
|
||||
|
||||
## Directory Organization Rationale
|
||||
|
||||
### 1. Clear Separation of Concerns
|
||||
- **backend/**: Contains all Django-related code, maintaining existing app structure
|
||||
- **frontend/**: Vue.js application with modern structure (Vite + Vue 3)
|
||||
- **shared/**: Common resources, documentation, and configuration
|
||||
|
||||
### 2. Backend Structure (`backend/`)
|
||||
- Preserves existing Django app organization under `apps/`
|
||||
- Maintains UV-based Python dependency management
|
||||
- Keeps configuration structure with `config/django/` and `config/settings/`
|
||||
- Separates templates for API responses vs. frontend UI
|
||||
|
||||
### 3. Frontend Structure (`frontend/`)
|
||||
- Modern Vue 3 + Vite setup with TypeScript support
|
||||
- Organized by feature areas (parks, rides, auth)
|
||||
- Composables for Vue 3 Composition API patterns
|
||||
- Service layer for API communication with Django backend
|
||||
- Tailwind CSS integration with shared design system
|
||||
|
||||
### 4. Shared Resources (`shared/`)
|
||||
- Centralized documentation and deployment scripts
|
||||
- Docker configuration for containerized deployment
|
||||
- TypeScript type definitions shared between frontend and API
|
||||
- CI/CD pipeline configuration
|
||||
|
||||
## Static File Strategy
|
||||
|
||||
### Development
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue Dev Server :3000] --> B[Vite HMR]
|
||||
C[Django Dev Server :8000] --> D[Django Static Files]
|
||||
E[Tailwind CSS] --> F[Both Frontend & Backend]
|
||||
```
|
||||
|
||||
### Production
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Vue Build] --> B[dist/frontend/]
|
||||
C[Django Collectstatic] --> D[dist/backend/]
|
||||
E[Nginx] --> F[Serves Both]
|
||||
F --> G[Frontend Assets]
|
||||
F --> H[API Endpoints]
|
||||
F --> I[Media Files]
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
1. **Development Mode**:
|
||||
- Frontend: Vite dev server on port 3000 with HMR
|
||||
- Backend: Django dev server on port 8000
|
||||
- Proxy API calls from frontend to backend
|
||||
|
||||
2. **Production Mode**:
|
||||
- Frontend built to `dist/frontend/`
|
||||
- Django static files collected to `dist/backend/`
|
||||
- Nginx serves static files and proxies API calls
|
||||
|
||||
## Media File Management
|
||||
|
||||
### Current Structure Preservation
|
||||
```
|
||||
media/
|
||||
├── avatars/ # User profile images
|
||||
├── park/ # Park-specific media
|
||||
│ ├── {park-slug}/
|
||||
│ │ └── {ride-slug}/
|
||||
└── submissions/ # User-submitted content
|
||||
└── photos/
|
||||
```
|
||||
|
||||
### Strategy
|
||||
- **Development**: Django serves media files directly
|
||||
- **Production**: CDN or object storage (S3/CloudFlare) integration
|
||||
- **Frontend Access**: Media URLs provided via API responses
|
||||
- **Upload Handling**: Django handles all file uploads, Vue.js provides UI
|
||||
|
||||
## Development Workflow Integration
|
||||
|
||||
### Package Management
|
||||
- **Root**: Node.js dependencies for frontend and tooling (using pnpm)
|
||||
- **Backend**: UV for Python dependencies (existing approach)
|
||||
- **Frontend**: pnpm for Vue.js dependencies
|
||||
|
||||
### Development Scripts
|
||||
```bash
|
||||
# Root level scripts
|
||||
pnpm run dev # Start both backend and frontend
|
||||
pnpm run dev:backend # Start only Django
|
||||
pnpm run dev:frontend # Start only Vue.js
|
||||
pnpm run build # Build for production
|
||||
pnpm run test # Run all tests
|
||||
|
||||
# Backend specific (using UV)
|
||||
cd backend && uv run manage.py runserver
|
||||
cd backend && uv run manage.py test
|
||||
|
||||
# Frontend specific
|
||||
cd frontend && pnpm run dev
|
||||
cd frontend && pnpm run build
|
||||
cd frontend && pnpm run test
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
```bash
|
||||
# Root .env (shared settings)
|
||||
DATABASE_URL=
|
||||
REDIS_URL=
|
||||
SECRET_KEY=
|
||||
|
||||
# Backend .env (Django specific)
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
DEBUG=True
|
||||
|
||||
# Frontend .env (Vue specific)
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_APP_TITLE=ThrillWiki
|
||||
```
|
||||
|
||||
### Package Manager Configuration
|
||||
|
||||
#### Root pnpm-workspace.yaml
|
||||
```yaml
|
||||
packages:
|
||||
- 'frontend'
|
||||
# Backend is managed separately with uv
|
||||
```
|
||||
|
||||
#### Root package.json
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-monorepo",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm run dev:backend\" \"pnpm run dev:frontend\"",
|
||||
"dev:backend": "cd backend && uv run manage.py runserver",
|
||||
"dev:frontend": "cd frontend && pnpm run dev",
|
||||
"build": "pnpm run build:frontend && cd backend && uv run manage.py collectstatic --noinput",
|
||||
"build:frontend": "cd frontend && pnpm run build",
|
||||
"test": "pnpm run test:backend && pnpm run test:frontend",
|
||||
"test:backend": "cd backend && uv run manage.py test",
|
||||
"test:frontend": "cd frontend && pnpm run test",
|
||||
"lint": "cd frontend && pnpm run lint && cd ../backend && uv run flake8 .",
|
||||
"format": "cd frontend && pnpm run format && cd ../backend && uv run black ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend package.json
|
||||
```json
|
||||
{
|
||||
"name": "thrillwiki-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"format": "prettier --write src/",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^2.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"prettier": "^3.2.0",
|
||||
"vitest": "^1.3.0",
|
||||
"@playwright/test": "^1.42.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Migration Mapping
|
||||
|
||||
### High-Level Moves
|
||||
```
|
||||
Current → New Location
|
||||
├── manage.py → backend/manage.py
|
||||
├── pyproject.toml → backend/pyproject.toml (+ root package.json)
|
||||
├── config/ → backend/config/
|
||||
├── thrillwiki/ → backend/thrillwiki/
|
||||
├── accounts/ → backend/apps/accounts/
|
||||
├── parks/ → backend/apps/parks/
|
||||
├── rides/ → backend/apps/rides/
|
||||
├── moderation/ → backend/apps/moderation/
|
||||
├── location/ → backend/apps/location/
|
||||
├── media/ → backend/apps/media/
|
||||
├── email_service/ → backend/apps/email_service/
|
||||
├── core/ → backend/apps/core/
|
||||
├── templates/ → backend/templates/ (API) + frontend/src/views/ (UI)
|
||||
├── static/ → backend/static/ (admin) + frontend/src/assets/
|
||||
├── media/ → media/ (shared, accessible to both)
|
||||
├── scripts/ → shared/scripts/
|
||||
├── docs/ → shared/docs/
|
||||
├── tests/ → backend/tests/ + frontend/tests/
|
||||
└── staticfiles/ → dist/backend/ (generated)
|
||||
```
|
||||
|
||||
### Detailed Backend App Moves
|
||||
Each Django app moves to `backend/apps/{app_name}/` with structure preserved:
|
||||
- Models, views, serializers stay the same
|
||||
- Templates for API responses remain in app directories
|
||||
- Static files move to frontend if UI-related
|
||||
- Tests remain with respective apps
|
||||
|
||||
## Build and Deployment Strategy
|
||||
|
||||
### Development Build Process
|
||||
1. **Backend**: No build step, runs directly with Django dev server
|
||||
2. **Frontend**: Vite development server with HMR
|
||||
3. **Shared**: Scripts orchestrate starting both services
|
||||
|
||||
### Production Build Process
|
||||
```mermaid
|
||||
graph TD
|
||||
A[CI/CD Trigger] --> B[Install Dependencies]
|
||||
B --> C[Build Frontend]
|
||||
B --> D[Collect Django Static]
|
||||
C --> E[Generate Frontend Bundle]
|
||||
D --> F[Collect Backend Assets]
|
||||
E --> G[Create Docker Images]
|
||||
F --> G
|
||||
G --> H[Deploy to Production]
|
||||
```
|
||||
|
||||
### Container Strategy
|
||||
- **Multi-stage Docker builds**: Separate backend and frontend images
|
||||
- **Nginx**: Reverse proxy and static file serving
|
||||
- **Volume mounts**: For media files and logs
|
||||
- **Environment-based configuration**: Development vs. production
|
||||
|
||||
## API Integration Strategy
|
||||
|
||||
### Backend API Structure
|
||||
```python
|
||||
# Enhanced DRF setup for SPA
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
],
|
||||
}
|
||||
|
||||
# CORS for development
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # Vue dev server
|
||||
]
|
||||
```
|
||||
|
||||
### Frontend API Service
|
||||
```javascript
|
||||
// API service with auth integration
|
||||
class ApiService {
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Park operations
|
||||
getParks(params = {}) {
|
||||
return this.client.get('/parks/', { params });
|
||||
}
|
||||
|
||||
// Ride operations
|
||||
getRides(parkId, params = {}) {
|
||||
return this.client.get(`/parks/${parkId}/rides/`, { params });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Shared Environment Variables
|
||||
- Database connections
|
||||
- Redis/Cache settings
|
||||
- Secret keys and API keys
|
||||
- Feature flags
|
||||
|
||||
### Application-Specific Settings
|
||||
- **Django**: `backend/config/django/`
|
||||
- **Vue.js**: `frontend/.env` files
|
||||
- **Docker**: `shared/config/docker/`
|
||||
|
||||
### Development vs. Production
|
||||
- Development: Multiple local servers, hot reloading
|
||||
- Production: Containerized deployment, CDN integration
|
||||
|
||||
## Benefits of This Structure
|
||||
|
||||
1. **Clear Separation**: Backend and frontend concerns are clearly separated
|
||||
2. **Scalability**: Each part can be developed, tested, and deployed independently
|
||||
3. **Modern Workflow**: Supports latest Vue 3, Vite, and Django patterns
|
||||
4. **Backward Compatibility**: Preserves existing Django app structure
|
||||
5. **Developer Experience**: Hot reloading, TypeScript support, modern tooling
|
||||
6. **Deployment Flexibility**: Can deploy as SPA + API or traditional Django
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Structure Setup
|
||||
1. Create new directory structure
|
||||
2. Move Django code to `backend/`
|
||||
3. Initialize Vue.js frontend
|
||||
4. Set up basic API integration
|
||||
|
||||
### Phase 2: Frontend Development
|
||||
1. Create Vue.js components for existing Django templates
|
||||
2. Implement routing and state management
|
||||
3. Integrate with Django API endpoints
|
||||
4. Add authentication flow
|
||||
|
||||
### Phase 3: Build & Deploy
|
||||
1. Set up build processes
|
||||
2. Configure CI/CD pipelines
|
||||
3. Implement production deployment
|
||||
4. Performance optimization
|
||||
|
||||
## Considerations and Trade-offs
|
||||
|
||||
### Advantages
|
||||
- Modern development experience
|
||||
- Better code organization
|
||||
- Independent scaling
|
||||
- Rich frontend interactions
|
||||
- API-first architecture
|
||||
|
||||
### Challenges
|
||||
- Increased complexity
|
||||
- Build process coordination
|
||||
- Authentication across services
|
||||
- SEO considerations (if needed)
|
||||
- Development environment setup
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Validate Architecture**: Review with development team
|
||||
2. **Prototype Setup**: Create basic structure with sample components
|
||||
3. **Migration Planning**: Detailed plan for moving existing code
|
||||
4. **Tool Selection**: Finalize Vue.js ecosystem choices (Pinia vs. Vuex, etc.)
|
||||
5. **Implementation**: Begin phase-by-phase migration
|
||||
|
||||
---
|
||||
|
||||
This architecture provides a solid foundation for migrating ThrillWiki to a modern Django + Vue.js monorepo while preserving existing functionality and enabling future growth.
|
||||
31
backend/.env.example
Normal file
31
backend/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Django Configuration
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email Configuration (Optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
|
||||
# Media and Static Files
|
||||
MEDIA_URL=/media/
|
||||
STATIC_URL=/static/
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# API Configuration
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_DEBUG_TOOLBAR=True
|
||||
ENABLE_SILK_PROFILER=False
|
||||
229
backend/README.md
Normal file
229
backend/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# ThrillWiki Backend
|
||||
|
||||
Django REST API backend for the ThrillWiki monorepo.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This backend follows Django best practices with a modular app structure:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── apps/ # Django applications
|
||||
│ ├── accounts/ # User management
|
||||
│ ├── parks/ # Theme park data
|
||||
│ ├── rides/ # Ride information
|
||||
│ ├── moderation/ # Content moderation
|
||||
│ ├── location/ # Geographic data
|
||||
│ ├── media/ # File management
|
||||
│ ├── email_service/ # Email functionality
|
||||
│ └── core/ # Core utilities
|
||||
├── config/ # Django configuration
|
||||
│ ├── django/ # Settings files
|
||||
│ └── settings/ # Modular settings
|
||||
├── templates/ # Django templates
|
||||
├── static/ # Static files
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Django 5.0+** - Web framework
|
||||
- **Django REST Framework** - API framework
|
||||
- **PostgreSQL** - Primary database
|
||||
- **Redis** - Caching and sessions
|
||||
- **UV** - Python package management
|
||||
- **Celery** - Background task processing
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- PostgreSQL 14+
|
||||
- Redis 6+
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **Environment configuration**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. **Database setup**
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. **Start development server**
|
||||
```bash
|
||||
uv run manage.py runserver
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
# Django
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email (optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
```
|
||||
|
||||
### Settings Structure
|
||||
|
||||
- `config/django/base.py` - Base settings
|
||||
- `config/django/local.py` - Development settings
|
||||
- `config/django/production.py` - Production settings
|
||||
- `config/django/test.py` - Test settings
|
||||
|
||||
## 📁 Apps Overview
|
||||
|
||||
### Core Apps
|
||||
|
||||
- **accounts** - User authentication and profile management
|
||||
- **parks** - Theme park models and operations
|
||||
- **rides** - Ride information and relationships
|
||||
- **core** - Shared utilities and base classes
|
||||
|
||||
### Support Apps
|
||||
|
||||
- **moderation** - Content moderation workflows
|
||||
- **location** - Geographic data and services
|
||||
- **media** - File upload and management
|
||||
- **email_service** - Email sending and templates
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
Base URL: `http://localhost:8000/api/`
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/login/` - User login
|
||||
- `POST /auth/logout/` - User logout
|
||||
- `POST /auth/register/` - User registration
|
||||
|
||||
### Parks
|
||||
- `GET /parks/` - List parks
|
||||
- `GET /parks/{id}/` - Park details
|
||||
- `POST /parks/` - Create park (admin)
|
||||
|
||||
### Rides
|
||||
- `GET /rides/` - List rides
|
||||
- `GET /rides/{id}/` - Ride details
|
||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run manage.py test
|
||||
|
||||
# Run specific app tests
|
||||
uv run manage.py test apps.parks
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run manage.py test
|
||||
uv run coverage report
|
||||
```
|
||||
|
||||
## 🔧 Management Commands
|
||||
|
||||
Custom management commands:
|
||||
|
||||
```bash
|
||||
# Import park data
|
||||
uv run manage.py import_parks data/parks.json
|
||||
|
||||
# Generate test data
|
||||
uv run manage.py generate_test_data
|
||||
|
||||
# Clean up expired sessions
|
||||
uv run manage.py clearsessions
|
||||
```
|
||||
|
||||
## 📊 Database
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||
- **Users** can create submissions and moderate content
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Create migrations
|
||||
uv run manage.py makemigrations
|
||||
|
||||
# Apply migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Show migration status
|
||||
uv run manage.py showmigrations
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- CORS configured for frontend integration
|
||||
- CSRF protection enabled
|
||||
- JWT token authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Input validation and sanitization
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Database query optimization
|
||||
- Redis caching for frequent queries
|
||||
- Background task processing with Celery
|
||||
- Database connection pooling
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
### Development Tools
|
||||
|
||||
- Django Debug Toolbar
|
||||
- Django Extensions
|
||||
- Silk profiler for performance analysis
|
||||
|
||||
### Logging
|
||||
|
||||
Logs are written to:
|
||||
- Console (development)
|
||||
- Files in `logs/` directory (production)
|
||||
- External logging service (production)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow Django coding standards
|
||||
2. Write tests for new features
|
||||
3. Update documentation
|
||||
4. Run linting: `uv run flake8 .`
|
||||
5. Format code: `uv run black .`
|
||||
6
backend/api/__init__.py
Normal file
6
backend/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Centralized API package for ThrillWiki.
|
||||
|
||||
This package contains all API endpoints organized by version.
|
||||
All API routes must be routed through this centralized structure.
|
||||
"""
|
||||
12
backend/api/urls.py
Normal file
12
backend/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Main API router for ThrillWiki.
|
||||
|
||||
This module routes all API requests to the appropriate version.
|
||||
Currently supports v1 API endpoints.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/", include("api.v1.urls")),
|
||||
]
|
||||
6
backend/api/v1/__init__.py
Normal file
6
backend/api/v1/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Version 1 API package for ThrillWiki.
|
||||
|
||||
This package contains all v1 API endpoints organized by domain.
|
||||
Domain-specific endpoints are in their respective subdirectories.
|
||||
"""
|
||||
6
backend/api/v1/auth/__init__.py
Normal file
6
backend/api/v1/auth/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Authentication API endpoints for ThrillWiki v1.
|
||||
|
||||
This package contains all authentication and authorization-related
|
||||
API functionality including login, logout, user management, and permissions.
|
||||
"""
|
||||
510
backend/api/v1/auth/serializers.py
Normal file
510
backend/api/v1/auth/serializers.py
Normal file
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
Auth domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains all serializers related to authentication, user accounts,
|
||||
profiles, top lists, and user statistics.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
# Import shared utilities
|
||||
|
||||
|
||||
class ModelChoices:
|
||||
"""Model choices utility class."""
|
||||
|
||||
@staticmethod
|
||||
def get_top_list_categories():
|
||||
"""Get top list category choices."""
|
||||
return [
|
||||
("RC", "Roller Coasters"),
|
||||
("DR", "Dark Rides"),
|
||||
("FR", "Flat Rides"),
|
||||
("WR", "Water Rides"),
|
||||
("PK", "Parks"),
|
||||
]
|
||||
|
||||
|
||||
# === AUTHENTICATION SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Example",
|
||||
summary="Example user response",
|
||||
description="A typical user object",
|
||||
value={
|
||||
"id": 1,
|
||||
"username": "john_doe",
|
||||
"email": "john@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"date_joined": "2024-01-01T12:00:00Z",
|
||||
"is_active": True,
|
||||
"avatar_url": "https://example.com/avatars/john.jpg",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserOutputSerializer(serializers.ModelSerializer):
|
||||
"""User serializer for API responses."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
"""Get user avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile.avatar:
|
||||
return obj.profile.avatar.url
|
||||
return None
|
||||
|
||||
|
||||
class LoginInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for user login."""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=254, help_text="Username or email address"
|
||||
)
|
||||
password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
return attrs
|
||||
|
||||
raise serializers.ValidationError("Must include username/email and password.")
|
||||
|
||||
|
||||
class LoginOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for successful login."""
|
||||
|
||||
token = serializers.CharField()
|
||||
user = UserOutputSerializer()
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
class SignupInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for user registration."""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserModel
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"password",
|
||||
"password_confirm",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"password": {"write_only": True},
|
||||
"email": {"required": True},
|
||||
}
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email is unique."""
|
||||
if UserModel.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return value
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique."""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match."""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data."""
|
||||
validated_data.pop("password_confirm", None)
|
||||
password = validated_data.pop("password")
|
||||
|
||||
# Use type: ignore for Django's create_user method which isn't properly typed
|
||||
user = UserModel.objects.create_user( # type: ignore[attr-defined]
|
||||
password=password, **validated_data
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class SignupOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for successful signup."""
|
||||
|
||||
token = serializers.CharField()
|
||||
user = UserOutputSerializer()
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
class PasswordResetInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password reset request."""
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email exists."""
|
||||
try:
|
||||
user = UserModel.objects.get(email=value)
|
||||
self.user = user
|
||||
return value
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal if email exists or not for security
|
||||
return value
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists."""
|
||||
if hasattr(self, "user"):
|
||||
# Create password reset token
|
||||
token = get_random_string(64)
|
||||
# Note: PasswordReset model would need to be imported
|
||||
# PasswordReset.objects.update_or_create(...)
|
||||
pass
|
||||
|
||||
|
||||
class PasswordResetOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password reset request."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
|
||||
|
||||
class PasswordChangeInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
max_length=128,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct."""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Old password is incorrect.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate new passwords match."""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Change user password."""
|
||||
user = self.context["request"].user
|
||||
# validated_data is guaranteed to exist after is_valid() is called
|
||||
new_password = self.validated_data["new_password"] # type: ignore[index]
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PasswordChangeOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for password change."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
|
||||
|
||||
class LogoutOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for logout."""
|
||||
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
class SocialProviderOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for social authentication providers."""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
authUrl = serializers.URLField()
|
||||
|
||||
|
||||
class AuthStatusOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for authentication status check."""
|
||||
|
||||
authenticated = serializers.BooleanField()
|
||||
user = UserOutputSerializer(allow_null=True)
|
||||
|
||||
|
||||
# === USER PROFILE SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"User Profile Example",
|
||||
summary="Example user profile response",
|
||||
description="A user's profile information",
|
||||
value={
|
||||
"id": 1,
|
||||
"profile_id": "1234",
|
||||
"display_name": "Coaster Enthusiast",
|
||||
"bio": "Love visiting theme parks around the world!",
|
||||
"pronouns": "they/them",
|
||||
"avatar_url": "/media/avatars/user1.jpg",
|
||||
"coaster_credits": 150,
|
||||
"dark_ride_credits": 45,
|
||||
"flat_ride_credits": 80,
|
||||
"water_ride_credits": 25,
|
||||
"user": {
|
||||
"username": "coaster_fan",
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class UserProfileOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for user profiles."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
profile_id = serializers.CharField()
|
||||
display_name = serializers.CharField()
|
||||
bio = serializers.CharField()
|
||||
pronouns = serializers.CharField()
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
twitter = serializers.URLField()
|
||||
instagram = serializers.URLField()
|
||||
youtube = serializers.URLField()
|
||||
discord = serializers.CharField()
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = serializers.IntegerField()
|
||||
dark_ride_credits = serializers.IntegerField()
|
||||
flat_ride_credits = serializers.IntegerField()
|
||||
water_ride_credits = serializers.IntegerField()
|
||||
|
||||
# User info (limited)
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj) -> str | None:
|
||||
return obj.get_avatar()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> dict:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"date_joined": obj.user.date_joined,
|
||||
}
|
||||
|
||||
|
||||
class UserProfileCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating user profiles."""
|
||||
|
||||
display_name = serializers.CharField(max_length=50)
|
||||
bio = serializers.CharField(max_length=500, allow_blank=True, default="")
|
||||
pronouns = serializers.CharField(max_length=50, allow_blank=True, default="")
|
||||
twitter = serializers.URLField(required=False, allow_blank=True)
|
||||
instagram = serializers.URLField(required=False, allow_blank=True)
|
||||
youtube = serializers.URLField(required=False, allow_blank=True)
|
||||
discord = serializers.CharField(max_length=100, allow_blank=True, default="")
|
||||
|
||||
|
||||
class UserProfileUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating user profiles."""
|
||||
|
||||
display_name = serializers.CharField(max_length=50, required=False)
|
||||
bio = serializers.CharField(max_length=500, allow_blank=True, required=False)
|
||||
pronouns = serializers.CharField(max_length=50, allow_blank=True, required=False)
|
||||
twitter = serializers.URLField(required=False, allow_blank=True)
|
||||
instagram = serializers.URLField(required=False, allow_blank=True)
|
||||
youtube = serializers.URLField(required=False, allow_blank=True)
|
||||
discord = serializers.CharField(max_length=100, allow_blank=True, required=False)
|
||||
coaster_credits = serializers.IntegerField(required=False)
|
||||
dark_ride_credits = serializers.IntegerField(required=False)
|
||||
flat_ride_credits = serializers.IntegerField(required=False)
|
||||
water_ride_credits = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
# === TOP LIST SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Example",
|
||||
summary="Example top list response",
|
||||
description="A user's top list of rides or parks",
|
||||
value={
|
||||
"id": 1,
|
||||
"title": "My Top 10 Roller Coasters",
|
||||
"category": "RC",
|
||||
"description": "My favorite roller coasters ranked",
|
||||
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-08-15T12:00:00Z",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for top lists."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
# User info
|
||||
user = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_user(self, obj) -> dict:
|
||||
return {
|
||||
"username": obj.user.username,
|
||||
"display_name": obj.user.get_display_name(),
|
||||
}
|
||||
|
||||
|
||||
class TopListCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating top lists."""
|
||||
|
||||
title = serializers.CharField(max_length=100)
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories())
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
|
||||
class TopListUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating top lists."""
|
||||
|
||||
title = serializers.CharField(max_length=100, required=False)
|
||||
category = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_top_list_categories(), required=False
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
|
||||
|
||||
# === TOP LIST ITEM SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Top List Item Example",
|
||||
summary="Example top list item response",
|
||||
description="An item in a user's top list",
|
||||
value={
|
||||
"id": 1,
|
||||
"rank": 1,
|
||||
"notes": "Amazing airtime and smooth ride",
|
||||
"object_name": "Steel Vengeance",
|
||||
"object_type": "Ride",
|
||||
"top_list": {"id": 1, "title": "My Top 10 Roller Coasters"},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class TopListItemOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for top list items."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
rank = serializers.IntegerField()
|
||||
notes = serializers.CharField()
|
||||
object_name = serializers.SerializerMethodField()
|
||||
object_type = serializers.SerializerMethodField()
|
||||
|
||||
# Top list info
|
||||
top_list = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_object_name(self, obj) -> str:
|
||||
"""Get the name of the referenced object."""
|
||||
# This would need to be implemented based on the generic foreign key
|
||||
return "Object Name" # Placeholder
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_object_type(self, obj) -> str:
|
||||
"""Get the type of the referenced object."""
|
||||
return obj.content_type.model_class().__name__
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_top_list(self, obj) -> dict:
|
||||
return {
|
||||
"id": obj.top_list.id,
|
||||
"title": obj.top_list.title,
|
||||
}
|
||||
|
||||
|
||||
class TopListItemCreateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for creating top list items."""
|
||||
|
||||
top_list_id = serializers.IntegerField()
|
||||
content_type_id = serializers.IntegerField()
|
||||
object_id = serializers.IntegerField()
|
||||
rank = serializers.IntegerField(min_value=1)
|
||||
notes = serializers.CharField(allow_blank=True, default="")
|
||||
|
||||
|
||||
class TopListItemUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating top list items."""
|
||||
|
||||
rank = serializers.IntegerField(min_value=1, required=False)
|
||||
notes = serializers.CharField(allow_blank=True, required=False)
|
||||
42
backend/api/v1/auth/urls.py
Normal file
42
backend/api/v1/auth/urls.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Auth domain URL Configuration for ThrillWiki API v1.
|
||||
|
||||
This module contains all URL patterns for authentication, user accounts,
|
||||
profiles, and top lists functionality.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
# Create router and register ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
|
||||
router.register(r"toplists", views.TopListViewSet, basename="top-list")
|
||||
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
|
||||
|
||||
urlpatterns = [
|
||||
# Authentication endpoints
|
||||
path("login/", views.LoginAPIView.as_view(), name="auth-login"),
|
||||
path("signup/", views.SignupAPIView.as_view(), name="auth-signup"),
|
||||
path("logout/", views.LogoutAPIView.as_view(), name="auth-logout"),
|
||||
path("user/", views.CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||
path(
|
||||
"password/reset/",
|
||||
views.PasswordResetAPIView.as_view(),
|
||||
name="auth-password-reset",
|
||||
),
|
||||
path(
|
||||
"password/change/",
|
||||
views.PasswordChangeAPIView.as_view(),
|
||||
name="auth-password-change",
|
||||
),
|
||||
path(
|
||||
"social/providers/",
|
||||
views.SocialProvidersAPIView.as_view(),
|
||||
name="auth-social-providers",
|
||||
),
|
||||
path("status/", views.AuthStatusAPIView.as_view(), name="auth-status"),
|
||||
# Include router URLs for ViewSets (profiles, top lists)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
624
backend/api/v1/auth/views.py
Normal file
624
backend/api/v1/auth/views.py
Normal file
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Auth domain views for ThrillWiki API v1.
|
||||
|
||||
This module contains all authentication-related API endpoints including
|
||||
login, signup, logout, password management, social authentication,
|
||||
user profiles, and top lists.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import authenticate, login, logout, get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.decorators import action
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||
from .serializers import (
|
||||
# Authentication serializers
|
||||
LoginInputSerializer,
|
||||
LoginOutputSerializer,
|
||||
SignupInputSerializer,
|
||||
SignupOutputSerializer,
|
||||
LogoutOutputSerializer,
|
||||
UserOutputSerializer,
|
||||
PasswordResetInputSerializer,
|
||||
PasswordResetOutputSerializer,
|
||||
PasswordChangeInputSerializer,
|
||||
PasswordChangeOutputSerializer,
|
||||
SocialProviderOutputSerializer,
|
||||
AuthStatusOutputSerializer,
|
||||
# User profile serializers
|
||||
UserProfileCreateInputSerializer,
|
||||
UserProfileUpdateInputSerializer,
|
||||
UserProfileOutputSerializer,
|
||||
# Top list serializers
|
||||
TopListCreateInputSerializer,
|
||||
TopListUpdateInputSerializer,
|
||||
TopListOutputSerializer,
|
||||
TopListItemCreateInputSerializer,
|
||||
TopListItemUpdateInputSerializer,
|
||||
TopListItemOutputSerializer,
|
||||
)
|
||||
|
||||
# Handle optional dependencies with fallback classes
|
||||
|
||||
|
||||
class FallbackTurnstileMixin:
|
||||
"""Fallback mixin if TurnstileMixin is not available."""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
pass
|
||||
|
||||
|
||||
# Try to import the real class, use fallback if not available
|
||||
try:
|
||||
from apps.accounts.mixins import TurnstileMixin
|
||||
except ImportError:
|
||||
TurnstileMixin = FallbackTurnstileMixin
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
# === AUTHENTICATION API VIEWS ===
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User login",
|
||||
description="Authenticate user with username/email and password.",
|
||||
request=LoginInputSerializer,
|
||||
responses={
|
||||
200: LoginOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class LoginAPIView(TurnstileMixin, APIView):
|
||||
"""API endpoint for user login."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = LoginInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Validate Turnstile if configured
|
||||
self.validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = LoginInputSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
# type: ignore[index]
|
||||
email_or_username = serializer.validated_data["username"]
|
||||
password = serializer.validated_data["password"] # type: ignore[index]
|
||||
|
||||
# Optimized user lookup: single query using Q objects
|
||||
user = None
|
||||
|
||||
# Single query to find user by email OR username
|
||||
try:
|
||||
if "@" in email_or_username:
|
||||
# Email-like input: try email first, then username as fallback
|
||||
user_obj = (
|
||||
UserModel.objects.select_related()
|
||||
.filter(
|
||||
Q(email=email_or_username) | Q(username=email_or_username)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
# Username-like input: try username first, then email as fallback
|
||||
user_obj = (
|
||||
UserModel.objects.select_related()
|
||||
.filter(
|
||||
Q(username=email_or_username) | Q(email=email_or_username)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if user_obj:
|
||||
user = authenticate(
|
||||
# type: ignore[attr-defined]
|
||||
request._request,
|
||||
username=user_obj.username,
|
||||
password=password,
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to original behavior
|
||||
user = authenticate(
|
||||
# type: ignore[attr-defined]
|
||||
request._request,
|
||||
username=email_or_username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if user:
|
||||
if user.is_active:
|
||||
login(request._request, user) # type: ignore[attr-defined]
|
||||
# Optimized token creation - get_or_create is atomic
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"token": token.key,
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Account is disabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Invalid credentials"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User registration",
|
||||
description="Register a new user account.",
|
||||
request=SignupInputSerializer,
|
||||
responses={
|
||||
201: SignupOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SignupAPIView(TurnstileMixin, APIView):
|
||||
"""API endpoint for user registration."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
serializer_class = SignupInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Validate Turnstile if configured
|
||||
self.validate_turnstile(request)
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = SignupInputSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
login(request._request, user) # type: ignore[attr-defined]
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
|
||||
response_serializer = SignupOutputSerializer(
|
||||
{
|
||||
"token": token.key,
|
||||
"user": user,
|
||||
"message": "Registration successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User logout",
|
||||
description="Logout the current user and invalidate their token.",
|
||||
responses={
|
||||
200: LogoutOutputSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class LogoutAPIView(APIView):
|
||||
"""API endpoint for user logout."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = LogoutOutputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
# Delete the token for token-based auth
|
||||
if hasattr(request.user, "auth_token"):
|
||||
request.user.auth_token.delete()
|
||||
|
||||
# Logout from session
|
||||
logout(request._request) # type: ignore[attr-defined]
|
||||
|
||||
response_serializer = LogoutOutputSerializer(
|
||||
{"message": "Logout successful"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
except Exception:
|
||||
return Response(
|
||||
{"error": "Logout failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get current user",
|
||||
description="Retrieve information about the currently authenticated user.",
|
||||
responses={
|
||||
200: UserOutputSerializer,
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class CurrentUserAPIView(APIView):
|
||||
"""API endpoint to get current user information."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = UserOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
serializer = UserOutputSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Request password reset",
|
||||
description="Send a password reset email to the user.",
|
||||
request=PasswordResetInputSerializer,
|
||||
responses={
|
||||
200: PasswordResetOutputSerializer,
|
||||
400: "Bad Request",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class PasswordResetAPIView(APIView):
|
||||
"""API endpoint to request password reset."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = PasswordResetInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordResetInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordResetOutputSerializer(
|
||||
{"detail": "Password reset email sent"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Change password",
|
||||
description="Change the current user's password.",
|
||||
request=PasswordChangeInputSerializer,
|
||||
responses={
|
||||
200: PasswordChangeOutputSerializer,
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class PasswordChangeAPIView(APIView):
|
||||
"""API endpoint to change password."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = PasswordChangeInputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
serializer = PasswordChangeInputSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
response_serializer = PasswordChangeOutputSerializer(
|
||||
{"detail": "Password changed successfully"}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get social providers",
|
||||
description="Retrieve available social authentication providers.",
|
||||
responses={200: "List of social providers"},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SocialProvidersAPIView(APIView):
|
||||
"""API endpoint to get available social authentication providers."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = SocialProviderOutputSerializer
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from django.core.cache import cache
|
||||
|
||||
site = get_current_site(request._request) # type: ignore[attr-defined]
|
||||
|
||||
# Cache key based on site and request host
|
||||
cache_key = (
|
||||
f"social_providers:{getattr(site, 'id', site.pk)}:{request.get_host()}"
|
||||
)
|
||||
|
||||
# Try to get from cache first (cache for 15 minutes)
|
||||
cached_providers = cache.get(cache_key)
|
||||
if cached_providers is not None:
|
||||
return Response(cached_providers)
|
||||
|
||||
providers_list = []
|
||||
|
||||
# Optimized query: filter by site and order by provider name
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
# Simplified provider name resolution - avoid expensive provider class loading
|
||||
provider_name = social_app.name or social_app.provider.title()
|
||||
|
||||
# Build auth URL efficiently
|
||||
auth_url = request.build_absolute_uri(
|
||||
f"/accounts/{social_app.provider}/login/"
|
||||
)
|
||||
|
||||
providers_list.append(
|
||||
{
|
||||
"id": social_app.provider,
|
||||
"name": provider_name,
|
||||
"authUrl": auth_url,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Skip if provider can't be loaded
|
||||
continue
|
||||
|
||||
# Serialize and cache the result
|
||||
serializer = SocialProviderOutputSerializer(providers_list, many=True)
|
||||
response_data = serializer.data
|
||||
|
||||
# Cache for 15 minutes (900 seconds)
|
||||
cache.set(cache_key, response_data, 900)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Check authentication status",
|
||||
description="Check if user is authenticated and return user data.",
|
||||
responses={200: AuthStatusOutputSerializer},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class AuthStatusAPIView(APIView):
|
||||
"""API endpoint to check authentication status."""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = AuthStatusOutputSerializer
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
if request.user.is_authenticated:
|
||||
response_data = {
|
||||
"authenticated": True,
|
||||
"user": request.user,
|
||||
}
|
||||
else:
|
||||
response_data = {
|
||||
"authenticated": False,
|
||||
"user": None,
|
||||
}
|
||||
|
||||
serializer = AuthStatusOutputSerializer(response_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# === USER PROFILE API VIEWS ===
|
||||
|
||||
|
||||
class UserProfileViewSet(ModelViewSet):
|
||||
"""ViewSet for managing user profiles."""
|
||||
|
||||
queryset = UserProfile.objects.select_related("user").all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return UserProfileCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return UserProfileUpdateInputSerializer
|
||||
return UserProfileOutputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter profiles based on user permissions."""
|
||||
if self.request.user.is_staff:
|
||||
return self.queryset
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def me(self, request):
|
||||
"""Get current user's profile."""
|
||||
try:
|
||||
profile = UserProfile.objects.get(user=request.user)
|
||||
serializer = self.get_serializer(profile)
|
||||
return Response(serializer.data)
|
||||
except UserProfile.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Profile not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
# === TOP LIST API VIEWS ===
|
||||
|
||||
|
||||
class TopListViewSet(ModelViewSet):
|
||||
"""ViewSet for managing user top lists."""
|
||||
|
||||
queryset = (
|
||||
TopList.objects.select_related("user").prefetch_related("items__ride").all()
|
||||
)
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return TopListCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return TopListUpdateInputSerializer
|
||||
return TopListOutputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter lists based on user permissions and visibility."""
|
||||
queryset = self.queryset
|
||||
|
||||
if not self.request.user.is_staff:
|
||||
# Non-staff users can only see their own lists and public lists
|
||||
queryset = queryset.filter(Q(user=self.request.user) | Q(is_public=True))
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the user when creating a top list."""
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def my_lists(self, request):
|
||||
"""Get current user's top lists."""
|
||||
lists = self.get_queryset().filter(user=request.user)
|
||||
serializer = self.get_serializer(lists, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""Duplicate a top list for the current user."""
|
||||
original_list = self.get_object()
|
||||
|
||||
# Create new list
|
||||
new_list = TopList.objects.create(
|
||||
user=request.user,
|
||||
name=f"Copy of {original_list.name}",
|
||||
description=original_list.description,
|
||||
is_public=False, # Duplicated lists are private by default
|
||||
)
|
||||
|
||||
# Copy all items
|
||||
for item in original_list.items.all():
|
||||
TopListItem.objects.create(
|
||||
top_list=new_list,
|
||||
ride=item.ride,
|
||||
position=item.position,
|
||||
notes=item.notes,
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(new_list)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class TopListItemViewSet(ModelViewSet):
|
||||
"""ViewSet for managing top list items."""
|
||||
|
||||
queryset = TopListItem.objects.select_related("top_list__user", "ride").all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return TopListItemCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return TopListItemUpdateInputSerializer
|
||||
return TopListItemOutputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter items based on user permissions."""
|
||||
queryset = self.queryset
|
||||
|
||||
if not self.request.user.is_staff:
|
||||
# Non-staff users can only see items from their own lists or public lists
|
||||
queryset = queryset.filter(
|
||||
Q(top_list__user=self.request.user) | Q(top_list__is_public=True)
|
||||
)
|
||||
|
||||
return queryset.order_by("top_list_id", "position")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Validate user can add items to the list."""
|
||||
top_list = serializer.validated_data["top_list"]
|
||||
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||
raise PermissionError("You can only add items to your own lists")
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Validate user can update items in the list."""
|
||||
top_list = serializer.instance.top_list
|
||||
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||
raise PermissionError("You can only update items in your own lists")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Validate user can delete items from the list."""
|
||||
if (
|
||||
instance.top_list.user != self.request.user
|
||||
and not self.request.user.is_staff
|
||||
):
|
||||
raise PermissionError("You can only delete items from your own lists")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def reorder(self, request):
|
||||
"""Reorder items in a top list."""
|
||||
top_list_id = request.data.get("top_list_id")
|
||||
item_ids = request.data.get("item_ids", [])
|
||||
|
||||
if not top_list_id or not item_ids:
|
||||
return Response(
|
||||
{"error": "top_list_id and item_ids are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
top_list = TopList.objects.get(id=top_list_id)
|
||||
if top_list.user != request.user and not request.user.is_staff:
|
||||
return Response(
|
||||
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Update positions
|
||||
for position, item_id in enumerate(item_ids, 1):
|
||||
TopListItem.objects.filter(id=item_id, top_list=top_list).update(
|
||||
position=position
|
||||
)
|
||||
|
||||
return Response({"success": True})
|
||||
|
||||
except TopList.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Top list not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
6
backend/api/v1/media/__init__.py
Normal file
6
backend/api/v1/media/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Media API endpoints for ThrillWiki v1.
|
||||
|
||||
This package contains all media-related API functionality including
|
||||
photo uploads, media management, and media-specific operations.
|
||||
"""
|
||||
222
backend/api/v1/media/serializers.py
Normal file
222
backend/api/v1/media/serializers.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Media domain serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for photo uploads, media management,
|
||||
and related media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
|
||||
# === MEDIA UPLOAD SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Photo Upload Example",
|
||||
summary="Example photo upload request",
|
||||
description="Upload a photo for a park or ride",
|
||||
value={
|
||||
"photo": "file_upload",
|
||||
"app_label": "parks",
|
||||
"model": "park",
|
||||
"object_id": 123,
|
||||
"caption": "Beautiful view of the park entrance",
|
||||
"alt_text": "Park entrance with landscaping",
|
||||
"is_primary": True,
|
||||
"photo_type": "general",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PhotoUploadInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo uploads."""
|
||||
|
||||
photo = serializers.ImageField(
|
||||
help_text="The image file to upload"
|
||||
)
|
||||
app_label = serializers.CharField(
|
||||
max_length=100,
|
||||
help_text="App label of the content object (e.g., 'parks', 'rides')"
|
||||
)
|
||||
model = serializers.CharField(
|
||||
max_length=100,
|
||||
help_text="Model name of the content object (e.g., 'park', 'ride')"
|
||||
)
|
||||
object_id = serializers.IntegerField(
|
||||
help_text="ID of the content object"
|
||||
)
|
||||
caption = serializers.CharField(
|
||||
max_length=500,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional caption for the photo"
|
||||
)
|
||||
alt_text = serializers.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional alt text for accessibility"
|
||||
)
|
||||
is_primary = serializers.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this should be the primary photo"
|
||||
)
|
||||
photo_type = serializers.CharField(
|
||||
max_length=50,
|
||||
default="general",
|
||||
required=False,
|
||||
help_text="Type of photo (for rides: 'general', 'on_ride', 'construction', etc.)"
|
||||
)
|
||||
|
||||
|
||||
class PhotoUploadOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo uploads."""
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.CharField()
|
||||
caption = serializers.CharField()
|
||||
alt_text = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
message = serializers.CharField()
|
||||
|
||||
|
||||
# === PHOTO DETAIL SERIALIZERS ===
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Photo Detail Example",
|
||||
summary="Example photo detail response",
|
||||
description="A photo with full details",
|
||||
value={
|
||||
"id": 1,
|
||||
"url": "https://example.com/media/photos/ride123.jpg",
|
||||
"thumbnail_url": "https://example.com/media/thumbnails/ride123_thumb.jpg",
|
||||
"caption": "Amazing view of Steel Vengeance",
|
||||
"alt_text": "Steel Vengeance roller coaster with blue sky",
|
||||
"is_primary": True,
|
||||
"uploaded_at": "2024-08-15T10:30:00Z",
|
||||
"uploaded_by": {
|
||||
"id": 1,
|
||||
"username": "coaster_photographer",
|
||||
"display_name": "Coaster Photographer",
|
||||
},
|
||||
"content_type": "Ride",
|
||||
"object_id": 123,
|
||||
"file_size": 2048576,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"format": "JPEG",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
class PhotoDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo details."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.URLField()
|
||||
thumbnail_url = serializers.URLField(required=False)
|
||||
caption = serializers.CharField()
|
||||
alt_text = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
uploaded_at = serializers.DateTimeField()
|
||||
content_type = serializers.CharField()
|
||||
object_id = serializers.IntegerField()
|
||||
|
||||
# File metadata
|
||||
file_size = serializers.IntegerField()
|
||||
width = serializers.IntegerField()
|
||||
height = serializers.IntegerField()
|
||||
format = serializers.CharField()
|
||||
|
||||
# Uploader info
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_uploaded_by(self, obj) -> dict:
|
||||
"""Get uploader information."""
|
||||
return {
|
||||
"id": obj.uploaded_by.id,
|
||||
"username": obj.uploaded_by.username,
|
||||
"display_name": getattr(
|
||||
obj.uploaded_by, "get_display_name", lambda: obj.uploaded_by.username
|
||||
)(),
|
||||
}
|
||||
|
||||
|
||||
class PhotoListOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for photo list view."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
url = serializers.URLField()
|
||||
thumbnail_url = serializers.URLField(required=False)
|
||||
caption = serializers.CharField()
|
||||
is_primary = serializers.BooleanField()
|
||||
uploaded_at = serializers.DateTimeField()
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_uploaded_by(self, obj) -> dict:
|
||||
"""Get uploader information."""
|
||||
return {
|
||||
"id": obj.uploaded_by.id,
|
||||
"username": obj.uploaded_by.username,
|
||||
}
|
||||
|
||||
|
||||
class PhotoUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating photos."""
|
||||
|
||||
caption = serializers.CharField(max_length=500, required=False, allow_blank=True)
|
||||
alt_text = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
# === MEDIA STATS SERIALIZERS ===
|
||||
|
||||
|
||||
class MediaStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for media statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
photos_by_content_type = serializers.DictField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
top_uploaders = serializers.ListField()
|
||||
storage_usage = serializers.DictField()
|
||||
|
||||
|
||||
# === BULK OPERATIONS SERIALIZERS ===
|
||||
|
||||
|
||||
class BulkPhotoActionInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for bulk photo actions."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of photo IDs to perform action on"
|
||||
)
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
('delete', 'Delete'),
|
||||
('approve', 'Approve'),
|
||||
('reject', 'Reject'),
|
||||
],
|
||||
help_text="Action to perform on selected photos"
|
||||
)
|
||||
|
||||
|
||||
class BulkPhotoActionOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for bulk photo actions."""
|
||||
|
||||
success_count = serializers.IntegerField()
|
||||
failed_count = serializers.IntegerField()
|
||||
errors = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
message = serializers.CharField()
|
||||
29
backend/api/v1/media/urls.py
Normal file
29
backend/api/v1/media/urls.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Media API URL configuration for ThrillWiki API v1.
|
||||
|
||||
This module contains URL patterns for media management endpoints
|
||||
including photo uploads, CRUD operations, and bulk actions.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
# Create router for ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"photos", views.PhotoViewSet, basename="photo")
|
||||
|
||||
urlpatterns = [
|
||||
# Photo upload endpoint
|
||||
path("upload/", views.PhotoUploadAPIView.as_view(), name="photo_upload"),
|
||||
|
||||
# Media statistics endpoint
|
||||
path("stats/", views.MediaStatsAPIView.as_view(), name="media_stats"),
|
||||
|
||||
# Bulk photo operations
|
||||
path("photos/bulk-action/", views.BulkPhotoActionAPIView.as_view(),
|
||||
name="bulk_photo_action"),
|
||||
|
||||
# Include router URLs for photo management (CRUD operations)
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
484
backend/api/v1/media/views.py
Normal file
484
backend/api/v1/media/views.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
Media API views for ThrillWiki API v1.
|
||||
|
||||
This module provides API endpoints for media management including
|
||||
photo uploads, captions, and media operations.
|
||||
Consolidated from apps.media.views with proper domain service integration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Union
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
# Import domain-specific models and services instead of generic Photo model
|
||||
from apps.parks.models import ParkPhoto, Park
|
||||
from apps.rides.models import RidePhoto, Ride
|
||||
from apps.parks.services import ParkMediaService
|
||||
from apps.rides.services import RideMediaService
|
||||
from .serializers import (
|
||||
PhotoUploadInputSerializer,
|
||||
PhotoUploadOutputSerializer,
|
||||
PhotoDetailOutputSerializer,
|
||||
PhotoUpdateInputSerializer,
|
||||
PhotoListOutputSerializer,
|
||||
MediaStatsOutputSerializer,
|
||||
BulkPhotoActionInputSerializer,
|
||||
BulkPhotoActionOutputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Upload photo",
|
||||
description="Upload a photo and associate it with a content object (park, ride, etc.)",
|
||||
request=PhotoUploadInputSerializer,
|
||||
responses={
|
||||
201: PhotoUploadOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Media"],
|
||||
),
|
||||
)
|
||||
class PhotoUploadAPIView(APIView):
|
||||
"""API endpoint for photo uploads."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Upload a photo and associate it with a content object."""
|
||||
try:
|
||||
serializer = PhotoUploadInputSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
|
||||
# Get content object
|
||||
try:
|
||||
content_type = ContentType.objects.get(
|
||||
app_label=validated_data["app_label"], model=validated_data["model"]
|
||||
)
|
||||
content_object = content_type.get_object_for_this_type(
|
||||
pk=validated_data["object_id"]
|
||||
)
|
||||
except ContentType.DoesNotExist:
|
||||
return Response(
|
||||
{
|
||||
"error": f"Invalid content type: {validated_data['app_label']}.{validated_data['model']}"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except content_type.model_class().DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Content object not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Determine which domain service to use based on content object
|
||||
if hasattr(content_object, '_meta') and content_object._meta.app_label == 'parks':
|
||||
# Check permissions for park photos
|
||||
if not request.user.has_perm("parks.add_parkphoto"):
|
||||
return Response(
|
||||
{"error": "You do not have permission to upload park photos"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Create park photo using park media service
|
||||
photo = ParkMediaService.upload_photo(
|
||||
park=content_object,
|
||||
image_file=validated_data["photo"],
|
||||
user=request.user,
|
||||
caption=validated_data.get("caption", ""),
|
||||
alt_text=validated_data.get("alt_text", ""),
|
||||
is_primary=validated_data.get("is_primary", False),
|
||||
)
|
||||
elif hasattr(content_object, '_meta') and content_object._meta.app_label == 'rides':
|
||||
# Check permissions for ride photos
|
||||
if not request.user.has_perm("rides.add_ridephoto"):
|
||||
return Response(
|
||||
{"error": "You do not have permission to upload ride photos"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Create ride photo using ride media service
|
||||
photo = RideMediaService.upload_photo(
|
||||
ride=content_object,
|
||||
image_file=validated_data["photo"],
|
||||
user=request.user,
|
||||
caption=validated_data.get("caption", ""),
|
||||
alt_text=validated_data.get("alt_text", ""),
|
||||
is_primary=validated_data.get("is_primary", False),
|
||||
photo_type=validated_data.get("photo_type", "general"),
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": f"Unsupported content type for media upload: {content_object._meta.label}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
response_serializer = PhotoUploadOutputSerializer(
|
||||
{
|
||||
"id": photo.id,
|
||||
"url": photo.image.url,
|
||||
"caption": photo.caption,
|
||||
"alt_text": photo.alt_text,
|
||||
"is_primary": photo.is_primary,
|
||||
"message": "Photo uploaded successfully",
|
||||
}
|
||||
)
|
||||
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in photo upload: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"An error occurred while uploading the photo: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List photos",
|
||||
description="Retrieve a list of photos with optional filtering",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="content_type",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by content type (e.g., 'parks.park', 'rides.ride')",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="object_id",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by object ID",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="is_primary",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by primary photos only",
|
||||
),
|
||||
],
|
||||
responses={200: PhotoListOutputSerializer(many=True)},
|
||||
tags=["Media"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get photo details",
|
||||
description="Retrieve detailed information about a specific photo",
|
||||
responses={
|
||||
200: PhotoDetailOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Media"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update photo",
|
||||
description="Update photo information (caption, alt text, etc.)",
|
||||
request=PhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: PhotoDetailOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Media"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete photo",
|
||||
description="Delete a photo (only by owner or admin)",
|
||||
responses={
|
||||
204: None,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Media"],
|
||||
),
|
||||
set_primary=extend_schema(
|
||||
summary="Set photo as primary",
|
||||
description="Set this photo as the primary photo for its content object",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Media"],
|
||||
),
|
||||
)
|
||||
class PhotoViewSet(ModelViewSet):
|
||||
"""ViewSet for managing photos across domains."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
"""Get queryset combining photos from all domains."""
|
||||
# Combine park and ride photos
|
||||
park_photos = ParkPhoto.objects.select_related('uploaded_by', 'park')
|
||||
ride_photos = RidePhoto.objects.select_related('uploaded_by', 'ride')
|
||||
|
||||
# Apply filters
|
||||
content_type = self.request.query_params.get('content_type')
|
||||
object_id = self.request.query_params.get('object_id')
|
||||
is_primary = self.request.query_params.get('is_primary')
|
||||
|
||||
if content_type == 'parks.park':
|
||||
queryset = park_photos
|
||||
if object_id:
|
||||
queryset = queryset.filter(park_id=object_id)
|
||||
elif content_type == 'rides.ride':
|
||||
queryset = ride_photos
|
||||
if object_id:
|
||||
queryset = queryset.filter(ride_id=object_id)
|
||||
else:
|
||||
# Return combined queryset (this is complex due to different models)
|
||||
# For now, return park photos as default - in production might need Union
|
||||
queryset = park_photos
|
||||
|
||||
if is_primary is not None:
|
||||
is_primary_bool = is_primary.lower() in ('true', '1', 'yes')
|
||||
queryset = queryset.filter(is_primary=is_primary_bool)
|
||||
|
||||
return queryset.order_by('-uploaded_at')
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return PhotoListOutputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return PhotoUpdateInputSerializer
|
||||
return PhotoDetailOutputSerializer
|
||||
|
||||
def get_object(self):
|
||||
"""Get photo object from either domain."""
|
||||
photo_id = self.kwargs.get('id')
|
||||
|
||||
# Try to find in park photos first
|
||||
try:
|
||||
return ParkPhoto.objects.select_related('uploaded_by', 'park').get(id=photo_id)
|
||||
except ParkPhoto.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Try ride photos
|
||||
try:
|
||||
return RidePhoto.objects.select_related('uploaded_by', 'ride').get(id=photo_id)
|
||||
except RidePhoto.DoesNotExist:
|
||||
pass
|
||||
|
||||
raise Http404("Photo not found")
|
||||
|
||||
def update(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Update photo details."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||
raise PermissionDenied("You can only edit your own photos")
|
||||
|
||||
serializer = self.get_serializer(data=request.data, partial=True)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update fields
|
||||
for field, value in serializer.validated_data.items():
|
||||
setattr(photo, field, value)
|
||||
|
||||
photo.save()
|
||||
|
||||
# Return updated photo details
|
||||
response_serializer = PhotoDetailOutputSerializer(photo)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
def destroy(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Delete a photo."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||
raise PermissionDenied("You can only delete your own photos")
|
||||
|
||||
photo.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def set_primary(self, request: Request, id=None) -> Response:
|
||||
"""Set this photo as primary for its content object."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||
raise PermissionDenied("You can only modify your own photos")
|
||||
|
||||
# Use appropriate service based on photo type
|
||||
if isinstance(photo, ParkPhoto):
|
||||
ParkMediaService.set_primary_photo(photo.park, photo)
|
||||
elif isinstance(photo, RidePhoto):
|
||||
RideMediaService.set_primary_photo(photo.ride, photo)
|
||||
|
||||
return Response({
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo_id": photo.id,
|
||||
"is_primary": True
|
||||
})
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
summary="Get media statistics",
|
||||
description="Retrieve statistics about photos and media usage",
|
||||
responses={200: MediaStatsOutputSerializer},
|
||||
tags=["Media"],
|
||||
),
|
||||
)
|
||||
class MediaStatsAPIView(APIView):
|
||||
"""API endpoint for media statistics."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get media statistics."""
|
||||
from django.db.models import Count
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Count photos by type
|
||||
park_photo_count = ParkPhoto.objects.count()
|
||||
ride_photo_count = RidePhoto.objects.count()
|
||||
total_photos = park_photo_count + ride_photo_count
|
||||
|
||||
# Recent uploads (last 30 days)
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
recent_park_uploads = ParkPhoto.objects.filter(
|
||||
uploaded_at__gte=thirty_days_ago).count()
|
||||
recent_ride_uploads = RidePhoto.objects.filter(
|
||||
uploaded_at__gte=thirty_days_ago).count()
|
||||
recent_uploads = recent_park_uploads + recent_ride_uploads
|
||||
|
||||
# Top uploaders
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
|
||||
# This is a simplified version - in production might need more complex aggregation
|
||||
top_uploaders = []
|
||||
|
||||
stats = MediaStatsOutputSerializer({
|
||||
"total_photos": total_photos,
|
||||
"photos_by_content_type": {
|
||||
"parks": park_photo_count,
|
||||
"rides": ride_photo_count,
|
||||
},
|
||||
"recent_uploads": recent_uploads,
|
||||
"top_uploaders": top_uploaders,
|
||||
"storage_usage": {
|
||||
"total_size": 0, # Would need to calculate from file sizes
|
||||
"average_size": 0,
|
||||
}
|
||||
})
|
||||
|
||||
return Response(stats.data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Bulk photo actions",
|
||||
description="Perform bulk actions on multiple photos (delete, approve, etc.)",
|
||||
request=BulkPhotoActionInputSerializer,
|
||||
responses={
|
||||
200: BulkPhotoActionOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Media"],
|
||||
),
|
||||
)
|
||||
class BulkPhotoActionAPIView(APIView):
|
||||
"""API endpoint for bulk photo operations."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Perform bulk action on photos."""
|
||||
serializer = BulkPhotoActionInputSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
photo_ids = serializer.validated_data['photo_ids']
|
||||
action = serializer.validated_data['action']
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
errors = []
|
||||
|
||||
for photo_id in photo_ids:
|
||||
try:
|
||||
# Find photo in either domain
|
||||
photo = None
|
||||
try:
|
||||
photo = ParkPhoto.objects.get(id=photo_id)
|
||||
except ParkPhoto.DoesNotExist:
|
||||
try:
|
||||
photo = RidePhoto.objects.get(id=photo_id)
|
||||
except RidePhoto.DoesNotExist:
|
||||
errors.append(f"Photo {photo_id} not found")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Check permissions
|
||||
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||
errors.append(f"No permission for photo {photo_id}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Perform action
|
||||
if action == 'delete':
|
||||
photo.delete()
|
||||
success_count += 1
|
||||
elif action == 'approve':
|
||||
if hasattr(photo, 'is_approved'):
|
||||
photo.is_approved = True
|
||||
photo.save()
|
||||
success_count += 1
|
||||
else:
|
||||
errors.append(f"Photo {photo_id} does not support approval")
|
||||
failed_count += 1
|
||||
elif action == 'reject':
|
||||
if hasattr(photo, 'is_approved'):
|
||||
photo.is_approved = False
|
||||
photo.save()
|
||||
success_count += 1
|
||||
else:
|
||||
errors.append(f"Photo {photo_id} does not support approval")
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing photo {photo_id}: {str(e)}")
|
||||
failed_count += 1
|
||||
|
||||
response_data = BulkPhotoActionOutputSerializer({
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"errors": errors,
|
||||
"message": f"Bulk {action} completed: {success_count} successful, {failed_count} failed"
|
||||
})
|
||||
|
||||
return Response(response_data.data)
|
||||
6
backend/api/v1/parks/__init__.py
Normal file
6
backend/api/v1/parks/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Parks API endpoints for ThrillWiki v1.
|
||||
|
||||
This package contains all park-related API functionality including
|
||||
park management, park photos, and park-specific operations.
|
||||
"""
|
||||
116
backend/api/v1/parks/serializers.py
Normal file
116
backend/api/v1/parks/serializers.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Park media serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for park-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
|
||||
class ParkPhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for park photos."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
file_size = serializers.ReadOnlyField()
|
||||
dimensions = serializers.ReadOnlyField()
|
||||
park_slug = serializers.CharField(source="park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating park photos."""
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class ParkPhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for park photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ParkPhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ParkPhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class ParkPhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for park photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
15
backend/api/v1/parks/urls.py
Normal file
15
backend/api/v1/parks/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Park API URLs for ThrillWiki API v1.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ParkPhotoViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"photos", ParkPhotoViewSet, basename="park-photo")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
274
backend/api/v1/parks/views.py
Normal file
274
backend/api/v1/parks/views.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Park API views for ThrillWiki API v1.
|
||||
|
||||
This module contains consolidated park photo viewset for the centralized API structure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.parks.models import ParkPhoto
|
||||
from apps.parks.services import ParkMediaService
|
||||
|
||||
from .serializers import (
|
||||
ParkPhotoOutputSerializer,
|
||||
ParkPhotoCreateInputSerializer,
|
||||
ParkPhotoUpdateInputSerializer,
|
||||
ParkPhotoListOutputSerializer,
|
||||
ParkPhotoApprovalInputSerializer,
|
||||
ParkPhotoStatsOutputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List park photos",
|
||||
description="Retrieve a paginated list of park photos with filtering capabilities.",
|
||||
responses={200: ParkPhotoListOutputSerializer(many=True)},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Upload park photo",
|
||||
description="Upload a new photo for a park. Requires authentication.",
|
||||
request=ParkPhotoCreateInputSerializer,
|
||||
responses={
|
||||
201: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get park photo details",
|
||||
description="Retrieve detailed information about a specific park photo.",
|
||||
responses={
|
||||
200: ParkPhotoOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update park photo",
|
||||
description="Update park photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=ParkPhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update park photo",
|
||||
description="Partially update park photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=ParkPhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: ParkPhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete park photo",
|
||||
description="Delete a park photo. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Park Media"],
|
||||
),
|
||||
)
|
||||
class ParkPhotoViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing park photos.
|
||||
|
||||
Provides CRUD operations for park photos with proper permission checking.
|
||||
Uses ParkMediaService for business logic operations.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get photos for the current park with optimized queries."""
|
||||
return (
|
||||
ParkPhoto.objects.select_related("park", "park__operator", "uploaded_by")
|
||||
.filter(park_id=self.kwargs.get("park_pk"))
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return ParkPhotoListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return ParkPhotoCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return ParkPhotoUpdateInputSerializer
|
||||
else:
|
||||
return ParkPhotoOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new park photo using ParkMediaService."""
|
||||
park_id = self.kwargs.get("park_pk")
|
||||
if not park_id:
|
||||
raise ValidationError("Park ID is required")
|
||||
|
||||
try:
|
||||
# Use the service to create the photo with proper business logic
|
||||
photo = ParkMediaService.create_photo(
|
||||
park_id=park_id,
|
||||
uploaded_by=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating park photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update park photo with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||
|
||||
# Handle primary photo logic using service
|
||||
if serializer.validated_data.get("is_primary", False):
|
||||
try:
|
||||
ParkMediaService.set_primary_photo(
|
||||
park_id=instance.park_id, photo_id=instance.id
|
||||
)
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete park photo with permission checking."""
|
||||
# Check permissions
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
ParkMediaService.delete_photo(instance.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting park photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary(self, request, **kwargs):
|
||||
"""Set this photo as the primary photo for the park."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||
raise PermissionDenied(
|
||||
"You can only modify your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
ParkMediaService.set_primary_photo(park_id=photo.park_id, photo_id=photo.id)
|
||||
|
||||
# Refresh the photo instance
|
||||
photo.refresh_from_db()
|
||||
serializer = self.get_serializer(photo)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo": serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def bulk_approve(self, request, **kwargs):
|
||||
"""Bulk approve or reject multiple photos (admin only)."""
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied("Only administrators can approve photos.")
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
photo_ids = serializer.validated_data["photo_ids"]
|
||||
approve = serializer.validated_data["approve"]
|
||||
park_id = self.kwargs.get("park_pk")
|
||||
|
||||
try:
|
||||
# Filter photos to only those belonging to this park
|
||||
photos = ParkPhoto.objects.filter(id__in=photo_ids, park_id=park_id)
|
||||
|
||||
updated_count = photos.update(is_approved=approve)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get photo statistics for the park."""
|
||||
park_id = self.kwargs.get("park_pk")
|
||||
|
||||
try:
|
||||
stats = ParkMediaService.get_photo_stats(park_id=park_id)
|
||||
serializer = ParkPhotoStatsOutputSerializer(stats)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting park photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
6
backend/api/v1/rides/__init__.py
Normal file
6
backend/api/v1/rides/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Rides API endpoints for ThrillWiki v1.
|
||||
|
||||
This package contains all ride-related API functionality including
|
||||
ride management, ride photos, and ride-specific operations.
|
||||
"""
|
||||
146
backend/api/v1/rides/serializers.py
Normal file
146
backend/api/v1/rides/serializers.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Ride media serializers for ThrillWiki API v1.
|
||||
|
||||
This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
|
||||
class RidePhotoOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride photos."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
file_size = serializers.ReadOnlyField()
|
||||
dimensions = serializers.ReadOnlyField()
|
||||
ride_slug = serializers.CharField(source="ride.slug", read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_slug = serializers.CharField(source="ride.park.slug", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"photo_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"date_taken",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"uploaded_by_username",
|
||||
"file_size",
|
||||
"dimensions",
|
||||
"ride_slug",
|
||||
"ride_name",
|
||||
"park_slug",
|
||||
"park_name",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride photos."""
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"caption",
|
||||
"alt_text",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
]
|
||||
|
||||
|
||||
class RidePhotoListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride photo lists."""
|
||||
|
||||
uploaded_by_username = serializers.CharField(
|
||||
source="uploaded_by.username", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RidePhoto
|
||||
fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"photo_type",
|
||||
"is_primary",
|
||||
"is_approved",
|
||||
"created_at",
|
||||
"uploaded_by_username",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RidePhotoApprovalInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for photo approval operations."""
|
||||
|
||||
photo_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), help_text="List of photo IDs to approve"
|
||||
)
|
||||
approve = serializers.BooleanField(
|
||||
default=True, help_text="Whether to approve (True) or reject (False) the photos"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride photo statistics."""
|
||||
|
||||
total_photos = serializers.IntegerField()
|
||||
approved_photos = serializers.IntegerField()
|
||||
pending_photos = serializers.IntegerField()
|
||||
has_primary = serializers.BooleanField()
|
||||
recent_uploads = serializers.IntegerField()
|
||||
by_type = serializers.DictField(
|
||||
child=serializers.IntegerField(), help_text="Photo counts by type"
|
||||
)
|
||||
|
||||
|
||||
class RidePhotoTypeFilterSerializer(serializers.Serializer):
|
||||
"""Serializer for filtering photos by type."""
|
||||
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
required=False,
|
||||
help_text="Filter photos by type",
|
||||
)
|
||||
15
backend/api/v1/rides/urls.py
Normal file
15
backend/api/v1/rides/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Ride API URLs for ThrillWiki API v1.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import RidePhotoViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"photos", RidePhotoViewSet, basename="ride-photo")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
274
backend/api/v1/rides/views.py
Normal file
274
backend/api/v1/rides/views.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Ride API views for ThrillWiki API v1.
|
||||
|
||||
This module contains consolidated ride photo viewset for the centralized API structure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.rides.models import RidePhoto
|
||||
from apps.rides.services import RideMediaService
|
||||
|
||||
from .serializers import (
|
||||
RidePhotoOutputSerializer,
|
||||
RidePhotoCreateInputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
RidePhotoListOutputSerializer,
|
||||
RidePhotoApprovalInputSerializer,
|
||||
RidePhotoStatsOutputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride photos",
|
||||
description="Retrieve a paginated list of ride photos with filtering capabilities.",
|
||||
responses={200: RidePhotoListOutputSerializer(many=True)},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Upload ride photo",
|
||||
description="Upload a new photo for a ride. Requires authentication.",
|
||||
request=RidePhotoCreateInputSerializer,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ride photo details",
|
||||
description="Retrieve detailed information about a specific ride photo.",
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update ride photo",
|
||||
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update ride photo",
|
||||
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete ride photo",
|
||||
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Media"],
|
||||
),
|
||||
)
|
||||
class RidePhotoViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing ride photos.
|
||||
|
||||
Provides CRUD operations for ride photos with proper permission checking.
|
||||
Uses RideMediaService for business logic operations.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get photos for the current ride with optimized queries."""
|
||||
return (
|
||||
RidePhoto.objects.select_related("ride", "ride__park", "uploaded_by")
|
||||
.filter(ride_id=self.kwargs.get("ride_pk"))
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return RidePhotoListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return RidePhotoCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return RidePhotoUpdateInputSerializer
|
||||
else:
|
||||
return RidePhotoOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new ride photo using RideMediaService."""
|
||||
ride_id = self.kwargs.get("ride_pk")
|
||||
if not ride_id:
|
||||
raise ValidationError("Ride ID is required")
|
||||
|
||||
try:
|
||||
# Use the service to create the photo with proper business logic
|
||||
photo = RideMediaService.create_photo(
|
||||
ride_id=ride_id,
|
||||
uploaded_by=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update ride photo with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||
|
||||
# Handle primary photo logic using service
|
||||
if serializer.validated_data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(
|
||||
ride_id=instance.ride_id, photo_id=instance.id
|
||||
)
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete ride photo with permission checking."""
|
||||
# Check permissions
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by or self.request.user.is_staff
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
RideMediaService.delete_photo(instance.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary(self, request, **kwargs):
|
||||
"""Set this photo as the primary photo for the ride."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions
|
||||
if not (request.user == photo.uploaded_by or request.user.is_staff):
|
||||
raise PermissionDenied(
|
||||
"You can only modify your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride_id=photo.ride_id, photo_id=photo.id)
|
||||
|
||||
# Refresh the photo instance
|
||||
photo.refresh_from_db()
|
||||
serializer = self.get_serializer(photo)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo": serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def bulk_approve(self, request, **kwargs):
|
||||
"""Bulk approve or reject multiple photos (admin only)."""
|
||||
if not request.user.is_staff:
|
||||
raise PermissionDenied("Only administrators can approve photos.")
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
photo_ids = serializer.validated_data["photo_ids"]
|
||||
approve = serializer.validated_data["approve"]
|
||||
ride_id = self.kwargs.get("ride_pk")
|
||||
|
||||
try:
|
||||
# Filter photos to only those belonging to this ride
|
||||
photos = RidePhoto.objects.filter(id__in=photo_ids, ride_id=ride_id)
|
||||
|
||||
updated_count = photos.update(is_approved=approve)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get photo statistics for the ride."""
|
||||
ride_id = self.kwargs.get("ride_pk")
|
||||
|
||||
try:
|
||||
stats = RideMediaService.get_photo_stats(ride_id=ride_id)
|
||||
serializer = RidePhotoStatsOutputSerializer(stats)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
18
backend/api/v1/urls.py
Normal file
18
backend/api/v1/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Version 1 API URL router for ThrillWiki.
|
||||
|
||||
This module routes API requests to domain-specific endpoints.
|
||||
All domain endpoints are organized in their respective subdirectories.
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
# Domain-specific API endpoints
|
||||
path("rides/", include("api.v1.rides.urls")),
|
||||
path("parks/", include("api.v1.parks.urls")),
|
||||
path("auth/", include("api.v1.auth.urls")),
|
||||
# Media endpoints (for photo management)
|
||||
# Will be consolidated from the various media implementations
|
||||
path("media/", include("api.v1.media.urls")),
|
||||
]
|
||||
6
backend/apps/__init__.py
Normal file
6
backend/apps/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Django apps package.
|
||||
|
||||
This directory contains all Django applications for the ThrillWiki backend.
|
||||
Each app is self-contained and follows Django best practices.
|
||||
"""
|
||||
@@ -6,18 +6,19 @@ from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
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")
|
||||
)
|
||||
245
backend/apps/accounts/serializers.py
Normal file
245
backend/apps/accounts/serializers.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from .models import User, PasswordReset
|
||||
from apps.email_service.services import EmailService
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
User serializer for API responses
|
||||
"""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get user avatar URL"""
|
||||
if hasattr(obj, "profile") and obj.profile.avatar:
|
||||
return obj.profile.avatar.url
|
||||
return None
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for user login
|
||||
"""
|
||||
|
||||
username = serializers.CharField(
|
||||
max_length=254, help_text="Username or email address"
|
||||
)
|
||||
password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get("username")
|
||||
password = attrs.get("password")
|
||||
|
||||
if username and password:
|
||||
return attrs
|
||||
|
||||
raise serializers.ValidationError("Must include username/email and password.")
|
||||
|
||||
|
||||
class SignupSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for user registration
|
||||
"""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"password",
|
||||
"password_confirm",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"password": {"write_only": True},
|
||||
"email": {"required": True},
|
||||
}
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email is unique"""
|
||||
if UserModel.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return value
|
||||
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique"""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate passwords match"""
|
||||
password = attrs.get("password")
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create user with validated data"""
|
||||
validated_data.pop("password_confirm", None)
|
||||
password = validated_data.pop("password")
|
||||
|
||||
user = UserModel.objects.create(**validated_data)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PasswordResetSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password reset request
|
||||
"""
|
||||
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
"""Validate email exists"""
|
||||
try:
|
||||
user = UserModel.objects.get(email=value)
|
||||
self.user = user
|
||||
return value
|
||||
except UserModel.DoesNotExist:
|
||||
# Don't reveal if email exists or not for security
|
||||
return value
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Send password reset email if user exists"""
|
||||
if hasattr(self, "user"):
|
||||
# Create password reset token
|
||||
token = get_random_string(64)
|
||||
PasswordReset.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
"token": token,
|
||||
"expires_at": timezone.now() + timedelta(hours=24),
|
||||
"used": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Send reset email
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
site = get_current_site(request)
|
||||
reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/"
|
||||
|
||||
context = {
|
||||
"user": self.user,
|
||||
"reset_url": reset_url,
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_reset.html", context
|
||||
)
|
||||
|
||||
EmailService.send_email(
|
||||
to=getattr(self.user, "email", None),
|
||||
subject="Reset your password",
|
||||
text=f"Click the link to reset your password: {reset_url}",
|
||||
site=site,
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
|
||||
class PasswordChangeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for password change
|
||||
"""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
max_length=128, validators=[validate_password], style={"input_type": "password"}
|
||||
)
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct"""
|
||||
user = self.context["request"].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Old password is incorrect.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate new passwords match"""
|
||||
new_password = attrs.get("new_password")
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""Change user password"""
|
||||
user = self.context["request"].user
|
||||
new_password = (
|
||||
self.initial_data.get("new_password") if self.initial_data else None
|
||||
)
|
||||
|
||||
if new_password is None:
|
||||
raise serializers.ValidationError("New password is required.")
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class SocialProviderSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for social authentication providers
|
||||
"""
|
||||
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
login_url = serializers.URLField()
|
||||
@@ -5,7 +5,8 @@ from django.db import transaction
|
||||
from django.core.files import File
|
||||
from django.core.files.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.
|
||||
"""
|
||||
19
backend/apps/api/apps.py
Normal file
19
backend/apps/api/apps.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
ThrillWiki API App Configuration
|
||||
|
||||
This module contains the Django app configuration for the centralized API application.
|
||||
All API endpoints are routed through this app following the pattern:
|
||||
- Frontend: /api/{endpoint}
|
||||
- Vite Proxy: /api/ -> /api/v1/
|
||||
- Django: backend/api/v1/{endpoint}
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
"""Configuration for the centralized API app."""
|
||||
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
||||
verbose_name = "ThrillWiki API"
|
||||
5
backend/apps/api/urls.py
Normal file
5
backend/apps/api/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/", include("apps.api.v1.urls")),
|
||||
]
|
||||
6
backend/apps/api/v1/__init__.py
Normal file
6
backend/apps/api/v1/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
ThrillWiki API v1.
|
||||
|
||||
This module provides the version 1 REST API for ThrillWiki, consolidating
|
||||
all endpoints under a unified, well-documented API structure.
|
||||
"""
|
||||
3
backend/apps/api/v1/accounts/__init__.py
Normal file
3
backend/apps/api/v1/accounts/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Accounts API module for user profile and top list management.
|
||||
"""
|
||||
18
backend/apps/api/v1/accounts/urls.py
Normal file
18
backend/apps/api/v1/accounts/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Accounts API URL Configuration
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import views
|
||||
|
||||
# Create router and register ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"profiles", views.UserProfileViewSet, basename="user-profile")
|
||||
router.register(r"toplists", views.TopListViewSet, basename="top-list")
|
||||
router.register(r"toplist-items", views.TopListItemViewSet, basename="top-list-item")
|
||||
|
||||
urlpatterns = [
|
||||
# Include router URLs for ViewSets
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
204
backend/apps/api/v1/accounts/views.py
Normal file
204
backend/apps/api/v1/accounts/views.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Accounts API ViewSets for user profiles and top lists.
|
||||
"""
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
||||
from ..serializers import (
|
||||
UserProfileCreateInputSerializer,
|
||||
UserProfileUpdateInputSerializer,
|
||||
UserProfileOutputSerializer,
|
||||
TopListCreateInputSerializer,
|
||||
TopListUpdateInputSerializer,
|
||||
TopListOutputSerializer,
|
||||
TopListItemCreateInputSerializer,
|
||||
TopListItemUpdateInputSerializer,
|
||||
TopListItemOutputSerializer,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserProfileViewSet(ModelViewSet):
|
||||
"""ViewSet for managing user profiles."""
|
||||
|
||||
queryset = UserProfile.objects.select_related("user").all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return UserProfileCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return UserProfileUpdateInputSerializer
|
||||
return UserProfileOutputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter profiles based on user permissions."""
|
||||
if self.request.user.is_staff:
|
||||
return self.queryset
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def me(self, request):
|
||||
"""Get current user's profile."""
|
||||
try:
|
||||
profile = UserProfile.objects.get(user=request.user)
|
||||
serializer = self.get_serializer(profile)
|
||||
return Response(serializer.data)
|
||||
except UserProfile.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Profile not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class TopListViewSet(ModelViewSet):
|
||||
"""ViewSet for managing user top lists."""
|
||||
|
||||
queryset = (
|
||||
TopList.objects.select_related("user").prefetch_related("items__ride").all()
|
||||
)
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return TopListCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return TopListUpdateInputSerializer
|
||||
return TopListOutputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter lists based on user permissions and visibility."""
|
||||
queryset = self.queryset
|
||||
|
||||
if not self.request.user.is_staff:
|
||||
# Non-staff users can only see their own lists and public lists
|
||||
queryset = queryset.filter(Q(user=self.request.user) | Q(is_public=True))
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the user when creating a top list."""
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def my_lists(self, request):
|
||||
"""Get current user's top lists."""
|
||||
lists = self.get_queryset().filter(user=request.user)
|
||||
serializer = self.get_serializer(lists, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""Duplicate a top list for the current user."""
|
||||
original_list = self.get_object()
|
||||
|
||||
# Create new list
|
||||
new_list = TopList.objects.create(
|
||||
user=request.user,
|
||||
name=f"Copy of {original_list.name}",
|
||||
description=original_list.description,
|
||||
is_public=False, # Duplicated lists are private by default
|
||||
)
|
||||
|
||||
# Copy all items
|
||||
for item in original_list.items.all():
|
||||
TopListItem.objects.create(
|
||||
top_list=new_list,
|
||||
ride=item.ride,
|
||||
position=item.position,
|
||||
notes=item.notes,
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(new_list)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class TopListItemViewSet(ModelViewSet):
|
||||
"""ViewSet for managing top list items."""
|
||||
|
||||
queryset = TopListItem.objects.select_related("top_list__user", "ride").all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "create":
|
||||
return TopListItemCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return TopListItemUpdateInputSerializer
|
||||
return TopListItemOutputSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter items based on user permissions."""
|
||||
queryset = self.queryset
|
||||
|
||||
if not self.request.user.is_staff:
|
||||
# Non-staff users can only see items from their own lists or public lists
|
||||
queryset = queryset.filter(
|
||||
Q(top_list__user=self.request.user) | Q(top_list__is_public=True)
|
||||
)
|
||||
|
||||
return queryset.order_by("top_list_id", "position")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Validate user can add items to the list."""
|
||||
top_list = serializer.validated_data["top_list"]
|
||||
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||
raise PermissionError("You can only add items to your own lists")
|
||||
serializer.save()
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Validate user can update items in the list."""
|
||||
top_list = serializer.instance.top_list
|
||||
if top_list.user != self.request.user and not self.request.user.is_staff:
|
||||
raise PermissionError("You can only update items in your own lists")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Validate user can delete items from the list."""
|
||||
if (
|
||||
instance.top_list.user != self.request.user
|
||||
and not self.request.user.is_staff
|
||||
):
|
||||
raise PermissionError("You can only delete items from your own lists")
|
||||
instance.delete()
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def reorder(self, request):
|
||||
"""Reorder items in a top list."""
|
||||
top_list_id = request.data.get("top_list_id")
|
||||
item_ids = request.data.get("item_ids", [])
|
||||
|
||||
if not top_list_id or not item_ids:
|
||||
return Response(
|
||||
{"error": "top_list_id and item_ids are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
top_list = TopList.objects.get(id=top_list_id)
|
||||
if top_list.user != request.user and not request.user.is_staff:
|
||||
return Response(
|
||||
{"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Update positions
|
||||
for position, item_id in enumerate(item_ids, 1):
|
||||
TopListItem.objects.filter(id=item_id, top_list=top_list).update(
|
||||
position=position
|
||||
)
|
||||
|
||||
return Response({"success": True})
|
||||
|
||||
except TopList.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Top list not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
26
backend/apps/api/v1/core/urls.py
Normal file
26
backend/apps/api/v1/core/urls.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Core API URL configuration.
|
||||
Centralized from apps.core.urls
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# Entity search endpoints - migrated from apps.core.urls
|
||||
urlpatterns = [
|
||||
path(
|
||||
"entities/search/",
|
||||
views.EntityFuzzySearchView.as_view(),
|
||||
name="entity_fuzzy_search",
|
||||
),
|
||||
path(
|
||||
"entities/not-found/",
|
||||
views.EntityNotFoundView.as_view(),
|
||||
name="entity_not_found",
|
||||
),
|
||||
path(
|
||||
"entities/suggestions/",
|
||||
views.QuickEntitySuggestionView.as_view(),
|
||||
name="entity_suggestions",
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user