mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 15:35:17 -05:00
Compare commits
6 Commits
claude/cod
...
96df23242e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96df23242e | ||
|
|
692c0bbbbf | ||
|
|
22ff0d1c49 | ||
|
|
fbbfea50a3 | ||
|
|
b37aedf82e | ||
|
|
fa570334fc |
143
.gitignore
vendored
143
.gitignore
vendored
@@ -1,143 +0,0 @@
|
||||
# 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/staticfiles/
|
||||
/backend/media/
|
||||
|
||||
# Celery Beat schedule database (runtime state, regenerated automatically)
|
||||
celerybeat-schedule*
|
||||
celerybeat.pid
|
||||
|
||||
# UV
|
||||
.uv/
|
||||
backend/.uv/
|
||||
|
||||
# Generated requirements files (auto-generated from pyproject.toml)
|
||||
# Uncomment if you want to track these files
|
||||
# backend/requirements.txt
|
||||
# backend/requirements-dev.txt
|
||||
# backend/requirements-test.txt
|
||||
|
||||
# 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
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 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
|
||||
*.backup
|
||||
*.orig
|
||||
*.swp
|
||||
*_backup.*
|
||||
*_OLD_*
|
||||
|
||||
# Archive files
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
|
||||
# Security
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Local development
|
||||
/uploads/
|
||||
/backups/
|
||||
.django_tailwind_cli/
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# Extracted packages
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
web/next-env.d.ts
|
||||
web/.next/types/cache-life.d.ts
|
||||
.gitignore
|
||||
web/.next/types/routes.d.ts
|
||||
web/.next/types/validator.ts
|
||||
@@ -1,592 +0,0 @@
|
||||
# ThrillWiki Codebase Quality Review
|
||||
|
||||
**Date:** January 2026
|
||||
**Scope:** Full-stack analysis (Django backend, frontend, infrastructure, tests)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This codebase is a **well-architected Django 5.2 application** with HTMX/Alpine.js frontend, PostgreSQL/PostGIS database, Redis caching, and Celery task queue. The project demonstrates strong engineering fundamentals but has accumulated technical debt in several areas that, if addressed, would significantly improve maintainability, performance, and security.
|
||||
|
||||
### Overall Assessment
|
||||
|
||||
| Area | Score | Notes |
|
||||
|------|-------|-------|
|
||||
| Architecture | ⭐⭐⭐⭐ | Well-organized modular Django apps |
|
||||
| Code Quality | ⭐⭐⭐ | Good patterns but inconsistencies exist |
|
||||
| Security | ⭐⭐⭐ | Solid foundation with some XSS risks |
|
||||
| Performance | ⭐⭐⭐ | Good caching but N+1 queries present |
|
||||
| Testing | ⭐⭐⭐ | 70% coverage with gaps |
|
||||
| Frontend | ⭐⭐⭐ | Clean JS but no tooling/types |
|
||||
| Infrastructure | ⭐⭐⭐⭐ | Comprehensive CI/CD and deployment |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Issues (Fix Immediately)
|
||||
|
||||
### 1. XSS Vulnerabilities in Admin Panel
|
||||
|
||||
**Location:** `backend/apps/moderation/admin.py`
|
||||
|
||||
```python
|
||||
# Line 228 - changes_preview() method
|
||||
return mark_safe("".join(html)) # User data not escaped!
|
||||
|
||||
# Line 740 - context_preview() method
|
||||
return mark_safe("".join(html)) # Context data not escaped!
|
||||
```
|
||||
|
||||
**Risk:** Attackers could inject malicious JavaScript through edit submissions.
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
from django.utils.html import escape
|
||||
|
||||
# In changes_preview():
|
||||
html.append(f"<td>{escape(str(old))}</td><td>{escape(str(new))}</td>")
|
||||
```
|
||||
|
||||
### 2. Debug Print Statements in Production Code
|
||||
|
||||
**Location:** `backend/apps/parks/models/parks.py:375-426`
|
||||
|
||||
```python
|
||||
print(f"\nLooking up slug: {slug}") # DEBUG CODE IN PRODUCTION
|
||||
print(f"Found current park with slug: {slug}")
|
||||
print(f"Checking historical slugs...")
|
||||
```
|
||||
|
||||
**Fix:** Remove or convert to `logging.debug()`.
|
||||
|
||||
### 3. Mass Assignment Vulnerability in Serializers
|
||||
|
||||
**Location:** `backend/apps/api/v1/accounts/serializers.py`
|
||||
|
||||
```python
|
||||
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = "__all__" # DANGEROUS - exposes all fields for update
|
||||
```
|
||||
|
||||
**Fix:** Explicitly list allowed fields:
|
||||
```python
|
||||
fields = ["display_name", "bio", "location", "website", "social_links"]
|
||||
```
|
||||
|
||||
### 4. N+1 Query in Trip Planning Views
|
||||
|
||||
**Location:** `backend/apps/parks/views.py:577-583, 635-639, 686-690`
|
||||
|
||||
```python
|
||||
# Current (N+1 problem - one query per park):
|
||||
for tid in _get_session_trip(request):
|
||||
try:
|
||||
parks.append(Park.objects.get(id=tid))
|
||||
except Park.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Fix (single query):
|
||||
park_ids = _get_session_trip(request)
|
||||
parks = list(Park.objects.filter(id__in=park_ids))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟠 High Priority Issues
|
||||
|
||||
### 5. Fat Models with Business Logic
|
||||
|
||||
The following models have 200+ lines of business logic that should be extracted to services:
|
||||
|
||||
| Model | Location | Lines | Issue |
|
||||
|-------|----------|-------|-------|
|
||||
| `Park` | `parks/models/parks.py` | 220-428 | FSM transitions, slug resolution, computed fields |
|
||||
| `EditSubmission` | `moderation/models.py` | 76-371 | Full approval workflow |
|
||||
| `PhotoSubmission` | `moderation/models.py` | 668-903 | Photo approval workflow |
|
||||
|
||||
**Recommendation:** Create service classes:
|
||||
```
|
||||
apps/parks/services/
|
||||
├── park_service.py # FSM transitions, computed fields
|
||||
├── slug_service.py # Historical slug resolution
|
||||
└── ...
|
||||
|
||||
apps/moderation/services/
|
||||
├── submission_service.py # EditSubmission workflow
|
||||
├── photo_service.py # PhotoSubmission workflow
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 6. Missing Database Indexes
|
||||
|
||||
**Critical indexes to add:**
|
||||
|
||||
```python
|
||||
# ParkPhoto - No index on frequently-filtered FK
|
||||
class ParkPhoto(models.Model):
|
||||
park = models.ForeignKey(Park, on_delete=models.CASCADE, db_index=True) # ADD db_index
|
||||
|
||||
# UserNotification - Missing composite index
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "created_at"]), # ADD for sorting
|
||||
]
|
||||
|
||||
# RideCredit - Missing index for ordering
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "display_order"]), # ADD
|
||||
]
|
||||
|
||||
# Company - Missing status filter index
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["status", "founded_year"]), # ADD
|
||||
]
|
||||
```
|
||||
|
||||
### 7. Inconsistent API Response Formats
|
||||
|
||||
**Current state (3+ different formats):**
|
||||
|
||||
```python
|
||||
# Format 1: rides endpoint
|
||||
{"rides": [...], "total_count": X, "strategy": "...", "has_more": bool}
|
||||
|
||||
# Format 2: parks endpoint
|
||||
{"parks": [...], "total_count": X, "strategy": "..."}
|
||||
|
||||
# Format 3: DRF paginator
|
||||
{"results": [...], "count": X, "next": "...", "previous": "..."}
|
||||
|
||||
# Format 4: Success responses
|
||||
{"success": True, "data": {...}} # vs
|
||||
{"detail": "Success message"} # vs
|
||||
{"message": "Success"}
|
||||
```
|
||||
|
||||
**Recommendation:** Create a standard response wrapper:
|
||||
|
||||
```python
|
||||
# apps/core/api/responses.py
|
||||
class StandardResponse:
|
||||
@staticmethod
|
||||
def success(data=None, message=None, meta=None):
|
||||
return {
|
||||
"success": True,
|
||||
"data": data,
|
||||
"message": message,
|
||||
"meta": meta # pagination, counts, etc.
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def error(message, code=None, details=None):
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"message": message,
|
||||
"code": code,
|
||||
"details": details
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Overly Broad Exception Handling
|
||||
|
||||
**Pattern found 15+ times:**
|
||||
|
||||
```python
|
||||
# BAD - masks actual errors
|
||||
try:
|
||||
queryset = self.apply_filters(queryset)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return Park.objects.none() # Silent failure!
|
||||
```
|
||||
|
||||
**Fix:** Catch specific exceptions:
|
||||
|
||||
```python
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DatabaseError
|
||||
|
||||
try:
|
||||
queryset = self.apply_filters(queryset)
|
||||
except ValidationError as e:
|
||||
messages.warning(request, f"Invalid filter: {e}")
|
||||
return base_queryset
|
||||
except DatabaseError as e:
|
||||
logger.error("Database error in filter", exc_info=True)
|
||||
raise # Let it bubble up or return error response
|
||||
```
|
||||
|
||||
### 9. Duplicated Permission Checks
|
||||
|
||||
**Found in 6+ locations:**
|
||||
|
||||
```python
|
||||
# Repeated pattern in views:
|
||||
if not (request.user == instance.uploaded_by or request.user.is_staff):
|
||||
raise PermissionDenied()
|
||||
```
|
||||
|
||||
**Fix:** Create reusable permission class:
|
||||
|
||||
```python
|
||||
# apps/core/permissions.py
|
||||
class IsOwnerOrStaff(permissions.BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
owner_field = getattr(view, 'owner_field', 'user')
|
||||
owner = getattr(obj, owner_field, None)
|
||||
return owner == request.user or request.user.is_staff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Medium Priority Issues
|
||||
|
||||
### 10. Frontend Has No Build Tooling
|
||||
|
||||
**Current state:**
|
||||
- No `package.json` or npm dependencies
|
||||
- No TypeScript (vanilla JS only)
|
||||
- No ESLint/Prettier configuration
|
||||
- No minification/bundling pipeline
|
||||
- No source maps for debugging
|
||||
|
||||
**Impact:**
|
||||
- No type safety in 8,000+ lines of JavaScript
|
||||
- Manual debugging without source maps
|
||||
- No automated code quality checks
|
||||
|
||||
**Recommendation:** Add minimal tooling:
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint backend/static/js/",
|
||||
"format": "prettier --write 'backend/static/js/**/*.js'",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Test Coverage Gaps
|
||||
|
||||
**Disabled tests (technical debt):**
|
||||
- `tests_disabled/test_models.py` - Park model tests
|
||||
- `tests_disabled/test_filters.py` - Filter tests
|
||||
- `tests_disabled/test_search.py` - Search/autocomplete tests
|
||||
|
||||
**Missing test coverage:**
|
||||
- Celery async tasks (not tested)
|
||||
- Cache hit/miss behavior
|
||||
- Concurrent operations/race conditions
|
||||
- Performance benchmarks
|
||||
- Component-level accessibility
|
||||
|
||||
**Recommendation:**
|
||||
1. Re-enable disabled tests with updated model references
|
||||
2. Add Celery task tests with `CELERY_TASK_ALWAYS_EAGER = True`
|
||||
3. Implement Page Object Model for E2E tests
|
||||
|
||||
### 12. Celery Configuration Issues
|
||||
|
||||
**Location:** `backend/config/celery.py`
|
||||
|
||||
```python
|
||||
# Issue 1: No retry policy visible
|
||||
# Tasks that fail don't have automatic retry with backoff
|
||||
|
||||
# Issue 2: Beat schedule lacks jitter
|
||||
# All daily tasks run at midnight - thundering herd problem
|
||||
CELERYBEAT_SCHEDULE = {
|
||||
"daily-ban-expiry": {"schedule": crontab(hour=0, minute=0)},
|
||||
"daily-deletion-processing": {"schedule": crontab(hour=0, minute=0)},
|
||||
"daily-closing-checks": {"schedule": crontab(hour=0, minute=0)},
|
||||
# All at midnight!
|
||||
}
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
|
||||
```python
|
||||
# Add retry policy to tasks
|
||||
@app.task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def process_submission(self, submission_id):
|
||||
try:
|
||||
# ... task logic
|
||||
except TransientError as e:
|
||||
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
# Stagger beat schedule
|
||||
CELERYBEAT_SCHEDULE = {
|
||||
"daily-ban-expiry": {"schedule": crontab(hour=0, minute=0)},
|
||||
"daily-deletion-processing": {"schedule": crontab(hour=0, minute=15)},
|
||||
"daily-closing-checks": {"schedule": crontab(hour=0, minute=30)},
|
||||
}
|
||||
```
|
||||
|
||||
### 13. Rate Limiting Only on Auth Endpoints
|
||||
|
||||
**Location:** `backend/apps/core/middleware/rate_limiting.py`
|
||||
|
||||
```python
|
||||
RATE_LIMITED_PATHS = {
|
||||
"/api/v1/auth/login/": {...},
|
||||
"/api/v1/auth/signup/": {...},
|
||||
# Missing: file uploads, form submissions, search endpoints
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Extend rate limiting:
|
||||
|
||||
```python
|
||||
RATE_LIMITED_PATHS = {
|
||||
# Auth
|
||||
"/api/v1/auth/login/": {"per_minute": 5, "per_hour": 30},
|
||||
"/api/v1/auth/signup/": {"per_minute": 3, "per_hour": 10},
|
||||
"/api/v1/auth/password-reset/": {"per_minute": 3, "per_hour": 10},
|
||||
|
||||
# File uploads
|
||||
"/api/v1/photos/upload/": {"per_minute": 10, "per_hour": 100},
|
||||
|
||||
# Search (prevent abuse)
|
||||
"/api/v1/search/": {"per_minute": 30, "per_hour": 500},
|
||||
|
||||
# Submissions
|
||||
"/api/v1/submissions/": {"per_minute": 5, "per_hour": 50},
|
||||
}
|
||||
```
|
||||
|
||||
### 14. Inconsistent Status Field Implementations
|
||||
|
||||
**Three different patterns used:**
|
||||
|
||||
```python
|
||||
# Pattern 1: RichFSMField (Park)
|
||||
status = RichFSMField(default=ParkStatus.OPERATING, ...)
|
||||
|
||||
# Pattern 2: CharField with choices (Company)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, ...)
|
||||
|
||||
# Pattern 3: RichChoiceField (User role)
|
||||
role = RichChoiceField(choices=UserRole.choices, ...)
|
||||
```
|
||||
|
||||
**Recommendation:** Standardize on one approach (RichFSMField for state machines, RichChoiceField for enums).
|
||||
|
||||
### 15. Magic Numbers Throughout Code
|
||||
|
||||
**Examples found:**
|
||||
|
||||
```python
|
||||
# auth/views.py
|
||||
get_random_string(64) # Why 64?
|
||||
timeout=300 # Why 300 seconds?
|
||||
MAX_AVATAR_SIZE = 10 * 1024 * 1024 # Inline constant
|
||||
|
||||
# Various files
|
||||
page_size = 20 # vs 24 in other places
|
||||
```
|
||||
|
||||
**Fix:** Create constants module:
|
||||
|
||||
```python
|
||||
# apps/core/constants.py
|
||||
class Security:
|
||||
MFA_TOKEN_LENGTH = 64
|
||||
MFA_TOKEN_TIMEOUT_SECONDS = 300
|
||||
MAX_AVATAR_SIZE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
class Pagination:
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Low Priority / Nice-to-Have
|
||||
|
||||
### 16. Deprecated Code Not Removed
|
||||
|
||||
**Location:** `backend/static/js/moderation/history.js`
|
||||
- File marked as DEPRECATED but still present
|
||||
- Should be removed or migrated
|
||||
|
||||
### 17. Unused Imports
|
||||
|
||||
Multiple files have duplicate or unused imports:
|
||||
- `backend/apps/api/v1/rides/views.py` - `Q` imported twice
|
||||
|
||||
### 18. Missing Docstrings on Complex Methods
|
||||
|
||||
Many service methods and complex views lack docstrings explaining:
|
||||
- Expected inputs/outputs
|
||||
- Business rules applied
|
||||
- Side effects
|
||||
|
||||
### 19. Template `|safe` Filter Usage
|
||||
|
||||
**Files using `|safe` that should use `|sanitize`:**
|
||||
- `templates/components/ui/icon.html:61`
|
||||
- `templates/components/navigation/breadcrumbs.html:116`
|
||||
|
||||
---
|
||||
|
||||
## Architecture Recommendations
|
||||
|
||||
### 1. Adopt Service Layer Pattern Consistently
|
||||
|
||||
```
|
||||
apps/
|
||||
├── parks/
|
||||
│ ├── models/ # Data models only
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── park_service.py
|
||||
│ │ ├── slug_service.py
|
||||
│ │ └── media_service.py
|
||||
│ ├── selectors/ # Read queries (already exists)
|
||||
│ └── api/ # Serializers, viewsets
|
||||
```
|
||||
|
||||
### 2. Create Shared Response/Error Handling
|
||||
|
||||
```python
|
||||
# apps/core/api/
|
||||
├── responses.py # StandardResponse class
|
||||
├── exceptions.py # Custom exceptions with codes
|
||||
├── error_handlers.py # DRF exception handler
|
||||
└── mixins.py # Reusable view mixins
|
||||
```
|
||||
|
||||
### 3. Implement Repository Pattern for Complex Queries
|
||||
|
||||
```python
|
||||
# apps/parks/repositories/park_repository.py
|
||||
class ParkRepository:
|
||||
@staticmethod
|
||||
def get_by_slug_with_history(slug: str) -> Park | None:
|
||||
"""Resolve slug including historical slugs."""
|
||||
# Move 60+ lines from Park.get_by_slug() here
|
||||
```
|
||||
|
||||
### 4. Add Event-Driven Architecture for Cross-App Communication
|
||||
|
||||
```python
|
||||
# Instead of direct imports between apps:
|
||||
from apps.parks.models import Park # Tight coupling
|
||||
|
||||
# Use signals/events:
|
||||
# apps/core/events.py
|
||||
park_status_changed = Signal()
|
||||
|
||||
# apps/parks/services/park_service.py
|
||||
park_status_changed.send(sender=Park, park=park, old_status=old, new_status=new)
|
||||
|
||||
# apps/notifications/handlers.py
|
||||
@receiver(park_status_changed)
|
||||
def notify_followers(sender, park, **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization Opportunities
|
||||
|
||||
### 1. Database Query Optimization
|
||||
|
||||
| Issue | Location | Impact |
|
||||
|-------|----------|--------|
|
||||
| N+1 in trip views | `parks/views.py:577` | High - loops with `.get()` |
|
||||
| Missing indexes | Multiple models | Medium - slow filters |
|
||||
| No query count monitoring | Production | Unknown query count |
|
||||
|
||||
### 2. Caching Strategy Improvements
|
||||
|
||||
```python
|
||||
# Add cache key versioning
|
||||
CACHE_VERSION = "v1"
|
||||
|
||||
def get_park_cache_key(park_id):
|
||||
return f"park:{CACHE_VERSION}:{park_id}"
|
||||
|
||||
# Add cache tags for invalidation
|
||||
from django.core.cache import cache
|
||||
|
||||
def invalidate_park_caches(park_id):
|
||||
cache.delete_pattern(f"park:*:{park_id}:*")
|
||||
```
|
||||
|
||||
### 3. Frontend Performance
|
||||
|
||||
- Add `loading="lazy"` to images below the fold
|
||||
- Implement virtual scrolling for long lists
|
||||
- Add service worker for offline capability
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening Checklist
|
||||
|
||||
- [ ] Fix XSS in admin `mark_safe()` calls
|
||||
- [ ] Replace `fields = "__all__"` in serializers
|
||||
- [ ] Add rate limiting to file upload endpoints
|
||||
- [ ] Review `|safe` template filter usage
|
||||
- [ ] Add Content Security Policy headers
|
||||
- [ ] Implement API request signing for sensitive operations
|
||||
- [ ] Add audit logging for admin actions
|
||||
- [ ] Review OAuth state management consistency
|
||||
|
||||
---
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Phase 1: Critical Fixes (This Sprint)
|
||||
1. Fix XSS vulnerabilities in admin
|
||||
2. Remove debug print statements
|
||||
3. Fix mass assignment in serializers
|
||||
4. Fix N+1 queries in trip views
|
||||
5. Add missing database indexes
|
||||
|
||||
### Phase 2: High Priority (Next 2 Sprints)
|
||||
1. Extract business logic to services
|
||||
2. Standardize API response format
|
||||
3. Fix overly broad exception handling
|
||||
4. Re-enable disabled tests
|
||||
5. Add rate limiting to more endpoints
|
||||
|
||||
### Phase 3: Medium Priority (Next Quarter)
|
||||
1. Add frontend build tooling
|
||||
2. Implement TypeScript for type safety
|
||||
3. Improve Celery configuration
|
||||
4. Standardize status field patterns
|
||||
5. Add comprehensive E2E tests
|
||||
|
||||
### Phase 4: Ongoing
|
||||
1. Remove deprecated code
|
||||
2. Add missing docstrings
|
||||
3. Monitor and optimize queries
|
||||
4. Security audits
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This codebase has a solid foundation with good architectural decisions (modular apps, service layer beginnings, comprehensive configuration). The main areas needing attention are:
|
||||
|
||||
1. **Security:** XSS vulnerabilities and mass assignment risks
|
||||
2. **Performance:** N+1 queries and missing indexes
|
||||
3. **Maintainability:** Fat models and inconsistent patterns
|
||||
4. **Testing:** Re-enable disabled tests and expand coverage
|
||||
|
||||
Addressing the critical and high-priority issues would significantly improve code quality and reduce technical debt. The codebase is well-positioned for scaling with relatively minor refactoring efforts.
|
||||
@@ -6,6 +6,7 @@ from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from . import views, views_credits, views_magic_link
|
||||
from .views import list_profiles
|
||||
|
||||
# Register ViewSets
|
||||
router = DefaultRouter()
|
||||
@@ -119,7 +120,8 @@ urlpatterns = [
|
||||
# Magic Link (Login by Code) endpoints
|
||||
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
|
||||
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
||||
# Public Profile
|
||||
# Public Profiles - List and Detail
|
||||
path("profiles/", list_profiles, name="list_profiles"),
|
||||
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||
# Bulk lookup endpoints
|
||||
path("profiles/bulk/", views.bulk_get_profiles, name="bulk_get_profiles"),
|
||||
|
||||
@@ -823,6 +823,119 @@ def check_user_deletion_eligibility(request, user_id):
|
||||
)
|
||||
|
||||
|
||||
# === PUBLIC PROFILE LIST ENDPOINT ===
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="list_profiles",
|
||||
summary="List user profiles with search and pagination",
|
||||
description=(
|
||||
"Returns a paginated list of public user profiles. "
|
||||
"Supports search by username or display name, and filtering by various criteria. "
|
||||
"This endpoint is used for user discovery, leaderboards, and friend finding."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="search",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Search term for username or display name",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Order by field: date_joined, -date_joined, username, -username",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Page number for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size",
|
||||
type=OpenApiTypes.INT,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Number of results per page (max 100)",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Paginated list of public profiles",
|
||||
"example": {
|
||||
"count": 150,
|
||||
"next": "https://api.thrillwiki.com/api/v1/accounts/profiles/?page=2",
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"user_id": "uuid-1",
|
||||
"username": "thrillseeker",
|
||||
"date_joined": "2024-01-01T00:00:00Z",
|
||||
"role": "USER",
|
||||
"profile": {
|
||||
"profile_id": "uuid-profile",
|
||||
"display_name": "Thrill Seeker",
|
||||
"avatar_url": "https://example.com/avatar.jpg",
|
||||
"bio": "Coaster enthusiast!",
|
||||
"total_credits": 150,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["User Profile"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([AllowAny])
|
||||
def list_profiles(request):
|
||||
"""
|
||||
List public user profiles with search and pagination.
|
||||
|
||||
This endpoint provides the missing /accounts/profiles/ list endpoint
|
||||
that the frontend expects for user discovery features.
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
# Base queryset: only active users with public profiles
|
||||
queryset = User.objects.filter(
|
||||
is_active=True,
|
||||
).select_related("profile").order_by("-date_joined")
|
||||
|
||||
# Search filter
|
||||
search = request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search) |
|
||||
Q(profile__display_name__icontains=search)
|
||||
)
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "-date_joined")
|
||||
valid_orderings = ["date_joined", "-date_joined", "username", "-username"]
|
||||
if ordering in valid_orderings:
|
||||
queryset = queryset.order_by(ordering)
|
||||
|
||||
# Pagination
|
||||
class ProfilePagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
paginator = ProfilePagination()
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
|
||||
if page is not None:
|
||||
serializer = PublicUserSerializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
# Fallback if pagination fails
|
||||
serializer = PublicUserSerializer(queryset[:20], many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# === USER PROFILE ENDPOINTS ===
|
||||
|
||||
|
||||
|
||||
@@ -96,10 +96,10 @@ def get_registration_options(request):
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Use the correct allauth API: begin_registration
|
||||
creation_options, state = webauthn_auth.begin_registration(request)
|
||||
# The function takes (user, passwordless) - passwordless=False for standard passkeys
|
||||
creation_options = webauthn_auth.begin_registration(request.user, passwordless=False)
|
||||
|
||||
# Store state in session for verification
|
||||
webauthn_auth.set_state(request, state)
|
||||
# State is stored internally by begin_registration via set_state()
|
||||
|
||||
return Response({
|
||||
"options": creation_options,
|
||||
@@ -154,8 +154,8 @@ def register_passkey(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session
|
||||
state = webauthn_auth.get_state(request)
|
||||
# Get stored state from session (no request needed, uses context)
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
return Response(
|
||||
{"detail": "No pending registration. Please start registration again."},
|
||||
@@ -164,19 +164,24 @@ def register_passkey(request):
|
||||
|
||||
# Use the correct allauth API: complete_registration
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
# Parse the credential response
|
||||
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||
|
||||
# Complete registration - this creates the Authenticator
|
||||
authenticator = webauthn_auth.complete_registration(
|
||||
request,
|
||||
credential_data,
|
||||
state,
|
||||
name=name,
|
||||
)
|
||||
# Complete registration - returns AuthenticatorData (binding)
|
||||
authenticator_data = webauthn_auth.complete_registration(credential_data)
|
||||
|
||||
# Clear session state
|
||||
webauthn_auth.clear_state(request)
|
||||
# Create the Authenticator record ourselves
|
||||
authenticator = Authenticator.objects.create(
|
||||
user=request.user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
data={
|
||||
"name": name,
|
||||
"credential": authenticator_data.credential_data.aaguid.hex if authenticator_data.credential_data else None,
|
||||
},
|
||||
)
|
||||
# State is cleared internally by complete_registration
|
||||
|
||||
return Response({
|
||||
"detail": "Passkey registered successfully",
|
||||
@@ -225,10 +230,8 @@ def get_authentication_options(request):
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Use the correct allauth API: begin_authentication
|
||||
request_options, state = webauthn_auth.begin_authentication(request)
|
||||
|
||||
# Store state in session for verification
|
||||
webauthn_auth.set_state(request, state)
|
||||
# Takes optional user, returns just options (state is stored internally)
|
||||
request_options = webauthn_auth.begin_authentication(request.user)
|
||||
|
||||
return Response({
|
||||
"options": request_options,
|
||||
@@ -281,8 +284,8 @@ def authenticate_passkey(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session
|
||||
state = webauthn_auth.get_state(request)
|
||||
# Get stored state from session (no request needed, uses context)
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
return Response(
|
||||
{"detail": "No pending authentication. Please start authentication again."},
|
||||
@@ -291,14 +294,9 @@ def authenticate_passkey(request):
|
||||
|
||||
# Use the correct allauth API: complete_authentication
|
||||
try:
|
||||
# Parse the credential response
|
||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
||||
|
||||
# Complete authentication
|
||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
||||
|
||||
# Clear session state
|
||||
webauthn_auth.clear_state(request)
|
||||
# Complete authentication - takes user and credential response
|
||||
# State is handled internally
|
||||
webauthn_auth.complete_authentication(request.user, credential)
|
||||
|
||||
return Response({"success": True})
|
||||
except Exception as e:
|
||||
@@ -514,9 +512,13 @@ def get_login_passkey_options(request):
|
||||
request.user = user
|
||||
|
||||
try:
|
||||
request_options, state = webauthn_auth.begin_authentication(request)
|
||||
# begin_authentication takes just user, returns options (state stored internally)
|
||||
request_options = webauthn_auth.begin_authentication(user)
|
||||
# Note: State is managed by allauth's session context, but for MFA login flow
|
||||
# we need to track user separately since they're not authenticated yet
|
||||
passkey_state_key = f"mfa_passkey_state:{mfa_token}"
|
||||
cache.set(passkey_state_key, state, timeout=300)
|
||||
# Store a reference that this user has a pending passkey auth
|
||||
cache.set(passkey_state_key, {"user_id": user_id}, timeout=300)
|
||||
return Response({"options": request_options})
|
||||
finally:
|
||||
if original_user is not None:
|
||||
|
||||
@@ -417,23 +417,23 @@ class MFALoginVerifyAPIView(APIView):
|
||||
return {"success": False, "error": "No passkey registered for this user"}
|
||||
|
||||
try:
|
||||
# Parse the authentication response
|
||||
credential_data = webauthn_auth.parse_authentication_response(credential)
|
||||
|
||||
# Get or create authentication state
|
||||
# For login flow, we need to set up the state first
|
||||
state = webauthn_auth.get_state(request)
|
||||
# For MFA login flow, we need to set up state first if not present
|
||||
# Note: allauth's begin_authentication stores state internally
|
||||
state = webauthn_auth.get_state()
|
||||
|
||||
if not state:
|
||||
# If no state, generate one for this user
|
||||
_, state = webauthn_auth.begin_authentication(request)
|
||||
webauthn_auth.set_state(request, state)
|
||||
# Need to temporarily set request.user for allauth context
|
||||
original_user = getattr(request, "user", None)
|
||||
request.user = user
|
||||
try:
|
||||
webauthn_auth.begin_authentication(user)
|
||||
finally:
|
||||
if original_user is not None:
|
||||
request.user = original_user
|
||||
|
||||
# Complete authentication
|
||||
webauthn_auth.complete_authentication(request, credential_data, state)
|
||||
|
||||
# Clear the state
|
||||
webauthn_auth.clear_state(request)
|
||||
# Complete authentication - takes user and credential dict
|
||||
# State is managed internally by allauth
|
||||
webauthn_auth.complete_authentication(user, credential)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ router.register(r"milestones", MilestoneViewSet, basename="milestone")
|
||||
|
||||
# Entity search endpoints - migrated from apps.core.urls
|
||||
urlpatterns = [
|
||||
# View counts endpoint for tracking page views
|
||||
path(
|
||||
"views/",
|
||||
views.ViewCountView.as_view(),
|
||||
name="view_counts",
|
||||
),
|
||||
path(
|
||||
"entities/search/",
|
||||
views.EntityFuzzySearchView.as_view(),
|
||||
|
||||
@@ -27,6 +27,106 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ViewCountView(APIView):
|
||||
"""
|
||||
Track and retrieve view counts for entities.
|
||||
|
||||
This endpoint provides the /core/views/ functionality expected by
|
||||
the frontend for tracking page views on parks, rides, and companies.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Get view counts for entities",
|
||||
description="Retrieve view counts for specified entities",
|
||||
)
|
||||
def get(self, request):
|
||||
"""Get view counts for entities by type and ID."""
|
||||
entity_type = request.query_params.get("entity_type")
|
||||
entity_id = request.query_params.get("entity_id")
|
||||
|
||||
if not entity_type or not entity_id:
|
||||
return Response(
|
||||
{"detail": "entity_type and entity_id are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Try to get view count from analytics tracking
|
||||
try:
|
||||
from apps.core.models import EntityViewCount
|
||||
|
||||
view_count = EntityViewCount.objects.filter(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
).first()
|
||||
|
||||
if view_count:
|
||||
return Response({
|
||||
"entity_type": entity_type,
|
||||
"entity_id": entity_id,
|
||||
"view_count": view_count.count,
|
||||
"last_viewed": view_count.last_viewed_at,
|
||||
})
|
||||
except Exception:
|
||||
# Model may not exist yet, return placeholder
|
||||
pass
|
||||
|
||||
return Response({
|
||||
"entity_type": entity_type,
|
||||
"entity_id": entity_id,
|
||||
"view_count": 0,
|
||||
"last_viewed": None,
|
||||
})
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Record a view for an entity",
|
||||
description="Increment the view count for a specified entity",
|
||||
)
|
||||
def post(self, request):
|
||||
"""Record a view for an entity."""
|
||||
entity_type = request.data.get("entity_type")
|
||||
entity_id = request.data.get("entity_id")
|
||||
|
||||
if not entity_type or not entity_id:
|
||||
return Response(
|
||||
{"detail": "entity_type and entity_id are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Track the view
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from apps.core.models import EntityViewCount
|
||||
|
||||
view_count, created = EntityViewCount.objects.get_or_create(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
defaults={"count": 0},
|
||||
)
|
||||
view_count.count += 1
|
||||
view_count.last_viewed_at = timezone.now()
|
||||
view_count.save(update_fields=["count", "last_viewed_at"])
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"entity_type": entity_type,
|
||||
"entity_id": entity_id,
|
||||
"view_count": view_count.count,
|
||||
}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
# Model may not exist, log and return success anyway
|
||||
logger.debug(f"View count tracking not available: {e}")
|
||||
return Response({
|
||||
"success": True,
|
||||
"entity_type": entity_type,
|
||||
"entity_id": entity_id,
|
||||
"view_count": 1, # Assume first view
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TelemetryView(APIView):
|
||||
"""
|
||||
Handle frontend telemetry and request metadata logging.
|
||||
|
||||
254
backend/apps/api/v1/rides/ride_model_views.py
Normal file
254
backend/apps/api/v1/rides/ride_model_views.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
Global Ride Model views for ThrillWiki API v1.
|
||||
|
||||
This module provides top-level ride model endpoints that don't require
|
||||
manufacturer context, matching the frontend's expectation of /rides/models/.
|
||||
"""
|
||||
|
||||
from django.db.models import Q
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
# Import serializers
|
||||
from apps.api.v1.serializers.ride_models import (
|
||||
RideModelDetailOutputSerializer,
|
||||
RideModelListOutputSerializer,
|
||||
)
|
||||
|
||||
# Attempt to import models
|
||||
try:
|
||||
from apps.rides.models import RideModel
|
||||
from apps.rides.models.company import Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
from apps.rides.models.rides import Company, RideModel
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except ImportError:
|
||||
RideModel = None
|
||||
Company = None
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class GlobalRideModelListAPIView(APIView):
|
||||
"""
|
||||
Global ride model list endpoint.
|
||||
|
||||
This endpoint provides a top-level list of all ride models without
|
||||
requiring a manufacturer slug, matching the frontend's expectation
|
||||
of calling /rides/models/ directly.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List all ride models with filtering and pagination",
|
||||
description=(
|
||||
"List all ride models across all manufacturers with comprehensive "
|
||||
"filtering and pagination support. This is a global endpoint that "
|
||||
"doesn't require manufacturer context."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="page",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Page number for pagination",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="page_size",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Number of results per page (max 100)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="search",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Search term for name, description, or manufacturer",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="category",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by category (e.g., RC, DR, FR, WR)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="manufacturer",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by manufacturer slug",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="target_market",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by target market (e.g., FAMILY, THRILL)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="is_discontinued",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.BOOL,
|
||||
description="Filter by discontinued status",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="ordering",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Order by field: name, -name, manufacturer__name, etc.",
|
||||
),
|
||||
],
|
||||
responses={200: RideModelListOutputSerializer(many=True)},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""List all ride models with filtering and pagination."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
"detail": "Ride model listing is not available.",
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Base queryset with eager loading
|
||||
qs = RideModel.objects.select_related("manufacturer").prefetch_related(
|
||||
"photos"
|
||||
).order_by("manufacturer__name", "name")
|
||||
|
||||
# Search filter
|
||||
search = request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=search)
|
||||
| Q(description__icontains=search)
|
||||
| Q(manufacturer__name__icontains=search)
|
||||
)
|
||||
|
||||
# Category filter
|
||||
category = request.query_params.get("category", "").strip()
|
||||
if category:
|
||||
# Support comma-separated categories
|
||||
categories = [c.strip() for c in category.split(",") if c.strip()]
|
||||
if categories:
|
||||
qs = qs.filter(category__in=categories)
|
||||
|
||||
# Manufacturer filter
|
||||
manufacturer = request.query_params.get("manufacturer", "").strip()
|
||||
if manufacturer:
|
||||
qs = qs.filter(manufacturer__slug=manufacturer)
|
||||
|
||||
# Target market filter
|
||||
target_market = request.query_params.get("target_market", "").strip()
|
||||
if target_market:
|
||||
markets = [m.strip() for m in target_market.split(",") if m.strip()]
|
||||
if markets:
|
||||
qs = qs.filter(target_market__in=markets)
|
||||
|
||||
# Discontinued filter
|
||||
is_discontinued = request.query_params.get("is_discontinued")
|
||||
if is_discontinued is not None:
|
||||
qs = qs.filter(is_discontinued=is_discontinued.lower() == "true")
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "manufacturer__name,name")
|
||||
valid_orderings = [
|
||||
"name", "-name",
|
||||
"manufacturer__name", "-manufacturer__name",
|
||||
"first_installation_year", "-first_installation_year",
|
||||
"total_installations", "-total_installations",
|
||||
"created_at", "-created_at",
|
||||
]
|
||||
if ordering:
|
||||
order_fields = [
|
||||
f.strip() for f in ordering.split(",")
|
||||
if f.strip() in valid_orderings or f.strip().lstrip("-") in [
|
||||
o.lstrip("-") for o in valid_orderings
|
||||
]
|
||||
]
|
||||
if order_fields:
|
||||
qs = qs.order_by(*order_fields)
|
||||
|
||||
# Paginate
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
|
||||
if page is not None:
|
||||
serializer = RideModelListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
# Fallback without pagination
|
||||
serializer = RideModelListOutputSerializer(
|
||||
qs[:100], many=True, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class GlobalRideModelDetailAPIView(APIView):
|
||||
"""
|
||||
Global ride model detail endpoint by ID or slug.
|
||||
|
||||
This endpoint provides detail for a single ride model without
|
||||
requiring manufacturer context.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Retrieve a ride model by ID",
|
||||
description="Get detailed information about a specific ride model by its ID.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="pk",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
required=True,
|
||||
description="Ride model ID",
|
||||
),
|
||||
],
|
||||
responses={200: RideModelDetailOutputSerializer()},
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request, pk: int) -> Response:
|
||||
"""Get ride model detail by ID."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Ride model not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
ride_model = (
|
||||
RideModel.objects.select_related("manufacturer")
|
||||
.prefetch_related("photos", "variants", "technical_specs")
|
||||
.get(pk=pk)
|
||||
)
|
||||
except RideModel.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Ride model not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = RideModelDetailOutputSerializer(
|
||||
ride_model, context={"request": request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
@@ -306,6 +306,12 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
banner_image_url = serializers.SerializerMethodField()
|
||||
card_image_url = serializers.SerializerMethodField()
|
||||
|
||||
# Metric unit conversions for frontend (duplicate of imperial fields)
|
||||
coaster_height_meters = serializers.SerializerMethodField()
|
||||
coaster_length_meters = serializers.SerializerMethodField()
|
||||
coaster_speed_kmh = serializers.SerializerMethodField()
|
||||
coaster_max_drop_meters = serializers.SerializerMethodField()
|
||||
|
||||
# Computed fields for filtering
|
||||
opening_year = serializers.IntegerField(read_only=True)
|
||||
search_text = serializers.CharField(read_only=True)
|
||||
@@ -502,6 +508,47 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
"""Check if ride has an announced closing date in the future."""
|
||||
return obj.is_closing
|
||||
|
||||
# Metric conversions for frontend compatibility
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_height_meters(self, obj):
|
||||
"""Convert coaster height from feet to meters."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.height_ft:
|
||||
return round(float(obj.coaster_stats.height_ft) * 0.3048, 2)
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_length_meters(self, obj):
|
||||
"""Convert coaster length from feet to meters."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.length_ft:
|
||||
return round(float(obj.coaster_stats.length_ft) * 0.3048, 2)
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_speed_kmh(self, obj):
|
||||
"""Convert coaster speed from mph to km/h."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.speed_mph:
|
||||
return round(float(obj.coaster_stats.speed_mph) * 1.60934, 2)
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_coaster_max_drop_meters(self, obj):
|
||||
"""Convert coaster max drop from feet to meters."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.max_drop_height_ft:
|
||||
return round(float(obj.coaster_stats.max_drop_height_ft) * 0.3048, 2)
|
||||
return None
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
# Water ride stats fields
|
||||
water_wetness_level = serializers.SerializerMethodField()
|
||||
water_splash_height_ft = serializers.SerializerMethodField()
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .photo_views import RidePhotoViewSet
|
||||
from .ride_model_views import GlobalRideModelDetailAPIView, GlobalRideModelListAPIView
|
||||
from .views import (
|
||||
CompanySearchAPIView,
|
||||
DesignerListAPIView,
|
||||
@@ -40,6 +41,9 @@ urlpatterns = [
|
||||
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
|
||||
# Filter options
|
||||
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
|
||||
# Global ride model endpoints - matches frontend's /rides/models/ expectation
|
||||
path("models/", GlobalRideModelListAPIView.as_view(), name="ride-model-global-list"),
|
||||
path("models/<int:pk>/", GlobalRideModelDetailAPIView.as_view(), name="ride-model-global-detail"),
|
||||
# Autocomplete / suggestion endpoints
|
||||
path(
|
||||
"search/companies/",
|
||||
|
||||
@@ -211,6 +211,18 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
# Former names (name history)
|
||||
former_names = serializers.SerializerMethodField()
|
||||
|
||||
# Coaster statistics - includes both imperial and metric units for frontend flexibility
|
||||
coaster_statistics = serializers.SerializerMethodField()
|
||||
|
||||
# Metric unit fields for frontend (converted from imperial)
|
||||
height_meters = serializers.SerializerMethodField()
|
||||
length_meters = serializers.SerializerMethodField()
|
||||
max_speed_kmh = serializers.SerializerMethodField()
|
||||
drop_meters = serializers.SerializerMethodField()
|
||||
|
||||
# Technical specifications list
|
||||
technical_specifications = serializers.SerializerMethodField()
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
@@ -427,6 +439,99 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
for entry in former_names
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_coaster_statistics(self, obj):
|
||||
"""Get coaster statistics with both imperial and metric units."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats:
|
||||
stats = obj.coaster_stats
|
||||
return {
|
||||
# Imperial units (stored in DB)
|
||||
"height_ft": float(stats.height_ft) if stats.height_ft else None,
|
||||
"length_ft": float(stats.length_ft) if stats.length_ft else None,
|
||||
"speed_mph": float(stats.speed_mph) if stats.speed_mph else None,
|
||||
"max_drop_height_ft": float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
|
||||
# Metric conversions for frontend
|
||||
"height_meters": round(float(stats.height_ft) * 0.3048, 2) if stats.height_ft else None,
|
||||
"length_meters": round(float(stats.length_ft) * 0.3048, 2) if stats.length_ft else None,
|
||||
"max_speed_kmh": round(float(stats.speed_mph) * 1.60934, 2) if stats.speed_mph else None,
|
||||
"drop_meters": round(float(stats.max_drop_height_ft) * 0.3048, 2) if stats.max_drop_height_ft else None,
|
||||
# Other stats
|
||||
"inversions": stats.inversions,
|
||||
"ride_time_seconds": stats.ride_time_seconds,
|
||||
"track_type": stats.track_type,
|
||||
"track_material": stats.track_material,
|
||||
"roller_coaster_type": stats.roller_coaster_type,
|
||||
"propulsion_system": stats.propulsion_system,
|
||||
"train_style": stats.train_style,
|
||||
"trains_count": stats.trains_count,
|
||||
"cars_per_train": stats.cars_per_train,
|
||||
"seats_per_car": stats.seats_per_car,
|
||||
}
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_height_meters(self, obj):
|
||||
"""Convert height from feet to meters for frontend."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.height_ft:
|
||||
return round(float(obj.coaster_stats.height_ft) * 0.3048, 2)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_length_meters(self, obj):
|
||||
"""Convert length from feet to meters for frontend."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.length_ft:
|
||||
return round(float(obj.coaster_stats.length_ft) * 0.3048, 2)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_max_speed_kmh(self, obj):
|
||||
"""Convert max speed from mph to km/h for frontend."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.speed_mph:
|
||||
return round(float(obj.coaster_stats.speed_mph) * 1.60934, 2)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||
def get_drop_meters(self, obj):
|
||||
"""Convert drop height from feet to meters for frontend."""
|
||||
try:
|
||||
if hasattr(obj, "coaster_stats") and obj.coaster_stats and obj.coaster_stats.max_drop_height_ft:
|
||||
return round(float(obj.coaster_stats.max_drop_height_ft) * 0.3048, 2)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_technical_specifications(self, obj):
|
||||
"""Get technical specifications list for this ride."""
|
||||
try:
|
||||
from apps.rides.models import RideTechnicalSpec
|
||||
|
||||
specs = RideTechnicalSpec.objects.filter(ride=obj).order_by("category", "name")
|
||||
return [
|
||||
{
|
||||
"id": spec.id,
|
||||
"name": spec.name,
|
||||
"value": spec.value,
|
||||
"unit": spec.unit,
|
||||
"category": spec.category,
|
||||
}
|
||||
for spec in specs
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for setting ride banner and card images."""
|
||||
|
||||
@@ -690,7 +690,8 @@ class SmartRideLoader:
|
||||
if category in category_labels:
|
||||
return category_labels[category]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category: {category}")
|
||||
# Return original value as fallback for unknown categories
|
||||
return category.replace("_", " ").title()
|
||||
|
||||
def _get_status_label(self, status: str) -> str:
|
||||
"""Convert status code to human-readable label."""
|
||||
@@ -707,7 +708,8 @@ class SmartRideLoader:
|
||||
if status in status_labels:
|
||||
return status_labels[status]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride status: {status}")
|
||||
# Return original value as fallback for unknown statuses
|
||||
return status.replace("_", " ").title()
|
||||
|
||||
def _get_rc_type_label(self, rc_type: str) -> str:
|
||||
"""Convert roller coaster type to human-readable label."""
|
||||
@@ -729,7 +731,8 @@ class SmartRideLoader:
|
||||
if rc_type in rc_type_labels:
|
||||
return rc_type_labels[rc_type]
|
||||
else:
|
||||
raise ValueError(f"Unknown roller coaster type: {rc_type}")
|
||||
# Return original value as fallback for unknown types
|
||||
return rc_type.replace("_", " ").title()
|
||||
|
||||
def _get_track_material_label(self, material: str) -> str:
|
||||
"""Convert track material to human-readable label."""
|
||||
@@ -741,7 +744,8 @@ class SmartRideLoader:
|
||||
if material in material_labels:
|
||||
return material_labels[material]
|
||||
else:
|
||||
raise ValueError(f"Unknown track material: {material}")
|
||||
# Return original value as fallback for unknown materials
|
||||
return material.replace("_", " ").title()
|
||||
|
||||
def _get_propulsion_system_label(self, propulsion_system: str) -> str:
|
||||
"""Convert propulsion system to human-readable label."""
|
||||
@@ -759,4 +763,6 @@ class SmartRideLoader:
|
||||
if propulsion_system in propulsion_labels:
|
||||
return propulsion_labels[propulsion_system]
|
||||
else:
|
||||
raise ValueError(f"Unknown propulsion system: {propulsion_system}")
|
||||
# Return original value as fallback for unknown propulsion systems
|
||||
return propulsion_system.replace("_", " ").title()
|
||||
|
||||
|
||||
Binary file not shown.
86
backend/run-dev.sh
Executable file
86
backend/run-dev.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# ThrillWiki Development Server
|
||||
# Runs Django, Celery worker, and Celery beat together
|
||||
#
|
||||
# Usage: ./run-dev.sh
|
||||
# Press Ctrl+C to stop all services
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Find backend directory (script may be run from different locations)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [ -d "$SCRIPT_DIR/backend" ]; then
|
||||
BACKEND_DIR="$SCRIPT_DIR/backend"
|
||||
elif [ -d "$SCRIPT_DIR/../backend" ]; then
|
||||
BACKEND_DIR="$SCRIPT_DIR/../backend"
|
||||
elif [ -f "$SCRIPT_DIR/manage.py" ]; then
|
||||
BACKEND_DIR="$SCRIPT_DIR"
|
||||
else
|
||||
echo -e "${RED}❌ Cannot find backend directory. Run from project root or backend folder.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
echo -e "${BLUE}🎢 ThrillWiki Development Server${NC}"
|
||||
echo "=================================="
|
||||
echo -e "Backend: ${GREEN}$BACKEND_DIR${NC}"
|
||||
echo ""
|
||||
|
||||
# Check Redis is running
|
||||
if ! redis-cli ping &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Redis not running. Starting Redis...${NC}"
|
||||
if command -v brew &> /dev/null; then
|
||||
brew services start redis 2>/dev/null || true
|
||||
sleep 1
|
||||
else
|
||||
echo -e "${RED}❌ Redis not running. Start with: docker run -d -p 6379:6379 redis:alpine${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}✓ Redis connected${NC}"
|
||||
|
||||
# Cleanup function to kill all background processes
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}Shutting down...${NC}"
|
||||
kill $PID_DJANGO $PID_CELERY_WORKER $PID_CELERY_BEAT 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
echo -e "${GREEN}✓ All services stopped${NC}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Starting services...${NC}"
|
||||
echo ""
|
||||
|
||||
# Start Django
|
||||
echo -e "${GREEN}▶ Django${NC} (http://localhost:8000)"
|
||||
uv run python manage.py runserver 2>&1 | sed 's/^/ [Django] /' &
|
||||
PID_DJANGO=$!
|
||||
|
||||
# Start Celery worker
|
||||
echo -e "${GREEN}▶ Celery Worker${NC}"
|
||||
uv run celery -A config worker -l info 2>&1 | sed 's/^/ [Worker] /' &
|
||||
PID_CELERY_WORKER=$!
|
||||
|
||||
# Start Celery beat
|
||||
echo -e "${GREEN}▶ Celery Beat${NC}"
|
||||
uv run celery -A config beat -l info 2>&1 | sed 's/^/ [Beat] /' &
|
||||
PID_CELERY_BEAT=$!
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}All services running. Press Ctrl+C to stop.${NC}"
|
||||
echo ""
|
||||
|
||||
# Wait for any process to exit
|
||||
wait
|
||||
@@ -36,12 +36,13 @@
|
||||
- main_class: Additional classes for <main> tag
|
||||
|
||||
Usage Example:
|
||||
{% extends "base/base.html" %}
|
||||
{% block title %}My Page - ThrillWiki{% endblock %}
|
||||
{% block content %}
|
||||
<h1>My Page Content</h1>
|
||||
{% endblock %}
|
||||
{% templatetag openblock %} extends "base/base.html" {% templatetag closeblock %}
|
||||
{% templatetag openblock %} block title {% templatetag closeblock %}My Page - ThrillWiki{% templatetag openblock %} endblock {% templatetag closeblock %}
|
||||
{% templatetag openblock %} block content {% templatetag closeblock %}
|
||||
<h1>My Page Content</h1>
|
||||
{% templatetag openblock %} endblock {% templatetag closeblock %}
|
||||
============================================================================= #}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
|
||||
@@ -20,17 +20,18 @@ from rest_framework.test import APIClient
|
||||
|
||||
from tests.factories import (
|
||||
CoasterFactory,
|
||||
DesignerCompanyFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
RideModelFactory,
|
||||
RidesDesignerFactory,
|
||||
RidesManufacturerFactory,
|
||||
StaffUserFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from tests.test_utils import EnhancedAPITestCase
|
||||
|
||||
|
||||
|
||||
class TestRideListAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideListCreateAPIView GET endpoint."""
|
||||
|
||||
@@ -38,8 +39,8 @@ class TestRideListAPIView(EnhancedAPITestCase):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.park = ParkFactory()
|
||||
self.manufacturer = ManufacturerCompanyFactory()
|
||||
self.designer = DesignerCompanyFactory()
|
||||
self.manufacturer = RidesManufacturerFactory()
|
||||
self.designer = RidesDesignerFactory()
|
||||
self.rides = [
|
||||
RideFactory(
|
||||
park=self.park,
|
||||
@@ -183,7 +184,7 @@ class TestRideCreateAPIView(EnhancedAPITestCase):
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.park = ParkFactory()
|
||||
self.manufacturer = ManufacturerCompanyFactory()
|
||||
self.manufacturer = RidesManufacturerFactory()
|
||||
self.url = "/api/v1/rides/"
|
||||
|
||||
self.valid_ride_data = {
|
||||
@@ -373,7 +374,7 @@ class TestHybridRideAPIView(EnhancedAPITestCase):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.park = ParkFactory()
|
||||
self.manufacturer = ManufacturerCompanyFactory()
|
||||
self.manufacturer = RidesManufacturerFactory()
|
||||
self.rides = [
|
||||
RideFactory(park=self.park, manufacturer=self.manufacturer, status="OPERATING", category="RC"),
|
||||
RideFactory(park=self.park, status="OPERATING", category="DR"),
|
||||
@@ -386,10 +387,9 @@ class TestHybridRideAPIView(EnhancedAPITestCase):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data.get("success", False))
|
||||
self.assertIn("data", response.data)
|
||||
self.assertIn("rides", response.data["data"])
|
||||
self.assertIn("total_count", response.data["data"])
|
||||
# API returns rides directly, not wrapped in success/data
|
||||
self.assertIn("rides", response.data)
|
||||
self.assertIn("total_count", response.data)
|
||||
|
||||
def test__hybrid_ride__with_category_filter__returns_filtered_rides(self):
|
||||
"""Test filtering by category."""
|
||||
@@ -420,7 +420,8 @@ class TestHybridRideAPIView(EnhancedAPITestCase):
|
||||
response = self.client.get(self.url, {"offset": 0})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("has_more", response.data["data"])
|
||||
# API returns has_more directly at top level
|
||||
self.assertIn("has_more", response.data)
|
||||
|
||||
def test__hybrid_ride__with_invalid_offset__returns_400(self):
|
||||
"""Test invalid offset parameter."""
|
||||
@@ -465,15 +466,15 @@ class TestRideFilterMetadataAPIView(EnhancedAPITestCase):
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.url = "/api/v1/rides/filter-metadata/"
|
||||
self.url = "/api/v1/rides/hybrid/filter-metadata/"
|
||||
|
||||
def test__filter_metadata__unscoped__returns_all_metadata(self):
|
||||
"""Test getting unscoped filter metadata."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data.get("success", False))
|
||||
self.assertIn("data", response.data)
|
||||
# API returns metadata directly, not wrapped in success/data
|
||||
self.assertIsInstance(response.data, dict)
|
||||
|
||||
def test__filter_metadata__scoped__returns_filtered_metadata(self):
|
||||
"""Test getting scoped filter metadata."""
|
||||
@@ -488,7 +489,7 @@ class TestCompanySearchAPIView(EnhancedAPITestCase):
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.manufacturer = ManufacturerCompanyFactory(name="Bolliger & Mabillard")
|
||||
self.manufacturer = RidesManufacturerFactory(name="Bolliger & Mabillard")
|
||||
self.url = "/api/v1/rides/search/companies/"
|
||||
|
||||
def test__company_search__with_query__returns_matching_companies(self):
|
||||
@@ -520,7 +521,7 @@ class TestRideModelSearchAPIView(EnhancedAPITestCase):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.ride_model = RideModelFactory(name="Hyper Coaster")
|
||||
self.url = "/api/v1/rides/search-ride-models/"
|
||||
self.url = "/api/v1/rides/search/ride-models/"
|
||||
|
||||
def test__ride_model_search__with_query__returns_matching_models(self):
|
||||
"""Test searching for ride models."""
|
||||
|
||||
@@ -743,18 +743,11 @@ def bulk_operation_pending(db):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_server(live_server_url):
|
||||
"""Provide the live server URL for tests.
|
||||
|
||||
Note: This fixture is provided by pytest-django. The live_server_url
|
||||
fixture provides the URL as a string.
|
||||
"""
|
||||
|
||||
class LiveServer:
|
||||
url = live_server_url
|
||||
|
||||
return LiveServer()
|
||||
# NOTE: The live_server fixture is provided by pytest-django.
|
||||
# It has a .url attribute that provides the server URL.
|
||||
# We previously had a custom wrapper here, but it broke because
|
||||
# it depended on a non-existent 'live_server_url' fixture.
|
||||
# The built-in live_server fixture already works correctly.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
635
docs/allauth_integration_guide.md
Normal file
635
docs/allauth_integration_guide.md
Normal file
@@ -0,0 +1,635 @@
|
||||
# Django-Allauth Integration Guide for ThrillWiki
|
||||
|
||||
This guide documents how to properly integrate django-allauth for authentication in ThrillWiki, covering JWT tokens, password authentication, MFA (TOTP/WebAuthn), and social OAuth (Google/Discord).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Installation & Setup](#installation--setup)
|
||||
2. [JWT Token Authentication](#jwt-token-authentication)
|
||||
3. [Password Authentication](#password-authentication)
|
||||
4. [MFA: TOTP (Authenticator App)](#mfa-totp-authenticator-app)
|
||||
5. [MFA: WebAuthn/Passkeys](#mfa-webauthnpasskeys)
|
||||
6. [Social OAuth: Google](#social-oauth-google)
|
||||
7. [Social OAuth: Discord](#social-oauth-discord)
|
||||
8. [API Patterns & DRF Integration](#api-patterns--drf-integration)
|
||||
9. [Internal API Reference](#internal-api-reference)
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Required Packages
|
||||
|
||||
```bash
|
||||
# Add packages to pyproject.toml
|
||||
uv add "django-allauth[headless,mfa,socialaccount]"
|
||||
uv add fido2 # For WebAuthn support
|
||||
|
||||
# Install all dependencies
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Running Django Commands
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Create superuser
|
||||
uv run manage.py createsuperuser
|
||||
|
||||
# Run development server
|
||||
uv run manage.py runserver
|
||||
|
||||
# Collect static files
|
||||
uv run manage.py collectstatic
|
||||
```
|
||||
|
||||
### INSTALLED_APPS Configuration
|
||||
|
||||
```python
|
||||
# config/django/base.py
|
||||
INSTALLED_APPS = [
|
||||
# Django built-in
|
||||
"django.contrib.auth",
|
||||
"django.contrib.sites",
|
||||
|
||||
# Allauth core (required)
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
|
||||
# Optional modules
|
||||
"allauth.headless", # For headless/API mode
|
||||
"allauth.mfa", # MFA support (TOTP, recovery codes)
|
||||
"allauth.mfa.webauthn", # WebAuthn/Passkey support
|
||||
"allauth.socialaccount", # Social auth base
|
||||
"allauth.socialaccount.providers.google",
|
||||
"allauth.socialaccount.providers.discord",
|
||||
]
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
```python
|
||||
MIDDLEWARE = [
|
||||
# ... other middleware
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
```
|
||||
|
||||
### URL Configuration
|
||||
|
||||
```python
|
||||
# urls.py
|
||||
urlpatterns = [
|
||||
# Allauth browser views (needed for OAuth callbacks)
|
||||
path("accounts/", include("allauth.urls")),
|
||||
|
||||
# Allauth headless API endpoints
|
||||
path("_allauth/", include("allauth.headless.urls")),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT Token Authentication
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
|
||||
# Token strategy - use JWT
|
||||
HEADLESS_TOKEN_STRATEGY = "allauth.headless.tokens.JWTTokenStrategy"
|
||||
|
||||
# Generate private key: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
HEADLESS_JWT_PRIVATE_KEY = """
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...
|
||||
-----END PRIVATE KEY-----
|
||||
"""
|
||||
|
||||
# Token lifetimes
|
||||
HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN = 300 # 5 minutes
|
||||
HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN = 86400 # 24 hours
|
||||
|
||||
# Authorization header scheme
|
||||
HEADLESS_JWT_AUTHORIZATION_HEADER_SCHEME = "Bearer"
|
||||
|
||||
# Stateful validation (invalidates tokens on logout)
|
||||
HEADLESS_JWT_STATEFUL_VALIDATION_ENABLED = True
|
||||
|
||||
# Rotate refresh tokens on use
|
||||
HEADLESS_JWT_ROTATE_REFRESH_TOKEN = True
|
||||
```
|
||||
|
||||
### DRF Integration
|
||||
|
||||
```python
|
||||
from allauth.headless.contrib.rest_framework.authentication import JWTTokenAuthentication
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.views import APIView
|
||||
|
||||
class ProtectedAPIView(APIView):
|
||||
authentication_classes = [JWTTokenAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response({"user": request.user.email})
|
||||
```
|
||||
|
||||
### JWT Flow
|
||||
|
||||
1. User authenticates (password/social/passkey)
|
||||
2. During auth, pass `X-Session-Token` header to allauth API
|
||||
3. Upon successful authentication, response `meta` contains:
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "abc123..."
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Use `Authorization: Bearer <access_token>` for subsequent requests
|
||||
5. Refresh tokens via `POST /_allauth/browser/v1/auth/token/refresh`
|
||||
|
||||
---
|
||||
|
||||
## Password Authentication
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# config/settings/third_party.py
|
||||
|
||||
# Signup fields (* = required)
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
||||
|
||||
# Login methods
|
||||
ACCOUNT_LOGIN_METHODS = {"email", "username"} # Allow both
|
||||
|
||||
# Email verification
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory" # Options: "mandatory", "optional", "none"
|
||||
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
|
||||
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True
|
||||
|
||||
# Security
|
||||
ACCOUNT_REAUTHENTICATION_REQUIRED = True # Require re-auth for sensitive operations
|
||||
ACCOUNT_EMAIL_NOTIFICATIONS = True
|
||||
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False # Don't reveal if email exists
|
||||
|
||||
# Redirects
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
# Custom adapters
|
||||
ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter"
|
||||
```
|
||||
|
||||
### Headless API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/_allauth/browser/v1/auth/login` | POST | Login with email/username + password |
|
||||
| `/_allauth/browser/v1/auth/signup` | POST | Register new account |
|
||||
| `/_allauth/browser/v1/auth/logout` | POST | Logout (invalidate tokens) |
|
||||
| `/_allauth/browser/v1/auth/password/reset` | POST | Request password reset |
|
||||
| `/_allauth/browser/v1/auth/password/reset/key` | POST | Complete password reset |
|
||||
| `/_allauth/browser/v1/auth/password/change` | POST | Change password (authenticated) |
|
||||
|
||||
### Magic Link (Login by Code)
|
||||
|
||||
```python
|
||||
# Enable magic link authentication
|
||||
ACCOUNT_LOGIN_BY_CODE_ENABLED = True
|
||||
ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 3
|
||||
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 300 # 5 minutes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MFA: TOTP (Authenticator App)
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# config/settings/third_party.py
|
||||
|
||||
# Enable TOTP in supported types
|
||||
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]
|
||||
|
||||
# TOTP settings
|
||||
MFA_TOTP_ISSUER = "ThrillWiki" # Shows in authenticator app
|
||||
MFA_TOTP_DIGITS = 6 # Code length
|
||||
MFA_TOTP_PERIOD = 30 # Seconds per code
|
||||
```
|
||||
|
||||
### TOTP API Flow
|
||||
|
||||
1. **Get TOTP Secret** (for QR code):
|
||||
```
|
||||
GET /_allauth/browser/v1/account/authenticators/totp
|
||||
```
|
||||
Response contains `totp_url` for QR code generation.
|
||||
|
||||
2. **Activate TOTP**:
|
||||
```
|
||||
POST /_allauth/browser/v1/account/authenticators/totp
|
||||
{
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Deactivate TOTP**:
|
||||
```
|
||||
DELETE /_allauth/browser/v1/account/authenticators/totp
|
||||
```
|
||||
|
||||
4. **MFA Login Flow**:
|
||||
- After password auth, if MFA enabled, receive `401` with `mfa_required`
|
||||
- Submit TOTP code:
|
||||
```
|
||||
POST /_allauth/browser/v1/auth/2fa/authenticate
|
||||
{
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MFA: WebAuthn/Passkeys
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# config/settings/third_party.py
|
||||
|
||||
# Include webauthn in supported types
|
||||
MFA_SUPPORTED_TYPES = ["totp", "webauthn"]
|
||||
|
||||
# Enable passkey-only login
|
||||
MFA_PASSKEY_LOGIN_ENABLED = True
|
||||
|
||||
# Allow insecure origin for localhost development
|
||||
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True # Only for DEBUG=True
|
||||
```
|
||||
|
||||
### Internal WebAuthn API Functions
|
||||
|
||||
The `allauth.mfa.webauthn.internal.auth` module provides these functions:
|
||||
|
||||
```python
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Registration Flow
|
||||
def begin_registration(user, passwordless: bool) -> Dict:
|
||||
"""
|
||||
Start passkey registration.
|
||||
|
||||
Args:
|
||||
user: The Django user object
|
||||
passwordless: True for passkey login, False for MFA-only
|
||||
|
||||
Returns:
|
||||
Dict with WebAuthn creation options (challenge, rp, user, etc.)
|
||||
|
||||
Note: State is stored internally via set_state()
|
||||
"""
|
||||
|
||||
def complete_registration(credential: Dict) -> AuthenticatorData:
|
||||
"""
|
||||
Complete passkey registration.
|
||||
|
||||
Args:
|
||||
credential: The parsed credential response from browser
|
||||
|
||||
Returns:
|
||||
AuthenticatorData (binding) - NOT an Authenticator model
|
||||
|
||||
Note: You must create the Authenticator record yourself!
|
||||
"""
|
||||
|
||||
# Authentication Flow
|
||||
def begin_authentication(user=None) -> Dict:
|
||||
"""
|
||||
Start passkey authentication.
|
||||
|
||||
Args:
|
||||
user: Optional user (for MFA). None for passwordless login.
|
||||
|
||||
Returns:
|
||||
Dict with WebAuthn request options
|
||||
|
||||
Note: State is stored internally via set_state()
|
||||
"""
|
||||
|
||||
def complete_authentication(user, response: Dict) -> Authenticator:
|
||||
"""
|
||||
Complete passkey authentication.
|
||||
|
||||
Args:
|
||||
user: The Django user object
|
||||
response: The credential response from browser
|
||||
|
||||
Returns:
|
||||
The matching Authenticator model instance
|
||||
"""
|
||||
|
||||
# State Management (internal, use context)
|
||||
def get_state() -> Optional[Dict]:
|
||||
"""Get stored WebAuthn state from session."""
|
||||
|
||||
def set_state(state: Dict) -> None:
|
||||
"""Store WebAuthn state in session."""
|
||||
|
||||
def clear_state() -> None:
|
||||
"""Clear WebAuthn state from session."""
|
||||
|
||||
# Helper functions
|
||||
def parse_registration_response(response: Any) -> RegistrationResponse:
|
||||
"""Parse browser registration response."""
|
||||
|
||||
def parse_authentication_response(response: Any) -> AuthenticationResponse:
|
||||
"""Parse browser authentication response."""
|
||||
```
|
||||
|
||||
### Custom Passkey API Implementation
|
||||
|
||||
```python
|
||||
# apps/api/v1/auth/passkey.py
|
||||
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_registration_options(request):
|
||||
"""Get WebAuthn registration options."""
|
||||
# passwordless=False for MFA passkeys, True for passwordless login
|
||||
creation_options = webauthn_auth.begin_registration(
|
||||
request.user,
|
||||
passwordless=False
|
||||
)
|
||||
# State is stored internally
|
||||
return Response({"options": creation_options})
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def register_passkey(request):
|
||||
"""Complete passkey registration."""
|
||||
credential = request.data.get("credential")
|
||||
name = request.data.get("name", "Passkey")
|
||||
|
||||
# Check for pending registration
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
return Response({"error": "No pending registration"}, status=400)
|
||||
|
||||
# Parse and complete registration
|
||||
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||
authenticator_data = webauthn_auth.complete_registration(credential_data)
|
||||
|
||||
# Create Authenticator record manually
|
||||
authenticator = Authenticator.objects.create(
|
||||
user=request.user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
data={"name": name},
|
||||
)
|
||||
|
||||
return Response({"id": str(authenticator.id), "name": name})
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_authentication_options(request):
|
||||
"""Get WebAuthn authentication options."""
|
||||
request_options = webauthn_auth.begin_authentication(request.user)
|
||||
return Response({"options": request_options})
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def authenticate_passkey(request):
|
||||
"""Verify passkey authentication."""
|
||||
credential = request.data.get("credential")
|
||||
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
return Response({"error": "No pending authentication"}, status=400)
|
||||
|
||||
# Complete authentication (handles state internally)
|
||||
webauthn_auth.complete_authentication(request.user, credential)
|
||||
|
||||
return Response({"success": True})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Social OAuth: Google
|
||||
|
||||
### Google Cloud Console Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.developers.google.com/)
|
||||
2. Create/select project → APIs & Services → Credentials
|
||||
3. Create OAuth 2.0 Client ID (Web application)
|
||||
4. Set Authorized JavaScript origins:
|
||||
- `http://localhost:3000` (development)
|
||||
- `https://thrillwiki.com` (production)
|
||||
5. Set Authorized redirect URIs:
|
||||
- `http://localhost:8000/accounts/google/login/callback/`
|
||||
- `https://api.thrillwiki.com/accounts/google/login/callback/`
|
||||
6. Note the Client ID and Client Secret
|
||||
|
||||
### Django Configuration
|
||||
|
||||
```python
|
||||
# config/settings/third_party.py
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"google": {
|
||||
"SCOPE": ["profile", "email"],
|
||||
"AUTH_PARAMS": {"access_type": "online"}, # Use "offline" for refresh tokens
|
||||
"OAUTH_PKCE_ENABLED": True,
|
||||
# "FETCH_USERINFO": True, # If you need avatar_url for private profiles
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Setup
|
||||
|
||||
1. Go to `/admin/socialaccount/socialapp/`
|
||||
2. Add new Social Application:
|
||||
- Provider: Google
|
||||
- Name: Google
|
||||
- Client ID: (from Google Console)
|
||||
- Secret key: (from Google Console)
|
||||
- Sites: Select your site
|
||||
|
||||
---
|
||||
|
||||
## Social OAuth: Discord
|
||||
|
||||
### Discord Developer Portal Setup
|
||||
|
||||
1. Go to [Discord Developer Portal](https://discordapp.com/developers/applications/me)
|
||||
2. Create New Application
|
||||
3. Go to OAuth2 → General
|
||||
4. Add Redirect URIs:
|
||||
- `http://127.0.0.1:8000/accounts/discord/login/callback/`
|
||||
- `https://api.thrillwiki.com/accounts/discord/login/callback/`
|
||||
5. Note Client ID and Client Secret
|
||||
|
||||
### Django Configuration
|
||||
|
||||
```python
|
||||
# config/settings/third_party.py
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"discord": {
|
||||
"SCOPE": ["identify", "email"], # "identify" is required
|
||||
"OAUTH_PKCE_ENABLED": True,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Setup
|
||||
|
||||
1. Go to `/admin/socialaccount/socialapp/`
|
||||
2. Add new Social Application:
|
||||
- Provider: Discord
|
||||
- Name: Discord
|
||||
- Client ID: (from Discord Portal)
|
||||
- Secret key: (from Discord Portal)
|
||||
- Sites: Select your site
|
||||
|
||||
---
|
||||
|
||||
## API Patterns & DRF Integration
|
||||
|
||||
### Authentication Classes
|
||||
|
||||
```python
|
||||
from allauth.headless.contrib.rest_framework.authentication import (
|
||||
JWTTokenAuthentication,
|
||||
SessionTokenAuthentication,
|
||||
)
|
||||
|
||||
# For JWT-based authentication
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"allauth.headless.contrib.rest_framework.authentication.JWTTokenAuthentication",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Headless Frontend URLs
|
||||
|
||||
```python
|
||||
# Required for email verification, password reset links
|
||||
HEADLESS_FRONTEND_URLS = {
|
||||
"account_confirm_email": "https://thrillwiki.com/account/verify-email/{key}",
|
||||
"account_reset_password_from_key": "https://thrillwiki.com/account/password/reset/{key}",
|
||||
"account_signup": "https://thrillwiki.com/signup",
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Adapters
|
||||
|
||||
```python
|
||||
# apps/accounts/adapters.py
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def save_user(self, request, user, form, commit=True):
|
||||
"""Customize user creation."""
|
||||
user = super().save_user(request, user, form, commit=False)
|
||||
# Custom logic here
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def is_open_for_signup(self, request):
|
||||
"""Control signup availability."""
|
||||
return True
|
||||
|
||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
"""Hook before social login completes."""
|
||||
# Link social account to existing user by email
|
||||
if sociallogin.is_existing:
|
||||
return
|
||||
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
if email:
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
sociallogin.connect(request, user)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Internal API Reference
|
||||
|
||||
### Authenticator Model
|
||||
|
||||
```python
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
# Types
|
||||
Authenticator.Type.TOTP # TOTP authenticator
|
||||
Authenticator.Type.WEBAUTHN # WebAuthn/Passkey
|
||||
Authenticator.Type.RECOVERY_CODES # Recovery codes
|
||||
|
||||
# Query user's authenticators
|
||||
passkeys = Authenticator.objects.filter(
|
||||
user=user,
|
||||
type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
|
||||
# Check if MFA is enabled
|
||||
from allauth.mfa.adapter import get_adapter
|
||||
is_mfa_enabled = get_adapter().is_mfa_enabled(user)
|
||||
```
|
||||
|
||||
### Session Token Header
|
||||
|
||||
For headless mode during authentication flow:
|
||||
```
|
||||
X-Session-Token: <session-token>
|
||||
```
|
||||
|
||||
After authentication completes with JWT enabled:
|
||||
```
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current ThrillWiki Implementation Summary
|
||||
|
||||
ThrillWiki already has these allauth features configured:
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Password Auth | ✅ Configured | Email + username login |
|
||||
| Email Verification | ✅ Mandatory | With resend support |
|
||||
| TOTP MFA | ✅ Configured | 6-digit codes, 30s period |
|
||||
| WebAuthn/Passkeys | ✅ Configured | Passkey login enabled |
|
||||
| Google OAuth | ✅ Configured | Needs admin SocialApp |
|
||||
| Discord OAuth | ✅ Configured | Needs admin SocialApp |
|
||||
| Magic Link | ✅ Configured | 5-minute timeout |
|
||||
| JWT Tokens | ❌ Not configured | Using SimpleJWT instead |
|
||||
|
||||
### Recommendation
|
||||
|
||||
To use allauth's native JWT support instead of SimpleJWT:
|
||||
|
||||
1. Add `"allauth.headless"` to INSTALLED_APPS
|
||||
2. Configure `HEADLESS_TOKEN_STRATEGY` and JWT settings
|
||||
3. Replace `rest_framework_simplejwt` authentication with `JWTTokenAuthentication`
|
||||
4. Add `/_allauth/` URL routes
|
||||
Reference in New Issue
Block a user