mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 14:11:08 -05:00
Compare commits
167 Commits
nuxt
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a628ba9a9 | ||
|
|
679de16e4f | ||
|
|
31a2d84f9f | ||
|
|
7d04c2baa0 | ||
|
|
6575ea68c7 | ||
|
|
e1cb76f1c6 | ||
|
|
acc8308fd2 | ||
|
|
de8b6f67a3 | ||
|
|
c437ddbf28 | ||
|
|
f7b1296263 | ||
|
|
e53414d795 | ||
|
|
2328c919c9 | ||
|
|
09e2c69493 | ||
|
|
5b7b203619 | ||
|
|
47c435d2f5 | ||
|
|
ce382a4361 | ||
|
|
07ab9f28f2 | ||
|
|
40e5cf3162 | ||
|
|
b9377ead37 | ||
|
|
851709058f | ||
|
|
757ad1be89 | ||
|
|
d4431acb39 | ||
|
|
f8907c7778 | ||
|
|
8c0c3df21a | ||
|
|
9b2124867a | ||
|
|
12deafaa09 | ||
|
|
8aa56c463a | ||
|
|
41b3c86437 | ||
|
|
b1c369c1bb | ||
|
|
4373d18176 | ||
|
|
82cbdecc4c | ||
|
|
616f6528b8 | ||
|
|
d31e4b4ebe | ||
|
|
0dd3f04137 | ||
|
|
41fb41838c | ||
|
|
fd42ee1161 | ||
|
|
33f5486000 | ||
|
|
2ff0bf5243 | ||
|
|
00d01f567a | ||
|
|
601538b494 | ||
|
|
fff180c476 | ||
|
|
6391b3d81c | ||
|
|
d978217577 | ||
|
|
4c954fff6f | ||
|
|
7feb7c462d | ||
|
|
7485477e26 | ||
|
|
1277835775 | ||
|
|
f2fccdf190 | ||
|
|
beac6ddfd8 | ||
|
|
6e0c3121be | ||
|
|
691f018e56 | ||
|
|
6697d8890b | ||
|
|
95f94cc799 | ||
|
|
cb3a9ddf3f | ||
|
|
6d30131f2c | ||
|
|
5737e5953d | ||
|
|
789d5db37a | ||
|
|
b8891fc65f | ||
|
|
331329d1ec | ||
|
|
120f215cad | ||
|
|
707546f279 | ||
|
|
b67353eff9 | ||
|
|
2cad07c198 | ||
|
|
30997cb615 | ||
|
|
0ee6e8c820 | ||
|
|
1a8171f918 | ||
|
|
ffebd5ce01 | ||
|
|
97bf980e45 | ||
|
|
3beeb91c7f | ||
|
|
25e6fdb496 | ||
|
|
0331e2087a | ||
|
|
1511fcfcfe | ||
|
|
88c16be231 | ||
|
|
3830b1ed50 | ||
|
|
db1441fcd2 | ||
|
|
b3e56ed465 | ||
|
|
6adbaf885f | ||
|
|
ee57a9ada1 | ||
|
|
66f57448be | ||
|
|
9d776aa5e3 | ||
|
|
b265d793a3 | ||
|
|
8c85963817 | ||
|
|
09f20c640d | ||
|
|
932deb876a | ||
|
|
7e9bd41316 | ||
|
|
bcdd2810a9 | ||
|
|
236b6f0254 | ||
|
|
ed400a5203 | ||
|
|
5046e55f05 | ||
|
|
d21ae6027d | ||
|
|
afdcfe7264 | ||
|
|
b24b12080b | ||
|
|
f3c59ad6ff | ||
|
|
9e724bd795 | ||
|
|
a7bd0505f9 | ||
|
|
ebe65e7c9d | ||
|
|
bddcc62ee6 | ||
|
|
0153af7339 | ||
|
|
821c94bc76 | ||
|
|
164cc15d90 | ||
|
|
fc654543f2 | ||
|
|
60661c9041 | ||
|
|
1eb35bce2e | ||
|
|
562126a3a1 | ||
|
|
081b5b7605 | ||
|
|
7fe9279d67 | ||
|
|
12a2e9823d | ||
|
|
f812a65271 | ||
|
|
ac344aea92 | ||
|
|
06bd7a8bdf | ||
|
|
62900d47bd | ||
|
|
a043163596 | ||
|
|
2c3ae4d937 | ||
|
|
b50e2e9e11 | ||
|
|
ac1ec18bb8 | ||
|
|
3f0588f947 | ||
|
|
7f96e85914 | ||
|
|
cfa7019a7c | ||
|
|
3896dcedcf | ||
|
|
988c2b2f06 | ||
|
|
a75e6a2098 | ||
|
|
6cf231be9d | ||
|
|
052a447bd7 | ||
|
|
f43c58f26e | ||
|
|
499c8c5abf | ||
|
|
828d7d9b9a | ||
|
|
e47c679bc0 | ||
|
|
a28272c784 | ||
|
|
c00d20cc4c | ||
|
|
54a472b207 | ||
|
|
3cad7c5641 | ||
|
|
434ac4c641 | ||
|
|
c8c871128e | ||
|
|
fc605715d3 | ||
|
|
cc914a1ca3 | ||
|
|
3ee3138055 | ||
|
|
a2501562a8 | ||
|
|
5eac88a5cd | ||
|
|
cb944485b8 | ||
|
|
1294b3009e | ||
|
|
3dd5baef19 | ||
|
|
0cf6805c18 | ||
|
|
26ff320806 | ||
|
|
a077bf236b | ||
|
|
7d745cd517 | ||
|
|
8f9e66d9f7 | ||
|
|
06e3efc603 | ||
|
|
4f14f5366f | ||
|
|
96290fdd58 | ||
|
|
30a59f7d6c | ||
|
|
79acc4a080 | ||
|
|
1208af9696 | ||
|
|
d0cfe61af3 | ||
|
|
388413fe70 | ||
|
|
69201cebb7 | ||
|
|
acd7b69ff7 | ||
|
|
5568f9e85c | ||
|
|
9e0259f739 | ||
|
|
31b7e5ee53 | ||
|
|
4a4b7924c5 | ||
|
|
7c8b8097e1 | ||
|
|
90e03355ac | ||
|
|
132872d2c8 | ||
|
|
6d33ea487e | ||
|
|
2f9bf30c9f | ||
|
|
540f40e689 | ||
|
|
75cc618c2b |
@@ -4,14 +4,9 @@
|
||||
"Bash(python manage.py check:*)",
|
||||
"Bash(uv run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(mkdir:*)"
|
||||
"Bash(python:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,98 @@
|
||||
## Brief overview
|
||||
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
|
||||
---
|
||||
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"]
|
||||
---
|
||||
|
||||
## Rule compliance and design decisions
|
||||
- Read ALL .clinerules files before making any code changes
|
||||
- Never assume exceptions to rules marked as "MANDATORY"
|
||||
- Take full responsibility for rule violations without excuses
|
||||
- Ask "What is the most optimal approach?" before ANY design decision
|
||||
- Justify every choice against user requirements - not your damn preferences
|
||||
- Stop making lazy design decisions without evaluation
|
||||
- Document your reasoning or get destroyed later
|
||||
# ThrillWiki Core Development Rules
|
||||
|
||||
## User vision, feedback, and assumptions
|
||||
- Figure out what the user actually wants, not your assumptions
|
||||
- Ask questions when unclear - stop guessing like an idiot
|
||||
- Deliver their vision, not your garbage
|
||||
- User dissatisfaction means you screwed up understanding their vision
|
||||
- Stop defending your bad choices and listen
|
||||
- Fix the actual problem, not band-aid symptoms
|
||||
- Scrap everything and restart if needed
|
||||
- NEVER assume user preferences without confirmation
|
||||
- Stop guessing at requirements like a moron
|
||||
- Your instincts are wrong - question everything
|
||||
- Get explicit approval or fail
|
||||
|
||||
## Implementation and backend integration
|
||||
- Think before you code, don't just hack away
|
||||
- Evaluate trade-offs or make terrible decisions
|
||||
- Question if your solution actually solves their damn problem
|
||||
- NEVER change color schemes without explicit user approval
|
||||
- ALWAYS use responsive design principles
|
||||
- ALWAYS follow best theme choice guidelines so users may choose light or dark mode
|
||||
- NEVER use quick fixes for complex problems
|
||||
- Support user goals, not your aesthetic ego
|
||||
- Follow established patterns unless they specifically want innovation
|
||||
- Make it work everywhere or you failed
|
||||
- Document decisions so you don't repeat mistakes
|
||||
- MANDATORY: Research ALL backend endpoints before making ANY frontend changes
|
||||
- Verify endpoint URLs, parameters, and response formats in actual Django codebase
|
||||
- Test complete frontend-backend integration before considering work complete
|
||||
- MANDATORY: Update ALL frontend documentation files after backend changes
|
||||
- Synchronize docs/frontend.md, docs/lib-api.ts, and docs/types-api.ts
|
||||
- Take immediate responsibility for integration failures without excuses
|
||||
- MUST create frontend integration prompt after every backend change affecting API
|
||||
- Include complete API endpoint information with all parameters and types
|
||||
- Document all mandatory API rules (trailing slashes, HTTP methods, authentication)
|
||||
- Never assume frontend developers have access to backend code
|
||||
## 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
|
||||
- Validate all endpoint URLs against the mandatory trailing slash rule
|
||||
- **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")
|
||||
- Individual rides reference BOTH the model (what product) and type (how it operates)
|
||||
- Ride types must be available for ALL ride categories, not just roller coasters
|
||||
- **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
|
||||
- **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>`
|
||||
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
|
||||
- Extract logical operations into separate methods with descriptive names
|
||||
- Use single responsibility principle - each method should have one clear purpose
|
||||
- Prefer composition over deeply nested conditional logic
|
||||
- Always handle None values explicitly to avoid type errors
|
||||
- Use proper type annotations, including union types (e.g., `Polygon | None`)
|
||||
- Structure API views with clear separation between parameter handling, business logic, and response building
|
||||
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
|
||||
|
||||
### 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
|
||||
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
|
||||
- **Slugs**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
|
||||
- **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
|
||||
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
|
||||
- **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**: 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. 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}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
|
||||
- **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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
## Brief overview
|
||||
---
|
||||
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
|
||||
- NEVER use Django tuple-based choices (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
|
||||
- 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
|
||||
- All choices MUST include rich metadata (color, icon, description, css_class at minimum)
|
||||
- Choice groups MUST be registered with global registry using `register_choices()` function
|
||||
- Import choices in domain `__init__.py` to trigger auto-registration on Django startup
|
||||
- Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
|
||||
- Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
|
||||
- DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
|
||||
- Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
|
||||
- Validate choice groups are correctly loaded in registry during application startup
|
||||
- Update serializers to use RichChoiceSerializer for choice fields
|
||||
- Follow established patterns from rides, parks, and accounts domains for consistency
|
||||
## 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
|
||||
|
||||
161
.clinerules/thrillwiki-context.md
Normal file
161
.clinerules/thrillwiki-context.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
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
|
||||
56
.clinerules/thrillwiki-simple.md
Normal file
56
.clinerules/thrillwiki-simple.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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!!!
|
||||
83
.github/SECURITY.md
vendored
83
.github/SECURITY.md
vendored
@@ -1,83 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| latest | :white_check_mark: |
|
||||
| < latest | :x: |
|
||||
|
||||
Only the latest version of ThrillWiki receives security updates.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
|
||||
|
||||
### How to Report
|
||||
|
||||
1. **Do not** create a public GitHub issue for security vulnerabilities
|
||||
2. Email your report to the project maintainers
|
||||
3. Include as much detail as possible:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Affected versions
|
||||
- Any proof of concept (if available)
|
||||
|
||||
### What to Expect
|
||||
|
||||
- **Acknowledgment**: We will acknowledge receipt within 48 hours
|
||||
- **Assessment**: We will assess the vulnerability and its impact
|
||||
- **Updates**: We will keep you informed of our progress
|
||||
- **Resolution**: We aim to resolve critical vulnerabilities within 7 days
|
||||
- **Credit**: With your permission, we will credit you in our security advisories
|
||||
|
||||
### Scope
|
||||
|
||||
The following are in scope for security reports:
|
||||
|
||||
- ThrillWiki web application vulnerabilities
|
||||
- Authentication and authorization issues
|
||||
- Data exposure vulnerabilities
|
||||
- Injection vulnerabilities (SQL, XSS, etc.)
|
||||
- CSRF vulnerabilities
|
||||
- Server-side request forgery (SSRF)
|
||||
- Insecure direct object references
|
||||
|
||||
### Out of Scope
|
||||
|
||||
The following are out of scope:
|
||||
|
||||
- Denial of service attacks
|
||||
- Social engineering attacks
|
||||
- Physical security issues
|
||||
- Issues in third-party applications or services
|
||||
- Issues requiring physical access to a user's device
|
||||
- Vulnerabilities in outdated versions
|
||||
|
||||
## Security Measures
|
||||
|
||||
ThrillWiki implements the following security measures:
|
||||
|
||||
- HTTPS enforcement with HSTS
|
||||
- Content Security Policy
|
||||
- XSS protection with input sanitization
|
||||
- CSRF protection
|
||||
- SQL injection prevention via ORM
|
||||
- Rate limiting on authentication endpoints
|
||||
- Secure session management
|
||||
- JWT token rotation and blacklisting
|
||||
|
||||
For more details, see [docs/SECURITY.md](../docs/SECURITY.md).
|
||||
|
||||
## Security Updates
|
||||
|
||||
Security updates are released as soon as possible after a vulnerability is confirmed. We recommend:
|
||||
|
||||
1. Keep your installation up to date
|
||||
2. Subscribe to release notifications
|
||||
3. Review security advisories
|
||||
|
||||
## Contact
|
||||
|
||||
For security-related inquiries, please contact the project maintainers.
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/django.yml
vendored
2
.github/workflows/django.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
python-version: [3.13.1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Homebrew on Linux
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
2
.github/workflows/review.yml
vendored
2
.github/workflows/review.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: development_environment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -122,4 +122,5 @@ frontend/.env
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
.snapshots
|
||||
uv.lock
|
||||
|
||||
73
.replit
Normal file
73
.replit
Normal file
@@ -0,0 +1,73 @@
|
||||
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"]
|
||||
@@ -1,649 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ThrillWiki API Endpoints - Complete Curl Commands
|
||||
# Generated from comprehensive URL analysis
|
||||
# Base URL - adjust as needed for your environment
|
||||
BASE_URL="http://localhost:8000"
|
||||
|
||||
# Command line options
|
||||
SKIP_AUTH=false
|
||||
ONLY_AUTH=false
|
||||
SKIP_DOCS=false
|
||||
HELP=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-auth)
|
||||
SKIP_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--only-auth)
|
||||
ONLY_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--skip-docs)
|
||||
SKIP_DOCS=true
|
||||
shift
|
||||
;;
|
||||
--base-url)
|
||||
BASE_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
HELP=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Show help
|
||||
if [ "$HELP" = true ]; then
|
||||
echo "ThrillWiki API Endpoints Test Suite"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --skip-auth Skip endpoints that require authentication"
|
||||
echo " --only-auth Only test endpoints that require authentication"
|
||||
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
|
||||
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Test all endpoints"
|
||||
echo " $0 --skip-auth # Test only public endpoints"
|
||||
echo " $0 --only-auth # Test only authenticated endpoints"
|
||||
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
|
||||
echo " $0 --base-url https://api.example.com # Use custom base URL"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate conflicting options
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Error: --skip-auth and --only-auth cannot be used together"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ThrillWiki API Endpoints Test Suite ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
if [ "$SKIP_AUTH" = true ]; then
|
||||
echo "Mode: Public endpoints only (skipping authentication required)"
|
||||
elif [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Mode: Authenticated endpoints only"
|
||||
else
|
||||
echo "Mode: All endpoints"
|
||||
fi
|
||||
if [ "$SKIP_DOCS" = true ]; then
|
||||
echo "Skipping: API documentation endpoints"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Helper function to check if we should run an endpoint
|
||||
should_run_endpoint() {
|
||||
local requires_auth=$1
|
||||
local is_docs=$2
|
||||
|
||||
# Skip docs if requested
|
||||
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Skip auth endpoints if requested
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Only run auth endpoints if requested
|
||||
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Counter for endpoint numbering
|
||||
ENDPOINT_NUM=1
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo "=== AUTHENTICATION ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Login"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/login/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "testuser", "password": "testpass"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Signup"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Logout"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
|
||||
-H "Content-Type: application/json"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Reset"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Social Providers"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/providers/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Auth Status"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/status/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Current User"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/user/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Change"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Health Check"
|
||||
curl -X GET "$BASE_URL/api/v1/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Simple Health"
|
||||
curl -X GET "$BASE_URL/api/v1/health/simple/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
|
||||
curl -X GET "$BASE_URL/api/v1/health/performance/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Trending Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/content/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. New Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/new/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS ENDPOINTS (/api/v1/stats/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
|
||||
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rankings"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking History"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
|
||||
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"category": "RC"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# PARKS API ENDPOINTS (/api/v1/parks/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== PARKS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Parks"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Park Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Park", "location": "Test City"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Park Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RIDES API ENDPOINTS (/api/v1/rides/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RIDES API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rides"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Ride Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test ride photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated ride photo caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List User Profiles"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top Lists"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top List Items"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Update User Profile"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"bio": "Updated bio"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Top List Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"toplist": 1, "ride": 1, "position": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"position": 2}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HISTORY API ENDPOINTS (/api/v1/history/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Park History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# EMAIL API ENDPOINTS (/api/v1/email/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Send Email"
|
||||
curl -X POST "$BASE_URL/api/v1/email/send/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# CORE API ENDPOINTS (/api/v1/core/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== CORE API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
|
||||
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "nonexistent park", "type": "park"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# MAPS API ENDPOINTS (/api/v1/maps/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== MAPS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Map Locations"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Search"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/cache/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
|
||||
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# API DOCUMENTATION ENDPOINTS
|
||||
# ============================================================================
|
||||
if should_run_endpoint false true; then
|
||||
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. OpenAPI Schema"
|
||||
curl -X GET "$BASE_URL/api/schema/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Swagger UI"
|
||||
curl -X GET "$BASE_URL/api/docs/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. ReDoc"
|
||||
curl -X GET "$BASE_URL/api/redoc/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK (Django Health Check)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Django Health Check"
|
||||
curl -X GET "$BASE_URL/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
|
||||
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
|
||||
echo ""
|
||||
echo "Notes:"
|
||||
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
|
||||
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
|
||||
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
|
||||
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
|
||||
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
|
||||
echo ""
|
||||
echo "Authentication required endpoints are marked with Authorization header"
|
||||
echo "File upload endpoints use multipart/form-data (-F flag)"
|
||||
echo "JSON endpoints use application/json content type"
|
||||
95
apps/accounts/adapters.py
Normal file
95
apps/accounts/adapters.py
Normal file
@@ -0,0 +1,95 @@
|
||||
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
|
||||
@@ -1,51 +1,369 @@
|
||||
from typing import Any
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
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
|
||||
|
||||
# Import models from the backend location
|
||||
from backend.apps.accounts.models import (
|
||||
from .models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
TopListItem,
|
||||
)
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = ('username', 'email', 'user_id', 'role', 'is_active', 'is_staff', 'date_joined')
|
||||
list_filter = ('role', 'is_active', 'is_staff', 'is_banned', 'date_joined')
|
||||
search_fields = ('username', 'email', 'user_id', 'display_name')
|
||||
readonly_fields = ('user_id', 'date_joined', 'last_login')
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = "Profile"
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Personal info', {'fields': ('email', 'display_name', 'user_id')}),
|
||||
('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
('Moderation', {'fields': ('is_banned', 'ban_reason', 'ban_date')}),
|
||||
('Preferences', {'fields': ('theme_preference', 'privacy_level')}),
|
||||
('Notifications', {'fields': ('email_notifications', 'push_notifications')}),
|
||||
(
|
||||
"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):
|
||||
list_display = ('user', 'profile_id', 'display_name', 'coaster_credits', 'dark_ride_credits')
|
||||
list_filter = ('user__role', 'user__is_active')
|
||||
search_fields = ('user__username', 'user__email', 'profile_id', 'display_name')
|
||||
readonly_fields = ('profile_id',)
|
||||
|
||||
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 = (
|
||||
(None, {'fields': ('user', 'profile_id', 'display_name')}),
|
||||
('Profile Info', {'fields': ('avatar', 'pronouns', 'bio')}),
|
||||
('Social Media', {'fields': ('twitter', 'instagram', 'youtube', 'discord')}),
|
||||
('Ride Statistics', {'fields': ('coaster_credits', 'dark_ride_credits', 'flat_ride_credits', 'water_ride_credits')}),
|
||||
(
|
||||
"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', 'token', 'created_at', 'last_sent')
|
||||
list_filter = ('created_at', 'last_sent')
|
||||
search_fields = ('user__username', 'user__email', 'token')
|
||||
readonly_fields = ('token', 'created_at', 'last_sent')
|
||||
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)
|
||||
|
||||
108
apps/accounts/management/commands/reset_db.py
Normal file
108
apps/accounts/management/commands/reset_db.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.contrib.auth.hashers import make_password
|
||||
import uuid
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reset database and create admin user"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("Resetting database...")
|
||||
|
||||
# Drop all tables
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
||||
quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT sequencename FROM pg_sequences
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || \
|
||||
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||
END LOOP;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
self.stdout.write("All tables dropped and sequences reset.")
|
||||
|
||||
# Run migrations
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("migrate")
|
||||
|
||||
self.stdout.write("Migrations applied.")
|
||||
|
||||
# Create superuser using raw SQL
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Create user
|
||||
user_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_user (
|
||||
username, password, email, is_superuser, is_staff,
|
||||
is_active, date_joined, user_id, first_name,
|
||||
last_name, role, is_banned, ban_reason,
|
||||
theme_preference
|
||||
) VALUES (
|
||||
'admin', %s, 'admin@thrillwiki.com', true, true,
|
||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
||||
'light'
|
||||
) RETURNING id;
|
||||
""",
|
||||
[make_password("admin"), user_id],
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
raise Exception("Failed to create user - no ID returned")
|
||||
user_db_id = result[0]
|
||||
|
||||
# Create profile
|
||||
profile_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_userprofile (
|
||||
profile_id, display_name, pronouns, bio,
|
||||
twitter, instagram, youtube, discord,
|
||||
coaster_credits, dark_ride_credits,
|
||||
flat_ride_credits, water_ride_credits,
|
||||
user_id, avatar
|
||||
) VALUES (
|
||||
%s, 'Admin', 'they/them', 'ThrillWiki Administrator',
|
||||
'', '', '', '',
|
||||
0, 0, 0, 0,
|
||||
%s, ''
|
||||
);
|
||||
""",
|
||||
[profile_id, user_db_id],
|
||||
)
|
||||
|
||||
self.stdout.write("Superuser created.")
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Error creating superuser: {str(e)}"))
|
||||
raise
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Database reset complete."))
|
||||
@@ -15,17 +15,17 @@ class Command(BaseCommand):
|
||||
create_default_groups()
|
||||
|
||||
# Sync existing users with groups based on their roles
|
||||
users = User.objects.exclude(role=User.Roles.USER)
|
||||
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 == User.Roles.SUPERUSER:
|
||||
if user.role == "SUPERUSER":
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
elif user.role in ["ADMIN", "MODERATOR"]:
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
|
||||
1523
apps/accounts/migrations/0001_initial.py
Normal file
1523
apps/accounts/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -49,9 +49,8 @@ class User(AbstractUser):
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
default="USER",
|
||||
db_index=True,
|
||||
)
|
||||
is_banned = models.BooleanField(default=False, db_index=True)
|
||||
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)
|
||||
@@ -122,24 +121,8 @@ class User(AbstractUser):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
if self.display_name:
|
||||
return self.display_name
|
||||
# Fallback to profile display_name for backward compatibility
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name='user_ban_consistency',
|
||||
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
|
||||
violation_error_message='Banned users must have a ban_date set'
|
||||
),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
@@ -648,4 +631,6 @@ class NotificationPreference(TrackedModel):
|
||||
def create_notification_preference(sender, instance, created, **kwargs):
|
||||
"""Create notification preferences when a new user is created."""
|
||||
if created:
|
||||
NotificationPreference.objects.create(user=instance)
|
||||
NotificationPreference.objects.get_or_create(user=instance)
|
||||
|
||||
# Signal moved to signals.py to avoid duplication
|
||||
@@ -2,281 +2,16 @@
|
||||
User management services for ThrillWiki.
|
||||
|
||||
This module contains services for user account management including
|
||||
user deletion while preserving submissions, password management,
|
||||
and email change functionality.
|
||||
|
||||
Recent additions:
|
||||
- AccountService: Handles password and email change operations
|
||||
- UserDeletionService: Manages user deletion while preserving content
|
||||
user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
from .models import EmailVerification, User, UserDeletionRequest, UserProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountService:
|
||||
"""Service for account management operations including password and email changes."""
|
||||
|
||||
@staticmethod
|
||||
def validate_password(password: str) -> bool:
|
||||
"""
|
||||
Validate password meets requirements.
|
||||
|
||||
Args:
|
||||
password: The password to validate
|
||||
|
||||
Returns:
|
||||
True if password meets requirements, False otherwise
|
||||
"""
|
||||
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))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def change_password(
|
||||
*,
|
||||
user: User,
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
request: HttpRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Change user password with validation and notification.
|
||||
|
||||
Validates the old password, checks new password requirements,
|
||||
updates the password, and sends a confirmation email.
|
||||
|
||||
Args:
|
||||
user: The user whose password is being changed
|
||||
old_password: Current password for verification
|
||||
new_password: New password to set
|
||||
request: HTTP request for session handling
|
||||
|
||||
Returns:
|
||||
Dictionary with success status, message, and optional redirect URL:
|
||||
{
|
||||
'success': bool,
|
||||
'message': str,
|
||||
'redirect_url': Optional[str]
|
||||
}
|
||||
"""
|
||||
# Verify old password
|
||||
if not user.check_password(old_password):
|
||||
logger.warning(
|
||||
f"Password change failed: incorrect current password for user {user.id}"
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'message': "Current password is incorrect",
|
||||
'redirect_url': None
|
||||
}
|
||||
|
||||
# Validate new password
|
||||
if not AccountService.validate_password(new_password):
|
||||
return {
|
||||
'success': False,
|
||||
'message': "Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
|
||||
'redirect_url': None
|
||||
}
|
||||
|
||||
# Update password
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# Keep user logged in after password change
|
||||
update_session_auth_hash(request, user)
|
||||
|
||||
# Send confirmation email
|
||||
AccountService._send_password_change_confirmation(request, user)
|
||||
|
||||
logger.info(f"Password changed successfully for user {user.id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': "Password changed successfully. Please check your email for confirmation.",
|
||||
'redirect_url': None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _send_password_change_confirmation(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
|
||||
)
|
||||
|
||||
try:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject="Password Changed Successfully",
|
||||
text="Your password has been changed successfully.",
|
||||
site=site,
|
||||
html=email_html,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send password change confirmation email: {e}")
|
||||
|
||||
@staticmethod
|
||||
def initiate_email_change(
|
||||
*,
|
||||
user: User,
|
||||
new_email: str,
|
||||
request: HttpRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Initiate email change with verification.
|
||||
|
||||
Creates a verification token and sends a verification email
|
||||
to the new email address.
|
||||
|
||||
Args:
|
||||
user: The user changing their email
|
||||
new_email: The new email address
|
||||
request: HTTP request for site context
|
||||
|
||||
Returns:
|
||||
Dictionary with success status and message:
|
||||
{
|
||||
'success': bool,
|
||||
'message': str
|
||||
}
|
||||
"""
|
||||
if not new_email:
|
||||
return {
|
||||
'success': False,
|
||||
'message': "New email is required"
|
||||
}
|
||||
|
||||
# Check if email is already in use
|
||||
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||
return {
|
||||
'success': False,
|
||||
'message': "This email address is already in use"
|
||||
}
|
||||
|
||||
# Generate verification token
|
||||
token = get_random_string(64)
|
||||
|
||||
# Create or update email verification record
|
||||
EmailVerification.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={"token": token}
|
||||
)
|
||||
|
||||
# Store pending email
|
||||
user.pending_email = new_email
|
||||
user.save()
|
||||
|
||||
# Send verification email
|
||||
AccountService._send_email_verification(request, user, new_email, token)
|
||||
|
||||
logger.info(f"Email change initiated for user {user.id} to {new_email}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': "Verification email sent to your new email address"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _send_email_verification(
|
||||
request: HttpRequest,
|
||||
user: User,
|
||||
new_email: str,
|
||||
token: str
|
||||
) -> None:
|
||||
"""Send email verification for email change."""
|
||||
from django.urls import reverse
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email verification: {e}")
|
||||
|
||||
@staticmethod
|
||||
def verify_email_change(*, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify email change token and update user email.
|
||||
|
||||
Args:
|
||||
token: The verification token
|
||||
|
||||
Returns:
|
||||
Dictionary with success status and message
|
||||
"""
|
||||
try:
|
||||
verification = EmailVerification.objects.select_related("user").get(
|
||||
token=token
|
||||
)
|
||||
except EmailVerification.DoesNotExist:
|
||||
return {
|
||||
'success': False,
|
||||
'message': "Invalid or expired verification token"
|
||||
}
|
||||
|
||||
user = verification.user
|
||||
|
||||
if not user.pending_email:
|
||||
return {
|
||||
'success': False,
|
||||
'message': "No pending email change found"
|
||||
}
|
||||
|
||||
# Update email
|
||||
old_email = user.email
|
||||
user.email = user.pending_email
|
||||
user.pending_email = None
|
||||
user.save()
|
||||
|
||||
# Delete verification record
|
||||
verification.delete()
|
||||
|
||||
logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': "Email address updated successfully"
|
||||
}
|
||||
from .models import User, UserProfile, UserDeletionRequest
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
@@ -296,7 +31,7 @@ class UserDeletionService:
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
"role": User.Roles.USER,
|
||||
"role": "USER",
|
||||
"is_banned": True,
|
||||
"ban_reason": "System placeholder for deleted users",
|
||||
"ban_date": timezone.now(),
|
||||
@@ -443,7 +178,7 @@ class UserDeletionService:
|
||||
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 == User.Roles.ADMIN and user.is_staff:
|
||||
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
|
||||
@@ -10,59 +10,41 @@ from .models import User, UserProfile
|
||||
|
||||
@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:
|
||||
# Try to get existing profile first
|
||||
"""Create UserProfile for new users - unified signal handler"""
|
||||
if created:
|
||||
try:
|
||||
profile = instance.profile
|
||||
profile.save()
|
||||
except UserProfile.DoesNotExist:
|
||||
# Profile doesn't exist, create it
|
||||
UserProfile.objects.create(user=instance)
|
||||
except Exception as e:
|
||||
print(f"Error saving profile for user {instance.username}: {str(e)}")
|
||||
# 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)
|
||||
@@ -75,43 +57,43 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
# Role has changed, update groups
|
||||
with transaction.atomic():
|
||||
# Remove from old role group if exists
|
||||
if old_instance.role != User.Roles.USER:
|
||||
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.Roles.USER:
|
||||
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 == User.Roles.SUPERUSER:
|
||||
if instance.role == "SUPERUSER":
|
||||
instance.is_superuser = True
|
||||
instance.is_staff = True
|
||||
elif old_instance.role == User.Roles.SUPERUSER:
|
||||
elif old_instance.role == "SUPERUSER":
|
||||
# If removing superuser role, remove superuser
|
||||
# status
|
||||
instance.is_superuser = False
|
||||
if instance.role not in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
]:
|
||||
instance.is_staff = False
|
||||
|
||||
# Handle staff status for admin and moderator roles
|
||||
if instance.role in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
]:
|
||||
instance.is_staff = True
|
||||
elif old_instance.role in [
|
||||
User.Roles.ADMIN,
|
||||
User.Roles.MODERATOR,
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
]:
|
||||
# If removing admin/moderator role, remove staff
|
||||
# status
|
||||
if instance.role not in [User.Roles.SUPERUSER]:
|
||||
if instance.role not in ["SUPERUSER"]:
|
||||
instance.is_staff = False
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
@@ -130,7 +112,7 @@ def create_default_groups():
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
# Create Moderator group
|
||||
moderator_group, _ = Group.objects.get_or_create(name=User.Roles.MODERATOR)
|
||||
moderator_group, _ = Group.objects.get_or_create(name="MODERATOR")
|
||||
moderator_permissions = [
|
||||
# Review moderation permissions
|
||||
"change_review",
|
||||
@@ -149,7 +131,7 @@ def create_default_groups():
|
||||
]
|
||||
|
||||
# Create Admin group
|
||||
admin_group, _ = Group.objects.get_or_create(name=User.Roles.ADMIN)
|
||||
admin_group, _ = Group.objects.get_or_create(name="ADMIN")
|
||||
admin_permissions = moderator_permissions + [
|
||||
# User management permissions
|
||||
"change_user",
|
||||
@@ -109,7 +109,7 @@ class SignalsTestCase(TestCase):
|
||||
|
||||
create_default_groups()
|
||||
|
||||
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
||||
moderator_group = Group.objects.get(name="MODERATOR")
|
||||
self.assertIsNotNone(moderator_group)
|
||||
self.assertTrue(
|
||||
moderator_group.permissions.filter(codename="change_review").exists()
|
||||
@@ -118,7 +118,7 @@ class SignalsTestCase(TestCase):
|
||||
moderator_group.permissions.filter(codename="change_user").exists()
|
||||
)
|
||||
|
||||
admin_group = Group.objects.get(name=User.Roles.ADMIN)
|
||||
admin_group = Group.objects.get(name="ADMIN")
|
||||
self.assertIsNotNone(admin_group)
|
||||
self.assertTrue(
|
||||
admin_group.permissions.filter(codename="change_review").exists()
|
||||
@@ -42,7 +42,7 @@ class UserDeletionServiceTest(TestCase):
|
||||
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.Roles.USER)
|
||||
self.assertEqual(deleted_user.role, "USER")
|
||||
|
||||
# Check profile was created
|
||||
self.assertTrue(hasattr(deleted_user, "profile"))
|
||||
6
apps/core/apps.py
Normal file
6
apps/core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
@@ -65,14 +65,6 @@ class BusinessLogicError(ThrillWikiException):
|
||||
status_code = 400
|
||||
|
||||
|
||||
class ServiceError(ThrillWikiException):
|
||||
"""Raised when a service operation fails."""
|
||||
|
||||
default_message = "Service operation failed"
|
||||
error_code = "SERVICE_ERROR"
|
||||
status_code = 500
|
||||
|
||||
|
||||
class ExternalServiceError(ThrillWikiException):
|
||||
"""Raised when external service calls fail."""
|
||||
|
||||
1138
apps/core/management/commands/seed_comprehensive_data.py
Normal file
1138
apps/core/management/commands/seed_comprehensive_data.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ Following Django styleguide best practices for database access.
|
||||
from typing import Optional, List, Union
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Avg, Max
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
# from django.contrib.gis.geos import Point # Disabled temporarily for setup
|
||||
# from django.contrib.gis.measure import Distance # Disabled temporarily for setup
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -88,7 +88,7 @@ class BaseManager(models.Manager):
|
||||
class LocationQuerySet(BaseQuerySet):
|
||||
"""QuerySet for location-based models with geographic functionality."""
|
||||
|
||||
def near_point(self, *, point: Point, distance_km: float = 50):
|
||||
def near_point(self, *, point, distance_km: float = 50): # Point type disabled for setup
|
||||
"""Filter locations near a geographic point."""
|
||||
if hasattr(self.model, "point"):
|
||||
return (
|
||||
@@ -134,7 +134,7 @@ class LocationManager(BaseManager):
|
||||
def get_queryset(self):
|
||||
return LocationQuerySet(self.model, using=self._db)
|
||||
|
||||
def near_point(self, *, point: Point, distance_km: float = 50):
|
||||
def near_point(self, *, point, distance_km: float = 50): # Point type disabled for setup
|
||||
return self.get_queryset().near_point(point=point, distance_km=distance_km)
|
||||
|
||||
def within_bounds(self, *, north: float, south: float, east: float, west: float):
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user