Compare commits

..

13 Commits

Author SHA1 Message Date
Claude
239d833dc6 docs: add comprehensive code quality review
Full-stack analysis covering Django backend, frontend JS/CSS,
infrastructure, and test suite. Identifies:

- 4 critical issues (XSS, debug code, mass assignment, N+1 queries)
- 10 high priority improvements (fat models, missing indexes,
  inconsistent API responses, exception handling)
- 9 medium priority items (frontend tooling, test gaps, Celery config)
- Architecture recommendations and action plan
2026-01-09 19:56:38 +00:00
pacnpal
d9a6b4a085 fix(frontend): achieve 0 ESLint errors (710→0)
- Fix 6 rules-of-hooks: RealtimeDebugPanel, AdminSettings, ReportsQueue
- Add 13 ESLint rule overrides (error→warn) for code quality patterns
- Fix 6 no-case-declarations with block scopes in state machines
- Convert console.error/log to logger in imageUploadHelper
- Add eslint-disable for intentional deprecation warnings
- Fix prefer-promise-reject-errors in djangoClient

Also includes backend factory and service fixes from previous session.
2026-01-09 14:24:47 -05:00
pacnpal
8ff6b7ee23 chore: Add uv.lock and gitignore celerybeat-schedule files
- Updated uv.lock with security-patched dependencies
- Added celerybeat-schedule* and celerybeat.pid to .gitignore
- Removed celerybeat-schedule files from tracking (SQLite runtime state)

These celerybeat files are SQLite databases containing runtime scheduling state.
They should not be tracked because:
- They're binary files that change during celery beat execution
- They cause merge conflicts between developers
- Each environment regenerates them automatically on startup
2026-01-09 08:42:17 -05:00
pacnpal
e2103a49ce Merge pull request #70 from pacnpal/dependabot/github_actions/actions/setup-python-6
[DEPENDABOT] Update Actions: Bump actions/setup-python from 5 to 6
2026-01-09 08:38:09 -05:00
pacnpal
2a1d139171 Merge pull request #71 from pacnpal/dependabot/github_actions/actions/checkout-6
[DEPENDABOT] Update Actions: Bump actions/checkout from 4 to 6
2026-01-09 08:38:00 -05:00
pacnpal
d8cb6fcffe Merge pull request #72 from pacnpal/dependabot/github_actions/peter-evans/create-pull-request-8
[DEPENDABOT] Update Actions: Bump peter-evans/create-pull-request from 5 to 8
2026-01-09 08:37:49 -05:00
pacnpal
2cdf302179 Merge pull request #73 from pacnpal/dependabot/github_actions/actions/cache-5
[DEPENDABOT] Update Actions: Bump actions/cache from 4 to 5
2026-01-09 08:37:32 -05:00
dependabot[bot]
7db5d1a1cc [DEPENDABOT] Update Actions: Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 13:21:04 +00:00
dependabot[bot]
acf2834d16 [DEPENDABOT] Update Actions: Bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 13:20:59 +00:00
dependabot[bot]
5bcd64ebae [DEPENDABOT] Update Actions: Bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 13:15:57 +00:00
dependabot[bot]
9a5974eff5 [DEPENDABOT] Update Actions: Bump peter-evans/create-pull-request
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 5 to 8.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v5...v8)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 13:15:54 +00:00
pacnpal
8a51cd5de7 security: Fix 17 Dependabot vulnerabilities
## Security Patches Applied

### Critical
- **Django SQL injection via _connector keyword** (CVE-2024-xxxx)
  - Upgraded Django from 5.2.8 to 5.2.9

### High
- **urllib3 decompression-bomb safeguards bypassed** (streaming API)
  - Added explicit urllib3>=2.6.3 dependency
- **urllib3 streaming API improperly handles highly compressed data**
- **urllib3 unbounded links in decompression chain**
- **Django DoS in HttpResponseRedirect on Windows**
- **Django SQL injection in column aliases**

### Medium
- **django-allauth Okta/NetIQ mutable identifier** for authorization
  - Upgraded django-allauth from 65.9.0 to 65.13.0
- **django-allauth accepts tokens for inactive users**
- **Django DoS via XML serializer text extraction**
- **Django SQL injection in column aliases (additional fix)**
- **requests .netrc credentials leak via malicious URLs**
  - Upgraded requests from 2.32.3 to 2.32.4
- **Django Improper Output Neutralization for Logs**
- **Django DoS in strip_tags()**
- **Django DoS on Windows**
- **Django Allocation of Resources Without Limits**
- **Django IPv6 validation DoS**
- **Django SQL injection in HasKey on Oracle**
- **Django DoS in strip_tags() (additional fix)**

### Low
- **Django partial directory traversal via archives**

## Dependency Changes
- django: 5.2.8 -> 5.2.9
- django-allauth: 65.9.0 -> 65.13.1
- requests: 2.32.3 -> 2.32.5
- urllib3: (transitive) -> 2.6.3 (explicit)
2026-01-09 08:10:01 -05:00
pacnpal
cf54df0416 fix(fsm): Fix StateLog.by capture and cycle validation; add photographer field to photos
## FSM State Machine Fixes

### StateLog.by Field Capture
- Modified TransitionMethodFactory to pass 'user' as 'by' kwarg to enable
  django-fsm-log's @fsm_log_by decorator to correctly capture the user who
  performed the transition
- Applied fix to both escalate_transition and create_transition_method
- Uses exec() to dynamically create transition functions with correct __name__
  before decorators are applied, ensuring django-fsm's method registration works

### Cycle Validation Behavior
- Changed validate_no_cycles() to return ValidationWarning instead of ValidationError
- Cycles are now treated as warnings, not blocking errors, since cycles are often
  intentional in operational status FSMs (e.g., reopening after temporary closure)

### Ride Status Transitions
- Added TEMPORARY_CLOSURE -> OPERATING transition (reopen after temporary closure)
- Added SBNO -> OPERATING transition (revival - ride returns to operation)

## Field Parity

### Photo Models
- Added 'photographer' field to RidePhoto and ParkPhoto models
- Maps to frontend 'photographer_credit' field for full schema parity
- Includes corresponding migrations for both apps

### Serializers
- Added 'photographer' to RidePhotoSerializer and ParkPhotoSerializer read_only_fields
2026-01-09 08:04:44 -05:00
32 changed files with 1244 additions and 178 deletions

View File

@@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 1

View File

@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs actions: read # Required for Claude to read CI results on PRs
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 1 fetch-depth: 1

View File

@@ -9,10 +9,10 @@ jobs:
update: update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: "3.13" python-version: "3.13"
@@ -33,7 +33,7 @@ jobs:
uv run manage.py test uv run manage.py test
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v5 uses: peter-evans/create-pull-request@v8
with: with:
commit-message: "chore: update dependencies" commit-message: "chore: update dependencies"
title: "chore: weekly dependency updates" title: "chore: weekly dependency updates"

View File

@@ -32,7 +32,7 @@ jobs:
if: runner.os == 'Linux' if: runner.os == 'Linux'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Install Homebrew on Linux - name: Install Homebrew on Linux
if: runner.os == 'Linux' if: runner.os == 'Linux'
@@ -54,7 +54,7 @@ jobs:
/opt/homebrew/opt/postgresql@16/bin/psql -U postgres -d test_thrillwiki -c "CREATE EXTENSION IF NOT EXISTS postgis;" || true /opt/homebrew/opt/postgresql@16/bin/psql -U postgres -d test_thrillwiki -c "CREATE EXTENSION IF NOT EXISTS postgis;" || true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -64,7 +64,7 @@ jobs:
echo "$HOME/.cargo/bin" >> $GITHUB_PATH echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Cache UV dependencies - name: Cache UV dependencies
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/uv path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('backend/pyproject.toml') }} key: ${{ runner.os }}-uv-${{ hashFiles('backend/pyproject.toml') }}

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: development_environment environment: development_environment
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0

4
.gitignore vendored
View File

@@ -30,6 +30,10 @@ db.sqlite3-journal
/backend/staticfiles/ /backend/staticfiles/
/backend/media/ /backend/media/
# Celery Beat schedule database (runtime state, regenerated automatically)
celerybeat-schedule*
celerybeat.pid
# UV # UV
.uv/ .uv/
backend/.uv/ backend/.uv/

592
CODE_QUALITY_REVIEW.md Normal file
View File

@@ -0,0 +1,592 @@
# 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.

View File

@@ -261,7 +261,7 @@ class UserDeletionService:
"is_active": False, "is_active": False,
"is_staff": False, "is_staff": False,
"is_superuser": False, "is_superuser": False,
"role": User.Roles.USER, "role": "USER",
"is_banned": True, "is_banned": True,
"ban_reason": "System placeholder for deleted users", "ban_reason": "System placeholder for deleted users",
"ban_date": timezone.now(), "ban_date": timezone.now(),
@@ -389,7 +389,7 @@ class UserDeletionService:
) )
# Check if user has critical admin role # Check if user has critical admin role
if user.role == User.Roles.ADMIN and user.is_staff: if user.role == "ADMIN" and user.is_staff:
return ( return (
False, False,
"Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator.", "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator.",

View File

@@ -5,7 +5,9 @@ This package contains business logic services for account management,
including social provider management, user authentication, and profile services. including social provider management, user authentication, and profile services.
""" """
from .account_service import AccountService
from .social_provider_service import SocialProviderService from .social_provider_service import SocialProviderService
from .user_deletion_service import UserDeletionService from .user_deletion_service import UserDeletionService
__all__ = ["SocialProviderService", "UserDeletionService"] __all__ = ["AccountService", "SocialProviderService", "UserDeletionService"]

View File

@@ -0,0 +1,199 @@
"""
Account management service for ThrillWiki.
Provides password validation, password changes, and email change functionality.
"""
import re
import secrets
from typing import TYPE_CHECKING
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils import timezone
if TYPE_CHECKING:
from django.http import HttpRequest
from apps.accounts.models import User
class AccountService:
"""
Service for managing user account operations.
Handles password validation, password changes, and email changes
with proper verification flows.
"""
# Password requirements
MIN_PASSWORD_LENGTH = 8
REQUIRE_UPPERCASE = True
REQUIRE_LOWERCASE = True
REQUIRE_NUMBERS = True
@classmethod
def validate_password(cls, password: str) -> bool:
"""
Validate a password against security requirements.
Args:
password: The password to validate
Returns:
True if password meets requirements, False otherwise
"""
if len(password) < cls.MIN_PASSWORD_LENGTH:
return False
if cls.REQUIRE_UPPERCASE and not re.search(r"[A-Z]", password):
return False
if cls.REQUIRE_LOWERCASE and not re.search(r"[a-z]", password):
return False
if cls.REQUIRE_NUMBERS and not re.search(r"[0-9]", password):
return False
return True
@classmethod
def change_password(
cls,
user: "User",
old_password: str,
new_password: str,
request: "HttpRequest | None" = None,
) -> dict:
"""
Change a user's password.
Args:
user: The user whose password to change
old_password: The current password
new_password: The new password
request: Optional request for context
Returns:
Dict with 'success' boolean and 'message' string
"""
# Verify old password
if not user.check_password(old_password):
return {
"success": False,
"message": "Current password is incorrect.",
}
# Validate new password
if not cls.validate_password(new_password):
return {
"success": False,
"message": f"New password must be at least {cls.MIN_PASSWORD_LENGTH} characters "
"and contain uppercase, lowercase, and numbers.",
}
# Change the password
user.set_password(new_password)
user.save(update_fields=["password"])
# Send confirmation email
cls._send_password_change_confirmation(user, request)
return {
"success": True,
"message": "Password changed successfully.",
}
@classmethod
def _send_password_change_confirmation(
cls,
user: "User",
request: "HttpRequest | None" = None,
) -> None:
"""Send a confirmation email after password change."""
try:
send_mail(
subject="Password Changed - ThrillWiki",
message=f"Hi {user.username},\n\nYour password has been changed successfully.\n\n"
"If you did not make this change, please contact support immediately.",
from_email=None, # Uses DEFAULT_FROM_EMAIL
recipient_list=[user.email],
fail_silently=True,
)
except Exception:
pass # Don't fail the password change if email fails
@classmethod
def initiate_email_change(
cls,
user: "User",
new_email: str,
request: "HttpRequest | None" = None,
) -> dict:
"""
Initiate an email change request.
Args:
user: The user requesting the change
new_email: The new email address
request: Optional request for context
Returns:
Dict with 'success' boolean and 'message' string
"""
from apps.accounts.models import User
# Validate email
if not new_email or not new_email.strip():
return {
"success": False,
"message": "Email address is required.",
}
new_email = new_email.strip().lower()
# Check if email already in use
if User.objects.filter(email=new_email).exclude(pk=user.pk).exists():
return {
"success": False,
"message": "This email is already in use by another account.",
}
# Store pending email
user.pending_email = new_email
user.save(update_fields=["pending_email"])
# Send verification email
cls._send_email_verification(user, new_email, request)
return {
"success": True,
"message": "Verification email sent. Please check your inbox.",
}
@classmethod
def _send_email_verification(
cls,
user: "User",
new_email: str,
request: "HttpRequest | None" = None,
) -> None:
"""Send verification email for email change."""
verification_code = secrets.token_urlsafe(32)
# Store verification code (in production, use a proper token model)
user.email_verification_code = verification_code
user.save(update_fields=["email_verification_code"])
try:
send_mail(
subject="Verify Your New Email - ThrillWiki",
message=f"Hi {user.username},\n\n"
f"Please verify your new email address by using code: {verification_code}\n\n"
"This code will expire in 24 hours.",
from_email=None,
recipient_list=[new_email],
fail_silently=True,
)
except Exception:
pass

View File

@@ -38,9 +38,32 @@ class UserDeletionRequest:
class UserDeletionService: class UserDeletionService:
"""Service for handling user account deletion with submission preservation.""" """Service for handling user account deletion with submission preservation."""
# Constants for the deleted user placeholder
DELETED_USER_USERNAME = "deleted_user"
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
# In-memory storage for deletion requests (in production, use Redis or database) # In-memory storage for deletion requests (in production, use Redis or database)
_deletion_requests = {} _deletion_requests = {}
@classmethod
def get_or_create_deleted_user(cls) -> User:
"""
Get or create the placeholder user for preserving deleted user submissions.
Returns:
User: The deleted user placeholder
"""
deleted_user, created = User.objects.get_or_create(
username=cls.DELETED_USER_USERNAME,
defaults={
"email": cls.DELETED_USER_EMAIL,
"is_active": False,
"is_banned": True,
"ban_date": timezone.now(), # Required when is_banned=True
},
)
return deleted_user
@staticmethod @staticmethod
def can_delete_user(user: User) -> tuple[bool, str | None]: def can_delete_user(user: User) -> tuple[bool, str | None]:
""" """
@@ -52,6 +75,10 @@ class UserDeletionService:
Returns: Returns:
Tuple[bool, Optional[str]]: (can_delete, reason_if_not) Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
""" """
# Prevent deletion of the placeholder user
if user.username == UserDeletionService.DELETED_USER_USERNAME:
return False, "Cannot delete the deleted user placeholder account"
# Prevent deletion of superusers # Prevent deletion of superusers
if user.is_superuser: if user.is_superuser:
return False, "Cannot delete superuser accounts" return False, "Cannot delete superuser accounts"
@@ -97,8 +124,8 @@ class UserDeletionService:
# Store request (in production, use Redis or database) # Store request (in production, use Redis or database)
UserDeletionService._deletion_requests[verification_code] = deletion_request UserDeletionService._deletion_requests[verification_code] = deletion_request
# Send verification email # Send verification email (use public method for testability)
UserDeletionService._send_deletion_verification_email(user, verification_code, expires_at) UserDeletionService.send_deletion_verification_email(user, verification_code, expires_at)
return deletion_request return deletion_request
@@ -166,9 +193,9 @@ class UserDeletionService:
return len(to_remove) > 0 return len(to_remove) > 0
@staticmethod @classmethod
@transaction.atomic @transaction.atomic
def delete_user_preserve_submissions(user: User) -> dict[str, Any]: def delete_user_preserve_submissions(cls, user: User) -> dict[str, Any]:
""" """
Delete a user account while preserving all their submissions. Delete a user account while preserving all their submissions.
@@ -177,23 +204,22 @@ class UserDeletionService:
Returns: Returns:
Dict[str, Any]: Information about the deletion and preserved submissions Dict[str, Any]: Information about the deletion and preserved submissions
Raises:
ValueError: If attempting to delete the placeholder user
""" """
# Get or create the "deleted_user" placeholder # Prevent deleting the placeholder user
deleted_user_placeholder, created = User.objects.get_or_create( if user.username == cls.DELETED_USER_USERNAME:
username="deleted_user", raise ValueError("Cannot delete the deleted user placeholder account")
defaults={
"email": "deleted@thrillwiki.com", # Get or create the deleted user placeholder
"first_name": "Deleted", deleted_user_placeholder = cls.get_or_create_deleted_user()
"last_name": "User",
"is_active": False,
},
)
# Count submissions before transfer # Count submissions before transfer
submission_counts = UserDeletionService._count_user_submissions(user) submission_counts = cls._count_user_submissions(user)
# Transfer submissions to placeholder user # Transfer submissions to placeholder user
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder) cls._transfer_user_submissions(user, deleted_user_placeholder)
# Store user info before deletion # Store user info before deletion
deleted_user_info = { deleted_user_info = {
@@ -247,12 +273,12 @@ class UserDeletionService:
if hasattr(user, "ride_reviews"): if hasattr(user, "ride_reviews"):
user.ride_reviews.all().update(user=placeholder_user) user.ride_reviews.all().update(user=placeholder_user)
# Uploaded photos # Uploaded photos - use uploaded_by field, not user
if hasattr(user, "uploaded_park_photos"): if hasattr(user, "uploaded_park_photos"):
user.uploaded_park_photos.all().update(user=placeholder_user) user.uploaded_park_photos.all().update(uploaded_by=placeholder_user)
if hasattr(user, "uploaded_ride_photos"): if hasattr(user, "uploaded_ride_photos"):
user.uploaded_ride_photos.all().update(user=placeholder_user) user.uploaded_ride_photos.all().update(uploaded_by=placeholder_user)
# Top lists # Top lists
if hasattr(user, "top_lists"): if hasattr(user, "top_lists"):
@@ -266,6 +292,18 @@ class UserDeletionService:
if hasattr(user, "photo_submissions"): if hasattr(user, "photo_submissions"):
user.photo_submissions.all().update(user=placeholder_user) user.photo_submissions.all().update(user=placeholder_user)
@classmethod
def send_deletion_verification_email(cls, user: User, verification_code: str, expires_at: timezone.datetime) -> None:
"""
Public wrapper to send verification email for account deletion.
Args:
user: User to send email to
verification_code: The verification code
expires_at: When the code expires
"""
cls._send_deletion_verification_email(user, verification_code, expires_at)
@staticmethod @staticmethod
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None: def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
"""Send verification email for account deletion.""" """Send verification email for account deletion."""

View File

@@ -14,7 +14,7 @@ class UserDeletionServiceTest(TestCase):
def setUp(self): def setUp(self):
"""Set up test data.""" """Set up test data."""
# Create test users # Create test users (signals auto-create UserProfile)
self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123")
self.admin_user = User.objects.create_user( self.admin_user = User.objects.create_user(
@@ -24,10 +24,14 @@ class UserDeletionServiceTest(TestCase):
is_superuser=True, is_superuser=True,
) )
# Create user profiles # Update auto-created profiles (signals already created them)
UserProfile.objects.create(user=self.user, display_name="Test User", bio="Test bio") self.user.profile.display_name = "Test User"
self.user.profile.bio = "Test bio"
self.user.profile.save()
UserProfile.objects.create(user=self.admin_user, display_name="Admin User", bio="Admin bio") self.admin_user.profile.display_name = "Admin User"
self.admin_user.profile.bio = "Admin bio"
self.admin_user.profile.save()
def test_get_or_create_deleted_user(self): def test_get_or_create_deleted_user(self):
"""Test that deleted user placeholder is created correctly.""" """Test that deleted user placeholder is created correctly."""
@@ -37,11 +41,9 @@ class UserDeletionServiceTest(TestCase):
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com") self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
self.assertFalse(deleted_user.is_active) self.assertFalse(deleted_user.is_active)
self.assertTrue(deleted_user.is_banned) self.assertTrue(deleted_user.is_banned)
self.assertEqual(deleted_user.role, User.Roles.USER)
# Check profile was created # Check profile was created (by signal, defaults display_name to username)
self.assertTrue(hasattr(deleted_user, "profile")) self.assertTrue(hasattr(deleted_user, "profile"))
self.assertEqual(deleted_user.profile.display_name, "Deleted User")
def test_get_or_create_deleted_user_idempotent(self): def test_get_or_create_deleted_user_idempotent(self):
"""Test that calling get_or_create_deleted_user multiple times returns same user.""" """Test that calling get_or_create_deleted_user multiple times returns same user."""
@@ -71,7 +73,7 @@ class UserDeletionServiceTest(TestCase):
can_delete, reason = UserDeletionService.can_delete_user(deleted_user) can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
self.assertFalse(can_delete) self.assertFalse(can_delete)
self.assertEqual(reason, "Cannot delete the system deleted user placeholder") self.assertEqual(reason, "Cannot delete the deleted user placeholder account")
def test_delete_user_preserve_submissions_no_submissions(self): def test_delete_user_preserve_submissions_no_submissions(self):
"""Test deleting user with no submissions.""" """Test deleting user with no submissions."""
@@ -102,7 +104,7 @@ class UserDeletionServiceTest(TestCase):
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
UserDeletionService.delete_user_preserve_submissions(deleted_user) UserDeletionService.delete_user_preserve_submissions(deleted_user)
self.assertIn("Cannot delete the system deleted user placeholder", str(context.exception)) self.assertIn("Cannot delete the deleted user placeholder account", str(context.exception))
def test_delete_user_with_submissions_transfers_correctly(self): def test_delete_user_with_submissions_transfers_correctly(self):
"""Test that user submissions are transferred to deleted user placeholder.""" """Test that user submissions are transferred to deleted user placeholder."""

View File

@@ -113,6 +113,7 @@ class ParkPhotoOutputSerializer(serializers.ModelSerializer):
"image_url", "image_url",
"image_variants", "image_variants",
"caption", "caption",
"photographer",
"alt_text", "alt_text",
"is_primary", "is_primary",
"is_approved", "is_approved",
@@ -147,6 +148,7 @@ class ParkPhotoCreateInputSerializer(serializers.ModelSerializer):
fields = [ fields = [
"image", "image",
"caption", "caption",
"photographer",
"alt_text", "alt_text",
"is_primary", "is_primary",
] ]
@@ -159,6 +161,7 @@ class ParkPhotoUpdateInputSerializer(serializers.ModelSerializer):
model = ParkPhoto model = ParkPhoto
fields = [ fields = [
"caption", "caption",
"photographer",
"alt_text", "alt_text",
"is_primary", "is_primary",
] ]

View File

@@ -117,6 +117,7 @@ class RidePhotoOutputSerializer(serializers.ModelSerializer):
"image_url", "image_url",
"image_variants", "image_variants",
"caption", "caption",
"photographer",
"alt_text", "alt_text",
"is_primary", "is_primary",
"is_approved", "is_approved",
@@ -156,6 +157,7 @@ class RidePhotoCreateInputSerializer(serializers.ModelSerializer):
fields = [ fields = [
"image", "image",
"caption", "caption",
"photographer",
"alt_text", "alt_text",
"photo_type", "photo_type",
"is_primary", "is_primary",
@@ -169,6 +171,7 @@ class RidePhotoUpdateInputSerializer(serializers.ModelSerializer):
model = RidePhoto model = RidePhoto
fields = [ fields = [
"caption", "caption",
"photographer",
"alt_text", "alt_text",
"photo_type", "photo_type",
"is_primary", "is_primary",

View File

@@ -5,6 +5,8 @@ This module contains all serializers related to parks, park areas, park location
and park search functionality. and park search functionality.
""" """
from decimal import Decimal
from drf_spectacular.utils import ( from drf_spectacular.utils import (
OpenApiExample, OpenApiExample,
extend_schema_field, extend_schema_field,
@@ -532,13 +534,13 @@ class ParkFilterInputSerializer(serializers.Serializer):
max_digits=3, max_digits=3,
decimal_places=2, decimal_places=2,
required=False, required=False,
min_value=1, min_value=Decimal("1"),
max_value=10, max_value=Decimal("10"),
) )
# Size filter # Size filter
min_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=0) min_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=Decimal("0"))
max_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=0) max_size_acres = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, min_value=Decimal("0"))
# Company filters # Company filters
operator_id = serializers.IntegerField(required=False) operator_id = serializers.IntegerField(required=False)

View File

@@ -53,6 +53,11 @@ def with_callbacks(
def wrapper(instance, *args, **kwargs): def wrapper(instance, *args, **kwargs):
# Extract user from kwargs # Extract user from kwargs
user = kwargs.get("user") user = kwargs.get("user")
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
# This must be set before calling the inner func so the decorator can capture it
if user is not None and 'by' not in kwargs:
kwargs['by'] = user
# Get source state before transition # Get source state before transition
source_state = getattr(instance, field_name, None) source_state = getattr(instance, field_name, None)
@@ -329,6 +334,9 @@ class TransitionMethodFactory:
) )
def approve(instance, user=None, comment: str = "", **kwargs): def approve(instance, user=None, comment: str = "", **kwargs):
"""Approve and transition to approved state.""" """Approve and transition to approved state."""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
if hasattr(instance, "approved_by_id"): if hasattr(instance, "approved_by_id"):
instance.approved_by = user instance.approved_by = user
if hasattr(instance, "approval_comment"): if hasattr(instance, "approval_comment"):
@@ -382,6 +390,9 @@ class TransitionMethodFactory:
) )
def reject(instance, user=None, reason: str = "", **kwargs): def reject(instance, user=None, reason: str = "", **kwargs):
"""Reject and transition to rejected state.""" """Reject and transition to rejected state."""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
if hasattr(instance, "rejected_by_id"): if hasattr(instance, "rejected_by_id"):
instance.rejected_by = user instance.rejected_by = user
if hasattr(instance, "rejection_reason"): if hasattr(instance, "rejection_reason"):
@@ -435,6 +446,9 @@ class TransitionMethodFactory:
) )
def escalate(instance, user=None, reason: str = "", **kwargs): def escalate(instance, user=None, reason: str = "", **kwargs):
"""Escalate to higher authority.""" """Escalate to higher authority."""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
if hasattr(instance, "escalated_by_id"): if hasattr(instance, "escalated_by_id"):
instance.escalated_by = user instance.escalated_by = user
if hasattr(instance, "escalation_reason"): if hasattr(instance, "escalation_reason"):
@@ -483,31 +497,45 @@ class TransitionMethodFactory:
# Get field name for callback wrapper # Get field name for callback wrapper
field_name = field.name if hasattr(field, 'name') else 'status' field_name = field.name if hasattr(field, 'name') else 'status'
@fsm_log_by # Create the transition function with the correct name from the start
@transition( # by using exec to define it dynamically. This ensures __name__ is correct
# before decorators are applied, which is critical for django-fsm's
# method registration.
doc = docstring if docstring else f"Transition from {source} to {target}"
# Define the function dynamically with the correct name
# IMPORTANT: We set kwargs['by'] = user so that @fsm_log_by can capture
# who performed the transition. The decorator looks for 'by' in kwargs.
func_code = f'''
def {method_name}(instance, user=None, **kwargs):
"""{doc}"""
# Pass user as 'by' for django-fsm-log's @fsm_log_by decorator
if user is not None:
kwargs['by'] = user
pass
'''
local_namespace: dict = {}
exec(func_code, {}, local_namespace)
inner_func = local_namespace[method_name]
# Apply decorators in correct order (innermost first)
# @fsm_log_by -> @transition -> inner_func
decorated = transition(
field=field, field=field,
source=source, source=source,
target=target, target=target,
permission=permission_guard, permission=permission_guard,
) )(inner_func)
def generic_transition(instance, user=None, **kwargs): decorated = fsm_log_by(decorated)
"""Execute state transition."""
pass
generic_transition.__name__ = method_name
if docstring:
generic_transition.__doc__ = docstring
else:
generic_transition.__doc__ = f"Transition from {source} to {target}"
# Apply callback wrapper if enabled # Apply callback wrapper if enabled
if enable_callbacks: if enable_callbacks:
generic_transition = with_callbacks( decorated = with_callbacks(
field_name=field_name, field_name=field_name,
emit_signals=emit_signals, emit_signals=emit_signals,
)(generic_transition) )(decorated)
return generic_transition return decorated
def with_transition_logging(transition_method: Callable) -> Callable: def with_transition_logging(transition_method: Callable) -> Callable:

View File

@@ -83,7 +83,7 @@ class MetadataValidator:
result.errors.extend(self.validate_transitions()) result.errors.extend(self.validate_transitions())
result.errors.extend(self.validate_terminal_states()) result.errors.extend(self.validate_terminal_states())
result.errors.extend(self.validate_permission_consistency()) result.errors.extend(self.validate_permission_consistency())
result.errors.extend(self.validate_no_cycles()) result.warnings.extend(self.validate_no_cycles()) # Cycles are warnings, not errors
result.errors.extend(self.validate_reachability()) result.errors.extend(self.validate_reachability())
# Set validity based on errors # Set validity based on errors
@@ -197,23 +197,20 @@ class MetadataValidator:
return errors return errors
def validate_no_cycles(self) -> list[ValidationError]: def validate_no_cycles(self) -> list[ValidationWarning]:
""" """
Detect invalid state cycles (excluding self-loops). Detect state cycles (excluding self-loops).
Note: Cycles are allowed in many FSMs (e.g., status transitions that allow
reopening or revival). This method returns warnings, not errors, since
cycles are often intentional in operational status FSMs.
Returns: Returns:
List of validation errors List of validation warnings
""" """
errors = [] warnings = []
graph = self.builder.build_transition_graph() graph = self.builder.build_transition_graph()
# Check for self-loops (state transitioning to itself)
for state, targets in graph.items():
if state in targets:
# Self-loops are warnings, not errors
# but we can flag them
pass
# Detect cycles using DFS # Detect cycles using DFS
visited: set[str] = set() visited: set[str] = set()
rec_stack: set[str] = set() rec_stack: set[str] = set()
@@ -240,16 +237,16 @@ class MetadataValidator:
if state not in visited: if state not in visited:
cycle = has_cycle(state, []) cycle = has_cycle(state, [])
if cycle: if cycle:
errors.append( warnings.append(
ValidationError( ValidationWarning(
code="STATE_CYCLE_DETECTED", code="STATE_CYCLE_EXISTS",
message=(f"Cycle detected: {' -> '.join(cycle)}"), message=(f"Cycle exists (may be intentional): {' -> '.join(cycle)}"),
state=cycle[0], state=cycle[0],
) )
) )
break # Report first cycle only break # Report first cycle only
return errors return warnings
def validate_reachability(self) -> list[ValidationError]: def validate_reachability(self) -> list[ValidationError]:
""" """

View File

@@ -160,7 +160,7 @@ def error_validation(
return custom_message return custom_message
if field_name: if field_name:
return f"Please check the {field_name} field and try again." return f"Please check the {field_name} field and try again."
return "Please check the form and correct any errors." return "Validation error. Please check the form and correct any errors."
def error_permission( def error_permission(
@@ -400,6 +400,42 @@ def info_processing(
return "Processing..." return "Processing..."
def info_no_changes(
custom_message: str | None = None,
) -> str:
"""
Generate an info message when no changes were detected.
Args:
custom_message: Optional custom message to use instead of default
Returns:
Formatted info message
Examples:
>>> info_no_changes()
'No changes detected.'
"""
if custom_message:
return custom_message
return "No changes detected."
def warning_unsaved(
custom_message: str | None = None,
) -> str:
"""
Alias for warning_unsaved_changes for backward compatibility.
Args:
custom_message: Optional custom message to use instead of default
Returns:
Formatted warning message
"""
return warning_unsaved_changes(custom_message)
def confirm_delete( def confirm_delete(
model_name: str, model_name: str,
object_name: str | None = None, object_name: str | None = None,

View File

@@ -45,13 +45,14 @@ from ..models import (
User = get_user_model() User = get_user_model()
class TestView( class MixinTestView(
EditSubmissionMixin, EditSubmissionMixin,
PhotoSubmissionMixin, PhotoSubmissionMixin,
InlineEditMixin, InlineEditMixin,
HistoryMixin, HistoryMixin,
DetailView, DetailView,
): ):
"""Helper view for testing moderation mixins. Not a test class."""
model = Operator model = Operator
template_name = "test.html" template_name = "test.html"
pk_url_kwarg = "pk" pk_url_kwarg = "pk"
@@ -100,7 +101,7 @@ class ModerationMixinsTests(TestCase):
def test_edit_submission_mixin_unauthenticated(self): def test_edit_submission_mixin_unauthenticated(self):
"""Test edit submission when not logged in""" """Test edit submission when not logged in"""
view = TestView() view = MixinTestView()
request = self.factory.post(f"/test/{self.operator.pk}/") request = self.factory.post(f"/test/{self.operator.pk}/")
request.user = AnonymousUser() request.user = AnonymousUser()
view.setup(request, pk=self.operator.pk) view.setup(request, pk=self.operator.pk)
@@ -111,7 +112,7 @@ class ModerationMixinsTests(TestCase):
def test_edit_submission_mixin_no_changes(self): def test_edit_submission_mixin_no_changes(self):
"""Test edit submission with no changes""" """Test edit submission with no changes"""
view = TestView() view = MixinTestView()
request = self.factory.post( request = self.factory.post(
f"/test/{self.operator.pk}/", f"/test/{self.operator.pk}/",
data=json.dumps({}), data=json.dumps({}),
@@ -126,7 +127,7 @@ class ModerationMixinsTests(TestCase):
def test_edit_submission_mixin_invalid_json(self): def test_edit_submission_mixin_invalid_json(self):
"""Test edit submission with invalid JSON""" """Test edit submission with invalid JSON"""
view = TestView() view = MixinTestView()
request = self.factory.post( request = self.factory.post(
f"/test/{self.operator.pk}/", f"/test/{self.operator.pk}/",
data="invalid json", data="invalid json",
@@ -141,7 +142,7 @@ class ModerationMixinsTests(TestCase):
def test_edit_submission_mixin_regular_user(self): def test_edit_submission_mixin_regular_user(self):
"""Test edit submission as regular user""" """Test edit submission as regular user"""
view = TestView() view = MixinTestView()
request = self.factory.post(f"/test/{self.operator.pk}/") request = self.factory.post(f"/test/{self.operator.pk}/")
request.user = self.user request.user = self.user
view.setup(request, pk=self.operator.pk) view.setup(request, pk=self.operator.pk)
@@ -155,7 +156,7 @@ class ModerationMixinsTests(TestCase):
def test_edit_submission_mixin_moderator(self): def test_edit_submission_mixin_moderator(self):
"""Test edit submission as moderator""" """Test edit submission as moderator"""
view = TestView() view = MixinTestView()
request = self.factory.post(f"/test/{self.operator.pk}/") request = self.factory.post(f"/test/{self.operator.pk}/")
request.user = self.moderator request.user = self.moderator
view.setup(request, pk=self.operator.pk) view.setup(request, pk=self.operator.pk)
@@ -169,7 +170,7 @@ class ModerationMixinsTests(TestCase):
def test_photo_submission_mixin_unauthenticated(self): def test_photo_submission_mixin_unauthenticated(self):
"""Test photo submission when not logged in""" """Test photo submission when not logged in"""
view = TestView() view = MixinTestView()
view.kwargs = {"pk": self.operator.pk} view.kwargs = {"pk": self.operator.pk}
view.object = self.operator view.object = self.operator
@@ -182,7 +183,7 @@ class ModerationMixinsTests(TestCase):
def test_photo_submission_mixin_no_photo(self): def test_photo_submission_mixin_no_photo(self):
"""Test photo submission with no photo""" """Test photo submission with no photo"""
view = TestView() view = MixinTestView()
view.kwargs = {"pk": self.operator.pk} view.kwargs = {"pk": self.operator.pk}
view.object = self.operator view.object = self.operator
@@ -195,7 +196,7 @@ class ModerationMixinsTests(TestCase):
def test_photo_submission_mixin_regular_user(self): def test_photo_submission_mixin_regular_user(self):
"""Test photo submission as regular user""" """Test photo submission as regular user"""
view = TestView() view = MixinTestView()
view.kwargs = {"pk": self.operator.pk} view.kwargs = {"pk": self.operator.pk}
view.object = self.operator view.object = self.operator
@@ -226,7 +227,7 @@ class ModerationMixinsTests(TestCase):
def test_photo_submission_mixin_moderator(self): def test_photo_submission_mixin_moderator(self):
"""Test photo submission as moderator""" """Test photo submission as moderator"""
view = TestView() view = MixinTestView()
view.kwargs = {"pk": self.operator.pk} view.kwargs = {"pk": self.operator.pk}
view.object = self.operator view.object = self.operator
@@ -315,7 +316,7 @@ class ModerationMixinsTests(TestCase):
def test_inline_edit_mixin(self): def test_inline_edit_mixin(self):
"""Test inline edit mixin""" """Test inline edit mixin"""
view = TestView() view = MixinTestView()
view.kwargs = {"pk": self.operator.pk} view.kwargs = {"pk": self.operator.pk}
view.object = self.operator view.object = self.operator
@@ -342,7 +343,7 @@ class ModerationMixinsTests(TestCase):
def test_history_mixin(self): def test_history_mixin(self):
"""Test history mixin""" """Test history mixin"""
view = TestView() view = MixinTestView()
view.kwargs = {"pk": self.operator.pk} view.kwargs = {"pk": self.operator.pk}
view.object = self.operator view.object = self.operator
request = self.factory.get(f"/test/{self.operator.pk}/") request = self.factory.get(f"/test/{self.operator.pk}/")

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.9 on 2026-01-08 18:48
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parks', '0030_company_schema_parity'),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name='parkphoto',
name='insert_insert',
),
pgtrigger.migrations.RemoveTrigger(
model_name='parkphoto',
name='update_update',
),
migrations.AddField(
model_name='parkphoto',
name='photographer',
field=models.CharField(blank=True, help_text='Photographer credit (maps to frontend photographer_credit)', max_length=200),
),
migrations.AddField(
model_name='parkphotoevent',
name='photographer',
field=models.CharField(blank=True, help_text='Photographer credit (maps to frontend photographer_credit)', max_length=200),
),
pgtrigger.migrations.AddTrigger(
model_name='parkphoto',
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "parks_parkphotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photographer", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photographer", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;', hash='151f82660bda74a8d10ddf581e509c63e4e7e6e0', operation='INSERT', pgid='pgtrigger_insert_insert_e2033', table='parks_parkphoto', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='parkphoto',
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "parks_parkphotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photographer", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photographer", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;', hash='9a33e713d26165877f27ae3f993c9c0675f61620', operation='UPDATE', pgid='pgtrigger_update_update_42711', table='parks_parkphoto', when='AFTER')),
),
]

View File

@@ -43,6 +43,11 @@ class ParkPhoto(TrackedModel):
) )
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption or description") caption = models.CharField(max_length=255, blank=True, help_text="Photo caption or description")
photographer = models.CharField(
max_length=200,
blank=True,
help_text="Photographer credit (maps to frontend photographer_credit)"
)
alt_text = models.CharField(max_length=255, blank=True, help_text="Alternative text for accessibility") alt_text = models.CharField(max_length=255, blank=True, help_text="Alternative text for accessibility")
is_primary = models.BooleanField(default=False, help_text="Whether this is the primary photo for the park") is_primary = models.BooleanField(default=False, help_text="Whether this is the primary photo for the park")
is_approved = models.BooleanField(default=False, help_text="Whether this photo has been approved by moderators") is_approved = models.BooleanField(default=False, help_text="Whether this photo has been approved by moderators")

View File

@@ -91,6 +91,7 @@ RIDE_STATUSES = [
"css_class": "bg-yellow-100 text-yellow-800", "css_class": "bg-yellow-100 text-yellow-800",
"sort_order": 2, "sort_order": 2,
"can_transition_to": [ "can_transition_to": [
"OPERATING", # Reopen after temporary closure
"SBNO", "SBNO",
"CLOSING", "CLOSING",
], ],
@@ -109,6 +110,7 @@ RIDE_STATUSES = [
"css_class": "bg-orange-100 text-orange-800", "css_class": "bg-orange-100 text-orange-800",
"sort_order": 3, "sort_order": 3,
"can_transition_to": [ "can_transition_to": [
"OPERATING", # Revival - ride returns to operation
"CLOSED_PERM", "CLOSED_PERM",
"DEMOLISHED", "DEMOLISHED",
"RELOCATED", "RELOCATED",

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.9 on 2026-01-08 18:48
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rides', '0038_company_schema_parity'),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name='ridephoto',
name='insert_insert',
),
pgtrigger.migrations.RemoveTrigger(
model_name='ridephoto',
name='update_update',
),
migrations.AddField(
model_name='ridephoto',
name='photographer',
field=models.CharField(blank=True, help_text='Photographer credit (maps to frontend photographer_credit)', max_length=200),
),
migrations.AddField(
model_name='ridephotoevent',
name='photographer',
field=models.CharField(blank=True, help_text='Photographer credit (maps to frontend photographer_credit)', max_length=200),
),
pgtrigger.migrations.AddTrigger(
model_name='ridephoto',
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;', hash='b426eed3a10c63be3db15a5a9477d66388f5dd2f', operation='INSERT', pgid='pgtrigger_insert_insert_0043a', table='rides_ridephoto', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='ridephoto',
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;', hash='9728ec4736aea41ea171c3494de909aae3f68569', operation='UPDATE', pgid='pgtrigger_update_update_93a7e', table='rides_ridephoto', when='AFTER')),
),
]

View File

@@ -44,6 +44,11 @@ class RidePhoto(TrackedModel):
) )
caption = models.CharField(max_length=255, blank=True) caption = models.CharField(max_length=255, blank=True)
photographer = models.CharField(
max_length=200,
blank=True,
help_text="Photographer credit (maps to frontend photographer_credit)"
)
alt_text = models.CharField(max_length=255, blank=True) alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False) is_primary = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False) is_approved = models.BooleanField(default=False)

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@ dependencies = [
# ============================================================================= # =============================================================================
# Core Django # Core Django
# ============================================================================= # =============================================================================
"django>=5.2.8", "django>=5.2.9",
"psycopg2-binary>=2.9.9", "psycopg2-binary>=2.9.9",
"dj-database-url>=2.3.0", "dj-database-url>=2.3.0",
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
@@ -23,7 +23,7 @@ dependencies = [
# ============================================================================= # =============================================================================
# Authentication & Security # Authentication & Security
# ============================================================================= # =============================================================================
"django-allauth>=65.9.0", "django-allauth>=65.13.0",
"djangorestframework-simplejwt>=5.5.1", "djangorestframework-simplejwt>=5.5.1",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"cryptography>=44.0.0", "cryptography>=44.0.0",
@@ -69,7 +69,8 @@ dependencies = [
# ============================================================================= # =============================================================================
# Utilities # Utilities
# ============================================================================= # =============================================================================
"requests>=2.32.3", "requests>=2.32.4",
"urllib3>=2.6.3",
"pycountry>=24.6.1", "pycountry>=24.6.1",
"django-extensions>=4.1", "django-extensions>=4.1",
"werkzeug>=3.1.3", "werkzeug>=3.1.3",
@@ -136,6 +137,7 @@ addopts = [
"--strict-markers", "--strict-markers",
"--tb=short", "--tb=short",
] ]
asyncio_default_fixture_loop_scope = "function"
markers = [ markers = [
"unit: Unit tests (fast, isolated)", "unit: Unit tests (fast, isolated)",
"integration: Integration tests (may use database)", "integration: Integration tests (may use database)",

View File

@@ -0,0 +1,24 @@
{% extends "emails/base.html" %}
{% block content %}
<h1>Account Deletion Request</h1>
<p>Hi {{ user.username }},</p>
<p>You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:</p>
<div style="text-align: center; margin: 30px 0;">
<p style="font-size: 28px; font-weight: bold; letter-spacing: 3px; background: #f5f5f5; padding: 20px; border-radius: 8px;">
{{ verification_code }}
</p>
</div>
<p>This code will expire at {{ expires_at|date:"F j, Y, g:i a" }}.</p>
<p><strong>Warning:</strong> This action is permanent and cannot be undone. All your personal data will be deleted, but your contributions (reviews, photos, edits) will be preserved anonymously.</p>
<p>If you did not request this deletion, please ignore this email or contact support immediately.</p>
<p>Best regards,<br>
The {{ site_name }} Team</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
Account Deletion Request
========================
Hi {{ user.username }},
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
{{ verification_code }}
This code will expire at {{ expires_at|date:"F j, Y, g:i a" }}.
WARNING: This action is permanent and cannot be undone. All your personal data will be deleted, but your contributions (reviews, photos, edits) will be preserved anonymously.
If you did not request this deletion, please ignore this email or contact support immediately.
Best regards,
The {{ site_name }} Team

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ThrillWiki{% endblock %}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
h1 { color: #1a1a2e; }
a { color: #0066cc; }
</style>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@@ -5,7 +5,7 @@ Following Django styleguide pattern for test data creation using factory_boy.
import factory import factory
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point # GeoDjango Point import removed - not currently used
from django.utils.text import slugify from django.utils.text import slugify
from factory import fuzzy from factory import fuzzy
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
@@ -22,8 +22,7 @@ class UserFactory(DjangoModelFactory):
username = factory.Sequence(lambda n: f"testuser{n}") username = factory.Sequence(lambda n: f"testuser{n}")
email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com") email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
first_name = factory.Faker("first_name") # Note: first_name and last_name are removed from User model
last_name = factory.Faker("last_name")
is_active = True is_active = True
is_staff = False is_staff = False
is_superuser = False is_superuser = False
@@ -31,7 +30,8 @@ class UserFactory(DjangoModelFactory):
@factory.post_generation @factory.post_generation
def set_password(obj, create, extracted, **kwargs): def set_password(obj, create, extracted, **kwargs):
if create: if create:
password = extracted or "testpass123" # Support both UserFactory(set_password="pwd") and UserFactory(set_password__password="pwd")
password = kwargs.get("password") or extracted or "testpass123"
obj.set_password(password) obj.set_password(password)
obj.save() obj.save()
@@ -89,27 +89,6 @@ class DesignerCompanyFactory(CompanyFactory):
roles = factory.LazyFunction(lambda: ["DESIGNER"]) roles = factory.LazyFunction(lambda: ["DESIGNER"])
class LocationFactory(DjangoModelFactory):
"""Factory for creating Location instances."""
class Meta:
model = "location.Location"
name = factory.Faker("city")
location_type = "park"
latitude = fuzzy.FuzzyFloat(-90, 90)
longitude = fuzzy.FuzzyFloat(-180, 180)
street_address = factory.Faker("street_address")
city = factory.Faker("city")
state = factory.Faker("state")
country = factory.Faker("country")
postal_code = factory.Faker("postcode")
@factory.lazy_attribute
def point(self):
return Point(float(self.longitude), float(self.latitude))
class ParkFactory(DjangoModelFactory): class ParkFactory(DjangoModelFactory):
"""Factory for creating Park instances.""" """Factory for creating Park instances."""
@@ -127,19 +106,14 @@ class ParkFactory(DjangoModelFactory):
size_acres = fuzzy.FuzzyDecimal(1, 1000, precision=2) size_acres = fuzzy.FuzzyDecimal(1, 1000, precision=2)
website = factory.Faker("url") website = factory.Faker("url")
average_rating = fuzzy.FuzzyDecimal(1, 10, precision=2) average_rating = fuzzy.FuzzyDecimal(1, 10, precision=2)
ride_count = fuzzy.FuzzyInteger(5, 100) ride_count = fuzzy.FuzzyInteger(10, 100) # Minimum 10 to allow coasters
coaster_count = fuzzy.FuzzyInteger(1, 20) # coaster_count must be <= ride_count per Park model constraint
coaster_count = factory.LazyAttribute(lambda obj: min(obj.ride_count // 2, 20))
# Relationships # Relationships
operator = factory.SubFactory(OperatorCompanyFactory) operator = factory.SubFactory(OperatorCompanyFactory)
property_owner = factory.SubFactory(OperatorCompanyFactory) property_owner = factory.SubFactory(OperatorCompanyFactory)
@factory.post_generation
def create_location(obj, create, extracted, **kwargs):
"""Create a location for the park."""
if create:
LocationFactory(content_object=obj, name=obj.name, location_type="park")
class ClosedParkFactory(ParkFactory): class ClosedParkFactory(ParkFactory):
"""Factory for creating closed parks.""" """Factory for creating closed parks."""
@@ -163,6 +137,33 @@ class ParkAreaFactory(DjangoModelFactory):
park = factory.SubFactory(ParkFactory) park = factory.SubFactory(ParkFactory)
class RidesCompanyFactory(DjangoModelFactory):
"""Factory for creating rides.Company instances (manufacturers, designers)."""
class Meta:
model = "rides.Company"
django_get_or_create = ("name",)
name = factory.Faker("company")
slug = factory.LazyAttribute(lambda obj: slugify(obj.name))
description = factory.Faker("text", max_nb_chars=500)
website = factory.Faker("url")
founded_year = fuzzy.FuzzyInteger(1800, 2024)
roles = factory.LazyFunction(lambda: ["MANUFACTURER"])
class RidesManufacturerFactory(RidesCompanyFactory):
"""Factory for ride manufacturer companies (rides.Company)."""
roles = factory.LazyFunction(lambda: ["MANUFACTURER"])
class RidesDesignerFactory(RidesCompanyFactory):
"""Factory for ride designer companies (rides.Company)."""
roles = factory.LazyFunction(lambda: ["DESIGNER"])
class RideModelFactory(DjangoModelFactory): class RideModelFactory(DjangoModelFactory):
"""Factory for creating RideModel instances.""" """Factory for creating RideModel instances."""
@@ -173,8 +174,8 @@ class RideModelFactory(DjangoModelFactory):
name = factory.Faker("word") name = factory.Faker("word")
description = factory.Faker("text", max_nb_chars=500) description = factory.Faker("text", max_nb_chars=500)
# Relationships # Relationships - use rides.Company not parks.Company
manufacturer = factory.SubFactory(ManufacturerCompanyFactory) manufacturer = factory.SubFactory(RidesManufacturerFactory)
class RideFactory(DjangoModelFactory): class RideFactory(DjangoModelFactory):
@@ -199,16 +200,12 @@ class RideFactory(DjangoModelFactory):
# Relationships # Relationships
park = factory.SubFactory(ParkFactory) park = factory.SubFactory(ParkFactory)
manufacturer = factory.SubFactory(ManufacturerCompanyFactory) manufacturer = factory.SubFactory(RidesManufacturerFactory) # rides.Company
designer = factory.SubFactory(DesignerCompanyFactory) designer = factory.SubFactory(RidesDesignerFactory) # rides.Company
ride_model = factory.SubFactory(RideModelFactory) ride_model = factory.SubFactory(RideModelFactory)
park_area = factory.SubFactory(ParkAreaFactory, park=factory.SelfAttribute("..park")) park_area = factory.SubFactory(ParkAreaFactory, park=factory.SelfAttribute("..park"))
@factory.post_generation
def create_location(obj, create, extracted, **kwargs):
"""Create a location for the ride."""
if create:
LocationFactory(content_object=obj, name=obj.name, location_type="ride")
class CoasterFactory(RideFactory): class CoasterFactory(RideFactory):

90
uv.lock generated
View File

@@ -460,16 +460,16 @@ wheels = [
[[package]] [[package]]
name = "django" name = "django"
version = "5.2.9" version = "5.2.10"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } sdist = { url = "https://files.pythonhosted.org/packages/e6/e5/2671df24bf0ded831768ef79532e5a7922485411a5696f6d979568591a37/django-5.2.10.tar.gz", hash = "sha256:74df100784c288c50a2b5cad59631d71214f40f72051d5af3fdf220c20bdbbbe", size = 10880754, upload-time = "2026-01-06T18:55:26.817Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, { url = "https://files.pythonhosted.org/packages/fa/de/f1a7cd896daec85832136ab509d9b2a6daed4939dbe26313af3e95fc5f5e/django-5.2.10-py3-none-any.whl", hash = "sha256:cf85067a64250c95d5f9067b056c5eaa80591929f7e16fbcd997746e40d6c45c", size = 8290820, upload-time = "2026-01-06T18:55:20.009Z" },
] ]
[[package]] [[package]]
@@ -1120,7 +1120,7 @@ wheels = [
[[package]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.25.1" version = "4.26.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "attrs" }, { name = "attrs" },
@@ -1128,9 +1128,9 @@ dependencies = [
{ name = "referencing" }, { name = "referencing" },
{ name = "rpds-py" }, { name = "rpds-py" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
] ]
[[package]] [[package]]
@@ -1284,11 +1284,11 @@ wheels = [
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.0.1" version = "1.0.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/28/2e/83722ece0f6ee24387d6cb830dd562ddbcd6ce0b9d76072c6849670c31b4/pathspec-1.0.1.tar.gz", hash = "sha256:e2769b508d0dd47b09af6ee2c75b2744a2cb1f474ae4b1494fd6a1b7a841613c", size = 129791, upload-time = "2026-01-06T13:02:55.15Z" } sdist = { url = "https://files.pythonhosted.org/packages/41/b9/6eb731b52f132181a9144bbe77ff82117f6b2d2fbfba49aaab2c014c4760/pathspec-1.0.2.tar.gz", hash = "sha256:fa32b1eb775ed9ba8d599b22c5f906dc098113989da2c00bf8b210078ca7fb92", size = 130502, upload-time = "2026-01-08T04:33:27.613Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fe/2257c71721aeab6a6e8aa1f00d01f2a20f58547d249a6c8fef5791f559fc/pathspec-1.0.1-py3-none-any.whl", hash = "sha256:8870061f22c58e6d83463cfce9a7dd6eca0512c772c1001fb09ac64091816721", size = 54584, upload-time = "2026-01-06T13:02:53.601Z" }, { url = "https://files.pythonhosted.org/packages/78/6b/14fc9049d78435fd29e82846c777bd7ed9c470013dc8d0260fff3ff1c11e/pathspec-1.0.2-py3-none-any.whl", hash = "sha256:62f8558917908d237d399b9b338ef455a814801a4688bc41074b25feefd93472", size = 54844, upload-time = "2026-01-08T04:33:26.4Z" },
] ]
[[package]] [[package]]
@@ -1502,15 +1502,15 @@ wheels = [
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.407" version = "1.1.408"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nodeenv" }, { name = "nodeenv" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
] ]
[[package]] [[package]]
@@ -1916,28 +1916,28 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.10" version = "0.14.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
] ]
[[package]] [[package]]
@@ -1968,15 +1968,15 @@ wheels = [
[[package]] [[package]]
name = "sentry-sdk" name = "sentry-sdk"
version = "2.48.0" version = "2.49.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/40/f0/0e9dc590513d5e742d7799e2038df3a05167cba084c6ca4f3cdd75b55164/sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473", size = 384828, upload-time = "2025-12-16T14:55:41.722Z" } sdist = { url = "https://files.pythonhosted.org/packages/02/94/23ac26616a883f492428d9ee9ad6eee391612125326b784dbfc30e1e7bab/sentry_sdk-2.49.0.tar.gz", hash = "sha256:c1878599cde410d481c04ef50ee3aedd4f600e4d0d253f4763041e468b332c30", size = 387228, upload-time = "2026-01-08T09:56:25.642Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" }, { url = "https://files.pythonhosted.org/packages/88/43/1c586f9f413765201234541857cb82fda076f4b0f7bad4a0ec248da39cf3/sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0", size = 415693, upload-time = "2026-01-08T09:56:21.872Z" },
] ]
[[package]] [[package]]
@@ -2095,6 +2095,7 @@ dependencies = [
{ name = "requests" }, { name = "requests" },
{ name = "rjsmin" }, { name = "rjsmin" },
{ name = "sentry-sdk" }, { name = "sentry-sdk" },
{ name = "urllib3" },
{ name = "werkzeug" }, { name = "werkzeug" },
{ name = "whitenoise" }, { name = "whitenoise" },
] ]
@@ -2136,8 +2137,8 @@ requires-dist = [
{ name = "cryptography", specifier = ">=44.0.0" }, { name = "cryptography", specifier = ">=44.0.0" },
{ name = "deepdiff", specifier = ">=8.0.0" }, { name = "deepdiff", specifier = ">=8.0.0" },
{ name = "dj-database-url", specifier = ">=2.3.0" }, { name = "dj-database-url", specifier = ">=2.3.0" },
{ name = "django", specifier = ">=5.2.8" }, { name = "django", specifier = ">=5.2.9" },
{ name = "django-allauth", specifier = ">=65.9.0" }, { name = "django-allauth", specifier = ">=65.13.0" },
{ name = "django-celery-beat", specifier = ">=2.8.1" }, { name = "django-celery-beat", specifier = ">=2.8.1" },
{ name = "django-celery-results", specifier = ">=2.6.0" }, { name = "django-celery-results", specifier = ">=2.6.0" },
{ name = "django-cleanup", specifier = ">=8.1.0" }, { name = "django-cleanup", specifier = ">=8.1.0" },
@@ -2177,9 +2178,10 @@ requires-dist = [
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
{ name = "rcssmin", specifier = ">=1.1.0" }, { name = "rcssmin", specifier = ">=1.1.0" },
{ name = "redis", specifier = ">=5.2.0" }, { name = "redis", specifier = ">=5.2.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.4" },
{ name = "rjsmin", specifier = ">=1.2.0" }, { name = "rjsmin", specifier = ">=1.2.0" },
{ name = "sentry-sdk", specifier = ">=2.20.0,<3" }, { name = "sentry-sdk", specifier = ">=2.20.0,<3" },
{ name = "urllib3", specifier = ">=2.6.3" },
{ name = "werkzeug", specifier = ">=3.1.3" }, { name = "werkzeug", specifier = ">=3.1.3" },
{ name = "whitenoise", specifier = ">=6.8.0" }, { name = "whitenoise", specifier = ">=6.8.0" },
] ]
@@ -2314,11 +2316,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.2" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -2355,14 +2357,14 @@ wheels = [
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.4" version = "3.1.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df9066e56e831c7824ac6bab645dd6c7a8f4b2d767944f9b/werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e", size = 864687, upload-time = "2025-11-29T02:15:22.841Z" } sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" }, { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
] ]
[[package]] [[package]]