Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
4f3472335a [DEPENDABOT] Update: Bump django-allauth from 65.4.1 to 65.8.0
Bumps [django-allauth](https://github.com/sponsors/pennersr) from 65.4.1 to 65.8.0.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-version: 65.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-12 16:52:24 +00:00
1160 changed files with 63952 additions and 141734 deletions

View File

@@ -1,51 +0,0 @@
# Project Startup & Development Rules
## Server & Package Management
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
```bash
$PROJECT_ROOT/shared/scripts/start-servers.sh
```
- **Python Packages:** Only use UV to add packages:
```bash
cd $PROJECT_ROOT/backend && uv add <package>
```
NEVER use pip or pipenv directly, or uv pip.
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
- **Node Commands:** Always use 'cd frontend && pnpm add <package>' for all Node.js package installations. NEVER use npm or a different node package manager.
## CRITICAL Frontend design rules
- EVERYTHING must support both dark and light mode.
- Make sure the light/dark mode toggle works with the Vue components and pages.
- Leverage Tailwind CSS 4 and Shadcn UI components.
## Frontend API URL Rules
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
- **Common Mistake:** Dont assume frontend URLs are wrong due to proxy configuration.
## Entity Relationship Patterns
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
- **Entities:**
- Operators: Operate parks.
- PropertyOwners: Own park property (optional).
- Manufacturers: Make rides.
- Designers: Design rides.
- All entities can have locations.
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
## General Best Practices
- Never assume blank output means success—always verify changes by testing.
- Use context7 for documentation when troubleshooting.
- Document changes with conport and reasoning.
- Include relevant context and information in all changes.
- Test and validate code before deployment.
- Communicate changes clearly with your team.
- Be open to feedback and continuous improvement.
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
- Log important events/errors for troubleshooting.
- Prefer existing modules/packages over new code.
- Keep documentation up to date.
- Consider security vulnerabilities and performance bottlenecks in all changes.

View File

@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(python manage.py check:*)",
"Bash(uv run:*)",
"Bash(find:*)",
"Bash(python:*)"
],
"deny": [],
"ask": []
}
}

30
.clinerules Normal file
View File

@@ -0,0 +1,30 @@
# Project Startup Rules
## Development Server
IMPORTANT: Always follow these instructions exactly when starting the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
## Package Management
IMPORTANT: When a Python package is needed, only use UV to add it:
```bash
uv add <package>
```
Do not attempt to install packages using any other method.
## Django Management Commands
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
```bash
uv run manage.py <command>
```
This applies to all management commands including but not limited to:
- Making migrations: `uv run manage.py makemigrations`
- Applying migrations: `uv run manage.py migrate`
- Creating superuser: `uv run manage.py createsuperuser`
- Starting shell: `uv run manage.py shell`
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.

View File

@@ -1,98 +0,0 @@
---
description: Core ThrillWiki development rules covering API organization, data models, development commands, code quality standards, and critical business rules
author: ThrillWiki Development Team
version: 1.0
globs: ["**/*.py", "apps/**/*", "thrillwiki/**/*", "**/*.md"]
tags: ["django", "api-design", "code-quality", "development-commands", "business-rules"]
---
# ThrillWiki Core Development Rules
## Objective
This rule defines the fundamental development standards, API organization patterns, code quality requirements, and critical business rules that MUST be followed for all ThrillWiki development work. It ensures consistency, maintainability, and adherence to project-specific constraints.
## API Organization and Data Models
### Mandatory API Structure
- **MANDATORY NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
- **MANDATORY TRAILING SLASHES**: All API endpoints MUST include trailing forward slashes unless ending with query parameters
- **Validation Required**: Validate all endpoint URLs against the mandatory trailing slash rule
### Ride System Architecture
**RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
- **Implementation**: Individual rides reference BOTH the model (what product) and type (how it operates)
- **Coverage**: Ride types MUST be available for ALL ride categories, not just roller coasters
## Development Commands and Code Quality
### Required Commands
- **Django Server**: ALWAYS use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
- **Django Migrations**: ALWAYS use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
- **Package Management**: ALWAYS use `uv add <package>` instead of `pip install <package>`
- **Django Management**: ALWAYS use `uv run manage.py <command>` instead of `python manage.py <command>`
### Code Quality Standards
- **Cognitive Complexity**: Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
- **Method Extraction**: Extract logical operations into separate methods with descriptive names
- **Single Responsibility**: Each method SHOULD have one clear purpose
- **Logic Structure**: Prefer composition over deeply nested conditional logic
- **Null Handling**: ALWAYS handle None values explicitly to avoid type errors
- **Type Annotations**: Use proper type annotations, including union types (e.g., `Polygon | None`)
- **API Structure**: Structure API views with clear separation between parameter handling, business logic, and response building
- **Quality Improvements**: When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
## ThrillWiki Project Rules
### Domain Architecture
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
- **Change Tracking**: All models use pghistory for change tracking and TrackedModel base class
- **Slug Management**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
### Status and Role Management
- **Status Management**: Rides have operational status (OPERATING, CLOSED_TEMP, SBNO, etc.) with date tracking
- **Company Roles**: Companies can be MANUFACTURER, OPERATOR, DESIGNER, PROPERTY_OWNER with array field
- **Location Data**: Use PostGIS for geographic data, separate location models for parks and rides
### Technical Patterns
- **API Patterns**: Use DRF with drf-spectacular, comprehensive serializers, nested endpoints, caching
- **Photo Management**: Banner/card image references, photo types, attribution fields, primary photo logic
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
## CRITICAL RULES
### Data Integrity (ABSOLUTE)
🚨 **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data MUST come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
### Domain Separation (CRITICAL BUSINESS RULE)
🚨 **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They SHOULD NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain.
**Correct URL Patterns:**
- **Parks**: `/parks/{park_slug}/` and `/parks/`
- **Rides**: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`
- **Parks Companies**: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`
- **Rides Companies**: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`
⚠️ **WARNING**: NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
### Photo Management Standards
🚨 **PHOTO MANAGEMENT**:
- Use CloudflareImagesField for all photo uploads with variants and transformations
- Clearly define and use photo types (e.g., banner, card) for all images
- Include attribution fields for all photos
- Implement logic to determine the primary photo for each model
## Verification Checklist
Before implementing any changes, verify:
- [ ] All API endpoints have trailing slashes
- [ ] Domain separation is maintained (parks vs rides companies)
- [ ] No mock data is used outside of schema documentation
- [ ] Proper uv commands are used for all Django operations
- [ ] Type annotations are complete and accurate
- [ ] Methods follow single responsibility principle
- [ ] CloudflareImagesField is used for all photo uploads

View File

@@ -1,100 +0,0 @@
---
description: Mandatory Rich Choice Objects system enforcement for ThrillWiki project replacing Django tuple-based choices with rich metadata-driven choice fields
author: ThrillWiki Development Team
version: 1.0
globs: ["apps/**/choices.py", "apps/**/models.py", "apps/**/serializers.py", "apps/**/__init__.py"]
tags: ["django", "choices", "rich-choice-objects", "data-modeling", "mandatory"]
---
# Rich Choice Objects System (MANDATORY)
## Objective
This rule enforces the mandatory use of the Rich Choice Objects system instead of Django's traditional tuple-based choices for ALL choice fields in the ThrillWiki project. It ensures consistent, metadata-rich choice handling with enhanced UI capabilities and maintainable code patterns.
## Brief Overview
Mandatory use of Rich Choice Objects system instead of Django tuple-based choices for all choice fields in ThrillWiki project.
## Rich Choice Objects Enforcement
### Absolute Requirements
🚨 **NEVER use Django tuple-based choices** (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
### Implementation Standards
- **Field Usage**: All choice fields MUST use `RichChoiceField(choice_group="group_name", domain="domain_name")` pattern
- **Choice Definitions**: MUST be created in domain-specific `choices.py` files using RichChoice dataclass
- **Rich Metadata**: All choices MUST include rich metadata (color, icon, description, css_class at minimum)
- **Registration**: Choice groups MUST be registered with global registry using `register_choices()` function
- **Auto-Registration**: Import choices in domain `__init__.py` to trigger auto-registration on Django startup
### Required Patterns
- **Categorization**: Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
- **Business Logic**: Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
- **Serialization**: Update serializers to use RichChoiceSerializer for choice fields
### Migration Requirements
- **NO Backwards Compatibility**: DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
- **Model Refactoring**: Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
- **Validation**: Validate choice groups are correctly loaded in registry during application startup
### Domain Consistency
- **Follow Established Patterns**: Follow established patterns from rides, parks, and accounts domains for consistency
- **Domain-Specific Organization**: Maintain domain-specific choice organization in separate `choices.py` files
## Implementation Checklist
Before implementing choice fields, verify:
- [ ] RichChoiceField is used instead of Django tuple choices
- [ ] Choice group and domain are properly specified
- [ ] Rich metadata includes color, icon, description, css_class
- [ ] Choices are defined in domain-specific `choices.py` file
- [ ] Choice group is registered with `register_choices()` function
- [ ] Domain `__init__.py` imports choices for auto-registration
- [ ] Appropriate ChoiceCategory enum is used
- [ ] Serializers use RichChoiceSerializer for choice fields
- [ ] No tuple-based choices remain in the codebase
## Examples
### ✅ CORRECT Implementation
```python
# In apps/rides/choices.py
from core.choices import RichChoice, ChoiceCategory, register_choices
RIDE_STATUS_CHOICES = [
RichChoice(
value="operating",
label="Operating",
color="#10b981",
icon="check-circle",
description="Ride is currently operating normally",
css_class="status-operating",
category=ChoiceCategory.STATUS
),
# ... more choices
]
register_choices("ride_status", RIDE_STATUS_CHOICES, domain="rides")
# In models.py
status = RichChoiceField(choice_group="ride_status", domain="rides")
```
### ❌ FORBIDDEN Implementation
```python
# NEVER DO THIS - Tuple-based choices are forbidden
STATUS_CHOICES = [
('operating', 'Operating'),
('closed', 'Closed'),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
```
## Verification Steps
To ensure compliance:
1. Search codebase for any remaining tuple-based choice patterns
2. Verify all choice fields use RichChoiceField
3. Confirm all choices have complete rich metadata
4. Test choice group registration during application startup
5. Validate serializers use RichChoiceSerializer where appropriate

View File

@@ -1,161 +0,0 @@
---
description: Comprehensive ThrillWiki Django project context including architecture, development patterns, business rules, and mandatory Context7 MCP integration workflow
author: ThrillWiki Development Team
version: 2.0
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
tags: ["django", "architecture", "api-design", "business-rules", "context7-integration", "thrillwiki"]
---
# ThrillWiki Django Project Context
## Objective
This rule provides comprehensive context for the ThrillWiki project, defining core architecture patterns, business rules, development workflows, and mandatory integration requirements. It serves as the primary reference for maintaining consistency across all ThrillWiki development activities.
## Project Overview
ThrillWiki is a comprehensive theme park database platform with user-generated content, expert moderation, and rich media support. Built with Django REST Framework, it serves 120+ API endpoints for parks, rides, companies, and user management.
## Core Architecture
### Technology Stack
- **Backend**: Django 5.0+ with DRF, PostgreSQL + PostGIS, Redis caching, Celery tasks
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton
- 🚨 **CRITICAL**: NO React/Vue/Angular allowed
- **Media**: Cloudflare Images using Direct Upload with variants and transformations
- **Tracking**: pghistory for all model changes, TrackedModel base class
- **Choices**: Rich Choice Objects system (NEVER use Django tuple choices)
### Domain Architecture
- **Parks Domain**: `parks/`, companies (OPERATOR/PROPERTY_OWNER roles only)
- **Rides Domain**: `rides/`, companies (MANUFACTURER/DESIGNER roles only)
- **Core Apps**: `accounts/`, `media/`, `moderation/`, `core/`
- 🚨 **CRITICAL BUSINESS RULE**: Never mix park/ride company roles - fundamental business rule violation
## Development Patterns
### Model Patterns
- **Base Classes**: All models MUST inherit from TrackedModel
- **Slug Handling**: Use SluggedModel for slugs with history tracking
- **Location Data**: Use PostGIS for geographic data, separate location models
- **Media Fields**: Use CloudflareImagesField for all image handling
### API Design Patterns
- **URL Structure**: Nested URLs (`/parks/{slug}/rides/{slug}/`)
- **Trailing Slashes**: MANDATORY trailing slashes on all endpoints
- **Authentication**: Token-based with role hierarchy (USER/MODERATOR/ADMIN/SUPERUSER)
- **Filtering**: Comprehensive filtering - rides (25+ parameters), parks (15+ parameters)
- **Responses**: Standard DRF pagination, rich error responses with details
- **Caching**: Multi-level (Redis, CDN, browser) with signal-based invalidation
### Choice System (MANDATORY)
- **Implementation**: `RichChoiceField(choice_group="group_name", domain="domain_name")`
- **Definition**: Domain-specific `choices.py` using RichChoice dataclass
- **Registration**: `register_choices()` function in domain `__init__.py`
- **Required Metadata**: color, icon, description, css_class (minimum)
- 🚨 **FORBIDDEN**: NO tuple-based choices allowed anywhere in codebase
## Development Commands
### Package Management
- **Python Packages**: `uv add <package>` (NOT `pip install`)
- **Server**: `uv run manage.py runserver_plus` (NOT `python manage.py`)
- **Migrations**: `uv run manage.py makemigrations/migrate`
- **Management**: ALWAYS use `uv run manage.py <command>`
## Business Rules
### Company Role Separation
- **Parks Domain**: Only OPERATOR and PROPERTY_OWNER roles
- **Rides Domain**: Only MANUFACTURER and DESIGNER roles
- 🚨 **CRITICAL**: Never allow cross-domain company roles
### Data Integrity
- **Model Changes**: All must be tracked via pghistory
- **API Responses**: MUST use real database data (NEVER MOCK DATA)
- **Geographic Data**: MUST use PostGIS for accuracy
## Frontend Constraints
### Architecture Requirements
- **HTMX**: Dynamic updates and AJAX interactions
- **AlpineJS**: Client-side state management
- **Tailwind CSS**: Styling framework
- **Progressive Enhancement**: Required approach
### Performance Targets
- **First Contentful Paint**: < 1.5s
- **Time to Interactive**: < 2s
- **Compliance**: Core Web Vitals compliance
- **Browser Support**: Latest 2 versions of major browsers
## Context7 MCP Integration (MANDATORY)
### Requirement
🚨 **CRITICAL**: ALWAYS use Context7 MCP for documentation lookups before making changes
### Libraries Requiring Context7
- **tailwindcss**: CSS utility classes, responsive design, component styling
- **django**: Models, views, forms, URL patterns, Django-specific patterns
- **django-cotton**: Component creation, template organization, Cotton-specific syntax
- **htmx**: Dynamic updates, form handling, AJAX interactions
- **alpinejs**: Client-side state management, reactive data, JavaScript interactions
- **django-rest-framework**: API design, serializers, viewsets, DRF patterns
- **postgresql**: Database queries, PostGIS functions, advanced SQL features
- **postgis**: Geographic data handling and spatial queries
- **redis**: Caching strategies, session management, performance optimization
### Mandatory Workflow Steps
1. **Before editing/creating code**: Query Context7 for relevant library documentation
2. **During debugging**: Use Context7 to verify syntax, patterns, and best practices
3. **When implementing new features**: Reference Context7 for current API and method signatures
4. **For performance issues**: Consult Context7 for optimization techniques and patterns
5. **For geographic data handling**: Use Context7 for PostGIS functions and best practices
6. **For caching strategies**: Refer to Context7 for Redis patterns and best practices
7. **For database queries**: Utilize Context7 for PostgreSQL best practices and advanced SQL features
### Mandatory Scenarios
- Creating new Django models or API endpoints
- Implementing HTMX dynamic functionality
- Writing AlpineJS reactive components
- Designing responsive layouts with Tailwind CSS
- Creating Django-Cotton components
- Debugging CSS, JavaScript, or Django issues
- Implementing caching or database optimizations
- Handling geographic data with PostGIS
- Utilizing Redis for session management
- Implementing real-time features with WebSockets
### Context7 Commands
1. **Resolve Library**: Always call `Context7:resolve-library-id` first to get correct library ID
2. **Get Documentation**: Then use `Context7:get-library-docs` with appropriate topic parameter
### Example Topics by Library
- **tailwindcss**: responsive design, flexbox, grid, animations
- **django**: models, views, forms, admin, signals
- **django-cotton**: components, templates, slots, props
- **htmx**: hx-get, hx-post, hx-swap, hx-trigger, hx-target
- **alpinejs**: x-data, x-show, x-if, x-for, x-model
- **django-rest-framework**: serializers, viewsets, routers, permissions
- **postgresql**: joins, indexes, transactions, window functions
- **postgis**: geospatial queries, distance calculations, spatial indexes
- **redis**: caching strategies, pub/sub, data structures
## Code Quality Standards
### Model Requirements
- All models MUST inherit from TrackedModel
- Use SluggedModel for entities with slugs and history tracking
- Always use RichChoiceField instead of Django choices
- Use CloudflareImagesField for all image handling
- Use PostGIS fields and separate location models for geographic data
### API Requirements
- MUST include trailing slashes and follow nested pattern
- All responses MUST use real database queries
- Implement comprehensive filtering and pagination
- Use signal-based cache invalidation
### Development Workflow
- Use uv for all Python package operations
- Use runserver_plus for enhanced development server
- Always use `uv run` for Django management commands
- All functionality MUST work with progressive enhancement

View File

@@ -1,56 +0,0 @@
---
description: Condensed ThrillWiki Django project context with architecture, patterns, and mandatory Context7 integration
author: ThrillWiki Development Team
version: 2.1
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
tags: ["django", "architecture", "context7-integration", "thrillwiki"]
---
# ThrillWiki Django Project Context
## Project Overview
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
## Core Architecture
- **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
- **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
- Clean, simple UX preferred
- **Media**: Cloudflare Images with Direct Upload
- **Tracking**: pghistory, TrackedModel base class
- **Choices**: Rich Choice Objects (NEVER Django tuple choices)
## Development Patterns
- **Models**: TrackedModel inheritance, SluggedModel for slugs, PostGIS for location
- **APIs**: Nested URLs (`/parks/{slug}/rides/{slug}/`), mandatory trailing slashes
- **Commands**: `uv add <package>`, `uv run manage.py <command>` (NOT pip/python)
- **Choices**: `RichChoiceField(choice_group="name", domain="domain")` MANDATORY
## Business Rules
🚨 **CRITICAL**: Company role separation - Parks (OPERATOR/PROPERTY_OWNER only), Rides (MANUFACTURER/DESIGNER only)
## Context7 MCP Integration (MANDATORY)
### Required Libraries
tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postgresql, postgis, redis
### Workflow
1. **ALWAYS** call `Context7:resolve-library-id` first
2. Then `Context7:get-library-docs` with topic parameter
3. Required for: new models/APIs, HTMX functionality, AlpineJS components, Tailwind layouts, Cotton components, debugging, optimizations
### Example Topics
- **tailwindcss**: responsive, flexbox, grid
- **django**: models, views, forms
- **htmx**: hx-get, hx-post, hx-swap, hx-target
- **alpinejs**: x-data, x-show, x-if, x-for
## Standards
- All models inherit TrackedModel
- Real database data only (NO MOCKING)
- RichChoiceField over Django choices
- Progressive enhancement required
- We prefer to edit existing files instead of creating new ones.
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!

BIN
.coverage

Binary file not shown.

View File

@@ -1,90 +0,0 @@
# [AWS-SECRET-REMOVED]===========================
# ThrillWiki Environment Configuration
# [AWS-SECRET-REMOVED]===========================
# Copy this file to ***REMOVED*** and fill in your actual values
# [AWS-SECRET-REMOVED]===========================
# Core Django Settings
# [AWS-SECRET-REMOVED]===========================
SECRET_KEY=your-secret-key-here-generate-a-new-one
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1,beta.thrillwiki.com
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
# [AWS-SECRET-REMOVED]===========================
# Database Configuration
# [AWS-SECRET-REMOVED]===========================
# PostgreSQL with PostGIS for production/development
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
# SQLite for quick local development (uncomment to use)
# DATABASE_URL=spatialite:///path/to/your/db.sqlite3
# [AWS-SECRET-REMOVED]===========================
# Cache Configuration
# [AWS-SECRET-REMOVED]===========================
# Local memory cache for development
CACHE_URL=locmem://
# Redis for production (uncomment and configure for production)
# CACHE_URL=redis://localhost:6379/1
# REDIS_URL=redis://localhost:6379/0
CACHE_MIDDLEWARE_SECONDS=300
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
# [AWS-SECRET-REMOVED]===========================
# Email Configuration
# [AWS-SECRET-REMOVED]===========================
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
SERVER_EMAIL=django_webmaster@thrillwiki.com
# ForwardEmail configuration (uncomment to use)
# EMAIL_BACKEND=email_service.backends.ForwardEmailBackend
# FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
# SMTP configuration (uncomment to use)
# EMAIL_URL=smtp://username:password@smtp.example.com:587
# [AWS-SECRET-REMOVED]===========================
# Security Settings
# [AWS-SECRET-REMOVED]===========================
# Cloudflare Turnstile (get keys from Cloudflare dashboard)
TURNSTILE_SITE_KEY=your-turnstile-site-key
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
# Security headers (set to True for production)
SECURE_SSL_REDIRECT=False
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
# [AWS-SECRET-REMOVED]===========================
# GeoDjango Settings (macOS with Homebrew)
# [AWS-SECRET-REMOVED]===========================
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
# Linux alternatives (uncomment if on Linux)
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
# [AWS-SECRET-REMOVED]===========================
# Optional: Third-party Integrations
# [AWS-SECRET-REMOVED]===========================
# Sentry for error tracking (uncomment to use)
# SENTRY_DSN=https://your-sentry-dsn-here
# Google Analytics (uncomment to use)
# GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
# [AWS-SECRET-REMOVED]===========================
# Development/Debug Settings
# [AWS-SECRET-REMOVED]===========================
# Set to comma-separated list for debug toolbar
# INTERNAL_IPS=127.0.0.1,::1
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_LEVEL=INFO

29
.flake8
View File

@@ -1,29 +0,0 @@
[flake8]
# Maximum line length (matches Black formatter)
max-line-length = 88
# Exclude common directories that shouldn't be linted
exclude =
.git,
__pycache__,
.venv,
venv,
env,
.env,
migrations,
node_modules,
.tox,
.mypy_cache,
.pytest_cache,
build,
dist,
*.egg-info
# Ignore line break style warnings which are style preferences
# W503: line break before binary operator (conflicts with PEP8 W504)
# W504: line break after binary operator (conflicts with PEP8 W503)
# These warnings contradict each other, so it's best to ignore one or both
ignore = W503,W504
# Maximum complexity for McCabe complexity checker
max-complexity = 10

View File

@@ -1,54 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'

425
.gitignore vendored
View File

@@ -1,8 +1,198 @@
# Python /.vscode
/dev.sh
/flake.nix
venv
/venv
./venv
venv/sour
.DS_Store
.DS_Store
.DS_Store
accounts/__pycache__/
__pycache__
thrillwiki/__pycache__
reviews/__pycache__
parks/__pycache__
media/__pycache__
email_service/__pycache__
core/__pycache__
companies/__pycache__
accounts/__pycache__
venv
accounts/__pycache__
thrillwiki/__pycache__/settings.cpython-311.pyc
accounts/migrations/__pycache__/__init__.cpython-311.pyc
accounts/migrations/__pycache__/0001_initial.cpython-311.pyc
companies/migrations/__pycache__
moderation/__pycache__
rides/__pycache__
ssh_tools.jsonc
thrillwiki/__pycache__/settings.cpython-312.pyc
parks/__pycache__/views.cpython-312.pyc
.venv/lib/python3.12/site-packages
thrillwiki/__pycache__/urls.cpython-312.pyc
thrillwiki/__pycache__/views.cpython-312.pyc
.pytest_cache.github
static/css/tailwind.css
static/css/tailwind.css
.venv
location/__pycache__
analytics/__pycache__
designers/__pycache__
history_tracking/__pycache__
media/migrations/__pycache__/0001_initial.cpython-312.pyc
accounts/__pycache__/__init__.cpython-312.pyc
accounts/__pycache__/adapters.cpython-312.pyc
accounts/__pycache__/admin.cpython-312.pyc
accounts/__pycache__/apps.cpython-312.pyc
accounts/__pycache__/models.cpython-312.pyc
accounts/__pycache__/signals.cpython-312.pyc
accounts/__pycache__/urls.cpython-312.pyc
accounts/__pycache__/views.cpython-312.pyc
accounts/migrations/__pycache__/__init__.cpython-312.pyc
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
companies/__pycache__/__init__.cpython-312.pyc
companies/__pycache__/admin.cpython-312.pyc
companies/__pycache__/apps.cpython-312.pyc
companies/__pycache__/models.cpython-312.pyc
companies/__pycache__/signals.cpython-312.pyc
companies/__pycache__/urls.cpython-312.pyc
companies/__pycache__/views.cpython-312.pyc
companies/migrations/__pycache__/__init__.cpython-312.pyc
companies/migrations/__pycache__/0001_initial.cpython-312.pyc
core/__pycache__/__init__.cpython-312.pyc
core/__pycache__/admin.cpython-312.pyc
core/__pycache__/apps.cpython-312.pyc
core/__pycache__/models.cpython-312.pyc
core/__pycache__/views.cpython-312.pyc
core/migrations/__pycache__/__init__.cpython-312.pyc
core/migrations/__pycache__/0001_initial.cpython-312.pyc
email_service/__pycache__/__init__.cpython-312.pyc
email_service/__pycache__/admin.cpython-312.pyc
email_service/__pycache__/apps.cpython-312.pyc
email_service/__pycache__/models.cpython-312.pyc
email_service/__pycache__/services.cpython-312.pyc
email_service/migrations/__pycache__/__init__.cpython-312.pyc
email_service/migrations/__pycache__/0001_initial.cpython-312.pyc
media/__pycache__/__init__.cpython-312.pyc
media/__pycache__/admin.cpython-312.pyc
media/__pycache__/apps.cpython-312.pyc
media/__pycache__/models.cpython-312.pyc
media/migrations/__pycache__/__init__.cpython-312.pyc
media/migrations/__pycache__/0001_initial.cpython-312.pyc
parks/__pycache__/__init__.cpython-312.pyc
parks/__pycache__/admin.cpython-312.pyc
parks/__pycache__/apps.cpython-312.pyc
parks/__pycache__/models.cpython-312.pyc
parks/__pycache__/signals.cpython-312.pyc
parks/__pycache__/urls.cpython-312.pyc
parks/__pycache__/views.cpython-312.pyc
parks/migrations/__pycache__/__init__.cpython-312.pyc
parks/migrations/__pycache__/0001_initial.cpython-312.pyc
reviews/__pycache__/__init__.cpython-312.pyc
reviews/__pycache__/admin.cpython-312.pyc
reviews/__pycache__/apps.cpython-312.pyc
reviews/__pycache__/models.cpython-312.pyc
reviews/__pycache__/signals.cpython-312.pyc
reviews/__pycache__/urls.cpython-312.pyc
reviews/__pycache__/views.cpython-312.pyc
reviews/migrations/__pycache__/__init__.cpython-312.pyc
reviews/migrations/__pycache__/0001_initial.cpython-312.pyc
rides/__pycache__/__init__.cpython-312.pyc
rides/__pycache__/admin.cpython-312.pyc
rides/__pycache__/apps.cpython-312.pyc
rides/__pycache__/models.cpython-312.pyc
rides/__pycache__/signals.cpython-312.pyc
rides/__pycache__/urls.cpython-312.pyc
rides/__pycache__/views.cpython-312.pyc
rides/migrations/__pycache__/__init__.cpython-312.pyc
rides/migrations/__pycache__/0001_initial.cpython-312.pyc
thrillwiki/__pycache__/__init__.cpython-312.pyc
thrillwiki/__pycache__/settings.cpython-312.pyc
thrillwiki/__pycache__/urls.cpython-312.pyc
thrillwiki/__pycache__/views.cpython-312.pyc
thrillwiki/__pycache__/wsgi.cpython-312.pyc
accounts/__pycache__/__init__.cpython-312.pyc
accounts/__pycache__/adapters.cpython-312.pyc
accounts/__pycache__/admin.cpython-312.pyc
accounts/__pycache__/apps.cpython-312.pyc
accounts/__pycache__/models.cpython-312.pyc
accounts/__pycache__/signals.cpython-312.pyc
accounts/__pycache__/urls.cpython-312.pyc
accounts/__pycache__/views.cpython-312.pyc
accounts/migrations/__pycache__/__init__.cpython-312.pyc
accounts/migrations/__pycache__/0001_initial.cpython-312.pyc
companies/__pycache__/__init__.cpython-312.pyc
companies/__pycache__/admin.cpython-312.pyc
companies/__pycache__/apps.cpython-312.pyc
companies/__pycache__/models.cpython-312.pyc
companies/__pycache__/signals.cpython-312.pyc
companies/__pycache__/urls.cpython-312.pyc
companies/__pycache__/views.cpython-312.pyc
companies/migrations/__pycache__/__init__.cpython-312.pyc
companies/migrations/__pycache__/0001_initial.cpython-312.pyc
core/__pycache__/__init__.cpython-312.pyc
core/__pycache__/admin.cpython-312.pyc
core/__pycache__/apps.cpython-312.pyc
core/__pycache__/models.cpython-312.pyc
core/__pycache__/views.cpython-312.pyc
core/migrations/__pycache__/__init__.cpython-312.pyc
core/migrations/__pycache__/0001_initial.cpython-312.pyc
email_service/__pycache__/__init__.cpython-312.pyc
email_service/__pycache__/admin.cpython-312.pyc
email_service/__pycache__/apps.cpython-312.pyc
email_service/__pycache__/models.cpython-312.pyc
email_service/__pycache__/services.cpython-312.pyc
email_service/migrations/__pycache__/__init__.cpython-312.pyc
email_service/migrations/__pycache__/0001_initial.cpython-312.pyc
media/__pycache__/__init__.cpython-312.pyc
media/__pycache__/admin.cpython-312.pyc
media/__pycache__/apps.cpython-312.pyc
media/__pycache__/models.cpython-312.pyc
media/migrations/__pycache__/__init__.cpython-312.pyc
media/migrations/__pycache__/0001_initial.cpython-312.pyc
parks/__pycache__/__init__.cpython-312.pyc
parks/__pycache__/admin.cpython-312.pyc
parks/__pycache__/apps.cpython-312.pyc
parks/__pycache__/models.cpython-312.pyc
parks/__pycache__/signals.cpython-312.pyc
parks/__pycache__/urls.cpython-312.pyc
parks/__pycache__/views.cpython-312.pyc
parks/migrations/__pycache__/__init__.cpython-312.pyc
parks/migrations/__pycache__/0001_initial.cpython-312.pyc
reviews/__pycache__/__init__.cpython-312.pyc
reviews/__pycache__/admin.cpython-312.pyc
reviews/__pycache__/apps.cpython-312.pyc
reviews/__pycache__/models.cpython-312.pyc
reviews/__pycache__/signals.cpython-312.pyc
reviews/__pycache__/urls.cpython-312.pyc
reviews/__pycache__/views.cpython-312.pyc
reviews/migrations/__pycache__/__init__.cpython-312.pyc
reviews/migrations/__pycache__/0001_initial.cpython-312.pyc
rides/__pycache__/__init__.cpython-312.pyc
rides/__pycache__/admin.cpython-312.pyc
rides/__pycache__/apps.cpython-312.pyc
rides/__pycache__/models.cpython-312.pyc
rides/__pycache__/signals.cpython-312.pyc
rides/__pycache__/urls.cpython-312.pyc
rides/__pycache__/views.cpython-312.pyc
rides/migrations/__pycache__/__init__.cpython-312.pyc
rides/migrations/__pycache__/0001_initial.cpython-312.pyc
thrillwiki/__pycache__/__init__.cpython-312.pyc
thrillwiki/__pycache__/settings.cpython-312.pyc
thrillwiki/__pycache__/urls.cpython-312.pyc
thrillwiki/__pycache__/views.cpython-312.pyc
thrillwiki/__pycache__/wsgi.cpython-312.pyc
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions
*.so *.so
# Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
@@ -22,105 +212,164 @@ share/python-wheels/
*.egg *.egg
MANIFEST MANIFEST
# Django # PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log *.log
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal
/backend/staticfiles/
/backend/media/
# UV # Flask stuff:
.uv/ instance/
backend/.uv/ .webassets-cache
# Node.js # Scrapy stuff:
node_modules/ .scrapy
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-store/
# Vue.js / Vite # Sphinx documentation
/frontend/dist/ docs/_build/
/frontend/dist-ssr/
*.local
# Environment variables # PyBuilder
.env .pybuilder/
.env.local target/
.env.development.local
.env.test.local
.env.production.local
backend/.env
frontend/.env
# IDEs # Jupyter Notebook
.vscode/ .ipynb_checkpoints
.idea/
*.swp
*.swo
*.sublime-project
*.sublime-workspace
# OS # IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
***REMOVED***
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.[AWS-SECRET-REMOVED]tBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# General
.DS_Store .DS_Store
Thumbs.db .AppleDouble
Desktop.ini .LSOverride
# Logs # Icon must end with two \r
logs/ Icon
*.log
# Coverage
coverage/ # Thumbnails
*.lcov
.nyc_output
htmlcov/
.coverage
.coverage.*
._* ._*
# Testing
.pytest_cache/ # Files that might appear in the root of a volume
.cache .DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent .com.apple.timemachine.donotpresent
# Temporary files
tmp/ # Directories potentially created on remote AFP share
temp/ .AppleDB
*.tmp .AppleDesktop
*.temp Network Trash Folder
Temporary Items
# Build outputs
/dist/
/build/
# Backup files
*.bak
*.orig
*.swp
# Archive files
*.tar.gz
*.zip
*.rar
# Security
*.pem
*.key
*.cert
# Local development
/uploads/
/backups/
.django_tailwind_cli/
backend/.env
frontend/.env
# Extracted packages
django-forwardemail/
frontend/
frontend
.snapshots
uv.lock

73
.replit
View File

@@ -1,73 +0,0 @@
modules = ["bash", "web", "nodejs-20", "python-3.13", "postgresql-16"]
[nix]
channel = "stable-25_05"
packages = [
"freetype",
"gdal",
"geos",
"gitFull",
"lcms2",
"libimagequant",
"libjpeg",
"libtiff",
"libwebp",
"libxcrypt",
"openjpeg",
"playwright-driver",
"postgresql",
"proj",
"tcl",
"tk",
"uv",
"zlib",
]
[agent]
expertMode = true
[workflows]
runButton = "Project"
[[workflows.workflow]]
name = "Project"
mode = "parallel"
author = "agent"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "ThrillWiki Server"
[[workflows.workflow]]
name = "ThrillWiki Server"
author = "agent"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "/home/runner/workspace/.venv/bin/python manage.py tailwind runserver 0.0.0.0:5000"
waitForPort = 5000
[workflows.workflow.metadata]
outputType = "webview"
[[ports]]
localPort = 5000
externalPort = 80
[[ports]]
localPort = 41923
externalPort = 3000
[[ports]]
localPort = 45245
externalPort = 3001
[deployment]
deploymentTarget = "autoscale"
run = [
"gunicorn",
"--bind=0.0.0.0:5000",
"--reuse-port",
"thrillwiki.wsgi:application",
]
build = ["uv", "pip", "install", "--system", "-r", "requirements.txt"]

View File

@@ -1,18 +0,0 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
},
"alwaysAllow": [
"resolve-library-id",
"get-library-docs"
]
}
}
}

View File

@@ -1,2 +0,0 @@
## CRITICAL: Centralized API Structure
All API endpoints MUST be centralized under the `backend/apps/api/v1/` structure. This is NON-NEGOTIABLE.

View File

@@ -1,49 +0,0 @@
# Project Startup & Development Rules
## Server & Package Management
- **Starting the Dev Server:** Always assume the server is running and changes have taken effect. If issues arise, run:
```bash
$PROJECT_ROOT/shared/scripts/start-servers.sh
```
- **Python Packages:** Only use UV to add packages:
```bash
cd $PROJECT_ROOT/backend && uv add <package>
```
- **Django Commands:** Always use `cd backend && uv run manage.py <command>` for all management tasks (migrations, shell, superuser, etc.). Never use `python manage.py` or `uv run python manage.py`.
## CRITICAL Frontend design rules
- EVERYTHING must support both dark and light mode.
- Make sure the light/dark mode toggle works with the Vue components and pages.
- Leverage Tailwind CSS 4 and Shadcn UI components.
## Frontend API URL Rules
- **Vite Proxy:** Always check `frontend/vite.config.ts` for proxy rules before changing frontend API URLs.
- **URL Flow:** Understand how frontend URLs are rewritten by Vite proxy (e.g., `/api/auth/login/` → `/api/v1/auth/login/`).
- **Verification:** Confirm proxy behavior via config and browser network tab. Only change URLs if proxy is NOT handling rewriting.
- **Common Mistake:** Dont assume frontend URLs are wrong due to proxy configuration.
## Entity Relationship Patterns
- **Park:** Must have Operator (required), may have PropertyOwner (optional), cannot reference Company directly.
- **Ride:** Must belong to Park, may have Manufacturer/Designer (optional), cannot reference Company directly.
- **Entities:**
- Operators: Operate parks.
- PropertyOwners: Own park property (optional).
- Manufacturers: Make rides.
- Designers: Design rides.
- All entities can have locations.
- **Constraints:** Operator and PropertyOwner can be same or different. Manufacturers and Designers are distinct. Use proper foreign keys with correct null/blank settings.
## General Best Practices
- Never assume blank output means success—always verify changes by testing.
- Use context7 for documentation when troubleshooting.
- Document changes with conport and reasoning.
- Include relevant context and information in all changes.
- Test and validate code before deployment.
- Communicate changes clearly with your team.
- Be open to feedback and continuous improvement.
- Prioritize readability, maintainability, security, performance, scalability, and modularity.
- Use meaningful names, DRY principles, clear comments, and handle errors gracefully.
- Log important events/errors for troubleshooting.
- Prefer existing modules/packages over new code.
- Keep documentation up to date.
- Consider security vulnerabilities and performance bottlenecks in all changes.

View File

@@ -1,390 +0,0 @@
# --- ConPort Memory Strategy ---
conport_memory_strategy:
# CRITICAL: At the beginning of every session, the agent MUST execute the 'initialization' sequence
# to determine the ConPort status and load relevant context.
workspace_id_source: "The agent must obtain the absolute path to the current workspace to use as `workspace_id` for all ConPort tool calls. This might be available as `${workspaceFolder}` or require asking the user."
initialization:
thinking_preamble: |
agent_action_plan:
- step: 1
action: "Determine `ACTUAL_WORKSPACE_ID`."
- step: 2
action: "Invoke `list_files` for `ACTUAL_WORKSPACE_ID + \"/context_portal/\"`."
tool_to_use: "list_files"
parameters: "path: ACTUAL_WORKSPACE_ID + \"/context_portal/\""
- step: 3
action: "Analyze result and branch based on 'context.db' existence."
conditions:
- if: "'context.db' is found"
then_sequence: "load_existing_conport_context"
- else: "'context.db' NOT found"
then_sequence: "handle_new_conport_setup"
load_existing_conport_context:
thinking_preamble: |
agent_action_plan:
- step: 1
description: "Attempt to load initial contexts from ConPort."
actions:
- "Invoke `get_product_context`... Store result."
- "Invoke `get_active_context`... Store result."
- "Invoke `get_decisions` (limit 5 for a better overview)... Store result."
- "Invoke `get_progress` (limit 5)... Store result."
- "Invoke `get_system_patterns` (limit 5)... Store result."
- "Invoke `get_custom_data` (category: \"critical_settings\")... Store result."
- "Invoke `get_custom_data` (category: \"ProjectGlossary\")... Store result."
- "Invoke `get_recent_activity_summary` (default params, e.g., last 24h, limit 3 per type) for a quick catch-up. Store result."
- step: 2
description: "Analyze loaded context."
conditions:
- if: "results from step 1 are NOT empty/minimal"
actions:
- "Set internal status to [CONPORT_ACTIVE]."
- "Inform user: \"ConPort memory initialized. Existing contexts and recent activity loaded.\""
- "Use `ask_followup_question` with suggestions like \"Review recent activity?\", \"Continue previous task?\", \"What would you like to work on?\"."
- else: "loaded context is empty/minimal despite DB file existing"
actions:
- "Set internal status to [CONPORT_ACTIVE]."
- "Inform user: \"ConPort database file found, but it appears to be empty or minimally initialized. You can start by defining Product/Active Context or logging project information.\""
- "Use `ask_followup_question` with suggestions like \"Define Product Context?\", \"Log a new decision?\"."
- step: 3
description: "Handle Load Failure (if step 1's `get_*` calls failed)."
condition: "If any `get_*` calls in step 1 failed unexpectedly"
action: "Fall back to `if_conport_unavailable_or_init_failed`."
handle_new_conport_setup:
thinking_preamble: |
agent_action_plan:
- step: 1
action: "Inform user: \"No existing ConPort database found at `ACTUAL_WORKSPACE_ID + \"/context_portal/context.db\"`.\""
- step: 2
action: "Use `ask_followup_question`."
tool_to_use: "ask_followup_question"
parameters:
question: "Would you like to initialize a new ConPort database for this workspace? The database will be created automatically when ConPort tools are first used."
suggestions:
- "Yes, initialize a new ConPort database."
- "No, do not use ConPort for this session."
- step: 3
description: "Process user response."
conditions:
- if_user_response_is: "Yes, initialize a new ConPort database."
actions:
- "Inform user: \"Okay, a new ConPort database will be created.\""
- description: "Attempt to bootstrap Product Context from projectBrief.md (this happens only on new setup)."
thinking_preamble: |
sub_steps:
- "Invoke `list_files` with `path: ACTUAL_WORKSPACE_ID` (non-recursive, just to check root)."
- description: "Analyze `list_files` result for 'projectBrief.md'."
conditions:
- if: "'projectBrief.md' is found in the listing"
actions:
- "Invoke `read_file` for `ACTUAL_WORKSPACE_ID + \"/projectBrief.md\"`."
- action: "Use `ask_followup_question`."
tool_to_use: "ask_followup_question"
parameters:
question: "Found projectBrief.md in your workspace. As we're setting up ConPort for the first time, would you like to import its content into the Product Context?"
suggestions:
- "Yes, import its content now."
- "No, skip importing it for now."
- description: "Process user response to import projectBrief.md."
conditions:
- if_user_response_is: "Yes, import its content now."
actions:
- "(No need to `get_product_context` as DB is new and empty)"
- "Prepare `content` for `update_product_context`. For example: `{\"initial_product_brief\": \"[content from projectBrief.md]\"}`."
- "Invoke `update_product_context` with the prepared content."
- "Inform user of the import result (success or failure)."
- else: "'projectBrief.md' NOT found"
actions:
- action: "Use `ask_followup_question`."
tool_to_use: "ask_followup_question"
parameters:
question: "`projectBrief.md` was not found in the workspace root. Would you like to define the initial Product Context manually now?"
suggestions:
- "Define Product Context manually."
- "Skip for now."
- "(If \"Define manually\", guide user through `update_product_context`)."
- "Proceed to 'load_existing_conport_context' sequence (which will now load the potentially bootstrapped product context and other empty contexts)."
- if_user_response_is: "No, do not use ConPort for this session."
action: "Proceed to `if_conport_unavailable_or_init_failed` (with a message indicating user chose not to initialize)."
if_conport_unavailable_or_init_failed:
thinking_preamble: |
agent_action: "Inform user: \"ConPort memory will not be used for this session. Status: [CONPORT_INACTIVE].\""
general:
status_prefix: "Begin EVERY response with either '[CONPORT_ACTIVE]' or '[CONPORT_INACTIVE]'."
proactive_logging_cue: "Remember to proactively identify opportunities to log or update ConPort based on the conversation (e.g., if user outlines a new plan, consider logging decisions or progress). Confirm with the user before logging."
proactive_error_handling: "When encountering errors (e.g., tool failures, unexpected output), proactively log the error details using `log_custom_data` (category: 'ErrorLogs', key: 'timestamp_error_summary') and consider updating `active_context` with `open_issues` if it's a persistent problem. Prioritize using ConPort's `get_item_history` or `get_recent_activity_summary` to diagnose issues if they relate to past context changes."
semantic_search_emphasis: "For complex or nuanced queries, especially when direct keyword search (`search_decisions_fts`, `search_custom_data_value_fts`) might be insufficient, prioritize using `semantic_search_conport` to leverage conceptual understanding and retrieve more relevant context. Explain to the user why semantic search is being used."
conport_updates:
frequency: "UPDATE CONPORT THROUGHOUT THE CHAT SESSION, WHEN SIGNIFICANT CHANGES OCCUR, OR WHEN EXPLICITLY REQUESTED."
workspace_id_note: "All ConPort tool calls require the `workspace_id`."
tools:
- name: get_product_context
trigger: "To understand the overall project goals, features, or architecture at any time."
action_description: |
# Agent Action: Invoke `get_product_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
- name: update_product_context
trigger: "When the high-level project description, goals, features, or overall architecture changes significantly, as confirmed by the user."
action_description: |
<thinking>
- Product context needs updating.
- Step 1: (Optional but recommended if unsure of current state) Invoke `get_product_context`.
- Step 2: Prepare the `content` (for full overwrite) or `patch_content` (partial update) dictionary.
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
- Confirm changes with the user.
</thinking>
# Agent Action: Invoke `update_product_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"key_to_update": "new_value", "key_to_delete": "__DELETE__"}}`).
- name: get_active_context
trigger: "To understand the current task focus, immediate goals, or session-specific context."
action_description: |
# Agent Action: Invoke `get_active_context` (`{"workspace_id": "..."}`). Result is a direct dictionary.
- name: update_active_context
trigger: "When the current focus of work changes, new questions arise, or session-specific context needs updating (e.g., `current_focus`, `open_issues`), as confirmed by the user."
action_description: |
<thinking>
- Active context needs updating.
- Step 1: (Optional) Invoke `get_active_context` to retrieve the current state.
- Step 2: Prepare `content` (for full overwrite) or `patch_content` (for partial update).
- Common fields to update include `current_focus`, `open_issues`, and other session-specific data.
- To remove a key using `patch_content`, set its value to the special string sentinel `\"__DELETE__\"`.
- Confirm changes with the user.
</thinking>
# Agent Action: Invoke `update_active_context` (`{"workspace_id": "...", "content": {...}}` or `{"workspace_id": "...", "patch_content": {"current_focus": "new_focus", "open_issues": ["issue1", "issue2"], "key_to_delete": "__DELETE__"}}`).
- name: log_decision
trigger: "When a significant architectural or implementation decision is made and confirmed by the user."
action_description: |
# Agent Action: Invoke `log_decision` (`{"workspace_id": "...", "summary": "...", "rationale": "...", "tags": ["optional_tag"]}}`).
- name: get_decisions
trigger: "To retrieve a list of past decisions, e.g., to review history or find a specific decision."
action_description: |
# Agent Action: Invoke `get_decisions` (`{"workspace_id": "...", "limit": N, "tags_filter_include_all": ["tag1"], "tags_filter_include_any": ["tag2"]}}`). Explain optional filters.
- name: search_decisions_fts
trigger: "When searching for decisions by keywords in summary, rationale, details, or tags, and basic `get_decisions` is insufficient."
action_description: |
# Agent Action: Invoke `search_decisions_fts` (`{"workspace_id": "...", "query_term": "search keywords", "limit": N}}`).
- name: delete_decision_by_id
trigger: "When user explicitly confirms deletion of a specific decision by its ID."
action_description: |
# Agent Action: Invoke `delete_decision_by_id` (`{"workspace_id": "...", "decision_id": ID}}`). Emphasize prior confirmation.
- name: log_progress
trigger: "When a task begins, its status changes (e.g., TODO, IN_PROGRESS, DONE), or it's completed. Also when a new sub-task is defined."
action_description: |
# Agent Action: Invoke `log_progress` (`{"workspace_id": "...", "description": "...", "status": "...", "linked_item_type": "...", "linked_item_id": "..."}}`). Note: 'summary' was changed to 'description' for log_progress.
- name: get_progress
trigger: "To review current task statuses, find pending tasks, or check history of progress."
action_description: |
# Agent Action: Invoke `get_progress` (`{"workspace_id": "...", "status_filter": "...", "parent_id_filter": ID, "limit": N}}`).
- name: update_progress
trigger: "Updates an existing progress entry."
action_description: |
# Agent Action: Invoke `update_progress` (`{"workspace_id": "...", "progress_id": ID, "status": "...", "description": "...", "parent_id": ID}}`).
- name: delete_progress_by_id
trigger: "Deletes a progress entry by its ID."
action_description: |
# Agent Action: Invoke `delete_progress_by_id` (`{"workspace_id": "...", "progress_id": ID}}`).
- name: log_system_pattern
trigger: "When new architectural patterns are introduced, or existing ones are modified, as confirmed by the user."
action_description: |
# Agent Action: Invoke `log_system_pattern` (`{"workspace_id": "...", "name": "...", "description": "...", "tags": ["optional_tag"]}}`).
- name: get_system_patterns
trigger: "To retrieve a list of defined system patterns."
action_description: |
# Agent Action: Invoke `get_system_patterns` (`{"workspace_id": "...", "tags_filter_include_all": ["tag1"], "limit": N}}`). Note: limit was not in original example, added for consistency.
- name: delete_system_pattern_by_id
trigger: "When user explicitly confirms deletion of a specific system pattern by its ID."
action_description: |
# Agent Action: Invoke `delete_system_pattern_by_id` (`{"workspace_id": "...", "pattern_id": ID}}`). Emphasize prior confirmation.
- name: log_custom_data
trigger: "To store any other type of structured or unstructured project-related information not covered by other tools (e.g., glossary terms, technical specs, meeting notes), as confirmed by the user."
action_description: |
# Agent Action: Invoke `log_custom_data` (`{"workspace_id": "...", "category": "...", "key": "...", "value": {... or "string"}}`). Note: 'metadata' field is not part of log_custom_data args.
- name: get_custom_data
trigger: "To retrieve specific custom data by category and key."
action_description: |
# Agent Action: Invoke `get_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`).
- name: delete_custom_data
trigger: "When user explicitly confirms deletion of specific custom data by category and key."
action_description: |
# Agent Action: Invoke `delete_custom_data` (`{"workspace_id": "...", "category": "...", "key": "..."}}`). Emphasize prior confirmation.
- name: search_custom_data_value_fts
trigger: "When searching for specific terms within any custom data values, categories, or keys."
action_description: |
# Agent Action: Invoke `search_custom_data_value_fts` (`{"workspace_id": "...", "query_term": "...", "category_filter": "...", "limit": N}}`).
- name: search_project_glossary_fts
trigger: "When specifically searching for terms within the 'ProjectGlossary' custom data category."
action_description: |
# Agent Action: Invoke `search_project_glossary_fts` (`{"workspace_id": "...", "query_term": "...", "limit": N}}`).
- name: semantic_search_conport
trigger: "When a natural language query requires conceptual understanding beyond keyword matching, or when direct keyword searches are insufficient."
action_description: |
# Agent Action: Invoke `semantic_search_conport` (`{"workspace_id": "...", "query_text": "...", "top_k": N, "filter_item_types": ["decision", "custom_data"]}}`). Explain filters.
- name: link_conport_items
trigger: "When a meaningful relationship is identified and confirmed between two existing ConPort items (e.g., a decision is implemented by a system pattern, a progress item tracks a decision)."
action_description: |
<thinking>
- Need to link two items. Identify source type/ID, target type/ID, and relationship.
- Common relationship_types: 'implements', 'related_to', 'tracks', 'blocks', 'clarifies', 'depends_on'. Propose a suitable one or ask user.
</thinking>
# Agent Action: Invoke `link_conport_items` (`{"workspace_id":"...", "source_item_type":"...", "source_item_id":"...", "target_item_type":"...", "target_item_id":"...", "relationship_type":"...", "description":"Optional notes"}`).
- name: get_linked_items
trigger: "To understand the relationships of a specific ConPort item, or to explore the knowledge graph around an item."
action_description: |
# Agent Action: Invoke `get_linked_items` (`{"workspace_id":"...", "item_type":"...", "item_id":"...", "relationship_type_filter":"...", "linked_item_type_filter":"...", "limit":N}`).
- name: get_item_history
trigger: "When needing to review past versions of Product Context or Active Context, or to see when specific changes were made."
action_description: |
# Agent Action: Invoke `get_item_history` (`{"workspace_id":"...", "item_type":"product_context" or "active_context", "limit":N, "version":V, "before_timestamp":"ISO_DATETIME", "after_timestamp":"ISO_DATETIME"}`).
- name: batch_log_items
trigger: "When the user provides a list of multiple items of the SAME type (e.g., several decisions, multiple new glossary terms) to be logged at once."
action_description: |
<thinking>
- User provided multiple items. Verify they are of the same loggable type.
- Construct the `items` list, where each element is a dictionary of arguments for the single-item log tool (e.g., for `log_decision`).
</thinking>
# Agent Action: Invoke `batch_log_items` (`{"workspace_id":"...", "item_type":"decision", "items": [{"summary":"...", "rationale":"..."}, {"summary":"..."}] }`).
- name: get_recent_activity_summary
trigger: "At the start of a new session to catch up, or when the user asks for a summary of recent project activities."
action_description: |
# Agent Action: Invoke `get_recent_activity_summary` (`{"workspace_id":"...", "hours_ago":H, "since_timestamp":"ISO_DATETIME", "limit_per_type":N}`). Explain default if no time args.
- name: get_conport_schema
trigger: "If there's uncertainty about available ConPort tools or their arguments during a session (internal LLM check), or if an advanced user specifically asks for the server's tool schema."
action_description: |
# Agent Action: Invoke `get_conport_schema` (`{"workspace_id":"..."}`). Primarily for internal LLM reference or direct user request.
- name: export_conport_to_markdown
trigger: "When the user requests to export the current ConPort data to markdown files (e.g., for backup, sharing, or version control)."
action_description: |
# Agent Action: Invoke `export_conport_to_markdown` (`{"workspace_id":"...", "output_path":"optional/relative/path"}`). Explain default output path if not provided.
- name: import_markdown_to_conport
trigger: "When the user requests to import ConPort data from a directory of markdown files previously exported by this system."
action_description: |
# Agent Action: Invoke `import_markdown_to_conport` (`{"workspace_id":"...", "input_path":"optional/relative/path"}`). Explain default input path. Warn about potential overwrites or merges if data already exists.
- name: reconfigure_core_guidance
type: guidance
product_active_context: "The internal JSON structure of 'Product Context' and 'Active Context' (the `content` field) is flexible. Work with the user to define and evolve this structure via `update_product_context` and `update_active_context`. The server stores this `content` as a JSON blob."
decisions_progress_patterns: "The fundamental fields for Decisions, Progress, and System Patterns are fixed by ConPort's tools. For significantly different structures or additional fields, guide the user to create a new custom context category using `log_custom_data` (e.g., category: 'project_milestones_detailed')."
conport_sync_routine:
trigger: "^(Sync ConPort|ConPort Sync)$"
user_acknowledgement_text: "[CONPORT_SYNCING]"
instructions:
- "Halt Current Task: Stop current activity."
- "Acknowledge Command: Send `[CONPORT_SYNCING]` to the user."
- "Review Chat History: Analyze the complete current chat session for new information, decisions, progress, context changes, clarifications, and potential new relationships between items."
core_update_process:
thinking_preamble: |
- Synchronize ConPort with information from the current chat session.
- Use appropriate ConPort tools based on identified changes.
- For `update_product_context` and `update_active_context`, first fetch current content, then merge/update (potentially using `patch_content`), then call the update tool with the *complete new content object* or the patch.
- All tool calls require the `workspace_id`.
agent_action_plan_illustrative:
- "Log new decisions (use `log_decision`)."
- "Log task progress/status changes (use `log_progress`)."
- "Update existing progress entries (use `update_progress`)."
- "Delete progress entries (use `delete_progress_by_id`)."
- "Log new system patterns (use `log_system_pattern`)."
- "Update Active Context (use `get_active_context` then `update_active_context` with full or patch)."
- "Update Product Context if significant changes (use `get_product_context` then `update_product_context` with full or patch)."
- "Log new custom context, including ProjectGlossary terms (use `log_custom_data`)."
- "Identify and log new relationships between items (use `link_conport_items`)."
- "If many items of the same type were discussed, consider `batch_log_items`."
- "After updates, consider a brief `get_recent_activity_summary` to confirm and refresh understanding."
post_sync_actions:
- "Inform user: ConPort synchronized with session info."
- "Resume previous task or await new instructions."
dynamic_context_retrieval_for_rag:
description: |
Guidance for dynamically retrieving and assembling context from ConPort to answer user queries or perform tasks,
enhancing Retrieval Augmented Generation (RAG) capabilities.
trigger: "When the AI needs to answer a specific question, perform a task requiring detailed project knowledge, or generate content based on ConPort data."
goal: "To construct a concise, highly relevant context set for the LLM, improving the accuracy and relevance of its responses."
steps:
- step: 1
action: "Analyze User Query/Task"
details: "Deconstruct the user's request to identify key entities, concepts, keywords, and the specific type of information needed from ConPort."
- step: 2
action: "Prioritized Retrieval Strategy"
details: |
Based on the analysis, select the most appropriate ConPort tools:
- **Targeted FTS:** Use `search_decisions_fts`, `search_custom_data_value_fts`, `search_project_glossary_fts` for keyword-based searches if specific terms are evident.
- **Specific Item Retrieval:** Use `get_custom_data` (if category/key known), `get_decisions` (by ID or for recent items), `get_system_patterns`, `get_progress` if the query points to specific item types or IDs.
- **(Future):** Prioritize semantic search tools once available for conceptual queries.
- **Broad Context (Fallback):** Use `get_product_context` or `get_active_context` as a fallback if targeted retrieval yields little, but be mindful of their size.
- step: 3
action: "Retrieve Initial Set"
details: "Execute the chosen tool(s) to retrieve an initial, small set (e.g., top 3-5) of the most relevant items or data snippets."
- step: 4
action: "Contextual Expansion (Optional)"
details: "For the most promising items from Step 3, consider using `get_linked_items` to fetch directly related items (1-hop). This can provide crucial context or disambiguation. Use judiciously to avoid excessive data."
- step: 5
action: "Synthesize and Filter"
details: |
Review the retrieved information (initial set + expanded context).
- **Filter:** Discard irrelevant items or parts of items.
- **Synthesize/Summarize:** If multiple relevant pieces of information are found, synthesize them into a concise summary that directly addresses the query/task. Extract only the most pertinent sentences or facts.
- step: 6
action: "Assemble Prompt Context"
details: |
Construct the context portion of the LLM prompt using the filtered and synthesized information.
- **Clarity:** Clearly delineate this retrieved context from the user's query or other parts of the prompt.
- **Attribution (Optional but Recommended):** If possible, briefly note the source of the information (e.g., "From Decision D-42:", "According to System Pattern SP-5:").
- **Brevity:** Strive for relevance and conciseness. Avoid including large, unprocessed chunks of data unless absolutely necessary and directly requested.
general_principles:
- "Prefer targeted retrieval over broad context dumps."
- "Iterate if initial retrieval is insufficient: try different keywords or tools."
- "Balance context richness with prompt token limits."
proactive_knowledge_graph_linking:
description: |
Guidance for the AI to proactively identify and suggest the creation of links between ConPort items,
enriching the project's knowledge graph based on conversational context.
trigger: "During ongoing conversation, when the AI observes potential relationships (e.g., causal, implementational, clarifying) between two or more discussed ConPort items or concepts that are likely represented as ConPort items."
goal: "To actively build and maintain a rich, interconnected knowledge graph within ConPort by capturing relationships that might otherwise be missed."
steps:
- step: 1
action: "Monitor Conversational Context"
details: "Continuously analyze the user's statements and the flow of discussion for mentions of ConPort items (explicitly by ID, or implicitly by well-known names/summaries) and the relationships being described or implied between them."
- step: 2
action: "Identify Potential Links"
details: |
Look for patterns such as:
- User states "Decision X led to us doing Y (which is Progress item P-3)."
- User discusses how System Pattern SP-2 helps address a concern noted in Decision D-5.
- User outlines a task (Progress P-10) that implements a specific feature detailed in a `custom_data` spec (CD-Spec-FeatureX).
- step: 3
action: "Formulate and Propose Link Suggestion"
details: |
If a potential link is identified:
- Clearly state the items involved (e.g., "Decision D-5", "System Pattern SP-2").
- Describe the perceived relationship (e.g., "It seems SP-2 addresses a concern in D-5.").
- Propose creating a link using `ask_followup_question`.
- Example Question: "I noticed we're discussing Decision D-5 and System Pattern SP-2. It sounds like SP-2 might 'address_concern_in' D-5. Would you like me to create this link in ConPort? You can also suggest a different relationship type."
- Suggested Answers:
- "Yes, link them with 'addresses_concern_in'."
- "Yes, but use relationship type: [user types here]."
- "No, don't link them now."
- Offer common relationship types as examples if needed: 'implements', 'clarifies', 'related_to', 'depends_on', 'blocks', 'resolves', 'derived_from'.
- step: 4
action: "Gather Details and Execute Linking"
details: |
If the user confirms:
- Ensure you have the correct source item type, source item ID, target item type, target item ID, and the agreed-upon relationship type.
- Ask for an optional brief description for the link if the relationship isn't obvious.
- Invoke the `link_conport_items` tool.
- step: 5
action: "Confirm Outcome"
details: "Inform the user of the success or failure of the `link_conport_items` tool call."
general_principles:
- "Be helpful, not intrusive. If the user declines a suggestion, accept and move on."
- "Prioritize clear, strong relationships over tenuous ones."
- "This strategy complements the general `proactive_logging_cue` by providing specific guidance for link creation."

File diff suppressed because one or more lines are too long

230
README.md
View File

@@ -1,229 +1 @@
# ThrillWiki Backend ThrillWiki.com
Django REST API backend for the ThrillWiki monorepo.
## 🏗️ Architecture
This backend follows Django best practices with a modular app structure:
```
backend/
├── apps/ # Django applications
│ ├── accounts/ # User management
│ ├── parks/ # Theme park data
│ ├── rides/ # Ride information
│ ├── moderation/ # Content moderation
│ ├── location/ # Geographic data
│ ├── media/ # File management
│ ├── email_service/ # Email functionality
│ └── core/ # Core utilities
├── config/ # Django configuration
│ ├── django/ # Settings files
│ └── settings/ # Modular settings
├── templates/ # Django templates
├── static/ # Static files
└── tests/ # Test files
```
## 🛠️ Technology Stack
- **Django 5.0+** - Web framework
- **Django REST Framework** - API framework
- **PostgreSQL** - Primary database
- **Redis** - Caching and sessions
- **UV** - Python package management
- **Celery** - Background task processing
## 🚀 Quick Start
### Prerequisites
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) package manager
- PostgreSQL 14+
- Redis 6+
### Setup
1. **Install dependencies**
```bash
cd backend
uv sync
```
2. **Environment configuration**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Database setup**
```bash
uv run manage.py migrate
uv run manage.py createsuperuser
```
4. **Start development server**
```bash
uv run manage.py runserver
```
## 🔧 Configuration
### Environment Variables
Required environment variables:
```bash
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
# Django
SECRET_KEY=your-secret-key
DEBUG=True
DJANGO_SETTINGS_MODULE=config.django.local
# Redis
REDIS_URL=redis://localhost:6379
# Email (optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
```
### Settings Structure
- `config/django/base.py` - Base settings
- `config/django/local.py` - Development settings
- `config/django/production.py` - Production settings
- `config/django/test.py` - Test settings
## 📁 Apps Overview
### Core Apps
- **accounts** - User authentication and profile management
- **parks** - Theme park models and operations
- **rides** - Ride information and relationships
- **core** - Shared utilities and base classes
### Support Apps
- **moderation** - Content moderation workflows
- **location** - Geographic data and services
- **media** - File upload and management
- **email_service** - Email sending and templates
## 🔌 API Endpoints
Base URL: `http://localhost:8000/api/`
### Authentication
- `POST /auth/login/` - User login
- `POST /auth/logout/` - User logout
- `POST /auth/register/` - User registration
### Parks
- `GET /parks/` - List parks
- `GET /parks/{id}/` - Park details
- `POST /parks/` - Create park (admin)
### Rides
- `GET /rides/` - List rides
- `GET /rides/{id}/` - Ride details
- `GET /parks/{park_id}/rides/` - Rides by park
## 🧪 Testing
```bash
# Run all tests
uv run manage.py test
# Run specific app tests
uv run manage.py test apps.parks
# Run with coverage
uv run coverage run manage.py test
uv run coverage report
```
## 🔧 Management Commands
Custom management commands:
```bash
# Import park data
uv run manage.py import_parks data/parks.json
# Generate test data
uv run manage.py generate_test_data
# Clean up expired sessions
uv run manage.py clearsessions
```
## 📊 Database
### Entity Relationships
- **Parks** have Operators (required) and PropertyOwners (optional)
- **Rides** belong to Parks and may have Manufacturers/Designers
- **Users** can create submissions and moderate content
### Migrations
```bash
# Create migrations
uv run manage.py makemigrations
# Apply migrations
uv run manage.py migrate
# Show migration status
uv run manage.py showmigrations
```
## 🔐 Security
- CORS configured for frontend integration
- CSRF protection enabled
- JWT token authentication
- Rate limiting on API endpoints
- Input validation and sanitization
## 📈 Performance
- Database query optimization
- Redis caching for frequent queries
- Background task processing with Celery
- Database connection pooling
## 🚀 Deployment
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
## 🐛 Debugging
### Development Tools
- Django Debug Toolbar
- Django Extensions
- Silk profiler for performance analysis
### Logging
Logs are written to:
- Console (development)
- Files in `logs/` directory (production)
- External logging service (production)
## 🤝 Contributing
1. Follow Django coding standards
2. Write tests for new features
3. Update documentation
4. Run linting: `uv run flake8 .`
5. Format code: `uv run black .`

62
accounts/adapters.py Normal file
View File

@@ -0,0 +1,62 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
"""
Whether to allow sign ups.
"""
return getattr(settings, 'ACCOUNT_ALLOW_SIGNUPS', True)
def get_email_confirmation_url(self, request, emailconfirmation):
"""
Constructs the email confirmation (activation) url.
"""
site = get_current_site(request)
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={emailconfirmation.key}"
def send_confirmation_mail(self, request, emailconfirmation, signup):
"""
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
ctx = {
'user': emailconfirmation.email_address.user,
'activate_url': activate_url,
'current_site': current_site,
'key': emailconfirmation.key,
}
if signup:
email_template = 'account/email/email_confirmation_signup'
else:
email_template = 'account/email/email_confirmation'
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
"""
Whether to allow social account sign ups.
"""
return getattr(settings, 'SOCIALACCOUNT_ALLOW_SIGNUPS', True)
def populate_user(self, request, sociallogin, data):
"""
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data)
if sociallogin.account.provider == 'discord':
user.discord_id = sociallogin.account.uid
return user
def save_user(self, request, sociallogin, form=None):
"""
Save the newly signed up social login.
"""
user = super().save_user(request, sociallogin, form)
return user

207
accounts/admin.py Normal file
View File

@@ -0,0 +1,207 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html
from django.urls import reverse
from django.contrib.auth.models import Group
from .models import User, UserProfile, EmailVerification, TopList, TopListItem
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
fieldsets = (
('Personal Info', {
'fields': ('display_name', 'avatar', 'pronouns', 'bio')
}),
('Social Media', {
'fields': ('twitter', 'instagram', 'youtube', 'discord')
}),
('Ride Credits', {
'fields': (
'coaster_credits',
'dark_ride_credits',
'flat_ride_credits',
'water_ride_credits'
)
}),
)
class TopListItemInline(admin.TabularInline):
model = TopListItem
extra = 1
fields = ('content_type', 'object_id', 'rank', 'notes')
ordering = ('rank',)
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ('username', 'email', 'get_avatar', 'get_status', 'role', 'date_joined', 'last_login', 'get_credits')
list_filter = ('is_active', 'is_staff', 'role', 'is_banned', 'groups', 'date_joined')
search_fields = ('username', 'email')
ordering = ('-date_joined',)
actions = ['activate_users', 'deactivate_users', 'ban_users', 'unban_users']
inlines = [UserProfileInline]
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('email', 'pending_email')}),
('Roles and Permissions', {
'fields': ('role', 'groups', 'user_permissions'),
'description': 'Role determines group membership. Groups determine permissions.',
}),
('Status', {
'fields': ('is_active', 'is_staff', 'is_superuser'),
'description': 'These are automatically managed based on role.',
}),
('Ban Status', {
'fields': ('is_banned', 'ban_reason', 'ban_date'),
}),
('Preferences', {
'fields': ('theme_preference',),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('username', 'email', 'password1', 'password2', 'role'),
}),
)
def get_avatar(self, obj):
if obj.profile.avatar:
return format_html('<img src="{}" width="30" height="30" style="border-radius:50%;" />', obj.profile.avatar.url)
return format_html('<div style="width:30px; height:30px; border-radius:50%; background-color:#007bff; color:white; display:flex; align-items:center; justify-content:center;">{}</div>', obj.username[0].upper())
get_avatar.short_description = 'Avatar'
def get_status(self, obj):
if obj.is_banned:
return format_html('<span style="color: red;">Banned</span>')
if not obj.is_active:
return format_html('<span style="color: orange;">Inactive</span>')
if obj.is_superuser:
return format_html('<span style="color: purple;">Superuser</span>')
if obj.is_staff:
return format_html('<span style="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
get_status.short_description = 'Status'
def get_credits(self, obj):
try:
profile = obj.profile
return format_html(
'RC: {}<br>DR: {}<br>FR: {}<br>WR: {}',
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits
)
except UserProfile.DoesNotExist:
return '-'
get_credits.short_description = 'Ride Credits'
def activate_users(self, request, queryset):
queryset.update(is_active=True)
activate_users.short_description = "Activate selected users"
def deactivate_users(self, request, queryset):
queryset.update(is_active=False)
deactivate_users.short_description = "Deactivate selected users"
def ban_users(self, request, queryset):
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
ban_users.short_description = "Ban selected users"
def unban_users(self, request, queryset):
queryset.update(is_banned=False, ban_date=None, ban_reason='')
unban_users.short_description = "Unban selected users"
def save_model(self, request, obj, form, change):
creating = not obj.pk
super().save_model(request, obj, form, change)
if creating and obj.role != User.Roles.USER:
# Ensure new user with role gets added to appropriate group
group = Group.objects.filter(name=obj.role).first()
if group:
obj.groups.add(group)
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'display_name', 'coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')
list_filter = ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')
search_fields = ('user__username', 'user__email', 'display_name', 'bio')
fieldsets = (
('User Information', {
'fields': ('user', 'display_name', 'avatar', 'pronouns', 'bio')
}),
('Social Media', {
'fields': ('twitter', 'instagram', 'youtube', 'discord')
}),
('Ride Credits', {
'fields': (
'coaster_credits',
'dark_ride_credits',
'flat_ride_credits',
'water_ride_credits'
)
}),
)
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin):
list_display = ('user', 'created_at', 'last_sent', 'is_expired')
list_filter = ('created_at', 'last_sent')
search_fields = ('user__username', 'user__email', 'token')
readonly_fields = ('created_at', 'last_sent')
fieldsets = (
('Verification Details', {
'fields': ('user', 'token')
}),
('Timing', {
'fields': ('created_at', 'last_sent')
}),
)
def is_expired(self, obj):
from django.utils import timezone
from datetime import timedelta
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
is_expired.short_description = 'Status'
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin):
list_display = ('title', 'user', 'category', 'created_at', 'updated_at')
list_filter = ('category', 'created_at', 'updated_at')
search_fields = ('title', 'user__username', 'description')
inlines = [TopListItemInline]
fieldsets = (
('Basic Information', {
'fields': ('user', 'title', 'category', 'description')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin):
list_display = ('top_list', 'content_type', 'object_id', 'rank')
list_filter = ('top_list__category', 'rank')
search_fields = ('top_list__title', 'notes')
ordering = ('top_list', 'rank')
fieldsets = (
('List Information', {
'fields': ('top_list', 'rank')
}),
('Item Details', {
'fields': ('content_type', 'object_id', 'notes')
}),
)

View File

@@ -3,7 +3,7 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts" name = "accounts"
def ready(self): def ready(self):
import apps.accounts.signals # noqa import accounts.signals # noqa

View File

@@ -0,0 +1,30 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = 'Check all social auth related tables'
def handle(self, *args, **options):
# Check SocialApp
self.stdout.write('\nChecking SocialApp table:')
for app in SocialApp.objects.all():
self.stdout.write(f'ID: {app.id}, Provider: {app.provider}, Name: {app.name}, Client ID: {app.client_id}')
self.stdout.write('Sites:')
for site in app.sites.all():
self.stdout.write(f' - {site.domain}')
# Check SocialAccount
self.stdout.write('\nChecking SocialAccount table:')
for account in SocialAccount.objects.all():
self.stdout.write(f'ID: {account.id}, Provider: {account.provider}, UID: {account.uid}')
# Check SocialToken
self.stdout.write('\nChecking SocialToken table:')
for token in SocialToken.objects.all():
self.stdout.write(f'ID: {token.id}, Account: {token.account}, App: {token.app}')
# Check Site
self.stdout.write('\nChecking Site table:')
for site in Site.objects.all():
self.stdout.write(f'ID: {site.id}, Domain: {site.domain}, Name: {site.name}')

View File

@@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = 'Check social app configurations'
def handle(self, *args, **options):
social_apps = SocialApp.objects.all()
if not social_apps:
self.stdout.write(self.style.ERROR('No social apps found'))
return
for app in social_apps:
self.stdout.write(self.style.SUCCESS(f'\nProvider: {app.provider}'))
self.stdout.write(f'Name: {app.name}')
self.stdout.write(f'Client ID: {app.client_id}')
self.stdout.write(f'Secret: {app.secret}')
self.stdout.write(f'Sites: {", ".join(str(site.domain) for site in app.sites.all())}')

View File

@@ -1,9 +1,8 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
class Command(BaseCommand): class Command(BaseCommand):
help = "Clean up social auth tables and migrations" help = 'Clean up social auth tables and migrations'
def handle(self, *args, **options): def handle(self, *args, **options):
with connection.cursor() as cursor: with connection.cursor() as cursor:
@@ -15,14 +14,9 @@ class Command(BaseCommand):
# Remove migration records # Remove migration records
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'") cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
cursor.execute( cursor.execute("DELETE FROM django_migrations WHERE app='accounts' AND name LIKE '%social%'")
"DELETE FROM django_migrations WHERE app='accounts' "
"AND name LIKE '%social%'"
)
# Reset sequences # Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'") cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
self.stdout.write( self.stdout.write(self.style.SUCCESS('Successfully cleaned up social auth configuration'))
self.style.SUCCESS("Successfully cleaned up social auth configuration")
)

View File

@@ -1,7 +1,10 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from apps.parks.models import ParkReview, Park, ParkPhoto from django.contrib.auth.models import Group
from apps.rides.models import Ride, RidePhoto from reviews.models import Review
from parks.models import Park
from rides.models import Ride
from media.models import Photo
User = get_user_model() User = get_user_model()
@@ -17,27 +20,16 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users")) self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
# Delete test reviews # Delete test reviews
reviews = ParkReview.objects.filter( reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
user__username__in=["testuser", "moderator"]
)
count = reviews.count() count = reviews.count()
reviews.delete() reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews")) self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos - both park and ride photos # Delete test photos
park_photos = ParkPhoto.objects.filter( photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
uploader__username__in=["testuser", "moderator"] count = photos.count()
) photos.delete()
park_count = park_photos.count() self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
park_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
ride_photos = RidePhoto.objects.filter(
uploader__username__in=["testuser", "moderator"]
)
ride_count = ride_photos.count()
ride_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
# Delete test parks # Delete test parks
parks = Park.objects.filter(name__startswith="Test Park") parks = Park.objects.filter(name__startswith="Test Park")

View File

@@ -0,0 +1,48 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = 'Create social apps for authentication'
def handle(self, *args, **options):
# Get the default site
site = Site.objects.get_or_create(
id=1,
defaults={
'domain': 'localhost:8000',
'name': 'ThrillWiki Development'
}
)[0]
# Create Discord app
discord_app, created = SocialApp.objects.get_or_create(
provider='discord',
defaults={
'name': 'Discord',
'client_id': '1299112802274902047',
'secret': 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
}
)
if not created:
discord_app.client_id = '1299112802274902047'
discord_app.secret = 'ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11'
discord_app.save()
discord_app.sites.add(site)
self.stdout.write(f'{"Created" if created else "Updated"} Discord app')
# Create Google app
google_app, created = SocialApp.objects.get_or_create(
provider='google',
defaults={
'name': 'Google',
'client_id': '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
'secret': 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue',
}
)
if not created:
google_app.client_id = '135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com'
google_app.secret = 'GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue'
google_app.save()
google_app.sites.add(site)
self.stdout.write(f'{"Created" if created else "Updated"} Google app')

View File

@@ -1,5 +1,8 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission, User from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
User = get_user_model()
class Command(BaseCommand): class Command(BaseCommand):
@@ -8,25 +11,22 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
# Create regular test user # Create regular test user
if not User.objects.filter(username="testuser").exists(): if not User.objects.filter(username="testuser").exists():
user = User.objects.create( user = User.objects.create_user(
username="testuser", username="testuser",
email="testuser@example.com", email="testuser@example.com",
[PASSWORD-REMOVED]",
) )
user.set_password("testpass123") self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
user.save()
self.stdout.write(
self.style.SUCCESS(f"Created test user: {user.get_username()}")
)
else: else:
self.stdout.write(self.style.WARNING("Test user already exists")) self.stdout.write(self.style.WARNING("Test user already exists"))
# Create moderator user
if not User.objects.filter(username="moderator").exists(): if not User.objects.filter(username="moderator").exists():
moderator = User.objects.create( moderator = User.objects.create_user(
username="moderator", username="moderator",
email="moderator@example.com", email="moderator@example.com",
[PASSWORD-REMOVED]",
) )
moderator.set_password("modpass123")
moderator.save()
# Create moderator group if it doesn't exist # Create moderator group if it doesn't exist
moderator_group, created = Group.objects.get_or_create(name="Moderators") moderator_group, created = Group.objects.get_or_create(name="Moderators")
@@ -48,9 +48,7 @@ class Command(BaseCommand):
moderator.groups.add(moderator_group) moderator.groups.add(moderator_group)
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(f"Created moderator user: {moderator.username}")
f"Created moderator user: {moderator.get_username()}"
)
) )
else: else:
self.stdout.write(self.style.WARNING("Moderator user already exists")) self.stdout.write(self.style.WARNING("Moderator user already exists"))

View File

@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Fix migration history by removing rides.0001_initial'
def handle(self, *args, **kwargs):
with connection.cursor() as cursor:
cursor.execute("DELETE FROM django_migrations WHERE app='rides' AND name='0001_initial';")
self.stdout.write(self.style.SUCCESS('Successfully removed rides.0001_initial from migration history'))

View File

@@ -0,0 +1,35 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
import os
class Command(BaseCommand):
help = 'Fix social app configurations'
def handle(self, *args, **options):
# Delete all existing social apps
SocialApp.objects.all().delete()
self.stdout.write('Deleted all existing social apps')
# Get the default site
site = Site.objects.get(id=1)
# Create Google provider
google_app = SocialApp.objects.create(
provider='google',
name='Google',
client_id=os.getenv('GOOGLE_CLIENT_ID'),
secret=os.getenv('GOOGLE_CLIENT_SECRET'),
)
google_app.sites.add(site)
self.stdout.write(f'Created Google app with client_id: {google_app.client_id}')
# Create Discord provider
discord_app = SocialApp.objects.create(
provider='discord',
name='Discord',
client_id=os.getenv('DISCORD_CLIENT_ID'),
secret=os.getenv('DISCORD_CLIENT_SECRET'),
)
discord_app.sites.add(site)
self.stdout.write(f'Created Discord app with client_id: {discord_app.client_id}')

View File

@@ -2,7 +2,6 @@ from django.core.management.base import BaseCommand
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import os import os
def generate_avatar(letter): def generate_avatar(letter):
"""Generate an avatar for a given letter or number""" """Generate an avatar for a given letter or number"""
avatar_size = (100, 100) avatar_size = (100, 100)
@@ -11,7 +10,7 @@ def generate_avatar(letter):
font_size = 100 font_size = 100
# Create a blank image with background color # Create a blank image with background color
image = Image.new("RGB", avatar_size, background_color) image = Image.new('RGB', avatar_size, background_color)
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
# Load a font # Load a font
@@ -20,14 +19,8 @@ def generate_avatar(letter):
# Calculate text size and position using textbbox # Calculate text size and position using textbbox
text_bbox = draw.textbbox((0, 0), letter, font=font) text_bbox = draw.textbbox((0, 0), letter, font=font)
text_width, text_height = ( text_width, text_height = text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]
text_bbox[2] - text_bbox[0], text_position = ((avatar_size[0] - text_width) / 2, (avatar_size[1] - text_height) / 2)
text_bbox[3] - text_bbox[1],
)
text_position = (
(avatar_size[0] - text_width) / 2,
(avatar_size[1] - text_height) / 2,
)
# Draw the text on the image # Draw the text on the image
draw.text(text_position, letter, font=font, fill=text_color) draw.text(text_position, letter, font=font, fill=text_color)
@@ -41,14 +34,11 @@ def generate_avatar(letter):
avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png") avatar_path = os.path.join(avatar_dir, f"{letter}_avatar.png")
image.save(avatar_path) image.save(avatar_path)
class Command(BaseCommand): class Command(BaseCommand):
help = "Generate avatars for letters A-Z and numbers 0-9" help = 'Generate avatars for letters A-Z and numbers 0-9'
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
characters = [chr(i) for i in range(65, 91)] + [ characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
str(i) for i in range(10)
] # A-Z and 0-9
for char in characters: for char in characters:
generate_avatar(char) generate_avatar(char)
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}")) self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))

View File

@@ -0,0 +1,11 @@
from django.core.management.base import BaseCommand
from accounts.models import UserProfile
class Command(BaseCommand):
help = 'Regenerate default avatars for users without an uploaded avatar'
def handle(self, *args, **kwargs):
profiles = UserProfile.objects.filter(avatar='')
for profile in profiles:
profile.save() # This will trigger the avatar generation logic in the save method
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))

View File

@@ -3,64 +3,49 @@ from django.db import connection
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
import uuid import uuid
class Command(BaseCommand): class Command(BaseCommand):
help = "Reset database and create admin user" help = 'Reset database and create admin user'
def handle(self, *args, **options): def handle(self, *args, **options):
self.stdout.write("Resetting database...") self.stdout.write('Resetting database...')
# Drop all tables # Drop all tables
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute("""
"""
DO $$ DECLARE DO $$ DECLARE
r RECORD; r RECORD;
BEGIN BEGIN
FOR r IN ( FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP
SELECT tablename FROM pg_tables EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' || \
quote_ident(r.tablename) || ' CASCADE';
END LOOP; END LOOP;
END $$; END $$;
""" """)
)
# Reset sequences # Reset sequences
cursor.execute( cursor.execute("""
"""
DO $$ DECLARE DO $$ DECLARE
r RECORD; r RECORD;
BEGIN BEGIN
FOR r IN ( FOR r IN (SELECT sequencename FROM pg_sequences WHERE schemaname = current_schema()) LOOP
SELECT sequencename FROM pg_sequences EXECUTE 'ALTER SEQUENCE ' || quote_ident(r.sequencename) || ' RESTART WITH 1';
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'ALTER SEQUENCE ' || \
quote_ident(r.sequencename) || ' RESTART WITH 1';
END LOOP; END LOOP;
END $$; END $$;
""" """)
)
self.stdout.write("All tables dropped and sequences reset.") self.stdout.write('All tables dropped and sequences reset.')
# Run migrations # Run migrations
from django.core.management import call_command from django.core.management import call_command
call_command('migrate')
call_command("migrate") self.stdout.write('Migrations applied.')
self.stdout.write("Migrations applied.")
# Create superuser using raw SQL # Create superuser using raw SQL
try: try:
with connection.cursor() as cursor: with connection.cursor() as cursor:
# Create user # Create user
user_id = str(uuid.uuid4())[:10] user_id = str(uuid.uuid4())[:10]
cursor.execute( cursor.execute("""
"""
INSERT INTO accounts_user ( INSERT INTO accounts_user (
username, password, email, is_superuser, is_staff, username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name, is_active, date_joined, user_id, first_name,
@@ -71,19 +56,13 @@ class Command(BaseCommand):
true, NOW(), %s, '', '', 'SUPERUSER', false, '', true, NOW(), %s, '', '', 'SUPERUSER', false, '',
'light' 'light'
) RETURNING id; ) RETURNING id;
""", """, [make_password('admin'), user_id])
[make_password("admin"), user_id],
)
result = cursor.fetchone() user_db_id = cursor.fetchone()[0]
if result is None:
raise Exception("Failed to create user - no ID returned")
user_db_id = result[0]
# Create profile # Create profile
profile_id = str(uuid.uuid4())[:10] profile_id = str(uuid.uuid4())[:10]
cursor.execute( cursor.execute("""
"""
INSERT INTO accounts_userprofile ( INSERT INTO accounts_userprofile (
profile_id, display_name, pronouns, bio, profile_id, display_name, pronouns, bio,
twitter, instagram, youtube, discord, twitter, instagram, youtube, discord,
@@ -96,13 +75,11 @@ class Command(BaseCommand):
0, 0, 0, 0, 0, 0, 0, 0,
%s, '' %s, ''
); );
""", """, [profile_id, user_db_id])
[profile_id, user_db_id],
)
self.stdout.write("Superuser created.") self.stdout.write('Superuser created.')
except Exception as e: except Exception as e:
self.stdout.write(self.style.ERROR(f"Error creating superuser: {str(e)}")) self.stdout.write(self.style.ERROR(f'Error creating superuser: {str(e)}'))
raise raise
self.stdout.write(self.style.SUCCESS("Database reset complete.")) self.stdout.write(self.style.SUCCESS('Database reset complete.'))

View File

@@ -3,9 +3,8 @@ from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.db import connection from django.db import connection
class Command(BaseCommand): class Command(BaseCommand):
help = "Reset social apps configuration" help = 'Reset social apps configuration'
def handle(self, *args, **options): def handle(self, *args, **options):
# Delete all social apps using raw SQL to bypass Django's ORM # Delete all social apps using raw SQL to bypass Django's ORM
@@ -18,22 +17,20 @@ class Command(BaseCommand):
# Create Discord app # Create Discord app
discord_app = SocialApp.objects.create( discord_app = SocialApp.objects.create(
provider="discord", provider='discord',
name="Discord", name='Discord',
client_id="1299112802274902047", client_id='1299112802274902047',
secret="ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11", secret='ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11',
) )
discord_app.sites.add(site) discord_app.sites.add(site)
self.stdout.write(f"Created Discord app with ID: {discord_app.pk}") self.stdout.write(f'Created Discord app with ID: {discord_app.id}')
# Create Google app # Create Google app
google_app = SocialApp.objects.create( google_app = SocialApp.objects.create(
provider="google", provider='google',
name="Google", name='Google',
client_id=( client_id='135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com',
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com" secret='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',
),
secret="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",
) )
google_app.sites.add(site) google_app.sites.add(site)
self.stdout.write(f"Created Google app with ID: {google_app.pk}") self.stdout.write(f'Created Google app with ID: {google_app.id}')

View File

@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = 'Reset social auth configuration'
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Delete all social apps
cursor.execute("DELETE FROM socialaccount_socialapp")
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
# Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
self.stdout.write(self.style.SUCCESS('Successfully reset social auth configuration'))

View File

@@ -0,0 +1,42 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from accounts.models import User
from accounts.signals import create_default_groups
class Command(BaseCommand):
help = 'Set up default groups and permissions for user roles'
def handle(self, *args, **options):
self.stdout.write('Creating default groups and permissions...')
try:
# Create default groups with permissions
create_default_groups()
# Sync existing users with groups based on their roles
users = User.objects.exclude(role=User.Roles.USER)
for user in users:
group = Group.objects.filter(name=user.role).first()
if group:
user.groups.add(group)
# Update staff/superuser status based on role
if user.role == User.Roles.SUPERUSER:
user.is_superuser = True
user.is_staff = True
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
user.is_staff = True
user.save()
self.stdout.write(self.style.SUCCESS('Successfully set up groups and permissions'))
# Print summary
for group in Group.objects.all():
self.stdout.write(f'\nGroup: {group.name}')
self.stdout.write('Permissions:')
for perm in group.permissions.all():
self.stdout.write(f' - {perm.codename}')
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error setting up groups: {str(e)}'))

View File

@@ -1,9 +1,8 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
class Command(BaseCommand): class Command(BaseCommand):
help = "Set up default site" help = 'Set up default site'
def handle(self, *args, **options): def handle(self, *args, **options):
# Delete any existing sites # Delete any existing sites
@@ -11,6 +10,8 @@ class Command(BaseCommand):
# Create default site # Create default site
site = Site.objects.create( site = Site.objects.create(
id=1, domain="localhost:8000", name="ThrillWiki Development" id=1,
domain='localhost:8000',
name='ThrillWiki Development'
) )
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}")) self.stdout.write(self.style.SUCCESS(f'Created site: {site.domain}'))

View File

@@ -0,0 +1,63 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
from dotenv import load_dotenv
import os
class Command(BaseCommand):
help = 'Sets up social authentication apps'
def handle(self, *args, **kwargs):
# Load environment variables
load_dotenv()
# Get environment variables
google_client_id = os.getenv('GOOGLE_CLIENT_ID')
google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
discord_client_id = os.getenv('DISCORD_CLIENT_ID')
discord_client_secret = os.getenv('DISCORD_CLIENT_SECRET')
if not all([google_client_id, google_client_secret, discord_client_id, discord_client_secret]):
self.stdout.write(self.style.ERROR('Missing required environment variables'))
return
# Get or create the default site
site, _ = Site.objects.get_or_create(
id=1,
defaults={
'domain': 'localhost:8000',
'name': 'localhost'
}
)
# Set up Google
google_app, created = SocialApp.objects.get_or_create(
provider='google',
defaults={
'name': 'Google',
'client_id': google_client_id,
'secret': google_client_secret,
}
)
if not created:
google_app.client_id = google_client_id
google_app.[SECRET-REMOVED]
google_app.save()
google_app.sites.add(site)
# Set up Discord
discord_app, created = SocialApp.objects.get_or_create(
provider='discord',
defaults={
'name': 'Discord',
'client_id': discord_client_id,
'secret': discord_client_secret,
}
)
if not created:
discord_app.client_id = discord_client_id
discord_app.[SECRET-REMOVED]
discord_app.save()
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS('Successfully set up social auth apps'))

View File

@@ -1,47 +1,39 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from allauth.socialaccount.models import SocialApp
User = get_user_model() User = get_user_model()
class Command(BaseCommand): class Command(BaseCommand):
help = "Set up social authentication through admin interface" help = 'Set up social authentication through admin interface'
def handle(self, *args, **options): def handle(self, *args, **options):
# Get or create the default site # Get or create the default site
site, _ = Site.objects.get_or_create( site, _ = Site.objects.get_or_create(
id=1, id=1,
defaults={ defaults={
"domain": "localhost:8000", 'domain': 'localhost:8000',
"name": "ThrillWiki Development", 'name': 'ThrillWiki Development'
}, }
) )
if not _: if not _:
site.domain = "localhost:8000" site.domain = 'localhost:8000'
site.name = "ThrillWiki Development" site.name = 'ThrillWiki Development'
site.save() site.save()
self.stdout.write(f"{'Created' if _ else 'Updated'} site: {site.domain}") self.stdout.write(f'{"Created" if _ else "Updated"} site: {site.domain}')
# Create superuser if it doesn't exist # Create superuser if it doesn't exist
if not User.objects.filter(username="admin").exists(): if not User.objects.filter(username='admin').exists():
admin_user = User.objects.create( User.objects.create_superuser('admin', 'admin@example.com', 'admin')
username="admin", self.stdout.write('Created superuser: admin/admin')
email="admin@example.com",
is_staff=True,
is_superuser=True,
)
admin_user.set_password("admin")
admin_user.save()
self.stdout.write("Created superuser: admin/admin")
self.stdout.write( self.stdout.write(self.style.SUCCESS('''
self.style.SUCCESS(
"""
Social auth setup instructions: Social auth setup instructions:
1. Run the development server: 1. Run the development server:
uv run manage.py runserver_plus python manage.py runserver
2. Go to the admin interface: 2. Go to the admin interface:
http://localhost:8000/admin/ http://localhost:8000/admin/
@@ -65,6 +57,4 @@ Social auth setup instructions:
Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com Client id: 135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com
Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue Secret key: GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue
Sites: Add "localhost:8000" Sites: Add "localhost:8000"
""" '''))
)
)

View File

@@ -0,0 +1,60 @@
from django.core.management.base import BaseCommand
from django.urls import reverse
from django.test import Client
from allauth.socialaccount.models import SocialApp
from urllib.parse import urljoin
class Command(BaseCommand):
help = 'Test Discord OAuth2 authentication flow'
def handle(self, *args, **options):
client = Client(HTTP_HOST='localhost:8000')
# Get Discord app
try:
discord_app = SocialApp.objects.get(provider='discord')
self.stdout.write('Found Discord app configuration:')
self.stdout.write(f'Client ID: {discord_app.client_id}')
# Test login URL
login_url = '/accounts/discord/login/'
response = client.get(login_url, HTTP_HOST='localhost:8000')
self.stdout.write(f'\nTesting login URL: {login_url}')
self.stdout.write(f'Status code: {response.status_code}')
if response.status_code == 302:
redirect_url = response['Location']
self.stdout.write(f'Redirects to: {redirect_url}')
# Parse OAuth2 parameters
self.stdout.write('\nOAuth2 Parameters:')
if 'client_id=' in redirect_url:
self.stdout.write('✓ client_id parameter present')
if 'redirect_uri=' in redirect_url:
self.stdout.write('✓ redirect_uri parameter present')
if 'scope=' in redirect_url:
self.stdout.write('✓ scope parameter present')
if 'response_type=' in redirect_url:
self.stdout.write('✓ response_type parameter present')
if 'code_challenge=' in redirect_url:
self.stdout.write('✓ PKCE enabled (code_challenge present)')
# Show callback URL
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
self.stdout.write(callback_url)
# Show frontend login URL
frontend_url = 'http://localhost:5173'
self.stdout.write('\nFrontend configuration:')
self.stdout.write(f'Frontend URL: {frontend_url}')
self.stdout.write('Discord login button should use:')
self.stdout.write('/accounts/discord/login/?process=login')
# Show allauth URLs
self.stdout.write('\nAllauth URLs:')
self.stdout.write('Login URL: /accounts/discord/login/?process=login')
self.stdout.write('Callback URL: /accounts/discord/login/callback/')
except SocialApp.DoesNotExist:
self.stdout.write(self.style.ERROR('Discord app not found'))

View File

@@ -2,9 +2,8 @@ from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
class Command(BaseCommand): class Command(BaseCommand):
help = "Update social apps to be associated with all sites" help = 'Update social apps to be associated with all sites'
def handle(self, *args, **options): def handle(self, *args, **options):
# Get all sites # Get all sites
@@ -12,12 +11,10 @@ class Command(BaseCommand):
# Update each social app # Update each social app
for app in SocialApp.objects.all(): for app in SocialApp.objects.all():
self.stdout.write(f"Updating {app.provider} app...") self.stdout.write(f'Updating {app.provider} app...')
# Clear existing sites # Clear existing sites
app.sites.clear() app.sites.clear()
# Add all sites # Add all sites
for site in sites: for site in sites:
app.sites.add(site) app.sites.add(site)
self.stdout.write( self.stdout.write(f'Added sites: {", ".join(site.domain for site in sites)}')
f"Added sites: {', '.join(site.domain for site in sites)}"
)

View File

@@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.urls import reverse
from django.conf import settings
class Command(BaseCommand):
help = 'Verify Discord OAuth2 settings'
def handle(self, *args, **options):
# Get Discord app
try:
discord_app = SocialApp.objects.get(provider='discord')
self.stdout.write('Found Discord app configuration:')
self.stdout.write(f'Client ID: {discord_app.client_id}')
self.stdout.write(f'Secret: {discord_app.secret}')
# Get sites
sites = discord_app.sites.all()
self.stdout.write('\nAssociated sites:')
for site in sites:
self.stdout.write(f'- {site.domain} ({site.name})')
# Show callback URL
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
self.stdout.write(callback_url)
# Show OAuth2 settings
self.stdout.write('\nOAuth2 settings in settings.py:')
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get('discord', {})
self.stdout.write(f'PKCE Enabled: {discord_settings.get("OAUTH_PKCE_ENABLED", False)}')
self.stdout.write(f'Scopes: {discord_settings.get("SCOPE", [])}')
except SocialApp.DoesNotExist:
self.stdout.write(self.style.ERROR('Discord app not found'))

View File

@@ -0,0 +1,517 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"user_id",
models.CharField(
editable=False,
help_text="Unique identifier for this user that remains constant even if the username changes",
max_length=10,
unique=True,
),
),
(
"role",
models.CharField(
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
max_length=10,
),
),
("is_banned", models.BooleanField(default=False)),
("ban_reason", models.TextField(blank=True)),
("ban_date", models.DateTimeField(blank=True, null=True)),
(
"pending_email",
models.EmailField(blank=True, max_length=254, null=True),
),
(
"theme_preference",
models.CharField(
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
max_length=5,
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="EmailVerification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(max_length=64, unique=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_sent", models.DateTimeField(auto_now_add=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Email Verification",
"verbose_name_plural": "Email Verifications",
},
),
migrations.CreateModel(
name="PasswordReset",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(max_length=64)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
("used", models.BooleanField(default=False)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Password Reset",
"verbose_name_plural": "Password Resets",
},
),
migrations.CreateModel(
name="TopList",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("title", models.CharField(max_length=100)),
(
"category",
models.CharField(
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
max_length=2,
),
),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="top_lists",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-updated_at"],
},
),
migrations.CreateModel(
name="TopListEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("title", models.CharField(max_length=100)),
(
"category",
models.CharField(
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
max_length=2,
),
),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplist",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TopListItem",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"top_list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="accounts.toplist",
),
),
],
options={
"ordering": ["rank"],
},
),
migrations.CreateModel(
name="TopListItemEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplistitem",
),
),
(
"top_list",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="accounts.toplist",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"profile_id",
models.CharField(
editable=False,
help_text="Unique identifier for this profile that remains constant",
max_length=10,
unique=True,
),
),
(
"display_name",
models.CharField(
help_text="This is the name that will be displayed on the site",
max_length=50,
unique=True,
),
),
("avatar", models.ImageField(blank=True, upload_to="avatars/")),
("pronouns", models.CharField(blank=True, max_length=50)),
("bio", models.TextField(blank=True, max_length=500)),
("twitter", models.URLField(blank=True)),
("instagram", models.URLField(blank=True)),
("youtube", models.URLField(blank=True)),
("discord", models.CharField(blank=True, max_length=100)),
("coaster_credits", models.IntegerField(default=0)),
("dark_ride_credits", models.IntegerField(default=0)),
("flat_ride_credits", models.IntegerField(default=0)),
("water_ride_credits", models.IntegerField(default=0)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
pgtrigger.migrations.AddTrigger(
model_name="toplist",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_26546",
table="accounts_toplist",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplist",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_84849",
table="accounts_toplist",
when="AFTER",
),
),
),
migrations.AlterUniqueTogether(
name="toplistitem",
unique_together={("top_list", "rank")},
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.AddField(
model_name="toplistitem",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitem",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="toplistitemevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitemevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="toplist",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="toplistitem",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

@@ -2,13 +2,11 @@ import requests
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
class TurnstileMixin: class TurnstileMixin:
""" """
Mixin to handle Cloudflare Turnstile validation. Mixin to handle Cloudflare Turnstile validation.
Bypasses validation when DEBUG is True. Bypasses validation when DEBUG is True.
""" """
def validate_turnstile(self, request): def validate_turnstile(self, request):
""" """
Validate the Turnstile response token. Validate the Turnstile response token.
@@ -17,19 +15,19 @@ class TurnstileMixin:
if settings.DEBUG: if settings.DEBUG:
return return
token = request.POST.get("cf-turnstile-response") token = request.POST.get('cf-turnstile-response')
if not token: if not token:
raise ValidationError("Please complete the Turnstile challenge.") raise ValidationError('Please complete the Turnstile challenge.')
# Verify the token with Cloudflare # Verify the token with Cloudflare
data = { data = {
"secret": settings.TURNSTILE_SECRET_KEY, 'secret': settings.TURNSTILE_SECRET_KEY,
"response": token, 'response': token,
"remoteip": request.META.get("REMOTE_ADDR"), 'remoteip': request.META.get('REMOTE_ADDR'),
} }
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60) response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
result = response.json() result = response.json()
if not result.get("success"): if not result.get('success'):
raise ValidationError("Turnstile validation failed. Please try again.") raise ValidationError('Turnstile validation failed. Please try again.')

212
accounts/models.py Normal file
View File

@@ -0,0 +1,212 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import base64
import os
import secrets
from history_tracking.models import TrackedModel
import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
# Try to get a 4-digit number first
new_id = str(secrets.SystemRandom().randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
# If all 4-digit numbers are taken, try 5 digits
new_id = str(secrets.SystemRandom().randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
class User(AbstractUser):
class Roles(models.TextChoices):
USER = 'USER', _('User')
MODERATOR = 'MODERATOR', _('Moderator')
ADMIN = 'ADMIN', _('Admin')
SUPERUSER = 'SUPERUSER', _('Superuser')
class ThemePreference(models.TextChoices):
LIGHT = 'light', _('Light')
DARK = 'dark', _('Dark')
# Read-only ID
user_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text='Unique identifier for this user that remains constant even if the username changes'
)
role = models.CharField(
max_length=10,
choices=Roles.choices,
default=Roles.USER,
)
is_banned = models.BooleanField(default=False)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
theme_preference = models.CharField(
max_length=5,
choices=ThemePreference.choices,
default=ThemePreference.LIGHT,
)
def __str__(self):
return self.get_display_name()
def get_absolute_url(self):
return reverse('profile', kwargs={'username': self.username})
def get_display_name(self):
"""Get the user's display name, falling back to username if not set"""
profile = getattr(self, 'profile', None)
if profile and profile.display_name:
return profile.display_name
return self.username
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, 'user_id')
super().save(*args, **kwargs)
class UserProfile(models.Model):
# Read-only ID
profile_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text='Unique identifier for this profile that remains constant'
)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='profile'
)
display_name = models.CharField(
max_length=50,
unique=True,
help_text="This is the name that will be displayed on the site"
)
avatar = models.ImageField(upload_to='avatars/', blank=True)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
# Social media links
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
# Ride statistics
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
def get_avatar(self):
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
if self.avatar:
return self.avatar.url
first_letter = self.user.username[0].upper()
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
if os.path.exists(avatar_path):
return f"/{avatar_path}"
return "/static/images/default-avatar.png"
def save(self, *args, **kwargs):
# If no display name is set, use the username
if not self.display_name:
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, 'profile_id')
super().save(*args, **kwargs)
def __str__(self):
return self.display_name
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email verification for {self.user.username}"
class Meta:
verbose_name = "Email Verification"
verbose_name_plural = "Email Verifications"
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
def __str__(self):
return f"Password reset for {self.user.username}"
class Meta:
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
@pghistory.track()
class TopList(TrackedModel):
class Categories(models.TextChoices):
ROLLER_COASTER = 'RC', _('Roller Coaster')
DARK_RIDE = 'DR', _('Dark Ride')
FLAT_RIDE = 'FR', _('Flat Ride')
WATER_RIDE = 'WR', _('Water Ride')
PARK = 'PK', _('Park')
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='top_lists' # Added related_name for User model access
)
title = models.CharField(max_length=100)
category = models.CharField(
max_length=2,
choices=Categories.choices
)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
def __str__(self):
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
@pghistory.track()
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList,
on_delete=models.CASCADE,
related_name='items'
)
content_type = models.ForeignKey(
'contenttypes.ContentType',
on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
rank = models.PositiveIntegerField()
notes = models.TextField(blank=True)
class Meta:
ordering = ['rank']
unique_together = [['top_list', 'rank']]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"

152
accounts/signals.py Normal file
View File

@@ -0,0 +1,152 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.contrib.auth.models import Group
from django.db import transaction
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
import requests
from .models import User, UserProfile, EmailVerification
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
"""Create UserProfile for new users"""
try:
if created:
# Create profile
profile = UserProfile.objects.create(user=instance)
# If user has a social account with avatar, download it
social_account = instance.socialaccount_set.first()
if social_account:
extra_data = social_account.extra_data
avatar_url = None
if social_account.provider == 'google':
avatar_url = extra_data.get('picture')
elif social_account.provider == 'discord':
avatar = extra_data.get('avatar')
discord_id = extra_data.get('id')
if avatar:
avatar_url = f'https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png'
if avatar_url:
try:
response = requests.get(avatar_url, timeout=60)
if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content)
img_temp.flush()
file_name = f"avatar_{instance.username}.png"
profile.avatar.save(
file_name,
File(img_temp),
save=True
)
except Exception as e:
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
except Exception as e:
print(f"Error creating profile for user {instance.username}: {str(e)}")
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
"""Ensure UserProfile exists and is saved"""
try:
if not hasattr(instance, 'profile'):
UserProfile.objects.create(user=instance)
instance.profile.save()
except Exception as e:
print(f"Error saving profile for user {instance.username}: {str(e)}")
@receiver(pre_save, sender=User)
def sync_user_role_with_groups(sender, instance, **kwargs):
"""Sync user role with Django groups"""
if instance.pk: # Only for existing users
try:
old_instance = User.objects.get(pk=instance.pk)
if old_instance.role != instance.role:
# Role has changed, update groups
with transaction.atomic():
# Remove from old role group if exists
if old_instance.role != User.Roles.USER:
old_group = Group.objects.filter(name=old_instance.role).first()
if old_group:
instance.groups.remove(old_group)
# Add to new role group
if instance.role != User.Roles.USER:
new_group, _ = Group.objects.get_or_create(name=instance.role)
instance.groups.add(new_group)
# Special handling for superuser role
if instance.role == User.Roles.SUPERUSER:
instance.is_superuser = True
instance.is_staff = True
elif old_instance.role == User.Roles.SUPERUSER:
# If removing superuser role, remove superuser status
instance.is_superuser = False
if instance.role not in [User.Roles.ADMIN, User.Roles.MODERATOR]:
instance.is_staff = False
# Handle staff status for admin and moderator roles
if instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
instance.is_staff = True
elif old_instance.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
# If removing admin/moderator role, remove staff status
if instance.role not in [User.Roles.SUPERUSER]:
instance.is_staff = False
except User.DoesNotExist:
pass
except Exception as e:
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
def create_default_groups():
"""
Create default groups with appropriate permissions.
Call this in a migration or management command.
"""
try:
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
# Create Moderator group
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
moderator_permissions = [
# Review moderation permissions
'change_review', 'delete_review',
'change_reviewreport', 'delete_reviewreport',
# Edit moderation permissions
'change_parkedit', 'delete_parkedit',
'change_rideedit', 'delete_rideedit',
'change_companyedit', 'delete_companyedit',
'change_manufactureredit', 'delete_manufactureredit',
]
# Create Admin group
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
admin_permissions = moderator_permissions + [
# User management permissions
'change_user', 'delete_user',
# Content management permissions
'add_park', 'change_park', 'delete_park',
'add_ride', 'change_ride', 'delete_ride',
'add_company', 'change_company', 'delete_company',
'add_manufacturer', 'change_manufacturer', 'delete_manufacturer',
]
# Assign permissions to groups
for codename in moderator_permissions:
try:
perm = Permission.objects.get(codename=codename)
moderator_group.permissions.add(perm)
except Permission.DoesNotExist:
print(f"Permission not found: {codename}")
for codename in admin_permissions:
try:
perm = Permission.objects.get(codename=codename)
admin_group.permissions.add(perm)
except Permission.DoesNotExist:
print(f"Permission not found: {codename}")
except Exception as e:
print(f"Error creating default groups: {str(e)}")

View File

@@ -4,7 +4,6 @@ from django.template.loader import render_to_string
register = template.Library() register = template.Library()
@register.simple_tag @register.simple_tag
def turnstile_widget(): def turnstile_widget():
""" """
@@ -14,10 +13,12 @@ def turnstile_widget():
Usage: {% load turnstile_tags %}{% turnstile_widget %} Usage: {% load turnstile_tags %}{% turnstile_widget %}
""" """
if settings.DEBUG: if settings.DEBUG:
template_name = "accounts/turnstile_widget_empty.html" template_name = 'accounts/turnstile_widget_empty.html'
context = {} context = {}
else: else:
template_name = "accounts/turnstile_widget.html" template_name = 'accounts/turnstile_widget.html'
context = {"site_key": settings.TURNSTILE_SITE_KEY} context = {
'site_key': settings.TURNSTILE_SITE_KEY
}
return render_to_string(template_name, context) return render_to_string(template_name, context)

3
accounts/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

25
accounts/urls.py Normal file
View File

@@ -0,0 +1,25 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from allauth.account.views import LogoutView
from . import views
app_name = 'accounts'
urlpatterns = [
# Override allauth's login and signup views with our Turnstile-enabled versions
path('login/', views.CustomLoginView.as_view(), name='account_login'),
path('signup/', views.CustomSignupView.as_view(), name='account_signup'),
# Authentication views
path('logout/', LogoutView.as_view(), name='logout'),
path('password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'),
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
# Profile views
path('profile/', views.user_redirect_view, name='profile_redirect'),
path('settings/', views.SettingsView.as_view(), name='settings'),
]

381
accounts/views.py Normal file
View File

@@ -0,0 +1,381 @@
from django.views.generic import DetailView, TemplateView
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.core.exceptions import ValidationError
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.socialaccount.providers.discord.views import DiscordOAuth2Adapter
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import Prefetch, QuerySet
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
from django.urls import reverse
from django.contrib.auth import login
from django.core.files.uploadedfile import UploadedFile
from accounts.models import User, PasswordReset, TopList, EmailVerification, UserProfile
from reviews.models import Review
from email_service.services import EmailService
from allauth.account.views import LoginView, SignupView
from .mixins import TurnstileMixin
from typing import Dict, Any, Optional, Union, cast, TYPE_CHECKING
from django_htmx.http import HttpResponseClientRefresh
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from contextlib import suppress
import re
if TYPE_CHECKING:
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
UserModel = get_user_model()
class CustomLoginView(TurnstileMixin, LoginView):
def form_valid(self, form):
try:
self.validate_turnstile(self.request)
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
response = super().form_valid(form)
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
def form_invalid(self, form):
if getattr(self.request, 'htmx', False):
return render(
self.request,
'account/partials/login_form.html',
self.get_context_data(form=form)
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, 'htmx', False):
return render(
request,
'account/partials/login_modal.html',
self.get_context_data()
)
return super().get(request, *args, **kwargs)
class CustomSignupView(TurnstileMixin, SignupView):
def form_valid(self, form):
try:
self.validate_turnstile(self.request)
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
response = super().form_valid(form)
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
def form_invalid(self, form):
if getattr(self.request, 'htmx', False):
return render(
self.request,
'account/partials/signup_modal.html',
self.get_context_data(form=form)
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, 'htmx', False):
return render(
request,
'account/partials/signup_modal.html',
self.get_context_data()
)
return super().get(request, *args, **kwargs)
@login_required
def user_redirect_view(request: HttpRequest) -> HttpResponse:
user = cast(User, request.user)
return redirect('profile', username=user.username)
def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
if sociallogin := request.session.get('socialaccount_sociallogin'):
sociallogin.user.email = email
sociallogin.save()
login(request, sociallogin.user)
del request.session['socialaccount_sociallogin']
messages.success(request, 'Successfully logged in')
return redirect('/')
def email_required(request: HttpRequest) -> HttpResponse:
if not request.session.get('socialaccount_sociallogin'):
messages.error(request, 'No social login in progress')
return redirect('/')
if request.method == 'POST':
if email := request.POST.get('email'):
return handle_social_login(request, email)
messages.error(request, 'Email is required')
return render(request, 'accounts/email_required.html', {'error': 'Email is required'})
return render(request, 'accounts/email_required.html')
class ProfileView(DetailView):
model = User
template_name = 'accounts/profile.html'
context_object_name = 'profile_user'
slug_field = 'username'
slug_url_kwarg = 'username'
def get_queryset(self) -> QuerySet[User]:
return User.objects.select_related('profile')
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
user = cast(User, self.get_object())
context['recent_reviews'] = self._get_user_reviews(user)
context['top_lists'] = self._get_user_top_lists(user)
return context
def _get_user_reviews(self, user: User) -> QuerySet[Review]:
return Review.objects.filter(
user=user,
is_published=True
).select_related(
'user',
'user__profile',
'content_type'
).prefetch_related(
'content_object'
).order_by('-created_at')[:5]
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
return TopList.objects.filter(
user=user
).select_related(
'user',
'user__profile'
).prefetch_related(
'items'
).order_by('-created_at')[:5]
class SettingsView(LoginRequiredMixin, TemplateView):
template_name = 'accounts/settings.html'
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context['user'] = self.request.user
return context
def _handle_profile_update(self, request: HttpRequest) -> None:
user = cast(User, request.user)
profile = get_object_or_404(UserProfile, user=user)
if display_name := request.POST.get('display_name'):
profile.display_name = display_name
if 'avatar' in request.FILES:
avatar_file = cast(UploadedFile, request.FILES['avatar'])
profile.avatar.save(avatar_file.name, avatar_file, save=False)
profile.save()
user.save()
messages.success(request, 'Profile updated successfully')
def _validate_password(self, password: str) -> bool:
"""Validate password meets requirements."""
return (
len(password) >= 8 and
bool(re.search(r'[A-Z]', password)) and
bool(re.search(r'[a-z]', password)) and
bool(re.search(r'[0-9]', password))
)
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
'user': user,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/password_change_confirmation.html', context)
EmailService.send_email(
to=user.email,
subject='Password Changed Successfully',
text='Your password has been changed successfully.',
site=site,
html=email_html
)
def _handle_password_change(self, request: HttpRequest) -> Optional[HttpResponseRedirect]:
user = cast(User, request.user)
old_password = request.POST.get('old_password', '')
new_password = request.POST.get('new_password', '')
confirm_password = request.POST.get('confirm_password', '')
if not user.check_password(old_password):
messages.error(request, 'Current password is incorrect')
return None
if new_password != confirm_password:
messages.error(request, 'New passwords do not match')
return None
if not self._validate_password(new_password):
messages.error(request, 'Password must be at least 8 characters and contain uppercase, lowercase, and numbers')
return None
user.set_password(new_password)
user.save()
self._send_password_change_confirmation(request, user)
messages.success(request, 'Password changed successfully. Please check your email for confirmation.')
return HttpResponseRedirect(reverse('account_login'))
def _handle_email_change(self, request: HttpRequest) -> None:
if new_email := request.POST.get('new_email'):
self._send_email_verification(request, new_email)
messages.success(request, 'Verification email sent to your new email address')
else:
messages.error(request, 'New email is required')
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
user = cast(User, request.user)
token = get_random_string(64)
EmailVerification.objects.update_or_create(
user=user,
defaults={'token': token}
)
site = cast(Site, get_current_site(request))
verification_url = reverse('verify_email', kwargs={'token': token})
context = {
'user': user,
'verification_url': verification_url,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/verify_email.html', context)
EmailService.send_email(
to=new_email,
subject='Verify your new email address',
text='Click the link to verify your new email address',
site=site,
html=email_html
)
user.pending_email = new_email
user.save()
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
action = request.POST.get('action')
if action == 'update_profile':
self._handle_profile_update(request)
elif action == 'change_password':
if response := self._handle_password_change(request):
return response
elif action == 'change_email':
self._handle_email_change(request)
return self.get(request, *args, **kwargs)
def create_password_reset_token(user: User) -> str:
token = get_random_string(64)
PasswordReset.objects.update_or_create(
user=user,
defaults={
'token': token,
'expires_at': timezone.now() + timedelta(hours=24)
}
)
return token
def send_password_reset_email(user: User, site: Union[Site, RequestSite], token: str) -> None:
reset_url = reverse('password_reset_confirm', kwargs={'token': token})
context = {
'user': user,
'reset_url': reset_url,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/password_reset.html', context)
EmailService.send_email(
to=user.email,
subject='Reset your password',
text='Click the link to reset your password',
site=site,
html=email_html
)
def request_password_reset(request: HttpRequest) -> HttpResponse:
if request.method != 'POST':
return render(request, 'accounts/password_reset.html')
if not (email := request.POST.get('email')):
messages.error(request, 'Email is required')
return redirect('account_reset_password')
with suppress(User.DoesNotExist):
user = User.objects.get(email=email)
token = create_password_reset_token(user)
site = get_current_site(request)
send_password_reset_email(user, site, token)
messages.success(request, 'Password reset email sent')
return redirect('account_login')
def handle_password_reset(request: HttpRequest, user: User, new_password: str, reset: PasswordReset, site: Union[Site, RequestSite]) -> None:
user.set_password(new_password)
user.save()
reset.used = True
reset.save()
send_password_reset_confirmation(user, site)
messages.success(request, 'Password reset successfully')
def send_password_reset_confirmation(user: User, site: Union[Site, RequestSite]) -> None:
context = {
'user': user,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/password_reset_complete.html', context)
EmailService.send_email(
to=user.email,
subject='Password Reset Complete',
text='Your password has been reset successfully.',
site=site,
html=email_html
)
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
try:
reset = PasswordReset.objects.select_related('user').get(
token=token,
expires_at__gt=timezone.now(),
used=False
)
if request.method == 'POST':
if new_password := request.POST.get('new_password'):
site = get_current_site(request)
handle_password_reset(request, reset.user, new_password, reset, site)
return redirect('account_login')
messages.error(request, 'New password is required')
return render(request, 'accounts/password_reset_confirm.html', {'token': token})
except PasswordReset.DoesNotExist:
messages.error(request, 'Invalid or expired reset token')
return redirect('account_reset_password')

1
analytics/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'analytics.apps.AnalyticsConfig'

3
analytics/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
analytics/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'analytics'

View File

@@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand
from django.core.cache import cache
from parks.models import Park
from rides.models import Ride
from analytics.models import PageView
class Command(BaseCommand):
help = 'Updates trending parks and rides cache based on views in the last 24 hours'
def handle(self, *args, **kwargs):
"""
Updates the trending parks and rides in the cache.
This command is designed to be run every hour via cron to keep the trending
items up to date. It looks at page views from the last 24 hours and caches
the top 10 most viewed parks and rides.
The cached data is used by the home page to display trending items without
having to query the database on every request.
"""
# Get top 10 trending parks and rides from the last 24 hours
trending_parks = PageView.get_trending_items(Park, hours=24, limit=10)
trending_rides = PageView.get_trending_items(Ride, hours=24, limit=10)
# Cache the results for 1 hour
cache.set('trending_parks', trending_parks, 3600) # 3600 seconds = 1 hour
cache.set('trending_rides', trending_rides, 3600)
self.stdout.write(
self.style.SUCCESS(
'Successfully updated trending parks and rides. '
'Cached 10 items each for parks and rides based on views in the last 24 hours.'
)
)

39
analytics/middleware.py Normal file
View File

@@ -0,0 +1,39 @@
from django.utils.deprecation import MiddlewareMixin
from django.contrib.contenttypes.models import ContentType
from django.views.generic.detail import DetailView
from .models import PageView
class PageViewMiddleware(MiddlewareMixin):
def process_view(self, request, view_func, view_args, view_kwargs):
# Only track GET requests
if request.method != 'GET':
return None
# Get view class if it exists
view_class = getattr(view_func, 'view_class', None)
if not view_class or not issubclass(view_class, DetailView):
return None
# Get the object if it's a detail view
try:
view_instance = view_class()
view_instance.request = request
view_instance.args = view_args
view_instance.kwargs = view_kwargs
obj = view_instance.get_object()
except (AttributeError, Exception):
return None
# Record the page view
try:
PageView.objects.create(
content_type=ContentType.objects.get_for_model(obj.__class__),
object_id=obj.pk,
ip_address=request.META.get('REMOTE_ADDR', ''),
user_agent=request.META.get('HTTP_USER_AGENT', '')[:512]
)
except Exception:
# Fail silently to not interrupt the request
pass
return None

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="PageView",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
("ip_address", models.GenericIPAddressField()),
("user_agent", models.CharField(blank=True, max_length=512)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_views",
to="contenttypes.contenttype",
),
),
],
options={
"indexes": [
models.Index(
fields=["timestamp"], name="analytics_p_timesta_835321_idx"
),
models.Index(
fields=["content_type", "object_id"],
name="analytics_p_content_73920a_idx",
),
],
},
),
]

57
analytics/models.py Normal file
View File

@@ -0,0 +1,57 @@
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.db.models import Count
from django.conf import settings
class PageView(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name='page_views')
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
ip_address = models.GenericIPAddressField()
user_agent = models.CharField(max_length=512, blank=True)
class Meta:
indexes = [
models.Index(fields=['timestamp']),
models.Index(fields=['content_type', 'object_id']),
]
@classmethod
def get_trending_items(cls, model_class, hours=24, limit=10):
"""Get trending items of a specific model class based on views in last X hours.
Args:
model_class: The model class to get trending items for (e.g., Park, Ride)
hours (int): Number of hours to look back for views (default: 24)
limit (int): Maximum number of items to return (default: 10)
Returns:
QuerySet: The trending items ordered by view count
"""
content_type = ContentType.objects.get_for_model(model_class)
cutoff = timezone.now() - timezone.timedelta(hours=hours)
# Query through the ContentType relationship
item_ids = cls.objects.filter(
content_type=content_type,
timestamp__gte=cutoff
).values('object_id').annotate(
view_count=Count('id')
).filter(
view_count__gt=0
).order_by('-view_count').values_list('object_id', flat=True)[:limit]
# Get the actual items in the correct order
if item_ids:
# Convert the list to a string of comma-separated values
id_list = list(item_ids)
# Use Case/When to preserve the ordering
from django.db.models import Case, When
preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(id_list)])
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
return model_class.objects.none()

3
analytics/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
analytics/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -1,6 +0,0 @@
"""
Django apps package.
This directory contains all Django applications for the ThrillWiki backend.
Each app is self-contained and follows Django best practices.
"""

View File

@@ -1,2 +0,0 @@
# Import choices to trigger registration
from .choices import *

View File

@@ -1,95 +0,0 @@
from django.conf import settings
from django.http import HttpRequest
from typing import Optional, Any, Dict, Literal, TYPE_CHECKING, cast
from allauth.account.adapter import DefaultAccountAdapter # type: ignore[import]
from allauth.account.models import EmailConfirmation, EmailAddress # type: ignore[import]
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter # type: ignore[import]
from allauth.socialaccount.models import SocialLogin # type: ignore[import]
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractUser
User = get_user_model()
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request: HttpRequest) -> Literal[True]:
"""
Whether to allow sign ups.
"""
return True
def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: EmailConfirmation) -> str:
"""
Constructs the email confirmation (activation) url.
"""
get_current_site(request)
# Ensure the key is treated as a string for the type checker
key = cast(str, getattr(emailconfirmation, "key", ""))
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={key}"
def send_confirmation_mail(self, request: HttpRequest, emailconfirmation: EmailConfirmation, signup: bool) -> None:
"""
Sends the confirmation email.
"""
current_site = get_current_site(request)
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
# Cast key to str for typing consistency and template context
key = cast(str, getattr(emailconfirmation, "key", ""))
# Determine template early
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
# Cast the possibly-unknown email_address to EmailAddress so the type checker knows its attributes
email_address = cast(EmailAddress, getattr(emailconfirmation, "email_address", None))
# Safely obtain email string (fallback to any top-level email on confirmation)
email_str = cast(str, getattr(email_address, "email", getattr(emailconfirmation, "email", "")))
# Safely obtain the user object, cast to the project's User model for typing
user_obj = cast("AbstractUser", getattr(email_address, "user", None))
# Explicitly type the context to avoid partial-unknown typing issues
ctx: Dict[str, Any] = {
"user": user_obj,
"activate_url": activate_url,
"current_site": current_site,
"key": key,
}
# Remove unnecessary cast; ctx is already Dict[str, Any]
self.send_mail(email_template, email_str, ctx) # type: ignore
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> Literal[True]:
"""
Whether to allow social account sign ups.
"""
return True
def populate_user(
self, request: HttpRequest, sociallogin: SocialLogin, data: Dict[str, Any]
) -> "AbstractUser": # type: ignore[override]
"""
Hook that can be used to further populate the user instance.
"""
user = super().populate_user(request, sociallogin, data) # type: ignore
if getattr(sociallogin.account, "provider", None) == "discord": # type: ignore
user.discord_id = getattr(sociallogin.account, "uid", None) # type: ignore
return cast("AbstractUser", user) # Ensure return type is explicit
def save_user(
self, request: HttpRequest, sociallogin: SocialLogin, form: Optional[Any] = None
) -> "AbstractUser": # type: ignore[override]
"""
Save the newly signed up social login.
"""
user = super().save_user(request, sociallogin, form) # type: ignore
if user is None:
raise ValueError("User creation failed")
return cast("AbstractUser", user) # Ensure return type is explicit

View File

@@ -1,369 +0,0 @@
from typing import Any
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.utils.html import format_html
from django.contrib.auth.models import Group
from django.http import HttpRequest
from django.db.models import QuerySet
from .models import (
User,
UserProfile,
EmailVerification,
PasswordReset,
TopList,
TopListItem,
)
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
fieldsets = (
(
"Personal Info",
{"fields": ("display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
{
"fields": (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
},
),
)
class TopListItemInline(admin.TabularInline[TopListItem]):
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
@admin.register(User)
class CustomUserAdmin(DjangoUserAdmin[User]):
list_display = (
"username",
"email",
"get_avatar",
"get_status",
"role",
"date_joined",
"last_login",
"get_credits",
)
list_filter = (
"is_active",
"is_staff",
"role",
"is_banned",
"groups",
"date_joined",
)
search_fields = ("username", "email")
ordering = ("-date_joined",)
actions = [
"activate_users",
"deactivate_users",
"ban_users",
"unban_users",
]
inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
fieldsets = (
(None, {"fields": ("username", "password")}),
("Personal info", {"fields": ("email", "pending_email")}),
(
"Roles and Permissions",
{
"fields": ("role", "groups", "user_permissions"),
"description": (
"Role determines group membership. Groups determine permissions."
),
},
),
(
"Status",
{
"fields": ("is_active", "is_staff", "is_superuser"),
"description": "These are automatically managed based on role.",
},
),
(
"Ban Status",
{
"fields": ("is_banned", "ban_reason", "ban_date"),
},
),
(
"Preferences",
{
"fields": ("theme_preference",),
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"username",
"email",
"password1",
"password2",
"role",
),
},
),
)
@admin.display(description="Avatar")
def get_avatar(self, obj: User) -> str:
profile = getattr(obj, "profile", None)
if profile and getattr(profile, "avatar", None):
return format_html(
'<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
getattr(profile.avatar, "url", ""), # type: ignore
)
return format_html(
'<div style="width:30px; height:30px; border-radius:50%; '
"background-color:#007bff; color:white; display:flex; "
'align-items:center; justify-content:center;">{0}</div>',
getattr(obj, "username", "?")[0].upper(), # type: ignore
)
@admin.display(description="Status")
def get_status(self, obj: User) -> str:
if getattr(obj, "is_banned", False):
return format_html('<span style="color: red;">{}</span>', "Banned")
if not getattr(obj, "is_active", True):
return format_html('<span style="color: orange;">{}</span>', "Inactive")
if getattr(obj, "is_superuser", False):
return format_html('<span style="color: purple;">{}</span>', "Superuser")
if getattr(obj, "is_staff", False):
return format_html('<span style="color: blue;">{}</span>', "Staff")
return format_html('<span style="color: green;">{}</span>', "Active")
@admin.display(description="Ride Credits")
def get_credits(self, obj: User) -> str:
try:
profile = getattr(obj, "profile", None)
if not profile:
return "-"
return format_html(
"RC: {0}<br>DR: {1}<br>FR: {2}<br>WR: {3}",
getattr(profile, "coaster_credits", 0),
getattr(profile, "dark_ride_credits", 0),
getattr(profile, "flat_ride_credits", 0),
getattr(profile, "water_ride_credits", 0),
)
except UserProfile.DoesNotExist:
return "-"
@admin.action(description="Activate selected users")
def activate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_active=False)
@admin.action(description="Ban selected users")
def ban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
@admin.action(description="Unban selected users")
def unban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(
self,
request: HttpRequest,
obj: User,
form: Any,
change: bool
) -> None:
creating = not obj.pk
super().save_model(request, obj, form, change)
if creating and getattr(obj, "role", "USER") != "USER":
group = Group.objects.filter(name=getattr(obj, "role", None)).first()
if group:
obj.groups.add(group) # type: ignore[attr-defined]
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
list_display = (
"user",
"display_name",
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
list_filter = (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
search_fields = ("user__username", "user__email", "display_name", "bio")
fieldsets = (
(
"User Information",
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
{
"fields": (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
},
),
)
@admin.register(EmailVerification)
class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
list_display = ("user", "created_at", "last_sent", "is_expired")
list_filter = ("created_at", "last_sent")
search_fields = ("user__username", "user__email", "token")
readonly_fields = ("created_at", "last_sent")
fieldsets = (
("Verification Details", {"fields": ("user", "token")}),
("Timing", {"fields": ("created_at", "last_sent")}),
)
@admin.display(description="Status")
def is_expired(self, obj: EmailVerification) -> str:
from django.utils import timezone
from datetime import timedelta
if timezone.now() - getattr(obj, "last_sent", timezone.now()) > timedelta(days=1):
return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">{}</span>', "Valid")
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin[TopList]):
list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description")
inlines: list[type[admin.TabularInline[TopListItem]]] = [TopListItemInline]
fieldsets = (
(
"Basic Information",
{"fields": ("user", "title", "category", "description")},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
readonly_fields = ("created_at", "updated_at")
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes")
ordering = ("top_list", "rank")
fieldsets = (
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)
@admin.register(PasswordReset)
class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
"""Admin interface for password reset tokens"""
list_display = (
"user",
"created_at",
"expires_at",
"is_expired",
"used",
)
list_filter = (
"used",
"created_at",
"expires_at",
)
search_fields = (
"user__username",
"user__email",
"token",
)
readonly_fields = (
"token",
"created_at",
"expires_at",
)
date_hierarchy = "created_at"
ordering = ("-created_at",)
fieldsets = (
(
"Reset Details",
{
"fields": (
"user",
"token",
"used",
)
},
),
(
"Timing",
{
"fields": (
"created_at",
"expires_at",
)
},
),
)
@admin.display(description="Status", boolean=True)
def is_expired(self, obj: PasswordReset) -> str:
from django.utils import timezone
if getattr(obj, "used", False):
return format_html('<span style="color: blue;">{}</span>', "Used")
elif timezone.now() > getattr(obj, "expires_at", timezone.now()):
return format_html('<span style="color: red;">{}</span>', "Expired")
return format_html('<span style="color: green;">{}</span>', "Valid")
def has_add_permission(self, request: HttpRequest) -> bool:
"""Disable manual creation of password reset tokens"""
return False
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)

View File

@@ -1,563 +0,0 @@
"""
Rich Choice Objects for Accounts Domain
This module defines all choice objects used in the accounts domain,
replacing tuple-based choices with rich, metadata-enhanced choice objects.
Last updated: 2025-01-15
"""
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
# =============================================================================
# USER ROLES
# =============================================================================
user_roles = ChoiceGroup(
name="user_roles",
choices=[
RichChoice(
value="USER",
label="User",
description="Standard user with basic permissions to create content, reviews, and lists",
metadata={
"color": "blue",
"icon": "user",
"css_class": "text-blue-600 bg-blue-50",
"permissions": ["create_content", "create_reviews", "create_lists"],
"sort_order": 1,
}
),
RichChoice(
value="MODERATOR",
label="Moderator",
description="Trusted user with permissions to moderate content and assist other users",
metadata={
"color": "green",
"icon": "shield-check",
"css_class": "text-green-600 bg-green-50",
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
"sort_order": 2,
}
),
RichChoice(
value="ADMIN",
label="Admin",
description="Administrator with elevated permissions to manage users and site configuration",
metadata={
"color": "purple",
"icon": "cog",
"css_class": "text-purple-600 bg-purple-50",
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
"sort_order": 3,
}
),
RichChoice(
value="SUPERUSER",
label="Superuser",
description="Full system administrator with unrestricted access to all features",
metadata={
"color": "red",
"icon": "key",
"css_class": "text-red-600 bg-red-50",
"permissions": ["full_access", "system_administration", "database_access"],
"sort_order": 4,
}
),
]
)
# =============================================================================
# THEME PREFERENCES
# =============================================================================
theme_preferences = ChoiceGroup(
name="theme_preferences",
choices=[
RichChoice(
value="light",
label="Light",
description="Light theme with bright backgrounds and dark text for daytime use",
metadata={
"color": "yellow",
"icon": "sun",
"css_class": "text-yellow-600 bg-yellow-50",
"preview_colors": {
"background": "#ffffff",
"text": "#1f2937",
"accent": "#3b82f6"
},
"sort_order": 1,
}
),
RichChoice(
value="dark",
label="Dark",
description="Dark theme with dark backgrounds and light text for nighttime use",
metadata={
"color": "gray",
"icon": "moon",
"css_class": "text-gray-600 bg-gray-50",
"preview_colors": {
"background": "#1f2937",
"text": "#f9fafb",
"accent": "#60a5fa"
},
"sort_order": 2,
}
),
]
)
# =============================================================================
# PRIVACY LEVELS
# =============================================================================
privacy_levels = ChoiceGroup(
name="privacy_levels",
choices=[
RichChoice(
value="public",
label="Public",
description="Profile and activity visible to all users and search engines",
metadata={
"color": "green",
"icon": "globe",
"css_class": "text-green-600 bg-green-50",
"visibility_scope": "everyone",
"search_indexable": True,
"implications": [
"Profile visible to all users",
"Activity appears in public feeds",
"Searchable by search engines",
"Can be found by username search"
],
"sort_order": 1,
}
),
RichChoice(
value="friends",
label="Friends Only",
description="Profile and activity visible only to accepted friends",
metadata={
"color": "blue",
"icon": "users",
"css_class": "text-blue-600 bg-blue-50",
"visibility_scope": "friends",
"search_indexable": False,
"implications": [
"Profile visible only to friends",
"Activity hidden from public feeds",
"Not searchable by search engines",
"Requires friend request approval"
],
"sort_order": 2,
}
),
RichChoice(
value="private",
label="Private",
description="Profile and activity completely private, visible only to you",
metadata={
"color": "red",
"icon": "lock",
"css_class": "text-red-600 bg-red-50",
"visibility_scope": "self",
"search_indexable": False,
"implications": [
"Profile completely hidden",
"No activity in any feeds",
"Not discoverable by other users",
"Maximum privacy protection"
],
"sort_order": 3,
}
),
]
)
# =============================================================================
# TOP LIST CATEGORIES
# =============================================================================
top_list_categories = ChoiceGroup(
name="top_list_categories",
choices=[
RichChoice(
value="RC",
label="Roller Coaster",
description="Top lists for roller coasters and thrill rides",
metadata={
"color": "red",
"icon": "roller-coaster",
"css_class": "text-red-600 bg-red-50",
"ride_category": "roller_coaster",
"typical_list_size": 10,
"sort_order": 1,
}
),
RichChoice(
value="DR",
label="Dark Ride",
description="Top lists for dark rides and indoor attractions",
metadata={
"color": "purple",
"icon": "moon",
"css_class": "text-purple-600 bg-purple-50",
"ride_category": "dark_ride",
"typical_list_size": 10,
"sort_order": 2,
}
),
RichChoice(
value="FR",
label="Flat Ride",
description="Top lists for flat rides and spinning attractions",
metadata={
"color": "blue",
"icon": "refresh",
"css_class": "text-blue-600 bg-blue-50",
"ride_category": "flat_ride",
"typical_list_size": 10,
"sort_order": 3,
}
),
RichChoice(
value="WR",
label="Water Ride",
description="Top lists for water rides and splash attractions",
metadata={
"color": "cyan",
"icon": "droplet",
"css_class": "text-cyan-600 bg-cyan-50",
"ride_category": "water_ride",
"typical_list_size": 10,
"sort_order": 4,
}
),
RichChoice(
value="PK",
label="Park",
description="Top lists for theme parks and amusement parks",
metadata={
"color": "green",
"icon": "map",
"css_class": "text-green-600 bg-green-50",
"entity_type": "park",
"typical_list_size": 10,
"sort_order": 5,
}
),
]
)
# =============================================================================
# NOTIFICATION TYPES
# =============================================================================
notification_types = ChoiceGroup(
name="notification_types",
choices=[
# Submission related
RichChoice(
value="submission_approved",
label="Submission Approved",
description="Notification when user's submission is approved by moderators",
metadata={
"color": "green",
"icon": "check-circle",
"css_class": "text-green-600 bg-green-50",
"category": "submission",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 1,
}
),
RichChoice(
value="submission_rejected",
label="Submission Rejected",
description="Notification when user's submission is rejected by moderators",
metadata={
"color": "red",
"icon": "x-circle",
"css_class": "text-red-600 bg-red-50",
"category": "submission",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 2,
}
),
RichChoice(
value="submission_pending",
label="Submission Pending Review",
description="Notification when user's submission is pending moderator review",
metadata={
"color": "yellow",
"icon": "clock",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "submission",
"default_channels": ["inapp"],
"priority": "low",
"sort_order": 3,
}
),
# Review related
RichChoice(
value="review_reply",
label="Review Reply",
description="Notification when someone replies to user's review",
metadata={
"color": "blue",
"icon": "chat-bubble",
"css_class": "text-blue-600 bg-blue-50",
"category": "review",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 4,
}
),
RichChoice(
value="review_helpful",
label="Review Marked Helpful",
description="Notification when user's review is marked as helpful",
metadata={
"color": "green",
"icon": "thumbs-up",
"css_class": "text-green-600 bg-green-50",
"category": "review",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 5,
}
),
# Social related
RichChoice(
value="friend_request",
label="Friend Request",
description="Notification when user receives a friend request",
metadata={
"color": "blue",
"icon": "user-plus",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 6,
}
),
RichChoice(
value="friend_accepted",
label="Friend Request Accepted",
description="Notification when user's friend request is accepted",
metadata={
"color": "green",
"icon": "user-check",
"css_class": "text-green-600 bg-green-50",
"category": "social",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 7,
}
),
RichChoice(
value="message_received",
label="Message Received",
description="Notification when user receives a private message",
metadata={
"color": "blue",
"icon": "mail",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 8,
}
),
RichChoice(
value="profile_comment",
label="Profile Comment",
description="Notification when someone comments on user's profile",
metadata={
"color": "blue",
"icon": "chat",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 9,
}
),
# System related
RichChoice(
value="system_announcement",
label="System Announcement",
description="Important announcements from the ThrillWiki team",
metadata={
"color": "purple",
"icon": "megaphone",
"css_class": "text-purple-600 bg-purple-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 10,
}
),
RichChoice(
value="account_security",
label="Account Security",
description="Security-related notifications for user's account",
metadata={
"color": "red",
"icon": "shield-exclamation",
"css_class": "text-red-600 bg-red-50",
"category": "system",
"default_channels": ["email", "push", "inapp"],
"priority": "high",
"sort_order": 11,
}
),
RichChoice(
value="feature_update",
label="Feature Update",
description="Notifications about new features and improvements",
metadata={
"color": "blue",
"icon": "sparkles",
"css_class": "text-blue-600 bg-blue-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "low",
"sort_order": 12,
}
),
RichChoice(
value="maintenance",
label="Maintenance Notice",
description="Scheduled maintenance and downtime notifications",
metadata={
"color": "yellow",
"icon": "wrench",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 13,
}
),
# Achievement related
RichChoice(
value="achievement_unlocked",
label="Achievement Unlocked",
description="Notification when user unlocks a new achievement",
metadata={
"color": "gold",
"icon": "trophy",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "achievement",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 14,
}
),
RichChoice(
value="milestone_reached",
label="Milestone Reached",
description="Notification when user reaches a significant milestone",
metadata={
"color": "purple",
"icon": "flag",
"css_class": "text-purple-600 bg-purple-50",
"category": "achievement",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 15,
}
),
]
)
# =============================================================================
# NOTIFICATION PRIORITIES
# =============================================================================
notification_priorities = ChoiceGroup(
name="notification_priorities",
choices=[
RichChoice(
value="low",
label="Low",
description="Low priority notifications that can be delayed or batched",
metadata={
"color": "gray",
"icon": "arrow-down",
"css_class": "text-gray-600 bg-gray-50",
"urgency_level": 1,
"batch_eligible": True,
"delay_minutes": 60,
"sort_order": 1,
}
),
RichChoice(
value="normal",
label="Normal",
description="Standard priority notifications sent in regular intervals",
metadata={
"color": "blue",
"icon": "minus",
"css_class": "text-blue-600 bg-blue-50",
"urgency_level": 2,
"batch_eligible": True,
"delay_minutes": 15,
"sort_order": 2,
}
),
RichChoice(
value="high",
label="High",
description="High priority notifications sent immediately",
metadata={
"color": "orange",
"icon": "arrow-up",
"css_class": "text-orange-600 bg-orange-50",
"urgency_level": 3,
"batch_eligible": False,
"delay_minutes": 0,
"sort_order": 3,
}
),
RichChoice(
value="urgent",
label="Urgent",
description="Critical notifications requiring immediate attention",
metadata={
"color": "red",
"icon": "exclamation",
"css_class": "text-red-600 bg-red-50",
"urgency_level": 4,
"batch_eligible": False,
"delay_minutes": 0,
"bypass_preferences": True,
"sort_order": 4,
}
),
]
)
# =============================================================================
# REGISTER ALL CHOICE GROUPS
# =============================================================================
# Register each choice group individually
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")

View File

@@ -1,41 +0,0 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = "Check all social auth related tables"
def handle(self, *args, **options):
# Check SocialApp
self.stdout.write("\nChecking SocialApp table:")
for app in SocialApp.objects.all():
self.stdout.write(
f"ID: {app.pk}, Provider: {app.provider}, Name: {app.name}, Client ID: {
app.client_id
}"
)
self.stdout.write("Sites:")
for site in app.sites.all():
self.stdout.write(f" - {site.domain}")
# Check SocialAccount
self.stdout.write("\nChecking SocialAccount table:")
for account in SocialAccount.objects.all():
self.stdout.write(
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
)
# Check SocialToken
self.stdout.write("\nChecking SocialToken table:")
for token in SocialToken.objects.all():
self.stdout.write(
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
)
# Check Site
self.stdout.write("\nChecking Site table:")
for site in Site.objects.all():
self.stdout.write(
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
)

View File

@@ -1,22 +0,0 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = "Check social app configurations"
def handle(self, *args, **options):
social_apps = SocialApp.objects.all()
if not social_apps:
self.stdout.write(self.style.ERROR("No social apps found"))
return
for app in social_apps:
self.stdout.write(self.style.SUCCESS(f"\nProvider: {app.provider}"))
self.stdout.write(f"Name: {app.name}")
self.stdout.write(f"Client ID: {app.client_id}")
self.stdout.write(f"Secret: {app.secret}")
self.stdout.write(
f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}"
)

View File

@@ -1,55 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = "Create social apps for authentication"
def handle(self, *args, **options):
# Get the default site
site = Site.objects.get_or_create(
id=1,
defaults={
"domain": "localhost:8000",
"name": "ThrillWiki Development",
},
)[0]
# Create Discord app
discord_app, created = SocialApp.objects.get_or_create(
provider="discord",
defaults={
"name": "Discord",
"client_id": "1299112802274902047",
"secret": "ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11",
},
)
if not created:
discord_app.client_id = "1299112802274902047"
discord_app.secret = "ece7Pe_M4mD4mYzAgcINjTEKL_3ftL11"
discord_app.save()
discord_app.sites.add(site)
self.stdout.write(f"{'Created' if created else 'Updated'} Discord app")
# Create Google app
google_app, created = SocialApp.objects.get_or_create(
provider="google",
defaults={
"name": "Google",
"client_id": (
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
"apps.googleusercontent.com"
),
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
},
)
if not created:
google_app.client_id = (
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
"apps.googleusercontent.com"
)
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
google_app.save()
google_app.sites.add(site)
self.stdout.write(f"{'Created' if created else 'Updated'} Google app")

View File

@@ -1,164 +0,0 @@
"""
Django management command to delete a user while preserving their submissions.
Usage:
uv run manage.py delete_user <username>
uv run manage.py delete_user --user-id <user_id>
uv run manage.py delete_user <username> --dry-run
"""
from django.core.management.base import BaseCommand, CommandError
from apps.accounts.models import User
from apps.accounts.services import UserDeletionService
class Command(BaseCommand):
help = "Delete a user while preserving all their submissions"
def add_arguments(self, parser):
parser.add_argument(
"username", nargs="?", type=str, help="Username of the user to delete"
)
parser.add_argument(
"--user-id",
type=str,
help="User ID of the user to delete (alternative to username)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be deleted without actually deleting",
)
parser.add_argument(
"--force", action="store_true", help="Skip confirmation prompt"
)
def handle(self, *args, **options):
username = options.get("username")
user_id = options.get("user_id")
dry_run = options.get("dry_run", False)
force = options.get("force", False)
# Validate arguments
if not username and not user_id:
raise CommandError("You must provide either a username or --user-id")
if username and user_id:
raise CommandError("You cannot provide both username and --user-id")
# Find the user
try:
if username:
user = User.objects.get(username=username)
else:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist:
identifier = username or user_id
raise CommandError(f'User "{identifier}" does not exist')
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
raise CommandError(f"Cannot delete user: {reason}")
# Count submissions
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
}
total_submissions = sum(submission_counts.values())
# Display user information
self.stdout.write(self.style.WARNING("\nUser Information:"))
self.stdout.write(f" Username: {user.username}")
self.stdout.write(f" User ID: {user.user_id}")
self.stdout.write(f" Email: {user.email}")
self.stdout.write(f" Date Joined: {user.date_joined}")
self.stdout.write(f" Role: {user.role}")
# Display submission counts
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
for submission_type, count in submission_counts.items():
if count > 0:
self.stdout.write(
f' {submission_type.replace("_", " ").title()}: {count}'
)
self.stdout.write(f"\nTotal submissions: {total_submissions}")
if total_submissions > 0:
self.stdout.write(
self.style.SUCCESS(
f'\nAll {total_submissions} submissions will be transferred to the "deleted_user" placeholder.'
)
)
else:
self.stdout.write(
self.style.WARNING("\nNo submissions found for this user.")
)
if dry_run:
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
return
# Confirmation prompt
if not force:
self.stdout.write(
self.style.WARNING(
f'\nThis will permanently delete the user "{user.username}" '
f"but preserve all {total_submissions} submissions."
)
)
confirm = input("Are you sure you want to continue? (yes/no): ")
if confirm.lower() not in ["yes", "y"]:
self.stdout.write(self.style.ERROR("Operation cancelled."))
return
# Perform the deletion
try:
result = UserDeletionService.delete_user_preserve_submissions(user)
self.stdout.write(
self.style.SUCCESS(
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
)
)
preserved_count = sum(result["preserved_submissions"].values())
if preserved_count > 0:
self.stdout.write(
self.style.SUCCESS(
f'Preserved {preserved_count} submissions under user "{result["transferred_to"]["username"]}"'
)
)
# Show detailed preservation summary
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
for submission_type, count in result["preserved_submissions"].items():
if count > 0:
self.stdout.write(
f' {submission_type.replace("_", " ").title()}: {count}'
)
except Exception as e:
raise CommandError(f"Error deleting user: {str(e)}")

View File

@@ -1,18 +0,0 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = "Fix migration history by removing rides.0001_initial"
def handle(self, *args, **kwargs):
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM django_migrations WHERE app='rides' "
"AND name='0001_initial';"
)
self.stdout.write(
self.style.SUCCESS(
"Successfully removed rides.0001_initial from migration history"
)
)

View File

@@ -1,38 +0,0 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
import os
class Command(BaseCommand):
help = "Fix social app configurations"
def handle(self, *args, **options):
# Delete all existing social apps
SocialApp.objects.all().delete()
self.stdout.write("Deleted all existing social apps")
# Get the default site
site = Site.objects.get(id=1)
# Create Google provider
google_app = SocialApp.objects.create(
provider="google",
name="Google",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
secret=os.getenv("GOOGLE_CLIENT_SECRET"),
)
google_app.sites.add(site)
self.stdout.write(f"Created Google app with client_id: {google_app.client_id}")
# Create Discord provider
discord_app = SocialApp.objects.create(
provider="discord",
name="Discord",
client_id=os.getenv("DISCORD_CLIENT_ID"),
secret=os.getenv("DISCORD_CLIENT_SECRET"),
)
discord_app.sites.add(site)
self.stdout.write(
f"Created Discord app with client_id: {discord_app.client_id}"
)

View File

@@ -1,15 +0,0 @@
from django.core.management.base import BaseCommand
from apps.accounts.models import UserProfile
class Command(BaseCommand):
help = "Regenerate default avatars for users without an uploaded avatar"
def handle(self, *args, **kwargs):
profiles = UserProfile.objects.filter(avatar="")
for profile in profiles:
# This will trigger the avatar generation logic in the save method
profile.save()
self.stdout.write(
self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}")
)

View File

@@ -1,24 +0,0 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = "Reset social auth configuration"
def handle(self, *args, **options):
with connection.cursor() as cursor:
# Delete all social apps
cursor.execute("DELETE FROM socialaccount_socialapp")
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
# Reset sequences
cursor.execute(
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'"
)
cursor.execute(
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'"
)
self.stdout.write(
self.style.SUCCESS("Successfully reset social auth configuration")
)

View File

@@ -1,44 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group
from apps.accounts.models import User
from apps.accounts.signals import create_default_groups
class Command(BaseCommand):
help = "Set up default groups and permissions for user roles"
def handle(self, *args, **options):
self.stdout.write("Creating default groups and permissions...")
try:
# Create default groups with permissions
create_default_groups()
# Sync existing users with groups based on their roles
users = User.objects.exclude(role="USER")
for user in users:
group = Group.objects.filter(name=user.role).first()
if group:
user.groups.add(group)
# Update staff/superuser status based on role
if user.role == "SUPERUSER":
user.is_superuser = True
user.is_staff = True
elif user.role in ["ADMIN", "MODERATOR"]:
user.is_staff = True
user.save()
self.stdout.write(
self.style.SUCCESS("Successfully set up groups and permissions")
)
# Print summary
for group in Group.objects.all():
self.stdout.write(f"\nGroup: {group.name}")
self.stdout.write("Permissions:")
for perm in group.permissions.all():
self.stdout.write(f" - {perm.codename}")
except Exception as e:
self.stdout.write(self.style.ERROR(f"Error setting up groups: {str(e)}"))

View File

@@ -1,129 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
from dotenv import load_dotenv
import os
class Command(BaseCommand):
help = "Sets up social authentication apps"
def handle(self, *args, **kwargs):
# Load environment variables
load_dotenv()
# Get environment variables
google_client_id = os.getenv("GOOGLE_CLIENT_ID")
google_client_secret = os.getenv("GOOGLE_CLIENT_SECRET")
discord_client_id = os.getenv("DISCORD_CLIENT_ID")
discord_client_secret = os.getenv("DISCORD_CLIENT_SECRET")
# DEBUG: Log environment variable values
self.stdout.write(
f"DEBUG: google_client_id type: {type(google_client_id)}, value: {
google_client_id
}"
)
self.stdout.write(
f"DEBUG: google_client_secret type: {type(google_client_secret)}, value: {
google_client_secret
}"
)
self.stdout.write(
f"DEBUG: discord_client_id type: {type(discord_client_id)}, value: {
discord_client_id
}"
)
self.stdout.write(
f"DEBUG: discord_client_secret type: {type(discord_client_secret)}, value: {
discord_client_secret
}"
)
if not all(
[
google_client_id,
google_client_secret,
discord_client_id,
discord_client_secret,
]
):
self.stdout.write(
self.style.ERROR("Missing required environment variables")
)
self.stdout.write(
f"DEBUG: google_client_id is None: {google_client_id is None}"
)
self.stdout.write(
f"DEBUG: google_client_secret is None: {google_client_secret is None}"
)
self.stdout.write(
f"DEBUG: discord_client_id is None: {discord_client_id is None}"
)
self.stdout.write(
f"DEBUG: discord_client_secret is None: {discord_client_secret is None}"
)
return
# Get or create the default site
site, _ = Site.objects.get_or_create(
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
)
# Set up Google
google_app, created = SocialApp.objects.get_or_create(
provider="google",
defaults={
"name": "Google",
"client_id": google_client_id,
"secret": google_client_secret,
},
)
if not created:
self.stdout.write(
f"DEBUG: About to assign google_client_id: {google_client_id} (type: {
type(google_client_id)
})"
)
if google_client_id is not None and google_client_secret is not None:
google_app.client_id = google_client_id
google_app.secret = google_client_secret
google_app.save()
self.stdout.write("DEBUG: Successfully updated Google app")
else:
self.stdout.write(
self.style.ERROR(
"Google client_id or secret is None, skipping update."
)
)
google_app.sites.add(site)
# Set up Discord
discord_app, created = SocialApp.objects.get_or_create(
provider="discord",
defaults={
"name": "Discord",
"client_id": discord_client_id,
"secret": discord_client_secret,
},
)
if not created:
self.stdout.write(
f"DEBUG: About to assign discord_client_id: {discord_client_id} (type: {
type(discord_client_id)
})"
)
if discord_client_id is not None and discord_client_secret is not None:
discord_app.client_id = discord_client_id
discord_app.secret = discord_client_secret
discord_app.save()
self.stdout.write("DEBUG: Successfully updated Discord app")
else:
self.stdout.write(
self.style.ERROR(
"Discord client_id or secret is None, skipping update."
)
)
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))

View File

@@ -1,47 +0,0 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
class Command(BaseCommand):
help = "Set up social authentication providers for development"
def handle(self, *args, **options):
# Get the current site
site = Site.objects.get_current()
self.stdout.write(f"Setting up social providers for site: {site}")
# Clear existing social apps to avoid duplicates
deleted_count = SocialApp.objects.all().delete()[0]
self.stdout.write(f"Cleared {deleted_count} existing social apps")
# Create Google social app
google_app = SocialApp.objects.create(
provider="google",
name="Google",
client_id="demo-google-client-id.apps.googleusercontent.com",
secret="demo-google-client-secret",
key="",
)
google_app.sites.add(site)
self.stdout.write(self.style.SUCCESS("✅ Created Google social app"))
# Create Discord social app
discord_app = SocialApp.objects.create(
provider="discord",
name="Discord",
client_id="demo-discord-client-id",
secret="demo-discord-client-secret",
key="",
)
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS("✅ Created Discord social app"))
# List all social apps
self.stdout.write("\nConfigured social apps:")
for app in SocialApp.objects.all():
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
self.stdout.write(
self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}")
)

View File

@@ -1,61 +0,0 @@
from django.core.management.base import BaseCommand
from django.test import Client
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
help = "Test Discord OAuth2 authentication flow"
def handle(self, *args, **options):
client = Client(HTTP_HOST="localhost:8000")
# Get Discord app
try:
discord_app = SocialApp.objects.get(provider="discord")
self.stdout.write("Found Discord app configuration:")
self.stdout.write(f"Client ID: {discord_app.client_id}")
# Test login URL
login_url = "/accounts/discord/login/"
response = client.get(login_url, HTTP_HOST="localhost:8000")
self.stdout.write(f"\nTesting login URL: {login_url}")
self.stdout.write(f"Status code: {response.status_code}")
if response.status_code == 302:
redirect_url = response["Location"]
self.stdout.write(f"Redirects to: {redirect_url}")
# Parse OAuth2 parameters
self.stdout.write("\nOAuth2 Parameters:")
if "client_id=" in redirect_url:
self.stdout.write("✓ client_id parameter present")
if "redirect_uri=" in redirect_url:
self.stdout.write("✓ redirect_uri parameter present")
if "scope=" in redirect_url:
self.stdout.write("✓ scope parameter present")
if "response_type=" in redirect_url:
self.stdout.write("✓ response_type parameter present")
if "code_challenge=" in redirect_url:
self.stdout.write("✓ PKCE enabled (code_challenge present)")
# Show callback URL
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
self.stdout.write(
"\nCallback URL to configure in Discord Developer Portal:"
)
self.stdout.write(callback_url)
# Show frontend login URL
frontend_url = "http://localhost:5173"
self.stdout.write("\nFrontend configuration:")
self.stdout.write(f"Frontend URL: {frontend_url}")
self.stdout.write("Discord login button should use:")
self.stdout.write("/accounts/discord/login/?process=login")
# Show allauth URLs
self.stdout.write("\nAllauth URLs:")
self.stdout.write("Login URL: /accounts/discord/login/?process=login")
self.stdout.write("Callback URL: /accounts/discord/login/callback/")
except SocialApp.DoesNotExist:
self.stdout.write(self.style.ERROR("Discord app not found"))

View File

@@ -1,39 +0,0 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.conf import settings
class Command(BaseCommand):
help = "Verify Discord OAuth2 settings"
def handle(self, *args, **options):
# Get Discord app
try:
discord_app = SocialApp.objects.get(provider="discord")
self.stdout.write("Found Discord app configuration:")
self.stdout.write(f"Client ID: {discord_app.client_id}")
self.stdout.write(f"Secret: {discord_app.secret}")
# Get sites
sites = discord_app.sites.all()
self.stdout.write("\nAssociated sites:")
for site in sites:
self.stdout.write(f"- {site.domain} ({site.name})")
# Show callback URL
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
self.stdout.write(
"\nCallback URL to configure in Discord Developer Portal:"
)
self.stdout.write(callback_url)
# Show OAuth2 settings
self.stdout.write("\nOAuth2 settings in settings.py:")
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
self.stdout.write(
f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}"
)
self.stdout.write(f"Scopes: {discord_settings.get('SCOPE', [])}")
except SocialApp.DoesNotExist:
self.stdout.write(self.style.ERROR("Discord app not found"))

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-21 01:29
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AddField(
model_name="userprofile",
name="avatar",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AddField(
model_name="userprofileevent",
name="avatar",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="81607e492ffea2a4c741452b860ee660374cc01d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
]

View File

@@ -1,636 +0,0 @@
from django.dispatch import receiver
from django.db.models.signals import post_save
from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import secrets
from datetime import timedelta
from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
# Try to get a 4-digit number first
new_id = str(secrets.SystemRandom().randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
# If all 4-digit numbers are taken, try 5 digits
new_id = str(secrets.SystemRandom().randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
@pghistory.track()
class User(AbstractUser):
# Override inherited fields to remove them
first_name = None
last_name = None
# Read-only ID
user_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text=(
"Unique identifier for this user that remains constant even if the "
"username changes"
),
)
role = RichChoiceField(
choice_group="user_roles",
domain="accounts",
max_length=10,
default="USER",
)
is_banned = models.BooleanField(default=False)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
theme_preference = RichChoiceField(
choice_group="theme_preferences",
domain="accounts",
max_length=5,
default="light",
)
# Notification preferences
email_notifications = models.BooleanField(default=True)
push_notifications = models.BooleanField(default=False)
# Privacy settings
privacy_level = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="public",
)
show_email = models.BooleanField(default=False)
show_real_name = models.BooleanField(default=True)
show_join_date = models.BooleanField(default=True)
show_statistics = models.BooleanField(default=True)
show_reviews = models.BooleanField(default=True)
show_photos = models.BooleanField(default=True)
show_top_lists = models.BooleanField(default=True)
allow_friend_requests = models.BooleanField(default=True)
allow_messages = models.BooleanField(default=True)
allow_profile_comments = models.BooleanField(default=False)
search_visibility = models.BooleanField(default=True)
activity_visibility = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="friends",
)
# Security settings
two_factor_enabled = models.BooleanField(default=False)
login_notifications = models.BooleanField(default=True)
session_timeout = models.IntegerField(default=30) # days
login_history_retention = models.IntegerField(default=90) # days
last_password_change = models.DateTimeField(auto_now_add=True)
# Display name - core user data for better performance
display_name = models.CharField(
max_length=50,
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
)
# Detailed notification preferences (JSON field for flexibility)
notification_preferences = models.JSONField(
default=dict,
blank=True,
help_text="Detailed notification preferences stored as JSON",
)
def __str__(self):
return self.get_display_name()
def get_absolute_url(self):
return reverse("profile", kwargs={"username": self.username})
def get_display_name(self):
"""Get the user's display name, falling back to username if not set"""
if self.display_name:
return self.display_name
return self.username
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, "user_id")
super().save(*args, **kwargs)
@pghistory.track()
class UserProfile(models.Model):
# Read-only ID
profile_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
display_name = models.CharField(
max_length=50,
blank=True,
help_text="Legacy display name field - use User.display_name instead",
)
avatar = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
# Social media links
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
# Ride statistics
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
def get_avatar_url(self):
"""
Return the avatar URL or generate a default letter-based avatar URL
"""
if self.avatar and self.avatar.is_uploaded:
# Try to get avatar variant first, fallback to public
avatar_url = self.avatar.get_url('avatar')
if avatar_url:
return avatar_url
# Fallback to public variant
public_url = self.avatar.get_url('public')
if public_url:
return public_url
# Last fallback - try any available variant
if self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
return self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
# Return first available variant
for variant_url in self.avatar.variants.values():
if variant_url:
return variant_url
# Generate default letter-based avatar using first letter of username
first_letter = self.user.username[0].upper() if self.user.username else "U"
# Use a service like UI Avatars or generate a simple colored avatar
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
def get_avatar_variants(self):
"""
Return avatar variants for different use cases
"""
if self.avatar and self.avatar.is_uploaded:
variants = {}
# Try to get specific variants
thumbnail_url = self.avatar.get_url('thumbnail')
avatar_url = self.avatar.get_url('avatar')
large_url = self.avatar.get_url('large')
public_url = self.avatar.get_url('public')
# Use specific variants if available, otherwise fallback to public or first available
fallback_url = public_url
if not fallback_url and self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
fallback_url = self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
fallback_url = next(iter(self.avatar.variants.values()), None)
variants = {
"thumbnail": thumbnail_url or fallback_url,
"avatar": avatar_url or fallback_url,
"large": large_url or fallback_url,
}
# Only return variants if we have at least one valid URL
if any(variants.values()):
return variants
# For default avatars, return the same URL for all variants
default_url = self.get_avatar_url()
return {
"thumbnail": default_url,
"avatar": default_url,
"large": default_url,
}
def save(self, *args, **kwargs):
# If no display name is set, use the username
if not self.display_name:
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, "profile_id")
super().save(*args, **kwargs)
def __str__(self):
return self.display_name
@pghistory.track()
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email verification for {self.user.username}"
class Meta:
verbose_name = "Email Verification"
verbose_name_plural = "Email Verifications"
@pghistory.track()
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
def __str__(self):
return f"Password reset for {self.user.username}"
class Meta:
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
# @pghistory.track()
class TopList(TrackedModel):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="top_lists", # Added related_name for User model access
)
title = models.CharField(max_length=100)
category = RichChoiceField(
choice_group="top_list_categories",
domain="accounts",
max_length=2,
)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
ordering = ["-updated_at"]
def __str__(self):
return (
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
)
# @pghistory.track()
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList, on_delete=models.CASCADE, related_name="items"
)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
rank = models.PositiveIntegerField()
notes = models.TextField(blank=True)
class Meta(TrackedModel.Meta):
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"
@pghistory.track()
class UserDeletionRequest(models.Model):
"""
Model to track user deletion requests with email verification.
When a user requests to delete their account, a verification code
is sent to their email. The deletion is only processed when they
provide the correct code.
"""
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="deletion_request"
)
verification_code = models.CharField(
max_length=32,
unique=True,
help_text="Unique verification code sent to user's email",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(help_text="When this deletion request expires")
email_sent_at = models.DateTimeField(
null=True, blank=True, help_text="When the verification email was sent"
)
attempts = models.PositiveIntegerField(
default=0, help_text="Number of verification attempts made"
)
max_attempts = models.PositiveIntegerField(
default=5, help_text="Maximum number of verification attempts allowed"
)
is_used = models.BooleanField(
default=False, help_text="Whether this deletion request has been used"
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["verification_code"]),
models.Index(fields=["expires_at"]),
models.Index(fields=["user", "is_used"]),
]
def __str__(self):
return f"Deletion request for {self.user.username} - {self.verification_code}"
def save(self, *args, **kwargs):
if not self.verification_code:
self.verification_code = self.generate_verification_code()
if not self.expires_at:
# Deletion requests expire after 24 hours
self.expires_at = timezone.now() + timedelta(hours=24)
super().save(*args, **kwargs)
@staticmethod
def generate_verification_code():
"""Generate a unique 8-character verification code."""
while True:
# Generate a random 8-character alphanumeric code
code = "".join(
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
)
# Ensure it's unique
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
return code
def is_expired(self):
"""Check if this deletion request has expired."""
return timezone.now() > self.expires_at
def is_valid(self):
"""Check if this deletion request is still valid."""
return (
not self.is_used
and not self.is_expired()
and self.attempts < self.max_attempts
)
def increment_attempts(self):
"""Increment the number of verification attempts."""
self.attempts += 1
self.save(update_fields=["attempts"])
def mark_as_used(self):
"""Mark this deletion request as used."""
self.is_used = True
self.save(update_fields=["is_used"])
@classmethod
def cleanup_expired(cls):
"""Remove expired deletion requests."""
expired_requests = cls.objects.filter(
expires_at__lt=timezone.now(), is_used=False
)
count = expired_requests.count()
expired_requests.delete()
return count
@pghistory.track()
class UserNotification(TrackedModel):
"""
Model to store user notifications for various events.
This includes submission approvals, rejections, system announcements,
and other user-relevant notifications.
"""
# Core fields
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="notifications"
)
notification_type = RichChoiceField(
choice_group="notification_types",
domain="accounts",
max_length=30,
)
title = models.CharField(max_length=200)
message = models.TextField()
# Optional related object (submission, review, etc.)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
)
object_id = models.PositiveIntegerField(null=True, blank=True)
related_object = GenericForeignKey("content_type", "object_id")
# Metadata
priority = RichChoiceField(
choice_group="notification_priorities",
domain="accounts",
max_length=10,
default="normal",
)
# Status tracking
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
# Delivery tracking
email_sent = models.BooleanField(default=False)
email_sent_at = models.DateTimeField(null=True, blank=True)
push_sent = models.BooleanField(default=False)
push_sent_at = models.DateTimeField(null=True, blank=True)
# Additional data (JSON field for flexibility)
extra_data = models.JSONField(default=dict, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
class Meta(TrackedModel.Meta):
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "is_read"]),
models.Index(fields=["user", "notification_type"]),
models.Index(fields=["created_at"]),
models.Index(fields=["expires_at"]),
]
def __str__(self):
return f"{self.user.username}: {self.title}"
def mark_as_read(self):
"""Mark notification as read."""
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=["is_read", "read_at"])
def is_expired(self):
"""Check if notification has expired."""
if not self.expires_at:
return False
return timezone.now() > self.expires_at
@classmethod
def cleanup_expired(cls):
"""Remove expired notifications."""
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
count = expired_notifications.count()
expired_notifications.delete()
return count
@classmethod
def mark_all_read_for_user(cls, user):
"""Mark all notifications as read for a specific user."""
return cls.objects.filter(user=user, is_read=False).update(
is_read=True, read_at=timezone.now()
)
@pghistory.track()
class NotificationPreference(TrackedModel):
"""
User preferences for different types of notifications.
This allows users to control which notifications they receive
and through which channels (email, push, in-app).
"""
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="notification_preference"
)
# Submission notifications
submission_approved_email = models.BooleanField(default=True)
submission_approved_push = models.BooleanField(default=True)
submission_approved_inapp = models.BooleanField(default=True)
submission_rejected_email = models.BooleanField(default=True)
submission_rejected_push = models.BooleanField(default=True)
submission_rejected_inapp = models.BooleanField(default=True)
submission_pending_email = models.BooleanField(default=False)
submission_pending_push = models.BooleanField(default=False)
submission_pending_inapp = models.BooleanField(default=True)
# Review notifications
review_reply_email = models.BooleanField(default=True)
review_reply_push = models.BooleanField(default=True)
review_reply_inapp = models.BooleanField(default=True)
review_helpful_email = models.BooleanField(default=False)
review_helpful_push = models.BooleanField(default=True)
review_helpful_inapp = models.BooleanField(default=True)
# Social notifications
friend_request_email = models.BooleanField(default=True)
friend_request_push = models.BooleanField(default=True)
friend_request_inapp = models.BooleanField(default=True)
friend_accepted_email = models.BooleanField(default=False)
friend_accepted_push = models.BooleanField(default=True)
friend_accepted_inapp = models.BooleanField(default=True)
message_received_email = models.BooleanField(default=True)
message_received_push = models.BooleanField(default=True)
message_received_inapp = models.BooleanField(default=True)
# System notifications
system_announcement_email = models.BooleanField(default=True)
system_announcement_push = models.BooleanField(default=False)
system_announcement_inapp = models.BooleanField(default=True)
account_security_email = models.BooleanField(default=True)
account_security_push = models.BooleanField(default=True)
account_security_inapp = models.BooleanField(default=True)
feature_update_email = models.BooleanField(default=True)
feature_update_push = models.BooleanField(default=False)
feature_update_inapp = models.BooleanField(default=True)
# Achievement notifications
achievement_unlocked_email = models.BooleanField(default=False)
achievement_unlocked_push = models.BooleanField(default=True)
achievement_unlocked_inapp = models.BooleanField(default=True)
milestone_reached_email = models.BooleanField(default=False)
milestone_reached_push = models.BooleanField(default=True)
milestone_reached_inapp = models.BooleanField(default=True)
class Meta(TrackedModel.Meta):
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
def __str__(self):
return f"Notification preferences for {self.user.username}"
def should_send_notification(self, notification_type, channel):
"""
Check if a notification should be sent for a specific type and channel.
Args:
notification_type: The type of notification (from UserNotification.NotificationType)
channel: The delivery channel ('email', 'push', 'inapp')
Returns:
bool: True if notification should be sent, False otherwise
"""
field_name = f"{notification_type}_{channel}"
return getattr(self, field_name, False)
# Signal handlers for automatic notification preference creation
@receiver(post_save, sender=User)
def create_notification_preference(sender, instance, created, **kwargs):
"""Create notification preferences when a new user is created."""
if created:
NotificationPreference.objects.get_or_create(user=instance)
# Signal moved to signals.py to avoid duplication

View File

@@ -1,272 +0,0 @@
"""
Selectors for user and account-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Dict, Any
from django.db.models import QuerySet, Q, F, Count
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
User = get_user_model()
def user_profile_optimized(*, user_id: int) -> Any:
"""
Get a user with optimized queries for profile display.
Args:
user_id: User ID
Returns:
User instance with prefetched related data
Raises:
User.DoesNotExist: If user doesn't exist
"""
return (
User.objects.prefetch_related(
"park_reviews", "ride_reviews", "socialaccount_set"
)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.get(id=user_id)
)
def active_users_with_stats() -> QuerySet:
"""
Get active users with review statistics.
Returns:
QuerySet of active users with review counts
"""
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.order_by("-total_review_count")
)
def users_with_recent_activity(*, days: int = 30) -> QuerySet:
"""
Get users who have been active in the last N days.
Args:
days: Number of days to look back for activity
Returns:
QuerySet of recently active users
"""
cutoff_date = timezone.now() - timedelta(days=days)
return (
User.objects.filter(
Q(last_login__gte=cutoff_date)
| Q(park_reviews__created_at__gte=cutoff_date)
| Q(ride_reviews__created_at__gte=cutoff_date)
)
.annotate(
recent_park_reviews=Count(
"park_reviews",
filter=Q(park_reviews__created_at__gte=cutoff_date),
),
recent_ride_reviews=Count(
"ride_reviews",
filter=Q(ride_reviews__created_at__gte=cutoff_date),
),
recent_total_reviews=F("recent_park_reviews") + F("recent_ride_reviews"),
)
.order_by("-last_login")
.distinct()
)
def top_reviewers(*, limit: int = 10) -> QuerySet:
"""
Get top users by review count.
Args:
limit: Maximum number of users to return
Returns:
QuerySet of top reviewers
"""
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gt=0)
.order_by("-total_review_count")[:limit]
)
def moderator_users() -> QuerySet:
"""
Get users with moderation permissions.
Returns:
QuerySet of users who can moderate content
"""
return (
User.objects.filter(
Q(is_staff=True)
| Q(groups__name="Moderators")
| Q(
user_permissions__codename__in=[
"change_parkreview",
"change_ridereview",
]
)
)
.distinct()
.order_by("username")
)
def users_by_registration_date(*, start_date, end_date) -> QuerySet:
"""
Get users who registered within a date range.
Args:
start_date: Start of date range
end_date: End of date range
Returns:
QuerySet of users registered in the date range
"""
return User.objects.filter(
date_joined__date__gte=start_date, date_joined__date__lte=end_date
).order_by("-date_joined")
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
"""
Get users matching a search query for autocomplete functionality.
Args:
query: Search string
limit: Maximum number of results
Returns:
QuerySet of matching users for autocomplete
"""
return User.objects.filter(
Q(username__icontains=query)
| Q(display_name__icontains=query),
is_active=True,
).order_by("username")[:limit]
def users_with_social_accounts() -> QuerySet:
"""
Get users who have connected social accounts.
Returns:
QuerySet of users with social account connections
"""
return (
User.objects.filter(socialaccount__isnull=False)
.prefetch_related("socialaccount_set")
.distinct()
.order_by("username")
)
def user_statistics_summary() -> Dict[str, Any]:
"""
Get overall user statistics for dashboard/analytics.
Returns:
Dictionary containing user statistics
"""
total_users = User.objects.count()
active_users = User.objects.filter(is_active=True).count()
staff_users = User.objects.filter(is_staff=True).count()
# Users with reviews
users_with_reviews = (
User.objects.filter(
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
)
.distinct()
.count()
)
# Recent registrations (last 30 days)
cutoff_date = timezone.now() - timedelta(days=30)
recent_registrations = User.objects.filter(date_joined__gte=cutoff_date).count()
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": total_users - active_users,
"staff_users": staff_users,
"users_with_reviews": users_with_reviews,
"recent_registrations": recent_registrations,
"review_participation_rate": (
(users_with_reviews / total_users * 100) if total_users > 0 else 0
),
}
def users_needing_email_verification() -> QuerySet:
"""
Get users who haven't verified their email addresses.
Returns:
QuerySet of users with unverified emails
"""
return (
User.objects.filter(is_active=True, emailaddress__verified=False)
.distinct()
.order_by("date_joined")
)
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
"""
Get users who have written at least a minimum number of reviews.
Args:
min_reviews: Minimum number of reviews required
Returns:
QuerySet of users with sufficient review activity
"""
return (
User.objects.annotate(
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gte=min_reviews)
.order_by("-total_review_count")
)

View File

@@ -1,269 +0,0 @@
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from django_forwardemail.services import EmailService
from django.template.loader import render_to_string
from typing import cast
UserModel = get_user_model()
class UserSerializer(serializers.ModelSerializer):
"""
User serializer for API responses
"""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
"id",
"username",
"email",
"display_name",
"date_joined",
"is_active",
"avatar_url",
]
read_only_fields = ["id", "date_joined", "is_active"]
def get_avatar_url(self, obj) -> str | None:
"""Get user avatar URL"""
if hasattr(obj, "profile") and obj.profile.avatar:
return obj.profile.avatar.url
return None
def get_display_name(self, obj) -> str:
"""Get user display name"""
return obj.get_display_name()
class LoginSerializer(serializers.Serializer):
"""
Serializer for user login
"""
username = serializers.CharField(
max_length=254, help_text="Username or email address"
)
password = serializers.CharField(
max_length=128, style={"input_type": "password"}, trim_whitespace=False
)
def validate(self, attrs):
username = attrs.get("username")
password = attrs.get("password")
if username and password:
return attrs
raise serializers.ValidationError("Must include username/email and password.")
class SignupSerializer(serializers.ModelSerializer):
"""
Serializer for user registration
"""
password = serializers.CharField(
write_only=True,
validators=[validate_password],
style={"input_type": "password"},
)
password_confirm = serializers.CharField(
write_only=True, style={"input_type": "password"}
)
class Meta:
model = User
fields = [
"username",
"email",
"display_name",
"password",
"password_confirm",
]
extra_kwargs = {
"password": {"write_only": True},
"email": {"required": True},
"display_name": {"required": True},
}
def validate_email(self, value):
"""Validate email is unique (normalize and check case-insensitively)."""
normalized = value.strip().lower() if value is not None else value
if UserModel.objects.filter(email__iexact=normalized).exists():
raise serializers.ValidationError("A user with this email already exists.")
return normalized
def validate_username(self, value):
"""Validate username is unique"""
if UserModel.objects.filter(username=value).exists():
raise serializers.ValidationError(
"A user with this username already exists."
)
return value
def validate(self, attrs):
"""Validate passwords match"""
password = attrs.get("password")
password_confirm = attrs.get("password_confirm")
if password != password_confirm:
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
return attrs
def create(self, validated_data):
"""Create user with validated data"""
validated_data.pop("password_confirm", None)
password = validated_data.pop("password")
user = UserModel.objects.create(**validated_data)
user.set_password(password)
user.save()
return user
class PasswordResetSerializer(serializers.Serializer):
"""
Serializer for password reset request
"""
email = serializers.EmailField()
def validate_email(self, value):
"""Normalize email and attach the user to the serializer when found (case-insensitive).
Returns the normalized email. Does not reveal whether the email exists.
"""
normalized = value.strip().lower() if value is not None else value
try:
user = UserModel.objects.get(email__iexact=normalized)
self.user = user
except UserModel.DoesNotExist:
# Do not reveal whether the email exists; keep behavior unchanged.
pass
return normalized
def save(self, **kwargs):
"""Send password reset email if user exists"""
if hasattr(self, "user"):
# Create password reset token
token = get_random_string(64)
PasswordReset.objects.update_or_create(
user=self.user,
defaults={
"token": token,
"expires_at": timezone.now() + timedelta(hours=24),
"used": False,
},
)
# Send reset email
request = self.context.get("request")
if request:
site = get_current_site(request)
reset_url = f"{request.scheme}://{site.domain}/reset-password/{token}/"
context = {
"user": self.user,
"reset_url": reset_url,
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_reset.html", context
)
# Narrow and validate email type for the static checker
email = getattr(self.user, "email", None)
if not email:
# No recipient email; skip sending
return
EmailService.send_email(
to=cast(str, email),
subject="Reset your password",
text=f"Click the link to reset your password: {reset_url}",
site=site,
html=email_html,
)
class PasswordChangeSerializer(serializers.Serializer):
"""
Serializer for password change
"""
old_password = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
new_password = serializers.CharField(
max_length=128, validators=[validate_password], style={"input_type": "password"}
)
new_password_confirm = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
def validate_old_password(self, value):
"""Validate old password is correct"""
user = self.context["request"].user
if not user.check_password(value):
raise serializers.ValidationError("Old password is incorrect.")
return value
def validate(self, attrs):
"""Validate new passwords match"""
new_password = attrs.get("new_password")
new_password_confirm = attrs.get("new_password_confirm")
if new_password != new_password_confirm:
raise serializers.ValidationError(
{"new_password_confirm": "New passwords do not match."}
)
return attrs
def save(self, **kwargs):
"""Change user password"""
user = self.context["request"].user
# Defensively obtain new_password from validated_data if it's a real dict,
# otherwise fall back to initial_data if that's a dict.
new_password = None
validated = getattr(self, "validated_data", None)
if isinstance(validated, dict):
new_password = validated.get("new_password")
elif isinstance(self.initial_data, dict):
new_password = self.initial_data.get("new_password")
if not new_password:
raise serializers.ValidationError("New password is required.")
user.set_password(new_password)
user.save()
return user
class SocialProviderSerializer(serializers.Serializer):
"""
Serializer for social authentication providers
"""
id = serializers.CharField()
name = serializers.CharField()
login_url = serializers.URLField()
name = serializers.CharField()
login_url = serializers.URLField()

View File

@@ -1,366 +0,0 @@
"""
User management services for ThrillWiki.
This module contains services for user account management including
user deletion while preserving submissions.
"""
from typing import Optional
from django.db import transaction
from django.utils import timezone
from django.conf import settings
from django.contrib.sites.models import Site
from django_forwardemail.services import EmailService
from .models import User, UserProfile, UserDeletionRequest
class UserDeletionService:
"""Service for handling user deletion while preserving submissions."""
DELETED_USER_USERNAME = "deleted_user"
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
DELETED_DISPLAY_NAME = "Deleted User"
@classmethod
def get_or_create_deleted_user(cls) -> User:
"""Get or create the system 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_staff": False,
"is_superuser": False,
"role": "USER",
"is_banned": True,
"ban_reason": "System placeholder for deleted users",
"ban_date": timezone.now(),
},
)
if created:
# Create profile for deleted user
UserProfile.objects.create(
user=deleted_user,
display_name=cls.DELETED_DISPLAY_NAME,
bio="This user account has been deleted.",
)
return deleted_user
@classmethod
@transaction.atomic
def delete_user_preserve_submissions(cls, user: User) -> dict:
"""
Delete a user while preserving all their submissions.
This method:
1. Transfers all user submissions to a system "deleted_user" placeholder
2. Deletes the user's profile and account data
3. Returns a summary of what was preserved
Args:
user: The user to delete
Returns:
dict: Summary of preserved submissions
"""
if user.username == cls.DELETED_USER_USERNAME:
raise ValueError("Cannot delete the system deleted user placeholder")
deleted_user = cls.get_or_create_deleted_user()
# Count submissions before transfer
submission_counts = {
"park_reviews": getattr(
user, "park_reviews", user.__class__.objects.none()
).count(),
"ride_reviews": getattr(
user, "ride_reviews", user.__class__.objects.none()
).count(),
"uploaded_park_photos": getattr(
user, "uploaded_park_photos", user.__class__.objects.none()
).count(),
"uploaded_ride_photos": getattr(
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
user, "top_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
).count(),
"photo_submissions": getattr(
user, "photo_submissions", user.__class__.objects.none()
).count(),
"moderated_park_reviews": getattr(
user, "moderated_park_reviews", user.__class__.objects.none()
).count(),
"moderated_ride_reviews": getattr(
user, "moderated_ride_reviews", user.__class__.objects.none()
).count(),
"handled_submissions": getattr(
user, "handled_submissions", user.__class__.objects.none()
).count(),
"handled_photos": getattr(
user, "handled_photos", user.__class__.objects.none()
).count(),
}
# Transfer all submissions to deleted user
# Reviews
if hasattr(user, "park_reviews"):
getattr(user, "park_reviews").update(user=deleted_user)
if hasattr(user, "ride_reviews"):
getattr(user, "ride_reviews").update(user=deleted_user)
# Photos
if hasattr(user, "uploaded_park_photos"):
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
if hasattr(user, "uploaded_ride_photos"):
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
# Top Lists
if hasattr(user, "top_lists"):
getattr(user, "top_lists").update(user=deleted_user)
# Moderation submissions
if hasattr(user, "edit_submissions"):
getattr(user, "edit_submissions").update(user=deleted_user)
if hasattr(user, "photo_submissions"):
getattr(user, "photo_submissions").update(user=deleted_user)
# Moderation actions - these can be set to NULL since they're not user content
if hasattr(user, "moderated_park_reviews"):
getattr(user, "moderated_park_reviews").update(moderated_by=None)
if hasattr(user, "moderated_ride_reviews"):
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
if hasattr(user, "handled_submissions"):
getattr(user, "handled_submissions").update(handled_by=None)
if hasattr(user, "handled_photos"):
getattr(user, "handled_photos").update(handled_by=None)
# Store user info for the summary
user_info = {
"username": user.username,
"user_id": user.user_id,
"email": user.email,
"date_joined": user.date_joined,
}
# Delete the user (this will cascade delete the profile)
user.delete()
return {
"deleted_user": user_info,
"preserved_submissions": submission_counts,
"transferred_to": {
"username": deleted_user.username,
"user_id": deleted_user.user_id,
},
}
@classmethod
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
"""
Check if a user can be safely deleted.
Args:
user: The user to check
Returns:
tuple: (can_delete: bool, reason: Optional[str])
"""
if user.username == cls.DELETED_USER_USERNAME:
return False, "Cannot delete the system deleted user placeholder"
if user.is_superuser:
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
# Check if user has critical admin role
if user.role == "ADMIN" and user.is_staff:
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
# Add any other business rules here
return True, None
@classmethod
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
"""
Create a user deletion request and send verification email.
Args:
user: The user requesting deletion
Returns:
UserDeletionRequest: The created deletion request
"""
# Check if user can be deleted
can_delete, reason = cls.can_delete_user(user)
if not can_delete:
raise ValueError(f"Cannot delete user: {reason}")
# Remove any existing deletion request for this user
UserDeletionRequest.objects.filter(user=user).delete()
# Create new deletion request
deletion_request = UserDeletionRequest.objects.create(user=user)
# Send verification email
cls.send_deletion_verification_email(deletion_request)
return deletion_request
@classmethod
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
"""
Send verification email for account deletion.
Args:
deletion_request: The deletion request to send email for
"""
user = deletion_request.user
# Get current site for email service
try:
site = Site.objects.get_current()
except Site.DoesNotExist:
# Fallback to default site
site = Site.objects.get_or_create(
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
)[0]
# Prepare email context
context = {
"user": user,
"verification_code": deletion_request.verification_code,
"expires_at": deletion_request.expires_at,
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
"frontend_domain": getattr(
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
),
}
# Render email content
subject = f"Confirm Account Deletion - {context['site_name']}"
# Create email message with 1-hour expiration notice
message = f"""
Hello {user.get_display_name()},
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
Verification Code: {deletion_request.verification_code}
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
If you did not request this deletion, please ignore this email and your account will remain active.
To complete the deletion, enter the verification code in the account deletion form on our website.
Best regards,
The ThrillWiki Team
""".strip()
# Send email using custom email service
try:
EmailService.send_email(
to=user.email,
subject=subject,
text=message,
site=site,
from_email="no-reply@thrillwiki.com",
)
# Update email sent timestamp
deletion_request.email_sent_at = timezone.now()
deletion_request.save(update_fields=["email_sent_at"])
except Exception as e:
# Log the error but don't fail the request creation
print(f"Failed to send deletion verification email to {user.email}: {e}")
@classmethod
@transaction.atomic
def verify_and_delete_user(cls, verification_code: str) -> dict:
"""
Verify deletion code and delete the user account.
Args:
verification_code: The verification code from the email
Returns:
dict: Summary of the deletion
Raises:
ValueError: If verification fails
"""
try:
deletion_request = UserDeletionRequest.objects.get(
verification_code=verification_code
)
except UserDeletionRequest.DoesNotExist:
raise ValueError("Invalid verification code")
# Check if request is still valid
if not deletion_request.is_valid():
if deletion_request.is_expired():
raise ValueError("Verification code has expired")
elif deletion_request.is_used:
raise ValueError("Verification code has already been used")
elif deletion_request.attempts >= deletion_request.max_attempts:
raise ValueError("Too many verification attempts")
else:
raise ValueError("Invalid verification code")
# Increment attempts
deletion_request.increment_attempts()
# Mark as used
deletion_request.mark_as_used()
# Delete the user
user = deletion_request.user
result = cls.delete_user_preserve_submissions(user)
# Add deletion request info to result
result["deletion_request"] = {
"verification_code": verification_code,
"created_at": deletion_request.created_at,
"verified_at": timezone.now(),
}
return result
@classmethod
def cancel_deletion_request(cls, user: User) -> bool:
"""
Cancel a pending deletion request.
Args:
user: The user whose deletion request to cancel
Returns:
bool: True if a request was cancelled, False if no request existed
"""
try:
deletion_request = getattr(user, "deletion_request", None)
if deletion_request:
deletion_request.delete()
return True
return False
except UserDeletionRequest.DoesNotExist:
return False
@classmethod
def cleanup_expired_deletion_requests(cls) -> int:
"""
Clean up expired deletion requests.
Returns:
int: Number of expired requests cleaned up
"""
return UserDeletionRequest.cleanup_expired()

View File

@@ -1,11 +0,0 @@
"""
Accounts Services Package
This package contains business logic services for account management,
including social provider management, user authentication, and profile services.
"""
from .social_provider_service import SocialProviderService
from .user_deletion_service import UserDeletionService
__all__ = ['SocialProviderService', 'UserDeletionService']

View File

@@ -1,351 +0,0 @@
"""
Notification service for creating and managing user notifications.
This service handles the creation, delivery, and management of notifications
for various events including submission approvals/rejections.
"""
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.conf import settings
from django.db import models
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
import logging
from apps.accounts.models import User, UserNotification, NotificationPreference
from django_forwardemail.services import EmailService
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for creating and managing user notifications."""
@staticmethod
def create_notification(
user: User,
notification_type: str,
title: str,
message: str,
related_object: Optional[Any] = None,
priority: str = UserNotification.Priority.NORMAL,
extra_data: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> UserNotification:
"""
Create a new notification for a user.
Args:
user: The user to notify
notification_type: Type of notification (from UserNotification.NotificationType)
title: Notification title
message: Notification message
related_object: Optional related object (submission, review, etc.)
priority: Notification priority
extra_data: Additional data to store with notification
expires_at: When the notification expires
Returns:
UserNotification: The created notification
"""
# Get content type and object ID if related object provided
content_type = None
object_id = None
if related_object:
content_type = ContentType.objects.get_for_model(related_object)
object_id = related_object.pk
# Create the notification
notification = UserNotification.objects.create(
user=user,
notification_type=notification_type,
title=title,
message=message,
content_type=content_type,
object_id=object_id,
priority=priority,
extra_data=extra_data or {},
expires_at=expires_at,
)
# Send notification through appropriate channels
NotificationService._send_notification(notification)
return notification
@staticmethod
def create_submission_approved_notification(
user: User,
submission_object: Any,
submission_type: str,
additional_message: str = "",
) -> UserNotification:
"""
Create a notification for submission approval.
Args:
user: User who submitted the content
submission_object: The approved submission object
submission_type: Type of submission (e.g., "park photo", "ride review")
additional_message: Additional message from moderator
Returns:
UserNotification: The created notification
"""
title = f"Your {submission_type} has been approved!"
message = f"Great news! Your {submission_type} submission has been approved and is now live on ThrillWiki."
if additional_message:
message += f"\n\nModerator note: {additional_message}"
extra_data = {
"submission_type": submission_type,
"moderator_message": additional_message,
"approved_at": timezone.now().isoformat(),
}
return NotificationService.create_notification(
user=user,
notification_type=UserNotification.NotificationType.SUBMISSION_APPROVED,
title=title,
message=message,
related_object=submission_object,
priority=UserNotification.Priority.NORMAL,
extra_data=extra_data,
)
@staticmethod
def create_submission_rejected_notification(
user: User,
submission_object: Any,
submission_type: str,
rejection_reason: str,
additional_message: str = "",
) -> UserNotification:
"""
Create a notification for submission rejection.
Args:
user: User who submitted the content
submission_object: The rejected submission object
submission_type: Type of submission (e.g., "park photo", "ride review")
rejection_reason: Reason for rejection
additional_message: Additional message from moderator
Returns:
UserNotification: The created notification
"""
title = f"Your {submission_type} needs attention"
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
message += f"\n\nReason: {rejection_reason}"
if additional_message:
message += f"\n\nModerator note: {additional_message}"
message += "\n\nYou can edit and resubmit your content from your profile page."
extra_data = {
"submission_type": submission_type,
"rejection_reason": rejection_reason,
"moderator_message": additional_message,
"rejected_at": timezone.now().isoformat(),
}
return NotificationService.create_notification(
user=user,
notification_type=UserNotification.NotificationType.SUBMISSION_REJECTED,
title=title,
message=message,
related_object=submission_object,
priority=UserNotification.Priority.HIGH,
extra_data=extra_data,
)
@staticmethod
def create_submission_pending_notification(
user: User, submission_object: Any, submission_type: str
) -> UserNotification:
"""
Create a notification for submission pending review.
Args:
user: User who submitted the content
submission_object: The pending submission object
submission_type: Type of submission (e.g., "park photo", "ride review")
Returns:
UserNotification: The created notification
"""
title = f"Your {submission_type} is under review"
message = f"Thanks for your {submission_type} submission! It's now under review by our moderation team."
message += "\n\nWe'll notify you once it's been reviewed. This usually takes 1-2 business days."
extra_data = {
"submission_type": submission_type,
"submitted_at": timezone.now().isoformat(),
}
return NotificationService.create_notification(
user=user,
notification_type=UserNotification.NotificationType.SUBMISSION_PENDING,
title=title,
message=message,
related_object=submission_object,
priority=UserNotification.Priority.LOW,
extra_data=extra_data,
)
@staticmethod
def _send_notification(notification: UserNotification) -> None:
"""
Send notification through appropriate channels based on user preferences.
Args:
notification: The notification to send
"""
user = notification.user
# Get user's notification preferences
try:
preferences = user.notification_preference
except NotificationPreference.DoesNotExist:
# Create default preferences if they don't exist
preferences = NotificationPreference.objects.create(user=user)
# Send email notification if enabled
if preferences.should_send_notification(
notification.notification_type, "email"
):
NotificationService._send_email_notification(notification)
# Toast notifications are always created (the notification object itself)
# The frontend will display them as toast notifications based on preferences
@staticmethod
def _send_email_notification(notification: UserNotification) -> None:
"""
Send email notification to user using the custom ForwardEmail service.
Args:
notification: The notification to send via email
"""
try:
user = notification.user
# Prepare email context
context = {
"user": user,
"notification": notification,
"site_name": "ThrillWiki",
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
}
# Render email templates
subject = f"ThrillWiki: {notification.title}"
html_message = render_to_string("emails/notification.html", context)
plain_message = render_to_string("emails/notification.txt", context)
# Send email using custom ForwardEmail service
EmailService.send_email(
to=user.email,
subject=subject,
text=plain_message,
html=html_message,
)
# Mark as sent
notification.email_sent = True
notification.email_sent_at = timezone.now()
notification.save(update_fields=["email_sent", "email_sent_at"])
logger.info(
f"Email notification sent to {user.email} for notification {notification.id}"
)
except Exception as e:
logger.error(
f"Failed to send email notification {notification.id}: {str(e)}"
)
@staticmethod
def get_user_notifications(
user: User,
unread_only: bool = False,
notification_types: Optional[List[str]] = None,
limit: Optional[int] = None,
) -> List[UserNotification]:
"""
Get notifications for a user.
Args:
user: User to get notifications for
unread_only: Only return unread notifications
notification_types: Filter by notification types
limit: Limit number of results
Returns:
List[UserNotification]: List of notifications
"""
queryset = UserNotification.objects.filter(user=user)
if unread_only:
queryset = queryset.filter(is_read=False)
if notification_types:
queryset = queryset.filter(notification_type__in=notification_types)
# Exclude expired notifications
queryset = queryset.filter(
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
)
if limit:
queryset = queryset[:limit]
return list(queryset)
@staticmethod
def mark_notifications_read(
user: User, notification_ids: Optional[List[int]] = None
) -> int:
"""
Mark notifications as read for a user.
Args:
user: User whose notifications to mark as read
notification_ids: Specific notification IDs to mark as read (if None, marks all)
Returns:
int: Number of notifications marked as read
"""
queryset = UserNotification.objects.filter(user=user, is_read=False)
if notification_ids:
queryset = queryset.filter(id__in=notification_ids)
return queryset.update(is_read=True, read_at=timezone.now())
@staticmethod
def cleanup_old_notifications(days: int = 90) -> int:
"""
Clean up old read notifications.
Args:
days: Number of days to keep read notifications
Returns:
int: Number of notifications deleted
"""
cutoff_date = timezone.now() - timedelta(days=days)
old_notifications = UserNotification.objects.filter(
is_read=True, read_at__lt=cutoff_date
)
count = old_notifications.count()
old_notifications.delete()
logger.info(f"Cleaned up {count} old notifications")
return count

View File

@@ -1,257 +0,0 @@
"""
Social Provider Management Service
This service handles the business logic for connecting and disconnecting
social authentication providers while ensuring users never lock themselves
out of their accounts.
"""
from typing import Dict, List, Tuple, TYPE_CHECKING
from django.contrib.auth import get_user_model
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers import registry
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest
import logging
if TYPE_CHECKING:
from apps.accounts.models import User
else:
User = get_user_model()
logger = logging.getLogger(__name__)
class SocialProviderService:
"""Service for managing social provider connections."""
@staticmethod
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
"""
Check if a user can safely disconnect a social provider.
Args:
user: The user attempting to disconnect
provider: The provider to disconnect (e.g., 'google', 'discord')
Returns:
Tuple of (can_disconnect: bool, reason: str)
"""
try:
# Count remaining social accounts after disconnection
remaining_social_accounts = user.socialaccount_set.exclude(
provider=provider
).count()
# Check if user has email/password auth
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password) # Not empty/unusable
)
# Allow disconnection only if alternative auth exists
can_disconnect = remaining_social_accounts > 0 or has_password_auth
if not can_disconnect:
if remaining_social_accounts == 0 and not has_password_auth:
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
elif not has_password_auth:
return False, "Please set up email/password authentication before disconnecting this provider."
else:
return False, "Cannot disconnect this provider at this time."
return True, "Provider can be safely disconnected."
except Exception as e:
logger.error(
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
return False, "Unable to verify disconnection safety. Please try again."
@staticmethod
def get_connected_providers(user: "User") -> List[Dict]:
"""
Get all social providers connected to a user's account.
Args:
user: The user to check
Returns:
List of connected provider information
"""
try:
connected_providers = []
for social_account in user.socialaccount_set.all():
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, social_account.provider
)
provider_info = {
'provider': social_account.provider,
'provider_name': social_account.get_provider().name,
'uid': social_account.uid,
'date_joined': social_account.date_joined,
'can_disconnect': can_disconnect,
'disconnect_reason': reason if not can_disconnect else None,
'extra_data': social_account.extra_data
}
connected_providers.append(provider_info)
return connected_providers
except Exception as e:
logger.error(f"Error getting connected providers for user {user.id}: {e}")
return []
@staticmethod
def get_available_providers(request: HttpRequest) -> List[Dict]:
"""
Get all available social providers for the current site.
Args:
request: The HTTP request
Returns:
List of available provider information
"""
try:
site = get_current_site(request)
available_providers = []
# Get all social apps configured for this site
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
for social_app in social_apps:
try:
provider = registry.by_id(social_app.provider)
provider_info = {
'id': social_app.provider,
'name': provider.name,
'auth_url': request.build_absolute_uri(
f'/accounts/{social_app.provider}/login/'
),
'connect_url': request.build_absolute_uri(
f'/api/v1/auth/social/connect/{social_app.provider}/'
)
}
available_providers.append(provider_info)
except Exception as e:
logger.warning(
f"Error processing provider {social_app.provider}: {e}")
continue
return available_providers
except Exception as e:
logger.error(f"Error getting available providers: {e}")
return []
@staticmethod
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
"""
Disconnect a social provider from a user's account.
Args:
user: The user to disconnect from
provider: The provider to disconnect
Returns:
Tuple of (success: bool, message: str)
"""
try:
# First check if disconnection is allowed
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
user, provider)
if not can_disconnect:
return False, reason
# Find and delete the social account
social_accounts = user.socialaccount_set.filter(provider=provider)
if not social_accounts.exists():
return False, f"No {provider} account found to disconnect."
# Delete all social accounts for this provider (in case of duplicates)
deleted_count = social_accounts.count()
social_accounts.delete()
logger.info(
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
return True, f"{provider.title()} account disconnected successfully."
except Exception as e:
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
return False, f"Failed to disconnect {provider} account. Please try again."
@staticmethod
def get_auth_status(user: "User") -> Dict:
"""
Get comprehensive authentication status for a user.
Args:
user: The user to check
Returns:
Dictionary with authentication status information
"""
try:
connected_providers = SocialProviderService.get_connected_providers(user)
has_password_auth = (
user.email and
user.has_usable_password() and
bool(user.password)
)
auth_methods_count = len(connected_providers) + \
(1 if has_password_auth else 0)
return {
'user_id': user.id,
'username': user.username,
'email': user.email,
'has_password_auth': has_password_auth,
'connected_providers': connected_providers,
'total_auth_methods': auth_methods_count,
'can_disconnect_any': auth_methods_count > 1,
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
}
except Exception as e:
logger.error(f"Error getting auth status for user {user.id}: {e}")
return {
'error': 'Unable to retrieve authentication status'
}
@staticmethod
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
"""
Validate that a social provider is configured and available.
Args:
provider: The provider ID to validate
Returns:
Tuple of (is_valid: bool, message: str)
"""
try:
# Check if provider is registered with allauth
if provider not in registry.provider_map:
return False, f"Provider '{provider}' is not supported."
# Check if provider has a social app configured
if not SocialApp.objects.filter(provider=provider).exists():
return False, f"Provider '{provider}' is not configured on this site."
return True, f"Provider '{provider}' is valid and available."
except Exception as e:
logger.error(f"Error validating provider {provider}: {e}")
return False, "Unable to validate provider."

View File

@@ -1,309 +0,0 @@
"""
User Deletion Service
This service handles user account deletion while preserving submissions
and maintaining data integrity across the platform.
"""
from django.utils import timezone
from django.db import transaction
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from typing import Dict, Any, Tuple, Optional
import logging
import secrets
import string
from datetime import datetime
from apps.accounts.models import User
logger = logging.getLogger(__name__)
User = get_user_model()
class UserDeletionRequest:
"""Model for tracking user deletion requests."""
def __init__(self, user: User, verification_code: str, expires_at: datetime):
self.user = user
self.verification_code = verification_code
self.expires_at = expires_at
self.created_at = timezone.now()
class UserDeletionService:
"""Service for handling user account deletion with submission preservation."""
# In-memory storage for deletion requests (in production, use Redis or database)
_deletion_requests = {}
@staticmethod
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
"""
Check if a user can be safely deleted.
Args:
user: User to check for deletion eligibility
Returns:
Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
"""
# Prevent deletion of superusers
if user.is_superuser:
return False, "Cannot delete superuser accounts"
# Prevent deletion of staff/admin users
if user.is_staff:
return False, "Cannot delete staff accounts"
# Check for system users (if you have any special system accounts)
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
return False, "Cannot delete admin or moderator accounts"
return True, None
@staticmethod
def request_user_deletion(user: User) -> UserDeletionRequest:
"""
Create a deletion request for a user and send verification email.
Args:
user: User requesting deletion
Returns:
UserDeletionRequest: The deletion request object
Raises:
ValueError: If user cannot be deleted
"""
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
raise ValueError(reason)
# Generate verification code
verification_code = ''.join(secrets.choice(
string.ascii_uppercase + string.digits) for _ in range(8))
# Set expiration (24 hours from now)
expires_at = timezone.now() + timezone.timedelta(hours=24)
# Create deletion request
deletion_request = UserDeletionRequest(user, verification_code, expires_at)
# Store request (in production, use Redis or database)
UserDeletionService._deletion_requests[verification_code] = deletion_request
# Send verification email
UserDeletionService._send_deletion_verification_email(
user, verification_code, expires_at)
return deletion_request
@staticmethod
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
"""
Verify deletion code and delete user account.
Args:
verification_code: Verification code from email
Returns:
Dict[str, Any]: Deletion result information
Raises:
ValueError: If verification code is invalid or expired
"""
# Find deletion request
deletion_request = UserDeletionService._deletion_requests.get(verification_code)
if not deletion_request:
raise ValueError("Invalid verification code")
# Check if expired
if timezone.now() > deletion_request.expires_at:
# Clean up expired request
del UserDeletionService._deletion_requests[verification_code]
raise ValueError("Verification code has expired")
user = deletion_request.user
# Perform deletion
result = UserDeletionService.delete_user_preserve_submissions(user)
# Clean up deletion request
del UserDeletionService._deletion_requests[verification_code]
# Add verification info to result
result['deletion_request'] = {
'verification_code': verification_code,
'created_at': deletion_request.created_at,
'verified_at': timezone.now(),
}
return result
@staticmethod
def cancel_deletion_request(user: User) -> bool:
"""
Cancel a pending deletion request for a user.
Args:
user: User whose deletion request to cancel
Returns:
bool: True if request was found and cancelled, False if no request found
"""
# Find and remove any deletion requests for this user
to_remove = []
for code, request in UserDeletionService._deletion_requests.items():
if request.user.id == user.id:
to_remove.append(code)
for code in to_remove:
del UserDeletionService._deletion_requests[code]
return len(to_remove) > 0
@staticmethod
@transaction.atomic
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
"""
Delete a user account while preserving all their submissions.
Args:
user: User to delete
Returns:
Dict[str, Any]: Information about the deletion and preserved submissions
"""
# Get or create the "deleted_user" placeholder
deleted_user_placeholder, created = User.objects.get_or_create(
username='deleted_user',
defaults={
'email': 'deleted@thrillwiki.com',
'first_name': 'Deleted',
'last_name': 'User',
'is_active': False,
}
)
# Count submissions before transfer
submission_counts = UserDeletionService._count_user_submissions(user)
# Transfer submissions to placeholder user
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder)
# Store user info before deletion
deleted_user_info = {
'username': user.username,
'user_id': getattr(user, 'user_id', user.id),
'email': user.email,
'date_joined': user.date_joined,
}
# Delete the user account
user.delete()
return {
'deleted_user': deleted_user_info,
'preserved_submissions': submission_counts,
'transferred_to': {
'username': deleted_user_placeholder.username,
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
}
}
@staticmethod
def _count_user_submissions(user: User) -> Dict[str, int]:
"""Count all submissions for a user."""
counts = {}
# Count different types of submissions
# Note: These are placeholder counts - adjust based on your actual models
counts['park_reviews'] = getattr(
user, 'park_reviews', user.__class__.objects.none()).count()
counts['ride_reviews'] = getattr(
user, 'ride_reviews', user.__class__.objects.none()).count()
counts['uploaded_park_photos'] = getattr(
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
counts['uploaded_ride_photos'] = getattr(
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
counts['top_lists'] = getattr(
user, 'top_lists', user.__class__.objects.none()).count()
counts['edit_submissions'] = getattr(
user, 'edit_submissions', user.__class__.objects.none()).count()
counts['photo_submissions'] = getattr(
user, 'photo_submissions', user.__class__.objects.none()).count()
return counts
@staticmethod
def _transfer_user_submissions(user: User, placeholder_user: User) -> None:
"""Transfer all user submissions to placeholder user."""
# Transfer different types of submissions
# Note: Adjust these based on your actual model relationships
# Park reviews
if hasattr(user, 'park_reviews'):
user.park_reviews.all().update(user=placeholder_user)
# Ride reviews
if hasattr(user, 'ride_reviews'):
user.ride_reviews.all().update(user=placeholder_user)
# Uploaded photos
if hasattr(user, 'uploaded_park_photos'):
user.uploaded_park_photos.all().update(user=placeholder_user)
if hasattr(user, 'uploaded_ride_photos'):
user.uploaded_ride_photos.all().update(user=placeholder_user)
# Top lists
if hasattr(user, 'top_lists'):
user.top_lists.all().update(user=placeholder_user)
# Edit submissions
if hasattr(user, 'edit_submissions'):
user.edit_submissions.all().update(user=placeholder_user)
# Photo submissions
if hasattr(user, 'photo_submissions'):
user.photo_submissions.all().update(user=placeholder_user)
@staticmethod
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
"""Send verification email for account deletion."""
try:
context = {
'user': user,
'verification_code': verification_code,
'expires_at': expires_at,
'site_name': 'ThrillWiki',
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
}
subject = 'ThrillWiki: Confirm Account Deletion'
html_message = render_to_string(
'emails/account_deletion_verification.html', context)
plain_message = render_to_string(
'emails/account_deletion_verification.txt', context)
send_mail(
subject=subject,
message=plain_message,
html_message=html_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
logger.info(f"Deletion verification email sent to {user.email}")
except Exception as e:
logger.error(
f"Failed to send deletion verification email to {user.email}: {str(e)}")
raise

View File

@@ -1,169 +0,0 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.contrib.auth.models import Group
from django.db import transaction
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
import requests
from .models import User, UserProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
"""Create UserProfile for new users - unified signal handler"""
if created:
try:
# Use get_or_create to prevent duplicates
profile, profile_created = UserProfile.objects.get_or_create(user=instance)
if profile_created:
# If user has a social account with avatar, download it
try:
social_account = instance.socialaccount_set.first()
if social_account:
extra_data = social_account.extra_data
avatar_url = None
if social_account.provider == "google":
avatar_url = extra_data.get("picture")
elif social_account.provider == "discord":
avatar = extra_data.get("avatar")
discord_id = extra_data.get("id")
if avatar:
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
if avatar_url:
response = requests.get(avatar_url, timeout=60)
if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content)
img_temp.flush()
file_name = f"avatar_{instance.username}.png"
profile.avatar.save(file_name, File(img_temp), save=True)
except Exception as e:
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
except Exception as e:
print(f"Error creating profile for user {instance.username}: {str(e)}")
@receiver(pre_save, sender=User)
def sync_user_role_with_groups(sender, instance, **kwargs):
"""Sync user role with Django groups"""
if instance.pk: # Only for existing users
try:
old_instance = User.objects.get(pk=instance.pk)
if old_instance.role != instance.role:
# Role has changed, update groups
with transaction.atomic():
# Remove from old role group if exists
if old_instance.role != "USER":
old_group = Group.objects.filter(name=old_instance.role).first()
if old_group:
instance.groups.remove(old_group)
# Add to new role group
if instance.role != "USER":
new_group, _ = Group.objects.get_or_create(name=instance.role)
instance.groups.add(new_group)
# Special handling for superuser role
if instance.role == "SUPERUSER":
instance.is_superuser = True
instance.is_staff = True
elif old_instance.role == "SUPERUSER":
# If removing superuser role, remove superuser
# status
instance.is_superuser = False
if instance.role not in [
"ADMIN",
"MODERATOR",
]:
instance.is_staff = False
# Handle staff status for admin and moderator roles
if instance.role in [
"ADMIN",
"MODERATOR",
]:
instance.is_staff = True
elif old_instance.role in [
"ADMIN",
"MODERATOR",
]:
# If removing admin/moderator role, remove staff
# status
if instance.role not in ["SUPERUSER"]:
instance.is_staff = False
except User.DoesNotExist:
pass
except Exception as e:
print(
f"Error syncing role with groups for user {instance.username}: {str(e)}"
)
def create_default_groups():
"""
Create default groups with appropriate permissions.
Call this in a migration or management command.
"""
try:
from django.contrib.auth.models import Permission
# Create Moderator group
moderator_group, _ = Group.objects.get_or_create(name="MODERATOR")
moderator_permissions = [
# Review moderation permissions
"change_review",
"delete_review",
"change_reviewreport",
"delete_reviewreport",
# Edit moderation permissions
"change_parkedit",
"delete_parkedit",
"change_rideedit",
"delete_rideedit",
"change_companyedit",
"delete_companyedit",
"change_manufactureredit",
"delete_manufactureredit",
]
# Create Admin group
admin_group, _ = Group.objects.get_or_create(name="ADMIN")
admin_permissions = moderator_permissions + [
# User management permissions
"change_user",
"delete_user",
# Content management permissions
"add_park",
"change_park",
"delete_park",
"add_ride",
"change_ride",
"delete_ride",
"add_company",
"change_company",
"delete_company",
"add_manufacturer",
"change_manufacturer",
"delete_manufacturer",
]
# Assign permissions to groups
for codename in moderator_permissions:
try:
perm = Permission.objects.get(codename=codename)
moderator_group.permissions.add(perm)
except Permission.DoesNotExist:
print(f"Permission not found: {codename}")
for codename in admin_permissions:
try:
perm = Permission.objects.get(codename=codename)
admin_group.permissions.add(perm)
except Permission.DoesNotExist:
print(f"Permission not found: {codename}")
except Exception as e:
print(f"Error creating default groups: {str(e)}")

View File

@@ -1,126 +0,0 @@
from django.test import TestCase
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from unittest.mock import patch, MagicMock
from .models import User, UserProfile
from .signals import create_default_groups
class SignalsTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
password="password",
)
def test_create_user_profile(self):
# Refresh user from database to ensure signals have been processed
self.user.refresh_from_db()
# Check if profile exists in database first
profile_exists = UserProfile.objects.filter(user=self.user).exists()
self.assertTrue(profile_exists, "UserProfile should be created by signals")
# Now safely access the profile
profile = UserProfile.objects.get(user=self.user)
self.assertIsInstance(profile, UserProfile)
# Test the reverse relationship
self.assertTrue(hasattr(self.user, "profile"))
# Test that we can access the profile through the user relationship
user_profile = getattr(self.user, "profile", None)
self.assertEqual(user_profile, profile)
@patch("accounts.signals.requests.get")
def test_create_user_profile_with_social_avatar(self, mock_get):
# Mock the response from requests.get
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b"fake-image-content"
mock_get.return_value = mock_response
# Create a social account for the user (we'll skip this test since socialaccount_set requires allauth setup)
# This test would need proper allauth configuration to work
self.skipTest("Requires proper allauth socialaccount setup")
def test_save_user_profile(self):
# Get the profile safely first
profile = UserProfile.objects.get(user=self.user)
profile.delete()
# Refresh user to clear cached profile relationship
self.user.refresh_from_db()
# Check that profile no longer exists
self.assertFalse(UserProfile.objects.filter(user=self.user).exists())
# Trigger save to recreate profile via signal
self.user.save()
# Verify profile was recreated
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
new_profile = UserProfile.objects.get(user=self.user)
self.assertIsInstance(new_profile, UserProfile)
def test_sync_user_role_with_groups(self):
self.user.role = User.Roles.MODERATOR
self.user.save()
self.assertTrue(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
self.assertTrue(self.user.is_staff)
self.user.role = User.Roles.ADMIN
self.user.save()
self.assertFalse(self.user.groups.filter(name=User.Roles.MODERATOR).exists())
self.assertTrue(self.user.groups.filter(name=User.Roles.ADMIN).exists())
self.assertTrue(self.user.is_staff)
self.user.role = User.Roles.SUPERUSER
self.user.save()
self.assertFalse(self.user.groups.filter(name=User.Roles.ADMIN).exists())
self.assertTrue(self.user.groups.filter(name=User.Roles.SUPERUSER).exists())
self.assertTrue(self.user.is_superuser)
self.assertTrue(self.user.is_staff)
self.user.role = User.Roles.USER
self.user.save()
self.assertFalse(self.user.groups.exists())
self.assertFalse(self.user.is_superuser)
self.assertFalse(self.user.is_staff)
def test_create_default_groups(self):
# Create some permissions for testing
content_type = ContentType.objects.get_for_model(User)
Permission.objects.create(
codename="change_review",
name="Can change review",
content_type=content_type,
)
Permission.objects.create(
codename="delete_review",
name="Can delete review",
content_type=content_type,
)
Permission.objects.create(
codename="change_user",
name="Can change user",
content_type=content_type,
)
create_default_groups()
moderator_group = Group.objects.get(name="MODERATOR")
self.assertIsNotNone(moderator_group)
self.assertTrue(
moderator_group.permissions.filter(codename="change_review").exists()
)
self.assertFalse(
moderator_group.permissions.filter(codename="change_user").exists()
)
admin_group = Group.objects.get(name="ADMIN")
self.assertIsNotNone(admin_group)
self.assertTrue(
admin_group.permissions.filter(codename="change_review").exists()
)
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())

View File

@@ -1,155 +0,0 @@
"""
Tests for user deletion while preserving submissions.
"""
from django.test import TestCase
from django.db import transaction
from apps.accounts.services import UserDeletionService
from apps.accounts.models import User, UserProfile
class UserDeletionServiceTest(TestCase):
"""Test cases for UserDeletionService."""
def setUp(self):
"""Set up test data."""
# Create test users
self.user = User.objects.create_user(
username="testuser", email="test@example.com", password="testpass123"
)
self.admin_user = User.objects.create_user(
username="admin",
email="admin@example.com",
password="adminpass123",
is_superuser=True,
)
# Create user profiles
UserProfile.objects.create(
user=self.user, display_name="Test User", bio="Test bio"
)
UserProfile.objects.create(
user=self.admin_user, display_name="Admin User", bio="Admin bio"
)
def test_get_or_create_deleted_user(self):
"""Test that deleted user placeholder is created correctly."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
self.assertEqual(deleted_user.username, "deleted_user")
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
self.assertFalse(deleted_user.is_active)
self.assertTrue(deleted_user.is_banned)
self.assertEqual(deleted_user.role, "USER")
# Check profile was created
self.assertTrue(hasattr(deleted_user, "profile"))
self.assertEqual(deleted_user.profile.display_name, "Deleted User")
def test_get_or_create_deleted_user_idempotent(self):
"""Test that calling get_or_create_deleted_user multiple times returns same user."""
deleted_user1 = UserDeletionService.get_or_create_deleted_user()
deleted_user2 = UserDeletionService.get_or_create_deleted_user()
self.assertEqual(deleted_user1.id, deleted_user2.id)
self.assertEqual(User.objects.filter(username="deleted_user").count(), 1)
def test_can_delete_user_normal_user(self):
"""Test that normal users can be deleted."""
can_delete, reason = UserDeletionService.can_delete_user(self.user)
self.assertTrue(can_delete)
self.assertIsNone(reason)
def test_can_delete_user_superuser(self):
"""Test that superusers cannot be deleted."""
can_delete, reason = UserDeletionService.can_delete_user(self.admin_user)
self.assertFalse(can_delete)
self.assertEqual(reason, "Cannot delete superuser accounts")
def test_can_delete_user_deleted_user_placeholder(self):
"""Test that deleted user placeholder cannot be deleted."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
self.assertFalse(can_delete)
self.assertEqual(reason, "Cannot delete the system deleted user placeholder")
def test_delete_user_preserve_submissions_no_submissions(self):
"""Test deleting user with no submissions."""
user_id = self.user.user_id
username = self.user.username
result = UserDeletionService.delete_user_preserve_submissions(self.user)
# Check user was deleted
self.assertFalse(User.objects.filter(user_id=user_id).exists())
# Check result structure
self.assertIn("deleted_user", result)
self.assertIn("preserved_submissions", result)
self.assertIn("transferred_to", result)
self.assertEqual(result["deleted_user"]["username"], username)
self.assertEqual(result["deleted_user"]["user_id"], user_id)
# All submission counts should be 0
for count in result["preserved_submissions"].values():
self.assertEqual(count, 0)
def test_delete_user_cannot_delete_deleted_user_placeholder(self):
"""Test that attempting to delete the deleted user placeholder raises error."""
deleted_user = UserDeletionService.get_or_create_deleted_user()
with self.assertRaises(ValueError) as context:
UserDeletionService.delete_user_preserve_submissions(deleted_user)
self.assertIn(
"Cannot delete the system deleted user placeholder", str(context.exception)
)
def test_delete_user_with_submissions_transfers_correctly(self):
"""Test that user submissions are transferred to deleted user placeholder."""
# This test would require creating park/ride data which is complex
# For now, we'll test the basic functionality
# Create deleted user first to ensure it exists
UserDeletionService.get_or_create_deleted_user()
# Delete the test user
result = UserDeletionService.delete_user_preserve_submissions(self.user)
# Verify the deleted user placeholder still exists
self.assertTrue(User.objects.filter(username="deleted_user").exists())
# Verify result structure
self.assertIn("deleted_user", result)
self.assertIn("preserved_submissions", result)
self.assertIn("transferred_to", result)
self.assertEqual(result["transferred_to"]["username"], "deleted_user")
def test_delete_user_atomic_transaction(self):
"""Test that user deletion is atomic."""
# This test ensures that if something goes wrong during deletion,
# the transaction is rolled back
original_user_count = User.objects.count()
# Mock a failure during the deletion process
with self.assertRaises(Exception):
with transaction.atomic():
# Start the deletion process
UserDeletionService.get_or_create_deleted_user()
# Simulate an error
raise Exception("Simulated error during deletion")
# Verify user count hasn't changed
self.assertEqual(User.objects.count(), original_user_count)
# Verify our test user still exists
self.assertTrue(User.objects.filter(user_id=self.user.user_id).exists())

View File

@@ -1,48 +0,0 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from allauth.account.views import LogoutView
from . import views
app_name = "accounts"
urlpatterns = [
# Override allauth's login and signup views with our Turnstile-enabled
# versions
path("login/", views.CustomLoginView.as_view(), name="account_login"),
path("signup/", views.CustomSignupView.as_view(), name="account_signup"),
# Authentication views
path("logout/", LogoutView.as_view(), name="logout"),
path(
"password_change/",
auth_views.PasswordChangeView.as_view(),
name="password_change",
),
path(
"password_change/done/",
auth_views.PasswordChangeDoneView.as_view(),
name="password_change_done",
),
path(
"password_reset/",
auth_views.PasswordResetView.as_view(),
name="password_reset",
),
path(
"password_reset/done/",
auth_views.PasswordResetDoneView.as_view(),
name="password_reset_done",
),
path(
"reset/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm",
),
path(
"reset/done/",
auth_views.PasswordResetCompleteView.as_view(),
name="password_reset_complete",
),
# Profile views
path("profile/", views.user_redirect_view, name="profile_redirect"),
path("settings/", views.SettingsView.as_view(), name="settings"),
]

View File

@@ -1,426 +0,0 @@
from django.views.generic import DetailView, TemplateView
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.db.models import QuerySet
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
from django.urls import reverse
from django.contrib.auth import login
from django.core.files.uploadedfile import UploadedFile
from apps.accounts.models import (
User,
PasswordReset,
TopList,
EmailVerification,
UserProfile,
)
from django_forwardemail.services import EmailService
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from allauth.account.views import LoginView, SignupView
from .mixins import TurnstileMixin
from typing import Dict, Any, Optional, Union, cast
from django_htmx.http import HttpResponseClientRefresh
from contextlib import suppress
import re
UserModel = get_user_model()
class CustomLoginView(TurnstileMixin, LoginView):
def form_valid(self, form):
try:
self.validate_turnstile(self.request)
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
response = super().form_valid(form)
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
else response
)
def form_invalid(self, form):
if getattr(self.request, "htmx", False):
return render(
self.request,
"account/partials/login_form.html",
self.get_context_data(form=form),
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, "htmx", False):
return render(
request,
"account/partials/login_modal.html",
self.get_context_data(),
)
return super().get(request, *args, **kwargs)
class CustomSignupView(TurnstileMixin, SignupView):
def form_valid(self, form):
try:
self.validate_turnstile(self.request)
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
response = super().form_valid(form)
return (
HttpResponseClientRefresh()
if getattr(self.request, "htmx", False)
else response
)
def form_invalid(self, form):
if getattr(self.request, "htmx", False):
return render(
self.request,
"account/partials/signup_modal.html",
self.get_context_data(form=form),
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, "htmx", False):
return render(
request,
"account/partials/signup_modal.html",
self.get_context_data(),
)
return super().get(request, *args, **kwargs)
@login_required
def user_redirect_view(request: HttpRequest) -> HttpResponse:
user = cast(User, request.user)
return redirect("profile", username=user.username)
def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
if sociallogin := request.session.get("socialaccount_sociallogin"):
sociallogin.user.email = email
sociallogin.save()
login(request, sociallogin.user)
del request.session["socialaccount_sociallogin"]
messages.success(request, "Successfully logged in")
return redirect("/")
def email_required(request: HttpRequest) -> HttpResponse:
if not request.session.get("socialaccount_sociallogin"):
messages.error(request, "No social login in progress")
return redirect("/")
if request.method == "POST":
if email := request.POST.get("email"):
return handle_social_login(request, email)
messages.error(request, "Email is required")
return render(
request,
"accounts/email_required.html",
{"error": "Email is required"},
)
return render(request, "accounts/email_required.html")
class ProfileView(DetailView):
model = User
template_name = "accounts/profile.html"
context_object_name = "profile_user"
slug_field = "username"
slug_url_kwarg = "username"
def get_queryset(self) -> QuerySet[User]:
return User.objects.select_related("profile")
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
user = cast(User, self.get_object())
context["park_reviews"] = self._get_user_park_reviews(user)
context["ride_reviews"] = self._get_user_ride_reviews(user)
context["top_lists"] = self._get_user_top_lists(user)
return context
def _get_user_park_reviews(self, user: User) -> QuerySet[ParkReview]:
return (
ParkReview.objects.filter(user=user, is_published=True)
.select_related("user", "user__profile", "park")
.order_by("-created_at")[:5]
)
def _get_user_ride_reviews(self, user: User) -> QuerySet[RideReview]:
return (
RideReview.objects.filter(user=user, is_published=True)
.select_related("user", "user__profile", "ride")
.order_by("-created_at")[:5]
)
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
return (
TopList.objects.filter(user=user)
.select_related("user", "user__profile")
.prefetch_related("items")
.order_by("-created_at")[:5]
)
class SettingsView(LoginRequiredMixin, TemplateView):
template_name = "accounts/settings.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["user"] = self.request.user
return context
def _handle_profile_update(self, request: HttpRequest) -> None:
user = cast(User, request.user)
profile = get_object_or_404(UserProfile, user=user)
if display_name := request.POST.get("display_name"):
profile.display_name = display_name
if "avatar" in request.FILES:
avatar_file = cast(UploadedFile, request.FILES["avatar"])
profile.avatar.save(avatar_file.name, avatar_file, save=False)
profile.save()
user.save()
messages.success(request, "Profile updated successfully")
def _validate_password(self, password: str) -> bool:
"""Validate password meets requirements."""
return (
len(password) >= 8
and bool(re.search(r"[A-Z]", password))
and bool(re.search(r"[a-z]", password))
and bool(re.search(r"[0-9]", password))
)
def _send_password_change_confirmation(
self, request: HttpRequest, user: User
) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
"user": user,
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_change_confirmation.html", context
)
EmailService.send_email(
to=user.email,
subject="Password Changed Successfully",
text="Your password has been changed successfully.",
site=site,
html=email_html,
)
def _handle_password_change(
self, request: HttpRequest
) -> Optional[HttpResponseRedirect]:
user = cast(User, request.user)
old_password = request.POST.get("old_password", "")
new_password = request.POST.get("new_password", "")
confirm_password = request.POST.get("confirm_password", "")
if not user.check_password(old_password):
messages.error(request, "Current password is incorrect")
return None
if new_password != confirm_password:
messages.error(request, "New passwords do not match")
return None
if not self._validate_password(new_password):
messages.error(
request,
"Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
)
return None
user.set_password(new_password)
user.save()
self._send_password_change_confirmation(request, user)
messages.success(
request,
"Password changed successfully. Please check your email for confirmation.",
)
return HttpResponseRedirect(reverse("account_login"))
def _handle_email_change(self, request: HttpRequest) -> None:
if new_email := request.POST.get("new_email"):
self._send_email_verification(request, new_email)
messages.success(
request, "Verification email sent to your new email address"
)
else:
messages.error(request, "New email is required")
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
user = cast(User, request.user)
token = get_random_string(64)
EmailVerification.objects.update_or_create(user=user, defaults={"token": token})
site = cast(Site, get_current_site(request))
verification_url = reverse("verify_email", kwargs={"token": token})
context = {
"user": user,
"verification_url": verification_url,
"site_name": site.name,
}
email_html = render_to_string("accounts/email/verify_email.html", context)
EmailService.send_email(
to=new_email,
subject="Verify your new email address",
text="Click the link to verify your new email address",
site=site,
html=email_html,
)
user.pending_email = new_email
user.save()
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
action = request.POST.get("action")
if action == "update_profile":
self._handle_profile_update(request)
elif action == "change_password":
if response := self._handle_password_change(request):
return response
elif action == "change_email":
self._handle_email_change(request)
return self.get(request, *args, **kwargs)
def create_password_reset_token(user: User) -> str:
token = get_random_string(64)
PasswordReset.objects.update_or_create(
user=user,
defaults={
"token": token,
"expires_at": timezone.now() + timedelta(hours=24),
},
)
return token
def send_password_reset_email(
user: User, site: Union[Site, RequestSite], token: str
) -> None:
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
context = {
"user": user,
"reset_url": reset_url,
"site_name": site.name,
}
email_html = render_to_string("accounts/email/password_reset.html", context)
EmailService.send_email(
to=user.email,
subject="Reset your password",
text="Click the link to reset your password",
site=site,
html=email_html,
)
def request_password_reset(request: HttpRequest) -> HttpResponse:
if request.method != "POST":
return render(request, "accounts/password_reset.html")
if not (email := request.POST.get("email")):
messages.error(request, "Email is required")
return redirect("account_reset_password")
with suppress(User.DoesNotExist):
user = User.objects.get(email=email)
token = create_password_reset_token(user)
site = get_current_site(request)
send_password_reset_email(user, site, token)
messages.success(request, "Password reset email sent")
return redirect("account_login")
def handle_password_reset(
request: HttpRequest,
user: User,
new_password: str,
reset: PasswordReset,
site: Union[Site, RequestSite],
) -> None:
user.set_password(new_password)
user.save()
reset.used = True
reset.save()
send_password_reset_confirmation(user, site)
messages.success(request, "Password reset successfully")
def send_password_reset_confirmation(
user: User, site: Union[Site, RequestSite]
) -> None:
context = {
"user": user,
"site_name": site.name,
}
email_html = render_to_string(
"accounts/email/password_reset_complete.html", context
)
EmailService.send_email(
to=user.email,
subject="Password Reset Complete",
text="Your password has been reset successfully.",
site=site,
html=email_html,
)
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
try:
reset = PasswordReset.objects.select_related("user").get(
token=token, expires_at__gt=timezone.now(), used=False
)
if request.method == "POST":
if new_password := request.POST.get("new_password"):
site = get_current_site(request)
handle_password_reset(request, reset.user, new_password, reset, site)
return redirect("account_login")
messages.error(request, "New password is required")
return render(request, "accounts/password_reset_confirm.html", {"token": token})
except PasswordReset.DoesNotExist:
messages.error(request, "Invalid or expired reset token")
return redirect("account_reset_password")

View File

@@ -1,43 +0,0 @@
# A generic Alembic configuration file.
[alembic]
# path to migration scripts
script_location = alembic
# The database URL is now set dynamically by ConPort's run_migrations function.
# sqlalchemy.url = sqlite:///your_database.db
# ... other Alembic settings ...
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,84 +0,0 @@
from logging.config import fileConfig
from alembic import context # type: ignore
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line prevents the need to have a separate logging config file.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Import SQLAlchemy lazily so environments without it (e.g. static analyzers)
# don't fail at module import time.
try:
from sqlalchemy import engine_from_config # type: ignore
from sqlalchemy import pool # type: ignore
except ImportError as exc:
raise RuntimeError(
"SQLAlchemy is required to run online Alembic migrations. "
"Install the 'sqlalchemy' package (e.g. pip install sqlalchemy)."
) from exc
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

Some files were not shown because too many files have changed in this diff Show More