mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 15:31:08 -05:00
Compare commits
13 Commits
add-claude
...
api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdbbca2add | ||
|
|
bf365693f8 | ||
|
|
42a3dc7637 | ||
|
|
209b433577 | ||
|
|
01195e198c | ||
|
|
a5fd56b117 | ||
|
|
6ce2c30065 | ||
|
|
cd6403615f | ||
|
|
6625fb5ba9 | ||
|
|
d5cd6ad0a3 | ||
|
|
516c847377 | ||
|
|
c2c26cfd1d | ||
|
|
61d73a2147 |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python manage.py check:*)",
|
||||
"Bash(uv run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(python:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
17
.clinerules/rich-choice-objects.md
Normal file
17
.clinerules/rich-choice-objects.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## 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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -120,3 +120,6 @@ frontend/.env
|
||||
|
||||
# Extracted packages
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
18
.roo/mcp.json
Normal file
18
.roo/mcp.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@upstash/context7-mcp"
|
||||
],
|
||||
"env": {
|
||||
"DEFAULT_MINIMUM_TOKENS": ""
|
||||
},
|
||||
"alwaysAllow": [
|
||||
"resolve-library-id",
|
||||
"get-library-docs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
232
CRITICAL_ANALYSIS_HTMX_ALPINE.md
Normal file
232
CRITICAL_ANALYSIS_HTMX_ALPINE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Critical Analysis: Current HTMX + Alpine.js Implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After thorough analysis, the current HTMX + Alpine.js implementation has **significant gaps** compared to the React frontend functionality. While the foundation exists, there are critical missing pieces that prevent it from being a true replacement for the React frontend.
|
||||
|
||||
## Major Issues Identified
|
||||
|
||||
### 1. **Incomplete Component Parity** ❌
|
||||
|
||||
**React Frontend Has:**
|
||||
- Sophisticated park/ride cards with hover effects, ratings, status badges
|
||||
- Advanced search with autocomplete and real-time suggestions
|
||||
- Complex filtering UI with multiple filter types
|
||||
- Rich user profile management
|
||||
- Modal-based authentication flows
|
||||
- Theme switching with system preference detection
|
||||
- Responsive image handling with Next.js Image optimization
|
||||
|
||||
**Current Django Templates Have:**
|
||||
- Basic card layouts without advanced interactions
|
||||
- Simple search without autocomplete
|
||||
- Limited filtering capabilities
|
||||
- Basic user menus
|
||||
- No modal authentication system
|
||||
- Basic theme toggle
|
||||
|
||||
### 2. **Missing Critical Pages** ❌
|
||||
|
||||
**React Frontend Pages Not Implemented:**
|
||||
- `/profile` - User profile management
|
||||
- `/settings` - User settings and preferences
|
||||
- `/api-test` - API testing interface
|
||||
- `/test-ride` - Ride testing components
|
||||
- Advanced search results page
|
||||
- User dashboard/account management
|
||||
|
||||
**Current Django Only Has:**
|
||||
- Basic park/ride listing pages
|
||||
- Simple detail pages
|
||||
- Admin/moderation interfaces
|
||||
|
||||
### 3. **Inadequate State Management** ❌
|
||||
|
||||
**React Frontend Uses:**
|
||||
- Complex state management with custom hooks
|
||||
- Global authentication state
|
||||
- Theme provider with system detection
|
||||
- Search state with debouncing
|
||||
- Filter state with URL synchronization
|
||||
|
||||
**Current Alpine.js Has:**
|
||||
- Basic component-level state
|
||||
- Simple theme toggle
|
||||
- No global state management
|
||||
- No URL state synchronization
|
||||
- No proper error handling
|
||||
|
||||
### 4. **Poor API Integration** ❌
|
||||
|
||||
**React Frontend Features:**
|
||||
- TypeScript API clients with proper typing
|
||||
- Error handling and loading states
|
||||
- Optimistic updates
|
||||
- Proper authentication headers
|
||||
- Response caching
|
||||
|
||||
**Current HTMX Implementation:**
|
||||
- Basic HTMX requests without error handling
|
||||
- No loading states
|
||||
- No proper authentication integration
|
||||
- No response validation
|
||||
- No caching strategy
|
||||
|
||||
### 5. **Missing Advanced UI Components** ❌
|
||||
|
||||
**React Frontend Components Missing:**
|
||||
- Advanced data tables with sorting/filtering
|
||||
- Image galleries with lightbox
|
||||
- Multi-step forms
|
||||
- Rich text editors
|
||||
- Date/time pickers
|
||||
- Advanced modals and dialogs
|
||||
- Toast notifications system
|
||||
- Skeleton loading states
|
||||
|
||||
### 6. **Inadequate Mobile Experience** ❌
|
||||
|
||||
**React Frontend Mobile Features:**
|
||||
- Responsive design with proper breakpoints
|
||||
- Touch-optimized interactions
|
||||
- Mobile-specific navigation patterns
|
||||
- Swipe gestures
|
||||
- Mobile-optimized forms
|
||||
|
||||
**Current Implementation:**
|
||||
- Basic responsive layout
|
||||
- No touch optimizations
|
||||
- Simple mobile menu
|
||||
- No mobile-specific interactions
|
||||
|
||||
## Specific Technical Gaps
|
||||
|
||||
### Authentication System
|
||||
```html
|
||||
<!-- Current: Basic login links -->
|
||||
<a href="{% url 'account_login' %}">Login</a>
|
||||
|
||||
<!-- Needed: Modal-based auth like React -->
|
||||
<div x-data="authModal()" x-show="open" class="modal">
|
||||
<!-- Complex auth flow with validation -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Search Functionality
|
||||
```javascript
|
||||
// Current: Basic search
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
async search() {
|
||||
// Basic fetch without proper error handling
|
||||
}
|
||||
}))
|
||||
|
||||
// Needed: Advanced search like React
|
||||
Alpine.data('advancedSearch', () => ({
|
||||
query: '',
|
||||
filters: {},
|
||||
suggestions: [],
|
||||
loading: false,
|
||||
debounceTimer: null,
|
||||
// Complex search logic with debouncing, caching, etc.
|
||||
}))
|
||||
```
|
||||
|
||||
### Component Architecture
|
||||
```html
|
||||
<!-- Current: Basic templates -->
|
||||
<div class="card">
|
||||
<h3>{{ park.name }}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Needed: Rich components like React -->
|
||||
<div x-data="parkCard({{ park|json }})" class="park-card">
|
||||
<!-- Complex interactions, animations, state management -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### 1. **No Code Splitting**
|
||||
- React frontend uses dynamic imports and code splitting
|
||||
- Current implementation loads everything upfront
|
||||
- No lazy loading of components or routes
|
||||
|
||||
### 2. **Inefficient HTMX Usage**
|
||||
- Multiple HTMX requests for simple interactions
|
||||
- No request batching or optimization
|
||||
- No proper caching headers
|
||||
|
||||
### 3. **Poor Asset Management**
|
||||
- No asset optimization
|
||||
- No image optimization (missing Next.js Image equivalent)
|
||||
- No CSS/JS minification strategy
|
||||
|
||||
## Missing Developer Experience
|
||||
|
||||
### 1. **No Type Safety**
|
||||
- React frontend has full TypeScript support
|
||||
- Current implementation has no type checking
|
||||
- No API contract validation
|
||||
|
||||
### 2. **Poor Error Handling**
|
||||
- No global error boundaries
|
||||
- No proper error reporting
|
||||
- No user-friendly error messages
|
||||
|
||||
### 3. **No Testing Strategy**
|
||||
- React frontend has component testing
|
||||
- Current implementation has no frontend tests
|
||||
- No integration testing
|
||||
|
||||
## Critical Missing Features
|
||||
|
||||
### 1. **Real-time Features**
|
||||
- No WebSocket integration
|
||||
- No live updates
|
||||
- No real-time notifications
|
||||
|
||||
### 2. **Advanced Interactions**
|
||||
- No drag and drop
|
||||
- No complex animations
|
||||
- No keyboard navigation
|
||||
- No accessibility features
|
||||
|
||||
### 3. **Data Management**
|
||||
- No client-side caching
|
||||
- No optimistic updates
|
||||
- No offline support
|
||||
- No data synchronization
|
||||
|
||||
## Recommended Action Plan
|
||||
|
||||
### Phase 1: Critical Component Migration (High Priority)
|
||||
1. **Authentication System** - Implement modal-based auth with proper validation
|
||||
2. **Advanced Search** - Build autocomplete with debouncing and caching
|
||||
3. **User Profile/Settings** - Create comprehensive user management
|
||||
4. **Enhanced Cards** - Implement rich park/ride cards with interactions
|
||||
|
||||
### Phase 2: Advanced Features (Medium Priority)
|
||||
1. **State Management** - Implement proper global state with Alpine stores
|
||||
2. **API Integration** - Build robust API client with error handling
|
||||
3. **Mobile Optimization** - Enhance mobile experience
|
||||
4. **Performance** - Implement caching and optimization
|
||||
|
||||
### Phase 3: Polish and Testing (Low Priority)
|
||||
1. **Error Handling** - Implement comprehensive error boundaries
|
||||
2. **Testing** - Add frontend testing suite
|
||||
3. **Accessibility** - Ensure WCAG compliance
|
||||
4. **Documentation** - Create comprehensive component docs
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current HTMX + Alpine.js implementation is **NOT ready** to replace the React frontend. It's missing approximately **60-70%** of the functionality and sophistication of the React application.
|
||||
|
||||
A proper migration requires:
|
||||
- **3-4 weeks of intensive development**
|
||||
- **Complete rewrite of most components**
|
||||
- **New architecture for state management**
|
||||
- **Comprehensive testing and optimization**
|
||||
|
||||
The existing Django templates are a good foundation, but they need **significant enhancement** to match the React frontend's capabilities.
|
||||
258
FRONTEND_MIGRATION_PLAN.md
Normal file
258
FRONTEND_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Frontend Migration Plan: React/Next.js to HTMX + Alpine.js
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Based on my analysis, this project already has a **fully functional HTMX + Alpine.js Django backend** with comprehensive templates. The task is to migrate the separate Next.js React frontend (`frontend/` directory) to integrate seamlessly with the existing Django HTMX + Alpine.js architecture.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ Django Backend (Already Complete)
|
||||
- **HTMX Integration**: Already implemented with proper headers and partial templates
|
||||
- **Alpine.js Components**: Extensive use of Alpine.js for interactivity
|
||||
- **Template Structure**: Comprehensive template hierarchy with partials
|
||||
- **Authentication**: Complete auth system with modals and forms
|
||||
- **Styling**: Tailwind CSS with dark mode support
|
||||
- **Components**: Reusable components for cards, pagination, forms, etc.
|
||||
|
||||
### 🔄 React Frontend (To Be Migrated)
|
||||
- **Next.js App Router**: Modern React application structure
|
||||
- **Component Library**: Extensive UI components using shadcn/ui
|
||||
- **Authentication**: React-based auth hooks and providers
|
||||
- **Theme Management**: React theme provider system
|
||||
- **API Integration**: TypeScript API clients for Django backend
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Template Enhancement (Extend Django Templates)
|
||||
|
||||
Instead of replacing the existing Django templates, we'll enhance them to match the React frontend's design and functionality.
|
||||
|
||||
#### 1.1 Header Component Migration
|
||||
**Current Django**: Basic header with navigation
|
||||
**React Frontend**: Advanced header with browse menu, search, theme toggle, user dropdown
|
||||
|
||||
**Action**: Enhance `backend/templates/base/base.html` header section
|
||||
|
||||
#### 1.2 Component Library Integration
|
||||
**Current Django**: Basic components
|
||||
**React Frontend**: Rich component library (buttons, cards, modals, etc.)
|
||||
|
||||
**Action**: Create Django template components matching shadcn/ui design system
|
||||
|
||||
#### 1.3 Advanced Interactivity
|
||||
**Current Django**: Basic Alpine.js usage
|
||||
**React Frontend**: Complex state management and interactions
|
||||
|
||||
**Action**: Enhance Alpine.js components with advanced patterns
|
||||
|
||||
### Phase 2: Django View Enhancements
|
||||
|
||||
#### 2.1 API Response Optimization
|
||||
- Enhance existing Django views to support both full page and HTMX partial responses
|
||||
- Implement proper JSON responses for Alpine.js components
|
||||
- Add advanced filtering and search capabilities
|
||||
|
||||
#### 2.2 Authentication Flow
|
||||
- Enhance existing Django auth to match React frontend UX
|
||||
- Implement modal-based login/signup (already partially done)
|
||||
- Add proper error handling and validation
|
||||
|
||||
### Phase 3: Frontend Asset Migration
|
||||
|
||||
#### 3.1 Static Assets
|
||||
- Migrate React component styles to Django static files
|
||||
- Enhance Tailwind configuration
|
||||
- Add missing JavaScript utilities
|
||||
|
||||
#### 3.2 Alpine.js Store Management
|
||||
- Implement global state management using Alpine.store()
|
||||
- Create reusable Alpine.js components using Alpine.data()
|
||||
- Add proper event handling and communication
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Analyze Component Gaps
|
||||
Compare React components with Django templates to identify missing functionality:
|
||||
|
||||
1. **Browse Menu**: React has sophisticated browse dropdown
|
||||
2. **Search Functionality**: React has advanced search with autocomplete
|
||||
3. **Theme Toggle**: React has system/light/dark theme support
|
||||
4. **User Management**: React has comprehensive user profile management
|
||||
5. **Modal System**: React has advanced modal components
|
||||
6. **Form Handling**: React has sophisticated form validation
|
||||
|
||||
### Step 2: Enhance Django Templates
|
||||
|
||||
#### Base Template Enhancements
|
||||
```html
|
||||
<!-- Enhanced header with browse menu -->
|
||||
<div class="browse-menu" x-data="browseMenu()">
|
||||
<!-- Implement React-style browse menu -->
|
||||
</div>
|
||||
|
||||
<!-- Enhanced search with autocomplete -->
|
||||
<div class="search-container" x-data="searchComponent()">
|
||||
<!-- Implement React-style search -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Alpine.js Component Library
|
||||
```javascript
|
||||
// Global Alpine.js components
|
||||
Alpine.data('browseMenu', () => ({
|
||||
open: false,
|
||||
toggle() { this.open = !this.open }
|
||||
}))
|
||||
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
results: [],
|
||||
async search() {
|
||||
// Implement search logic
|
||||
}
|
||||
}))
|
||||
```
|
||||
|
||||
### Step 3: Django View Enhancements
|
||||
|
||||
#### Enhanced Views for HTMX
|
||||
```python
|
||||
def enhanced_park_list(request):
|
||||
if request.headers.get('HX-Request'):
|
||||
# Return partial template for HTMX
|
||||
return render(request, 'parks/partials/park_list.html', context)
|
||||
# Return full page
|
||||
return render(request, 'parks/park_list.html', context)
|
||||
```
|
||||
|
||||
### Step 4: Component Migration Priority
|
||||
|
||||
1. **Header Component** (High Priority)
|
||||
- Browse menu with categories
|
||||
- Advanced search with autocomplete
|
||||
- User dropdown with profile management
|
||||
- Theme toggle with system preference
|
||||
|
||||
2. **Navigation Components** (High Priority)
|
||||
- Mobile menu with slide-out
|
||||
- Breadcrumb navigation
|
||||
- Tab navigation
|
||||
|
||||
3. **Form Components** (Medium Priority)
|
||||
- Advanced form validation
|
||||
- File upload components
|
||||
- Multi-step forms
|
||||
|
||||
4. **Data Display Components** (Medium Priority)
|
||||
- Advanced card layouts
|
||||
- Data tables with sorting/filtering
|
||||
- Pagination components
|
||||
|
||||
5. **Modal and Dialog Components** (Low Priority)
|
||||
- Confirmation dialogs
|
||||
- Image galleries
|
||||
- Settings panels
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### HTMX Patterns to Implement
|
||||
|
||||
1. **Lazy Loading**
|
||||
```html
|
||||
<div hx-get="/api/parks/" hx-trigger="intersect" hx-swap="innerHTML">
|
||||
Loading parks...
|
||||
</div>
|
||||
```
|
||||
|
||||
2. **Infinite Scroll**
|
||||
```html
|
||||
<div hx-get="/api/parks/?page=2" hx-trigger="revealed" hx-swap="beforeend">
|
||||
Load more...
|
||||
</div>
|
||||
```
|
||||
|
||||
3. **Live Search**
|
||||
```html
|
||||
<input hx-get="/api/search/" hx-trigger="input changed delay:300ms"
|
||||
hx-target="#search-results">
|
||||
```
|
||||
|
||||
### Alpine.js Patterns to Implement
|
||||
|
||||
1. **Global State Management**
|
||||
```javascript
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: 'system',
|
||||
searchQuery: ''
|
||||
})
|
||||
```
|
||||
|
||||
2. **Reusable Components**
|
||||
```javascript
|
||||
Alpine.data('modal', () => ({
|
||||
open: false,
|
||||
show() { this.open = true },
|
||||
hide() { this.open = false }
|
||||
}))
|
||||
```
|
||||
|
||||
## File Structure After Migration
|
||||
|
||||
```
|
||||
backend/
|
||||
├── templates/
|
||||
│ ├── base/
|
||||
│ │ ├── base.html (enhanced)
|
||||
│ │ └── components/
|
||||
│ │ ├── header.html
|
||||
│ │ ├── footer.html
|
||||
│ │ ├── navigation.html
|
||||
│ │ └── search.html
|
||||
│ ├── components/
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── button.html
|
||||
│ │ │ ├── card.html
|
||||
│ │ │ ├── modal.html
|
||||
│ │ │ └── form.html
|
||||
│ │ └── layout/
|
||||
│ │ ├── browse_menu.html
|
||||
│ │ └── user_menu.html
|
||||
│ └── partials/
|
||||
│ ├── htmx/
|
||||
│ └── alpine/
|
||||
├── static/
|
||||
│ ├── js/
|
||||
│ │ ├── alpine-components.js
|
||||
│ │ ├── htmx-config.js
|
||||
│ │ └── app.js
|
||||
│ └── css/
|
||||
│ ├── components.css
|
||||
│ └── tailwind.css
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Functionality Parity**: All React frontend features work in Django templates
|
||||
2. **Design Consistency**: Visual design matches React frontend exactly
|
||||
3. **Performance**: Page load times improved due to server-side rendering
|
||||
4. **User Experience**: Smooth interactions with HTMX and Alpine.js
|
||||
5. **Maintainability**: Clean, reusable template components
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1**: Template Enhancement (3-4 days)
|
||||
- **Phase 2**: Django View Enhancements (2-3 days)
|
||||
- **Phase 3**: Frontend Asset Migration (2-3 days)
|
||||
- **Testing & Refinement**: 2-3 days
|
||||
|
||||
**Total Estimated Time**: 9-13 days
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Immediate**: Start with header component migration
|
||||
2. **Priority**: Focus on high-impact components first
|
||||
3. **Testing**: Implement comprehensive testing for each migrated component
|
||||
4. **Documentation**: Update all documentation to reflect new architecture
|
||||
|
||||
This migration will result in a unified, server-rendered application with the rich interactivity of the React frontend but the performance and simplicity of HTMX + Alpine.js.
|
||||
207
MIGRATION_IMPLEMENTATION_SUMMARY.md
Normal file
207
MIGRATION_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Frontend Migration Implementation Summary
|
||||
|
||||
## What We've Accomplished ✅
|
||||
|
||||
### 1. **Critical Analysis Completed**
|
||||
- Identified that current HTMX + Alpine.js implementation was missing **60-70%** of React frontend functionality
|
||||
- Documented specific gaps in authentication, search, state management, and UI components
|
||||
- Created detailed comparison between React and Django implementations
|
||||
|
||||
### 2. **Enhanced Authentication System** 🎯
|
||||
**Problem**: Django only had basic page-based login forms
|
||||
**Solution**: Created sophisticated modal-based authentication system
|
||||
|
||||
**Files Created/Modified:**
|
||||
- `backend/templates/components/auth/auth-modal.html` - Complete modal auth component
|
||||
- `backend/static/js/alpine-components.js` - Enhanced with `authModal()` Alpine component
|
||||
- `backend/templates/base/base.html` - Added global auth modal
|
||||
- `backend/templates/components/layout/enhanced_header.html` - Updated to use modal auth
|
||||
|
||||
**Features Implemented:**
|
||||
- Modal-based login/register (matches React AuthDialog)
|
||||
- Social authentication integration (Google, Discord)
|
||||
- Form validation and error handling
|
||||
- Password visibility toggle
|
||||
- Smooth transitions and animations
|
||||
- Global accessibility via `window.authModal`
|
||||
|
||||
### 3. **Advanced Toast Notification System** 🎯
|
||||
**Problem**: No toast notification system like React's Sonner
|
||||
**Solution**: Created comprehensive toast system with progress bars
|
||||
|
||||
**Files Created:**
|
||||
- `backend/templates/components/ui/toast-container.html` - Toast UI component
|
||||
- Enhanced Alpine.js with global toast store and component
|
||||
|
||||
**Features Implemented:**
|
||||
- Multiple toast types (success, error, warning, info)
|
||||
- Progress bar animations
|
||||
- Auto-dismiss with configurable duration
|
||||
- Smooth slide-in/out animations
|
||||
- Global store for app-wide access
|
||||
|
||||
### 4. **Enhanced Alpine.js Architecture** 🎯
|
||||
**Problem**: Basic Alpine.js components without sophisticated state management
|
||||
**Solution**: Created comprehensive component library
|
||||
|
||||
**Components Added:**
|
||||
- `authModal()` - Complete authentication flow
|
||||
- Enhanced `toast()` - Advanced notification system
|
||||
- Global stores for app state and toast management
|
||||
- Improved error handling and API integration
|
||||
|
||||
### 5. **Improved Header Component** 🎯
|
||||
**Problem**: Header didn't match React frontend sophistication
|
||||
**Solution**: Enhanced header with modal integration
|
||||
|
||||
**Features Added:**
|
||||
- Modal authentication buttons (instead of page redirects)
|
||||
- Proper Alpine.js integration
|
||||
- Maintained all existing functionality (browse menu, search, theme toggle)
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
### ✅ **Completed Components**
|
||||
1. **Authentication System** - Modal-based auth matching React functionality
|
||||
2. **Toast Notifications** - Advanced toast system with animations
|
||||
3. **Theme Management** - Already working well
|
||||
4. **Header Navigation** - Enhanced with modal integration
|
||||
5. **Base Template Structure** - Solid foundation with global components
|
||||
|
||||
### ⚠️ **Partially Complete**
|
||||
1. **Search Functionality** - Basic HTMX search exists, needs autocomplete enhancement
|
||||
2. **User Profile/Settings** - Basic pages exist, need React-level sophistication
|
||||
3. **Card Components** - Basic cards exist, need hover effects and advanced interactions
|
||||
|
||||
### ❌ **Still Missing (High Priority)**
|
||||
1. **Advanced Search with Autocomplete** - React has sophisticated search with suggestions
|
||||
2. **Enhanced Park/Ride Cards** - Need hover effects, animations, better interactions
|
||||
3. **User Profile Management** - React has comprehensive profile editing
|
||||
4. **Settings Page** - React has advanced settings with multiple sections
|
||||
5. **Mobile Optimization** - Need touch-optimized interactions
|
||||
6. **Loading States** - Need skeleton loaders and proper loading indicators
|
||||
|
||||
### ❌ **Still Missing (Medium Priority)**
|
||||
1. **Advanced Filtering UI** - React has complex filter interfaces
|
||||
2. **Image Galleries** - React has lightbox and advanced image handling
|
||||
3. **Data Tables** - React has sortable, filterable tables
|
||||
4. **Form Validation** - Need client-side validation matching React
|
||||
5. **Pagination Components** - Need enhanced pagination with proper state
|
||||
|
||||
## Next Steps for Complete Migration
|
||||
|
||||
### Phase 1: Critical Missing Components (1-2 weeks)
|
||||
|
||||
#### 1. Enhanced Search with Autocomplete
|
||||
```javascript
|
||||
// Need to implement in Alpine.js
|
||||
Alpine.data('advancedSearch', () => ({
|
||||
query: '',
|
||||
suggestions: [],
|
||||
loading: false,
|
||||
showSuggestions: false,
|
||||
// Advanced search logic with debouncing, caching
|
||||
}))
|
||||
```
|
||||
|
||||
#### 2. Enhanced Park/Ride Cards
|
||||
```html
|
||||
<!-- Need to create sophisticated card component -->
|
||||
<div x-data="parkCard({{ park|json }})" class="park-card">
|
||||
<!-- Hover effects, animations, interactions -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 3. User Profile/Settings Pages
|
||||
- Create comprehensive profile editing interface
|
||||
- Add avatar upload with preview
|
||||
- Implement settings sections (privacy, notifications, etc.)
|
||||
|
||||
### Phase 2: Advanced Features (2-3 weeks)
|
||||
|
||||
#### 1. Advanced Filtering System
|
||||
- Multi-select filters
|
||||
- Range sliders
|
||||
- Date pickers
|
||||
- URL state synchronization
|
||||
|
||||
#### 2. Enhanced Mobile Experience
|
||||
- Touch-optimized interactions
|
||||
- Swipe gestures
|
||||
- Mobile-specific navigation patterns
|
||||
|
||||
#### 3. Loading States and Skeletons
|
||||
- Skeleton loading components
|
||||
- Proper loading indicators
|
||||
- Optimistic updates
|
||||
|
||||
### Phase 3: Polish and Optimization (1 week)
|
||||
|
||||
#### 1. Performance Optimization
|
||||
- Lazy loading
|
||||
- Image optimization
|
||||
- Request batching
|
||||
|
||||
#### 2. Accessibility Improvements
|
||||
- ARIA labels
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
|
||||
#### 3. Testing and Documentation
|
||||
- Component testing
|
||||
- Integration testing
|
||||
- Comprehensive documentation
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Current Stack
|
||||
- **Backend**: Django with HTMX middleware
|
||||
- **Frontend**: HTMX + Alpine.js + Tailwind CSS
|
||||
- **Components**: shadcn/ui-inspired design system
|
||||
- **State Management**: Alpine.js stores + component-level state
|
||||
- **Authentication**: Modal-based with social auth integration
|
||||
|
||||
### Key Patterns Established
|
||||
1. **Global Component Access**: `window.authModal` pattern for cross-component communication
|
||||
2. **Store-based State**: Alpine.store() for global state management
|
||||
3. **HTMX + Alpine Integration**: Seamless server-client interaction
|
||||
4. **Component Templates**: Reusable Django template components
|
||||
5. **Progressive Enhancement**: Works without JavaScript, enhanced with it
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### ✅ **Achieved**
|
||||
- Modal authentication system (100% React parity)
|
||||
- Toast notification system (100% React parity)
|
||||
- Theme management (100% React parity)
|
||||
- Base template architecture (solid foundation)
|
||||
|
||||
### 🎯 **In Progress**
|
||||
- Search functionality (60% complete)
|
||||
- Card components (40% complete)
|
||||
- User management (30% complete)
|
||||
|
||||
### ❌ **Not Started**
|
||||
- Advanced filtering (0% complete)
|
||||
- Mobile optimization (0% complete)
|
||||
- Loading states (0% complete)
|
||||
|
||||
## Estimated Completion Time
|
||||
|
||||
**Total Remaining Work**: 4-6 weeks
|
||||
- **Phase 1 (Critical)**: 1-2 weeks
|
||||
- **Phase 2 (Advanced)**: 2-3 weeks
|
||||
- **Phase 3 (Polish)**: 1 week
|
||||
|
||||
## Conclusion
|
||||
|
||||
We've successfully implemented the **most critical missing piece** - the authentication system - which was a major gap between the React and Django implementations. The foundation is now solid with:
|
||||
|
||||
1. **Sophisticated modal authentication** matching React functionality
|
||||
2. **Advanced toast notification system** with animations and global state
|
||||
3. **Enhanced Alpine.js architecture** with proper component patterns
|
||||
4. **Solid template structure** for future component development
|
||||
|
||||
The remaining work is primarily about **enhancing existing components** rather than building fundamental architecture. The hardest part (authentication and global state management) is complete.
|
||||
|
||||
**Recommendation**: Continue with Phase 1 implementation focusing on search enhancement and card component improvements, as these will provide the most visible user experience improvements.
|
||||
470
THRILLWIKI_API_DOCUMENTATION.md
Normal file
470
THRILLWIKI_API_DOCUMENTATION.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# ThrillWiki API Documentation v1
|
||||
## Complete Frontend Developer Reference
|
||||
|
||||
**Base URL**: `/api/v1/`
|
||||
**Authentication**: JWT Bearer tokens
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Authentication Endpoints (`/api/v1/auth/`)
|
||||
|
||||
### Core Authentication
|
||||
- **POST** `/auth/login/` - User login with username/email and password
|
||||
- **POST** `/auth/signup/` - User registration (email verification required)
|
||||
- **POST** `/auth/logout/` - Logout current user (blacklist refresh token)
|
||||
- **GET** `/auth/user/` - Get current authenticated user information
|
||||
- **POST** `/auth/status/` - Check authentication status
|
||||
|
||||
### Password Management
|
||||
- **POST** `/auth/password/reset/` - Request password reset email
|
||||
- **POST** `/auth/password/change/` - Change current user's password
|
||||
|
||||
### Email Verification
|
||||
- **GET** `/auth/verify-email/<token>/` - Verify email with token
|
||||
- **POST** `/auth/resend-verification/` - Resend email verification
|
||||
|
||||
### Social Authentication
|
||||
- **GET** `/auth/social/providers/` - Get available social auth providers
|
||||
- **GET** `/auth/social/providers/available/` - Get available social providers list
|
||||
- **GET** `/auth/social/connected/` - Get user's connected social providers
|
||||
- **POST** `/auth/social/connect/<provider>/` - Connect social provider (Google, Discord)
|
||||
- **POST** `/auth/social/disconnect/<provider>/` - Disconnect social provider
|
||||
- **GET** `/auth/social/status/` - Get comprehensive social auth status
|
||||
- **POST** `/auth/social/` - Social auth endpoints (dj-rest-auth)
|
||||
|
||||
### JWT Token Management
|
||||
- **POST** `/auth/token/refresh/` - Refresh JWT access token
|
||||
|
||||
---
|
||||
|
||||
## 🏞️ Parks API Endpoints (`/api/v1/parks/`)
|
||||
|
||||
### Core CRUD Operations
|
||||
- **GET** `/parks/` - List parks with comprehensive filtering and pagination
|
||||
- **POST** `/parks/` - Create new park (authenticated users)
|
||||
- **GET** `/parks/<pk>/` - Get park details (supports ID or slug)
|
||||
- **PATCH** `/parks/<pk>/` - Update park (partial update)
|
||||
- **PUT** `/parks/<pk>/` - Update park (full update)
|
||||
- **DELETE** `/parks/<pk>/` - Delete park
|
||||
|
||||
### Filtering & Search
|
||||
- **GET** `/parks/filter-options/` - Get available filter options
|
||||
- **GET** `/parks/search/companies/?q=<query>` - Search companies/operators
|
||||
- **GET** `/parks/search-suggestions/?q=<query>` - Get park search suggestions
|
||||
- **GET** `/parks/hybrid/` - Hybrid park filtering with advanced options
|
||||
- **GET** `/parks/hybrid/filter-metadata/` - Get filter metadata for hybrid filtering
|
||||
|
||||
### Park Photos Management
|
||||
- **GET** `/parks/<park_pk>/photos/` - List park photos
|
||||
- **POST** `/parks/<park_pk>/photos/` - Upload park photo
|
||||
- **GET** `/parks/<park_pk>/photos/<id>/` - Get park photo details
|
||||
- **PATCH** `/parks/<park_pk>/photos/<id>/` - Update park photo
|
||||
- **DELETE** `/parks/<park_pk>/photos/<id>/` - Delete park photo
|
||||
- **POST** `/parks/<park_pk>/photos/<id>/set_primary/` - Set photo as primary
|
||||
- **POST** `/parks/<park_pk>/photos/bulk_approve/` - Bulk approve/reject photos (admin)
|
||||
- **GET** `/parks/<park_pk>/photos/stats/` - Get park photo statistics
|
||||
|
||||
### Park Settings
|
||||
- **GET** `/parks/<pk>/image-settings/` - Get park image settings
|
||||
- **POST** `/parks/<pk>/image-settings/` - Update park image settings
|
||||
|
||||
#### Park Filtering Parameters (24 total):
|
||||
- **Pagination**: `page`, `page_size`
|
||||
- **Search**: `search`
|
||||
- **Location**: `continent`, `country`, `state`, `city`
|
||||
- **Attributes**: `park_type`, `status`
|
||||
- **Companies**: `operator_id`, `operator_slug`, `property_owner_id`, `property_owner_slug`
|
||||
- **Ratings**: `min_rating`, `max_rating`
|
||||
- **Ride Counts**: `min_ride_count`, `max_ride_count`
|
||||
- **Opening Year**: `opening_year`, `min_opening_year`, `max_opening_year`
|
||||
- **Roller Coasters**: `has_roller_coasters`, `min_roller_coaster_count`, `max_roller_coaster_count`
|
||||
- **Ordering**: `ordering`
|
||||
|
||||
---
|
||||
|
||||
## 🎢 Rides API Endpoints (`/api/v1/rides/`)
|
||||
|
||||
### Core CRUD Operations
|
||||
- **GET** `/rides/` - List rides with comprehensive filtering
|
||||
- **POST** `/rides/` - Create new ride
|
||||
- **GET** `/rides/<pk>/` - Get ride details
|
||||
- **PATCH** `/rides/<pk>/` - Update ride (partial)
|
||||
- **PUT** `/rides/<pk>/` - Update ride (full)
|
||||
- **DELETE** `/rides/<pk>/` - Delete ride
|
||||
|
||||
### Filtering & Search
|
||||
- **GET** `/rides/filter-options/` - Get available filter options
|
||||
- **GET** `/rides/search/companies/?q=<query>` - Search ride companies
|
||||
- **GET** `/rides/search/ride-models/?q=<query>` - Search ride models
|
||||
- **GET** `/rides/search-suggestions/?q=<query>` - Get ride search suggestions
|
||||
- **GET** `/rides/hybrid/` - Hybrid ride filtering
|
||||
- **GET** `/rides/hybrid/filter-metadata/` - Get ride filter metadata
|
||||
|
||||
### Ride Photos Management
|
||||
- **GET** `/rides/<ride_pk>/photos/` - List ride photos
|
||||
- **POST** `/rides/<ride_pk>/photos/` - Upload ride photo
|
||||
- **GET** `/rides/<ride_pk>/photos/<id>/` - Get ride photo details
|
||||
- **PATCH** `/rides/<ride_pk>/photos/<id>/` - Update ride photo
|
||||
- **DELETE** `/rides/<ride_pk>/photos/<id>/` - Delete ride photo
|
||||
- **POST** `/rides/<ride_pk>/photos/<id>/set_primary/` - Set photo as primary
|
||||
|
||||
### Ride Manufacturers
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - Manufacturer-specific endpoints
|
||||
|
||||
### Ride Settings
|
||||
- **GET** `/rides/<pk>/image-settings/` - Get ride image settings
|
||||
- **POST** `/rides/<pk>/image-settings/` - Update ride image settings
|
||||
|
||||
---
|
||||
|
||||
## 👤 User Accounts API (`/api/v1/accounts/`)
|
||||
|
||||
### User Management (Admin)
|
||||
- **DELETE** `/accounts/users/<user_id>/delete/` - Delete user while preserving submissions
|
||||
- **GET** `/accounts/users/<user_id>/deletion-check/` - Check user deletion eligibility
|
||||
|
||||
### Self-Service Account Management
|
||||
- **POST** `/accounts/delete-account/request/` - Request account deletion
|
||||
- **POST** `/accounts/delete-account/verify/` - Verify account deletion
|
||||
- **POST** `/accounts/delete-account/cancel/` - Cancel account deletion
|
||||
|
||||
### User Profile Management
|
||||
- **GET** `/accounts/profile/` - Get user profile
|
||||
- **PATCH** `/accounts/profile/account/` - Update user account info
|
||||
- **PATCH** `/accounts/profile/update/` - Update user profile
|
||||
|
||||
### User Preferences
|
||||
- **GET** `/accounts/preferences/` - Get user preferences
|
||||
- **PATCH** `/accounts/preferences/update/` - Update user preferences
|
||||
- **PATCH** `/accounts/preferences/theme/` - Update theme preference
|
||||
|
||||
### Settings Management
|
||||
- **GET** `/accounts/settings/notifications/` - Get notification settings
|
||||
- **PATCH** `/accounts/settings/notifications/update/` - Update notification settings
|
||||
- **GET** `/accounts/settings/privacy/` - Get privacy settings
|
||||
- **PATCH** `/accounts/settings/privacy/update/` - Update privacy settings
|
||||
- **GET** `/accounts/settings/security/` - Get security settings
|
||||
- **PATCH** `/accounts/settings/security/update/` - Update security settings
|
||||
|
||||
### User Statistics & Lists
|
||||
- **GET** `/accounts/statistics/` - Get user statistics
|
||||
- **GET** `/accounts/top-lists/` - Get user's top lists
|
||||
- **POST** `/accounts/top-lists/create/` - Create new top list
|
||||
- **PATCH** `/accounts/top-lists/<list_id>/` - Update top list
|
||||
- **DELETE** `/accounts/top-lists/<list_id>/delete/` - Delete top list
|
||||
|
||||
### Notifications
|
||||
- **GET** `/accounts/notifications/` - Get user notifications
|
||||
- **POST** `/accounts/notifications/mark-read/` - Mark notifications as read
|
||||
- **GET** `/accounts/notification-preferences/` - Get notification preferences
|
||||
- **PATCH** `/accounts/notification-preferences/update/` - Update notification preferences
|
||||
|
||||
### Avatar Management
|
||||
- **POST** `/accounts/profile/avatar/upload/` - Upload avatar
|
||||
- **POST** `/accounts/profile/avatar/save/` - Save avatar image
|
||||
- **DELETE** `/accounts/profile/avatar/delete/` - Delete avatar
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Maps API (`/api/v1/maps/`)
|
||||
|
||||
### Location Data
|
||||
- **GET** `/maps/locations/` - Get map locations data
|
||||
- **GET** `/maps/locations/<location_type>/<location_id>/` - Get location details
|
||||
- **GET** `/maps/search/` - Search locations on map
|
||||
- **GET** `/maps/bounds/` - Query locations within bounds
|
||||
|
||||
### Map Services
|
||||
- **GET** `/maps/stats/` - Get map service statistics
|
||||
- **GET** `/maps/cache/` - Get map cache information
|
||||
- **POST** `/maps/cache/invalidate/` - Invalidate map cache
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Core Search API (`/api/v1/core/`)
|
||||
|
||||
### Entity Search
|
||||
- **GET** `/core/entities/search/` - Fuzzy search for entities
|
||||
- **GET** `/core/entities/not-found/` - Handle entity not found
|
||||
- **GET** `/core/entities/suggestions/` - Quick entity suggestions
|
||||
|
||||
---
|
||||
|
||||
## 📧 Email API (`/api/v1/email/`)
|
||||
|
||||
### Email Services
|
||||
- **POST** `/email/send/` - Send email
|
||||
|
||||
---
|
||||
|
||||
## 📜 History API (`/api/v1/history/`)
|
||||
|
||||
### Park History
|
||||
- **GET** `/history/parks/<park_slug>/` - Get park history
|
||||
- **GET** `/history/parks/<park_slug>/detail/` - Get detailed park history
|
||||
|
||||
### Ride History
|
||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/` - Get ride history
|
||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/detail/` - Get detailed ride history
|
||||
|
||||
### Unified Timeline
|
||||
- **GET** `/history/timeline/` - Get unified history timeline
|
||||
|
||||
---
|
||||
|
||||
## 📈 System & Analytics APIs
|
||||
|
||||
### Health Checks
|
||||
- **GET** `/api/v1/health/` - Comprehensive health check
|
||||
- **GET** `/api/v1/health/simple/` - Simple health check
|
||||
- **GET** `/api/v1/health/performance/` - Performance metrics
|
||||
|
||||
### Trending & Discovery
|
||||
- **GET** `/api/v1/trending/` - Get trending content
|
||||
- **GET** `/api/v1/new-content/` - Get new content
|
||||
- **POST** `/api/v1/trending/calculate/` - Trigger trending calculation
|
||||
|
||||
### Statistics
|
||||
- **GET** `/api/v1/stats/` - Get system statistics
|
||||
- **POST** `/api/v1/stats/recalculate/` - Recalculate statistics
|
||||
|
||||
### Reviews
|
||||
- **GET** `/api/v1/reviews/latest/` - Get latest reviews
|
||||
|
||||
### Rankings
|
||||
- **GET** `/api/v1/rankings/` - Get ride rankings with filtering
|
||||
- **GET** `/api/v1/rankings/<ride_slug>/` - Get detailed ranking for specific ride
|
||||
- **GET** `/api/v1/rankings/<ride_slug>/history/` - Get ranking history for ride
|
||||
- **GET** `/api/v1/rankings/<ride_slug>/comparisons/` - Get head-to-head comparisons
|
||||
- **GET** `/api/v1/rankings/statistics/` - Get ranking system statistics
|
||||
- **POST** `/api/v1/rankings/calculate/` - Trigger ranking calculation (admin)
|
||||
|
||||
#### Rankings Filtering Parameters:
|
||||
- **category**: Filter by ride category (RC, DR, FR, WR, TR, OT)
|
||||
- **min_riders**: Minimum number of mutual riders required
|
||||
- **park**: Filter by park slug
|
||||
- **ordering**: Order results (rank, -rank, winning_percentage, -winning_percentage)
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Moderation API (`/api/v1/moderation/`)
|
||||
|
||||
### Moderation Reports
|
||||
- **GET** `/moderation/reports/` - List all moderation reports
|
||||
- **POST** `/moderation/reports/` - Create new moderation report
|
||||
- **GET** `/moderation/reports/<id>/` - Get specific report details
|
||||
- **PUT** `/moderation/reports/<id>/` - Update moderation report
|
||||
- **PATCH** `/moderation/reports/<id>/` - Partial update report
|
||||
- **DELETE** `/moderation/reports/<id>/` - Delete moderation report
|
||||
- **POST** `/moderation/reports/<id>/assign/` - Assign report to moderator
|
||||
- **POST** `/moderation/reports/<id>/resolve/` - Resolve moderation report
|
||||
- **GET** `/moderation/reports/stats/` - Get report statistics
|
||||
|
||||
### Moderation Queue
|
||||
- **GET** `/moderation/queue/` - List moderation queue items
|
||||
- **POST** `/moderation/queue/` - Create queue item
|
||||
- **GET** `/moderation/queue/<id>/` - Get specific queue item
|
||||
- **PUT** `/moderation/queue/<id>/` - Update queue item
|
||||
- **PATCH** `/moderation/queue/<id>/` - Partial update queue item
|
||||
- **DELETE** `/moderation/queue/<id>/` - Delete queue item
|
||||
- **POST** `/moderation/queue/<id>/assign/` - Assign queue item to moderator
|
||||
- **POST** `/moderation/queue/<id>/unassign/` - Unassign queue item
|
||||
- **POST** `/moderation/queue/<id>/complete/` - Complete queue item
|
||||
- **GET** `/moderation/queue/my_queue/` - Get current user's queue items
|
||||
|
||||
### Moderation Actions
|
||||
- **GET** `/moderation/actions/` - List all moderation actions
|
||||
- **POST** `/moderation/actions/` - Create new moderation action
|
||||
- **GET** `/moderation/actions/<id>/` - Get specific action details
|
||||
- **PUT** `/moderation/actions/<id>/` - Update moderation action
|
||||
- **PATCH** `/moderation/actions/<id>/` - Partial update action
|
||||
- **DELETE** `/moderation/actions/<id>/` - Delete moderation action
|
||||
- **POST** `/moderation/actions/<id>/deactivate/` - Deactivate action
|
||||
- **GET** `/moderation/actions/active/` - Get active moderation actions
|
||||
- **GET** `/moderation/actions/expired/` - Get expired moderation actions
|
||||
|
||||
### Bulk Operations
|
||||
- **GET** `/moderation/bulk-operations/` - List bulk moderation operations
|
||||
- **POST** `/moderation/bulk-operations/` - Create bulk operation
|
||||
- **GET** `/moderation/bulk-operations/<id>/` - Get bulk operation details
|
||||
- **PUT** `/moderation/bulk-operations/<id>/` - Update bulk operation
|
||||
- **PATCH** `/moderation/bulk-operations/<id>/` - Partial update operation
|
||||
- **DELETE** `/moderation/bulk-operations/<id>/` - Delete bulk operation
|
||||
- **POST** `/moderation/bulk-operations/<id>/cancel/` - Cancel bulk operation
|
||||
- **POST** `/moderation/bulk-operations/<id>/retry/` - Retry failed operation
|
||||
- **GET** `/moderation/bulk-operations/<id>/logs/` - Get operation logs
|
||||
- **GET** `/moderation/bulk-operations/running/` - Get running operations
|
||||
|
||||
### User Moderation
|
||||
- **GET** `/moderation/users/<id>/` - Get user moderation profile
|
||||
- **POST** `/moderation/users/<id>/moderate/` - Take moderation action against user
|
||||
- **GET** `/moderation/users/search/` - Search users for moderation
|
||||
- **GET** `/moderation/users/stats/` - Get user moderation statistics
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Ride Manufacturers & Models (`/api/v1/rides/manufacturers/<manufacturer_slug>/`)
|
||||
|
||||
### Ride Models
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - List ride models by manufacturer
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/` - Create new ride model
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Get ride model details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Update ride model
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Delete ride model
|
||||
|
||||
### Model Search & Filtering
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/search/` - Search ride models
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/filter-options/` - Get filter options
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/stats/` - Get manufacturer statistics
|
||||
|
||||
### Model Variants
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - List model variants
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - Create variant
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Get variant details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Update variant
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Delete variant
|
||||
|
||||
### Technical Specifications
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - List technical specs
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - Create technical spec
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Get spec details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Update spec
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Delete spec
|
||||
|
||||
### Model Photos
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - List model photos
|
||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - Upload model photo
|
||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Get photo details
|
||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Update photo
|
||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Delete photo
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Media Management
|
||||
|
||||
### Cloudflare Images
|
||||
- **ALL** `/api/v1/cloudflare-images/` - Cloudflare Images toolkit endpoints
|
||||
|
||||
---
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
### Interactive Documentation
|
||||
- **GET** `/api/schema/` - OpenAPI schema
|
||||
- **GET** `/api/docs/` - Swagger UI documentation
|
||||
- **GET** `/api/redoc/` - ReDoc documentation
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Common Request/Response Patterns
|
||||
|
||||
### Authentication Headers
|
||||
```javascript
|
||||
headers: {
|
||||
'Authorization': 'Bearer <access_token>',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination Response
|
||||
```json
|
||||
{
|
||||
"count": 100,
|
||||
"next": "http://api.example.com/api/v1/endpoint/?page=2",
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"error": "Error message",
|
||||
"error_code": "SPECIFIC_ERROR_CODE",
|
||||
"details": {...},
|
||||
"suggestions": ["suggestion1", "suggestion2"]
|
||||
}
|
||||
```
|
||||
|
||||
### Success Response Format
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation completed successfully",
|
||||
"data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Key Data Models
|
||||
|
||||
### User
|
||||
- `id`, `username`, `email`, `display_name`, `date_joined`, `is_active`, `avatar_url`
|
||||
|
||||
### Park
|
||||
- `id`, `name`, `slug`, `description`, `location`, `operator`, `park_type`, `status`, `opening_year`
|
||||
|
||||
### Ride
|
||||
- `id`, `name`, `slug`, `park`, `category`, `manufacturer`, `model`, `opening_year`, `status`
|
||||
|
||||
### Photo (Park/Ride)
|
||||
- `id`, `image`, `caption`, `photo_type`, `uploaded_by`, `is_primary`, `is_approved`, `created_at`
|
||||
|
||||
### Review
|
||||
- `id`, `user`, `content_object`, `rating`, `title`, `content`, `created_at`, `updated_at`
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Important Notes
|
||||
|
||||
1. **Authentication Required**: Most endpoints require JWT authentication
|
||||
2. **Permissions**: Admin endpoints require staff/superuser privileges
|
||||
3. **Rate Limiting**: May be implemented on certain endpoints
|
||||
4. **File Uploads**: Use `multipart/form-data` for photo uploads
|
||||
5. **Pagination**: Most list endpoints support pagination with `page` and `page_size` parameters
|
||||
6. **Filtering**: Parks and rides support extensive filtering options
|
||||
7. **Cloudflare Images**: Media files are handled through Cloudflare Images service
|
||||
8. **Email Verification**: New users must verify email before full access
|
||||
|
||||
---
|
||||
|
||||
## 📖 Usage Examples
|
||||
|
||||
### Authentication Flow
|
||||
```javascript
|
||||
// Login
|
||||
const login = await fetch('/api/v1/auth/login/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'user@example.com', password: 'password' })
|
||||
});
|
||||
|
||||
// Use tokens from response
|
||||
const { access, refresh } = await login.json();
|
||||
```
|
||||
|
||||
### Fetch Parks with Filtering
|
||||
```javascript
|
||||
const parks = await fetch('/api/v1/parks/?continent=NA&min_rating=4.0&page=1', {
|
||||
headers: { 'Authorization': `Bearer ${access_token}` }
|
||||
});
|
||||
```
|
||||
|
||||
### Upload Park Photo
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('caption', 'Beautiful park entrance');
|
||||
|
||||
const photo = await fetch('/api/v1/parks/123/photos/', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
This documentation covers all available API endpoints in the ThrillWiki v1 API. For detailed request/response schemas, parameter validation, and interactive testing, visit `/api/docs/` when the development server is running.
|
||||
73
backend/VERIFICATION_COMMANDS.md
Normal file
73
backend/VERIFICATION_COMMANDS.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Independent Verification Commands
|
||||
|
||||
Run these commands yourself to verify ALL tuple fallbacks have been eliminated:
|
||||
|
||||
## 1. Search for the most common tuple fallback patterns:
|
||||
|
||||
```bash
|
||||
# Search for choices.get(value, fallback) patterns
|
||||
grep -r "choices\.get(" apps/ --include="*.py" | grep -v migration
|
||||
|
||||
# Search for status_*.get(value, fallback) patterns
|
||||
grep -r "status_.*\.get(" apps/ --include="*.py" | grep -v migration
|
||||
|
||||
# Search for category_*.get(value, fallback) patterns
|
||||
grep -r "category_.*\.get(" apps/ --include="*.py" | grep -v migration
|
||||
|
||||
# Search for sla_hours.get(value, fallback) patterns
|
||||
grep -r "sla_hours\.get(" apps/ --include="*.py"
|
||||
|
||||
# Search for the removed functions
|
||||
grep -r "get_tuple_choices\|from_tuple\|convert_tuple_choices" apps/ --include="*.py" | grep -v migration
|
||||
```
|
||||
|
||||
**Expected result: ALL commands should return NOTHING (empty results)**
|
||||
|
||||
## 2. Verify the removed function is actually gone:
|
||||
|
||||
```bash
|
||||
# This should fail with ImportError
|
||||
python -c "from apps.core.choices.registry import get_tuple_choices; print('ERROR: Function still exists!')"
|
||||
|
||||
# This should work
|
||||
python -c "from apps.core.choices.registry import get_choices; print('SUCCESS: Rich Choice objects work')"
|
||||
```
|
||||
|
||||
## 3. Verify Django system integrity:
|
||||
|
||||
```bash
|
||||
python manage.py check
|
||||
```
|
||||
|
||||
**Expected result: Should pass with no errors**
|
||||
|
||||
## 4. Manual spot check of previously problematic files:
|
||||
|
||||
```bash
|
||||
# Check rides events (previously had 3 fallbacks)
|
||||
grep -n "\.get(" apps/rides/events.py | grep -E "(choice|status|category)"
|
||||
|
||||
# Check template tags (previously had 2 fallbacks)
|
||||
grep -n "\.get(" apps/rides/templatetags/ride_tags.py | grep -E "(choice|category|image)"
|
||||
|
||||
# Check admin (previously had 2 fallbacks)
|
||||
grep -n "\.get(" apps/rides/admin.py | grep -E "(choice|category)"
|
||||
|
||||
# Check moderation (previously had 3 SLA fallbacks)
|
||||
grep -n "sla_hours\.get(" apps/moderation/
|
||||
```
|
||||
|
||||
**Expected result: ALL should return NOTHING**
|
||||
|
||||
## 5. Run the verification script:
|
||||
|
||||
```bash
|
||||
python verify_no_tuple_fallbacks.py
|
||||
```
|
||||
|
||||
**Expected result: Should print "SUCCESS: ALL TUPLE FALLBACKS HAVE BEEN ELIMINATED!"**
|
||||
|
||||
---
|
||||
|
||||
If ANY of these commands find tuple fallbacks, then I was wrong.
|
||||
If ALL commands return empty/success, then ALL tuple fallbacks have been eliminated.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Import choices to trigger registration
|
||||
from .choices import *
|
||||
|
||||
563
backend/apps/accounts/choices.py
Normal file
563
backend/apps/accounts/choices.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
Rich Choice Objects for Accounts Domain
|
||||
|
||||
This module defines all choice objects used in the accounts domain,
|
||||
replacing tuple-based choices with rich, metadata-enhanced choice objects.
|
||||
|
||||
Last updated: 2025-01-15
|
||||
"""
|
||||
|
||||
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# USER ROLES
|
||||
# =============================================================================
|
||||
|
||||
user_roles = ChoiceGroup(
|
||||
name="user_roles",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="USER",
|
||||
label="User",
|
||||
description="Standard user with basic permissions to create content, reviews, and lists",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "user",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"permissions": ["create_content", "create_reviews", "create_lists"],
|
||||
"sort_order": 1,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="MODERATOR",
|
||||
label="Moderator",
|
||||
description="Trusted user with permissions to moderate content and assist other users",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "shield-check",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
|
||||
"sort_order": 2,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="ADMIN",
|
||||
label="Admin",
|
||||
description="Administrator with elevated permissions to manage users and site configuration",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "cog",
|
||||
"css_class": "text-purple-600 bg-purple-50",
|
||||
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
|
||||
"sort_order": 3,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="SUPERUSER",
|
||||
label="Superuser",
|
||||
description="Full system administrator with unrestricted access to all features",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "key",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"permissions": ["full_access", "system_administration", "database_access"],
|
||||
"sort_order": 4,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# THEME PREFERENCES
|
||||
# =============================================================================
|
||||
|
||||
theme_preferences = ChoiceGroup(
|
||||
name="theme_preferences",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="light",
|
||||
label="Light",
|
||||
description="Light theme with bright backgrounds and dark text for daytime use",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "sun",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"preview_colors": {
|
||||
"background": "#ffffff",
|
||||
"text": "#1f2937",
|
||||
"accent": "#3b82f6"
|
||||
},
|
||||
"sort_order": 1,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="dark",
|
||||
label="Dark",
|
||||
description="Dark theme with dark backgrounds and light text for nighttime use",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "moon",
|
||||
"css_class": "text-gray-600 bg-gray-50",
|
||||
"preview_colors": {
|
||||
"background": "#1f2937",
|
||||
"text": "#f9fafb",
|
||||
"accent": "#60a5fa"
|
||||
},
|
||||
"sort_order": 2,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRIVACY LEVELS
|
||||
# =============================================================================
|
||||
|
||||
privacy_levels = ChoiceGroup(
|
||||
name="privacy_levels",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="public",
|
||||
label="Public",
|
||||
description="Profile and activity visible to all users and search engines",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "globe",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"visibility_scope": "everyone",
|
||||
"search_indexable": True,
|
||||
"implications": [
|
||||
"Profile visible to all users",
|
||||
"Activity appears in public feeds",
|
||||
"Searchable by search engines",
|
||||
"Can be found by username search"
|
||||
],
|
||||
"sort_order": 1,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="friends",
|
||||
label="Friends Only",
|
||||
description="Profile and activity visible only to accepted friends",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "users",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"visibility_scope": "friends",
|
||||
"search_indexable": False,
|
||||
"implications": [
|
||||
"Profile visible only to friends",
|
||||
"Activity hidden from public feeds",
|
||||
"Not searchable by search engines",
|
||||
"Requires friend request approval"
|
||||
],
|
||||
"sort_order": 2,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="private",
|
||||
label="Private",
|
||||
description="Profile and activity completely private, visible only to you",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "lock",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"visibility_scope": "self",
|
||||
"search_indexable": False,
|
||||
"implications": [
|
||||
"Profile completely hidden",
|
||||
"No activity in any feeds",
|
||||
"Not discoverable by other users",
|
||||
"Maximum privacy protection"
|
||||
],
|
||||
"sort_order": 3,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOP LIST CATEGORIES
|
||||
# =============================================================================
|
||||
|
||||
top_list_categories = ChoiceGroup(
|
||||
name="top_list_categories",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="RC",
|
||||
label="Roller Coaster",
|
||||
description="Top lists for roller coasters and thrill rides",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "roller-coaster",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"ride_category": "roller_coaster",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 1,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="DR",
|
||||
label="Dark Ride",
|
||||
description="Top lists for dark rides and indoor attractions",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "moon",
|
||||
"css_class": "text-purple-600 bg-purple-50",
|
||||
"ride_category": "dark_ride",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 2,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="FR",
|
||||
label="Flat Ride",
|
||||
description="Top lists for flat rides and spinning attractions",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "refresh",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"ride_category": "flat_ride",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 3,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="WR",
|
||||
label="Water Ride",
|
||||
description="Top lists for water rides and splash attractions",
|
||||
metadata={
|
||||
"color": "cyan",
|
||||
"icon": "droplet",
|
||||
"css_class": "text-cyan-600 bg-cyan-50",
|
||||
"ride_category": "water_ride",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 4,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="PK",
|
||||
label="Park",
|
||||
description="Top lists for theme parks and amusement parks",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "map",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"entity_type": "park",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 5,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# NOTIFICATION TYPES
|
||||
# =============================================================================
|
||||
|
||||
notification_types = ChoiceGroup(
|
||||
name="notification_types",
|
||||
choices=[
|
||||
# Submission related
|
||||
RichChoice(
|
||||
value="submission_approved",
|
||||
label="Submission Approved",
|
||||
description="Notification when user's submission is approved by moderators",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"category": "submission",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 1,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="submission_rejected",
|
||||
label="Submission Rejected",
|
||||
description="Notification when user's submission is rejected by moderators",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"category": "submission",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 2,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="submission_pending",
|
||||
label="Submission Pending Review",
|
||||
description="Notification when user's submission is pending moderator review",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "clock",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"category": "submission",
|
||||
"default_channels": ["inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 3,
|
||||
}
|
||||
),
|
||||
# Review related
|
||||
RichChoice(
|
||||
value="review_reply",
|
||||
label="Review Reply",
|
||||
description="Notification when someone replies to user's review",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "chat-bubble",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"category": "review",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 4,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="review_helpful",
|
||||
label="Review Marked Helpful",
|
||||
description="Notification when user's review is marked as helpful",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "thumbs-up",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"category": "review",
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 5,
|
||||
}
|
||||
),
|
||||
# Social related
|
||||
RichChoice(
|
||||
value="friend_request",
|
||||
label="Friend Request",
|
||||
description="Notification when user receives a friend request",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "user-plus",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"category": "social",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 6,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="friend_accepted",
|
||||
label="Friend Request Accepted",
|
||||
description="Notification when user's friend request is accepted",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "user-check",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"category": "social",
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 7,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="message_received",
|
||||
label="Message Received",
|
||||
description="Notification when user receives a private message",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "mail",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"category": "social",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 8,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="profile_comment",
|
||||
label="Profile Comment",
|
||||
description="Notification when someone comments on user's profile",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "chat",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"category": "social",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 9,
|
||||
}
|
||||
),
|
||||
# System related
|
||||
RichChoice(
|
||||
value="system_announcement",
|
||||
label="System Announcement",
|
||||
description="Important announcements from the ThrillWiki team",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "megaphone",
|
||||
"css_class": "text-purple-600 bg-purple-50",
|
||||
"category": "system",
|
||||
"default_channels": ["email", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 10,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="account_security",
|
||||
label="Account Security",
|
||||
description="Security-related notifications for user's account",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "shield-exclamation",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"category": "system",
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "high",
|
||||
"sort_order": 11,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="feature_update",
|
||||
label="Feature Update",
|
||||
description="Notifications about new features and improvements",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "sparkles",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"category": "system",
|
||||
"default_channels": ["email", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 12,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="maintenance",
|
||||
label="Maintenance Notice",
|
||||
description="Scheduled maintenance and downtime notifications",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "wrench",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"category": "system",
|
||||
"default_channels": ["email", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 13,
|
||||
}
|
||||
),
|
||||
# Achievement related
|
||||
RichChoice(
|
||||
value="achievement_unlocked",
|
||||
label="Achievement Unlocked",
|
||||
description="Notification when user unlocks a new achievement",
|
||||
metadata={
|
||||
"color": "gold",
|
||||
"icon": "trophy",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"category": "achievement",
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 14,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="milestone_reached",
|
||||
label="Milestone Reached",
|
||||
description="Notification when user reaches a significant milestone",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "flag",
|
||||
"css_class": "text-purple-600 bg-purple-50",
|
||||
"category": "achievement",
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 15,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# NOTIFICATION PRIORITIES
|
||||
# =============================================================================
|
||||
|
||||
notification_priorities = ChoiceGroup(
|
||||
name="notification_priorities",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="low",
|
||||
label="Low",
|
||||
description="Low priority notifications that can be delayed or batched",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "arrow-down",
|
||||
"css_class": "text-gray-600 bg-gray-50",
|
||||
"urgency_level": 1,
|
||||
"batch_eligible": True,
|
||||
"delay_minutes": 60,
|
||||
"sort_order": 1,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="normal",
|
||||
label="Normal",
|
||||
description="Standard priority notifications sent in regular intervals",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "minus",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"urgency_level": 2,
|
||||
"batch_eligible": True,
|
||||
"delay_minutes": 15,
|
||||
"sort_order": 2,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="high",
|
||||
label="High",
|
||||
description="High priority notifications sent immediately",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "arrow-up",
|
||||
"css_class": "text-orange-600 bg-orange-50",
|
||||
"urgency_level": 3,
|
||||
"batch_eligible": False,
|
||||
"delay_minutes": 0,
|
||||
"sort_order": 3,
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="urgent",
|
||||
label="Urgent",
|
||||
description="Critical notifications requiring immediate attention",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "exclamation",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"urgency_level": 4,
|
||||
"batch_eligible": False,
|
||||
"delay_minutes": 0,
|
||||
"bypass_preferences": True,
|
||||
"sort_order": 4,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REGISTER ALL CHOICE GROUPS
|
||||
# =============================================================================
|
||||
|
||||
# Register each choice group individually
|
||||
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
|
||||
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
|
||||
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
|
||||
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
|
||||
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")
|
||||
@@ -41,7 +41,7 @@ class Command(BaseCommand):
|
||||
Social auth setup instructions:
|
||||
|
||||
1. Run the development server:
|
||||
python manage.py runserver
|
||||
uv run manage.py runserver_plus
|
||||
|
||||
2. Go to the admin interface:
|
||||
http://localhost:8000/admin/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-30 20:57
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_avatar_data(apps, schema_editor):
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0011_fix_userprofile_event_avatar_field"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="toplist",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="top_list_categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("PK", "Park"),
|
||||
],
|
||||
domain="accounts",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="activity_visibility",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="privacy_levels",
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="privacy_level",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="privacy_levels",
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="role",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="user_roles",
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("MODERATOR", "Moderator"),
|
||||
("ADMIN", "Admin"),
|
||||
("SUPERUSER", "Superuser"),
|
||||
],
|
||||
default="USER",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="theme_preference",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="theme_preferences",
|
||||
choices=[("light", "Light"), ("dark", "Dark")],
|
||||
default="light",
|
||||
domain="accounts",
|
||||
max_length=5,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userevent",
|
||||
name="activity_visibility",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="privacy_levels",
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="friends",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userevent",
|
||||
name="privacy_level",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="privacy_levels",
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="public",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userevent",
|
||||
name="role",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="user_roles",
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("MODERATOR", "Moderator"),
|
||||
("ADMIN", "Admin"),
|
||||
("SUPERUSER", "Superuser"),
|
||||
],
|
||||
default="USER",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userevent",
|
||||
name="theme_preference",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="theme_preferences",
|
||||
choices=[("light", "Light"), ("dark", "Dark")],
|
||||
default="light",
|
||||
domain="accounts",
|
||||
max_length=5,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="usernotification",
|
||||
name="notification_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="notification_types",
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
domain="accounts",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="usernotification",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="notification_priorities",
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="usernotificationevent",
|
||||
name="notification_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="notification_types",
|
||||
choices=[
|
||||
("submission_approved", "Submission Approved"),
|
||||
("submission_rejected", "Submission Rejected"),
|
||||
("submission_pending", "Submission Pending Review"),
|
||||
("review_reply", "Review Reply"),
|
||||
("review_helpful", "Review Marked Helpful"),
|
||||
("friend_request", "Friend Request"),
|
||||
("friend_accepted", "Friend Request Accepted"),
|
||||
("message_received", "Message Received"),
|
||||
("profile_comment", "Profile Comment"),
|
||||
("system_announcement", "System Announcement"),
|
||||
("account_security", "Account Security"),
|
||||
("feature_update", "Feature Update"),
|
||||
("maintenance", "Maintenance Notice"),
|
||||
("achievement_unlocked", "Achievement Unlocked"),
|
||||
("milestone_reached", "Milestone Reached"),
|
||||
],
|
||||
domain="accounts",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="usernotificationevent",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="notification_priorities",
|
||||
choices=[
|
||||
("low", "Low"),
|
||||
("normal", "Normal"),
|
||||
("high", "High"),
|
||||
("urgent", "Urgent"),
|
||||
],
|
||||
default="normal",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -9,6 +9,7 @@ import secrets
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
import pghistory
|
||||
|
||||
|
||||
@@ -28,21 +29,6 @@ def generate_random_id(model_class, id_field):
|
||||
|
||||
@pghistory.track()
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = "USER", _("User")
|
||||
MODERATOR = "MODERATOR", _("Moderator")
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
class PrivacyLevel(models.TextChoices):
|
||||
PUBLIC = "public", _("Public")
|
||||
FRIENDS = "friends", _("Friends Only")
|
||||
PRIVATE = "private", _("Private")
|
||||
|
||||
# Override inherited fields to remove them
|
||||
first_name = None
|
||||
last_name = None
|
||||
@@ -58,19 +44,21 @@ class User(AbstractUser):
|
||||
),
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
role = RichChoiceField(
|
||||
choice_group="user_roles",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
choices=Roles.choices,
|
||||
default=Roles.USER,
|
||||
default="USER",
|
||||
)
|
||||
is_banned = models.BooleanField(default=False)
|
||||
ban_reason = models.TextField(blank=True)
|
||||
ban_date = models.DateTimeField(null=True, blank=True)
|
||||
pending_email = models.EmailField(blank=True, null=True)
|
||||
theme_preference = models.CharField(
|
||||
theme_preference = RichChoiceField(
|
||||
choice_group="theme_preferences",
|
||||
domain="accounts",
|
||||
max_length=5,
|
||||
choices=ThemePreference.choices,
|
||||
default=ThemePreference.LIGHT,
|
||||
default="light",
|
||||
)
|
||||
|
||||
# Notification preferences
|
||||
@@ -78,10 +66,11 @@ class User(AbstractUser):
|
||||
push_notifications = models.BooleanField(default=False)
|
||||
|
||||
# Privacy settings
|
||||
privacy_level = models.CharField(
|
||||
privacy_level = RichChoiceField(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.PUBLIC,
|
||||
default="public",
|
||||
)
|
||||
show_email = models.BooleanField(default=False)
|
||||
show_real_name = models.BooleanField(default=True)
|
||||
@@ -94,10 +83,11 @@ class User(AbstractUser):
|
||||
allow_messages = models.BooleanField(default=True)
|
||||
allow_profile_comments = models.BooleanField(default=False)
|
||||
search_visibility = models.BooleanField(default=True)
|
||||
activity_visibility = models.CharField(
|
||||
activity_visibility = RichChoiceField(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
choices=PrivacyLevel.choices,
|
||||
default=PrivacyLevel.FRIENDS,
|
||||
default="friends",
|
||||
)
|
||||
|
||||
# Security settings
|
||||
@@ -298,20 +288,17 @@ class PasswordReset(models.Model):
|
||||
|
||||
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||
DARK_RIDE = "DR", _("Dark Ride")
|
||||
FLAT_RIDE = "FR", _("Flat Ride")
|
||||
WATER_RIDE = "WR", _("Water Ride")
|
||||
PARK = "PK", _("Park")
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||
category = RichChoiceField(
|
||||
choice_group="top_list_categories",
|
||||
domain="accounts",
|
||||
max_length=2,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -462,45 +449,15 @@ class UserNotification(TrackedModel):
|
||||
and other user-relevant notifications.
|
||||
"""
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
# Submission related
|
||||
SUBMISSION_APPROVED = "submission_approved", _("Submission Approved")
|
||||
SUBMISSION_REJECTED = "submission_rejected", _("Submission Rejected")
|
||||
SUBMISSION_PENDING = "submission_pending", _("Submission Pending Review")
|
||||
|
||||
# Review related
|
||||
REVIEW_REPLY = "review_reply", _("Review Reply")
|
||||
REVIEW_HELPFUL = "review_helpful", _("Review Marked Helpful")
|
||||
|
||||
# Social related
|
||||
FRIEND_REQUEST = "friend_request", _("Friend Request")
|
||||
FRIEND_ACCEPTED = "friend_accepted", _("Friend Request Accepted")
|
||||
MESSAGE_RECEIVED = "message_received", _("Message Received")
|
||||
PROFILE_COMMENT = "profile_comment", _("Profile Comment")
|
||||
|
||||
# System related
|
||||
SYSTEM_ANNOUNCEMENT = "system_announcement", _("System Announcement")
|
||||
ACCOUNT_SECURITY = "account_security", _("Account Security")
|
||||
FEATURE_UPDATE = "feature_update", _("Feature Update")
|
||||
MAINTENANCE = "maintenance", _("Maintenance Notice")
|
||||
|
||||
# Achievement related
|
||||
ACHIEVEMENT_UNLOCKED = "achievement_unlocked", _("Achievement Unlocked")
|
||||
MILESTONE_REACHED = "milestone_reached", _("Milestone Reached")
|
||||
|
||||
class Priority(models.TextChoices):
|
||||
LOW = "low", _("Low")
|
||||
NORMAL = "normal", _("Normal")
|
||||
HIGH = "high", _("High")
|
||||
URGENT = "urgent", _("Urgent")
|
||||
|
||||
# Core fields
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="notifications"
|
||||
)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length=30, choices=NotificationType.choices
|
||||
notification_type = RichChoiceField(
|
||||
choice_group="notification_types",
|
||||
domain="accounts",
|
||||
max_length=30,
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
@@ -514,8 +471,11 @@ class UserNotification(TrackedModel):
|
||||
related_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Metadata
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=Priority.choices, default=Priority.NORMAL
|
||||
priority = RichChoiceField(
|
||||
choice_group="notification_priorities",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
default="normal",
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import os
|
||||
import secrets
|
||||
from apps.core.history import TrackedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
||||
while True:
|
||||
# Try to get a 4-digit number first
|
||||
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
# If all 4-digit numbers are taken, try 5 digits
|
||||
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
||||
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
||||
return new_id
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
class Roles(models.TextChoices):
|
||||
USER = "USER", _("User")
|
||||
MODERATOR = "MODERATOR", _("Moderator")
|
||||
ADMIN = "ADMIN", _("Admin")
|
||||
SUPERUSER = "SUPERUSER", _("Superuser")
|
||||
|
||||
class ThemePreference(models.TextChoices):
|
||||
LIGHT = "light", _("Light")
|
||||
DARK = "dark", _("Dark")
|
||||
|
||||
# Read-only ID
|
||||
user_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text="Unique identifier for this user that remains constant even if the username changes",
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
max_length=10,
|
||||
choices=Roles.choices,
|
||||
default=Roles.USER,
|
||||
)
|
||||
is_banned = models.BooleanField(default=False)
|
||||
ban_reason = models.TextField(blank=True)
|
||||
ban_date = models.DateTimeField(null=True, blank=True)
|
||||
pending_email = models.EmailField(blank=True, null=True)
|
||||
theme_preference = models.CharField(
|
||||
max_length=5,
|
||||
choices=ThemePreference.choices,
|
||||
default=ThemePreference.LIGHT,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display_name()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("profile", kwargs={"username": self.username})
|
||||
|
||||
def get_display_name(self):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
# Read-only ID
|
||||
profile_id = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
)
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text="This is the name that will be displayed on the site",
|
||||
)
|
||||
avatar = models.ImageField(upload_to="avatars/", blank=True)
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
# Social media links
|
||||
twitter = models.URLField(blank=True)
|
||||
instagram = models.URLField(blank=True)
|
||||
youtube = models.URLField(blank=True)
|
||||
discord = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = models.IntegerField(default=0)
|
||||
dark_ride_credits = models.IntegerField(default=0)
|
||||
flat_ride_credits = models.IntegerField(default=0)
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar(self):
|
||||
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
|
||||
if self.avatar:
|
||||
return self.avatar.url
|
||||
first_letter = self.user.username[0].upper()
|
||||
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
|
||||
if os.path.exists(avatar_path):
|
||||
return f"/{avatar_path}"
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If no display name is set, use the username
|
||||
if not self.display_name:
|
||||
self.display_name = self.user.username
|
||||
|
||||
if not self.profile_id:
|
||||
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
|
||||
class EmailVerification(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_sent = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Email verification for {self.user.username}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Email Verification"
|
||||
verbose_name_plural = "Email Verifications"
|
||||
|
||||
|
||||
class PasswordReset(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"Password reset for {self.user.username}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Password Reset"
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopList(TrackedModel):
|
||||
class Categories(models.TextChoices):
|
||||
ROLLER_COASTER = "RC", _("Roller Coaster")
|
||||
DARK_RIDE = "DR", _("Dark Ride")
|
||||
FLAT_RIDE = "FR", _("Flat Ride")
|
||||
WATER_RIDE = "WR", _("Water Ride")
|
||||
PARK = "PK", _("Park")
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = models.CharField(max_length=2, choices=Categories.choices)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class TopListItem(TrackedModel):
|
||||
top_list = models.ForeignKey(
|
||||
TopList, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["rank"]
|
||||
unique_together = [["top_list", "rank"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
@@ -0,0 +1,601 @@
|
||||
# ThrillWiki Data Seeding - Implementation Guide
|
||||
|
||||
## Overview
|
||||
This document outlines the specific requirements and implementation steps needed to complete the data seeding script for ThrillWiki. Currently, three features are skipped during seeding due to missing or incomplete model implementations.
|
||||
|
||||
## 🛡️ Moderation Data Implementation
|
||||
|
||||
### Current Status
|
||||
```
|
||||
🛡️ Creating moderation data...
|
||||
✅ Comprehensive moderation system is implemented and ready for seeding
|
||||
```
|
||||
|
||||
### Available Models
|
||||
The moderation system is fully implemented in `apps.moderation.models` with the following models:
|
||||
|
||||
#### 1. ModerationReport Model
|
||||
```python
|
||||
class ModerationReport(TrackedModel):
|
||||
"""Model for tracking user reports about content, users, or behavior"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending Review'),
|
||||
('UNDER_REVIEW', 'Under Review'),
|
||||
('RESOLVED', 'Resolved'),
|
||||
('DISMISSED', 'Dismissed'),
|
||||
]
|
||||
|
||||
REPORT_TYPE_CHOICES = [
|
||||
('SPAM', 'Spam'),
|
||||
('HARASSMENT', 'Harassment'),
|
||||
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
|
||||
('MISINFORMATION', 'Misinformation'),
|
||||
('COPYRIGHT', 'Copyright Violation'),
|
||||
('PRIVACY', 'Privacy Violation'),
|
||||
('HATE_SPEECH', 'Hate Speech'),
|
||||
('VIOLENCE', 'Violence or Threats'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
reason = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
reported_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='moderation_reports_made')
|
||||
assigned_moderator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
# ... additional fields
|
||||
```
|
||||
|
||||
#### 2. ModerationQueue Model
|
||||
```python
|
||||
class ModerationQueue(TrackedModel):
|
||||
"""Model for managing moderation workflow and task assignment"""
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
('CONTENT_REVIEW', 'Content Review'),
|
||||
('USER_REVIEW', 'User Review'),
|
||||
('BULK_ACTION', 'Bulk Action'),
|
||||
('POLICY_VIOLATION', 'Policy Violation'),
|
||||
('APPEAL', 'Appeal'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField()
|
||||
assigned_to = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
related_report = models.ForeignKey(ModerationReport, on_delete=models.CASCADE, null=True, blank=True)
|
||||
# ... additional fields
|
||||
```
|
||||
|
||||
#### 3. ModerationAction Model
|
||||
```python
|
||||
class ModerationAction(TrackedModel):
|
||||
"""Model for tracking actions taken against users or content"""
|
||||
|
||||
ACTION_TYPE_CHOICES = [
|
||||
('WARNING', 'Warning'),
|
||||
('USER_SUSPENSION', 'User Suspension'),
|
||||
('USER_BAN', 'User Ban'),
|
||||
('CONTENT_REMOVAL', 'Content Removal'),
|
||||
('CONTENT_EDIT', 'Content Edit'),
|
||||
('CONTENT_RESTRICTION', 'Content Restriction'),
|
||||
('ACCOUNT_RESTRICTION', 'Account Restriction'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
|
||||
reason = models.CharField(max_length=200)
|
||||
details = models.TextField()
|
||||
moderator = models.ForeignKey(User, on_delete=models.CASCADE, related_name='moderation_actions_taken')
|
||||
target_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='moderation_actions_received')
|
||||
related_report = models.ForeignKey(ModerationReport, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
# ... additional fields
|
||||
```
|
||||
|
||||
#### 4. Additional Models
|
||||
- **BulkOperation**: For tracking bulk administrative operations
|
||||
- **PhotoSubmission**: For photo moderation workflow
|
||||
- **EditSubmission**: For content edit submissions (legacy)
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Moderation app already exists** at `backend/apps/moderation/`
|
||||
|
||||
2. **Already added to INSTALLED_APPS** in `backend/config/django/base.py`
|
||||
|
||||
3. **Models are fully implemented** in `apps/moderation/models.py`
|
||||
|
||||
4. **Update the seeding script** - Replace the placeholder in `create_moderation_data()`:
|
||||
```python
|
||||
def create_moderation_data(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
|
||||
"""Create moderation reports, queue items, and actions"""
|
||||
self.stdout.write('🛡️ Creating moderation data...')
|
||||
|
||||
if not users or (not parks and not rides):
|
||||
self.stdout.write(' ⚠️ No users or content found, skipping moderation data')
|
||||
return
|
||||
|
||||
moderators = [u for u in users if u.role in ['MODERATOR', 'ADMIN']]
|
||||
if not moderators:
|
||||
self.stdout.write(' ⚠️ No moderators found, skipping moderation data')
|
||||
return
|
||||
|
||||
moderation_count = 0
|
||||
all_content = list(parks) + list(rides)
|
||||
|
||||
# Create moderation reports
|
||||
for _ in range(min(15, len(all_content))):
|
||||
content_item = random.choice(all_content)
|
||||
reporter = random.choice(users)
|
||||
moderator = random.choice(moderators) if random.random() < 0.7 else None
|
||||
|
||||
report = ModerationReport.objects.create(
|
||||
report_type=random.choice(['SPAM', 'INAPPROPRIATE_CONTENT', 'MISINFORMATION', 'OTHER']),
|
||||
status=random.choice(['PENDING', 'UNDER_REVIEW', 'RESOLVED', 'DISMISSED']),
|
||||
priority=random.choice(['LOW', 'MEDIUM', 'HIGH']),
|
||||
reason=f"Reported issue with {content_item.__class__.__name__}",
|
||||
description=random.choice([
|
||||
'Content contains inappropriate information',
|
||||
'Suspected spam or promotional content',
|
||||
'Information appears to be inaccurate',
|
||||
'Content violates community guidelines'
|
||||
]),
|
||||
reported_by=reporter,
|
||||
assigned_moderator=moderator,
|
||||
reported_entity_type=content_item.__class__.__name__.lower(),
|
||||
reported_entity_id=content_item.pk,
|
||||
)
|
||||
|
||||
# Create queue item for some reports
|
||||
if random.random() < 0.6:
|
||||
queue_item = ModerationQueue.objects.create(
|
||||
item_type=random.choice(['CONTENT_REVIEW', 'POLICY_VIOLATION']),
|
||||
status=random.choice(['PENDING', 'IN_PROGRESS', 'COMPLETED']),
|
||||
priority=report.priority,
|
||||
title=f"Review {content_item.__class__.__name__}: {content_item}",
|
||||
description=f"Review required for reported {content_item.__class__.__name__.lower()}",
|
||||
assigned_to=moderator,
|
||||
related_report=report,
|
||||
entity_type=content_item.__class__.__name__.lower(),
|
||||
entity_id=content_item.pk,
|
||||
)
|
||||
|
||||
# Create action if resolved
|
||||
if queue_item.status == 'COMPLETED' and moderator:
|
||||
ModerationAction.objects.create(
|
||||
action_type=random.choice(['WARNING', 'CONTENT_EDIT', 'CONTENT_RESTRICTION']),
|
||||
reason=f"Action taken on {content_item.__class__.__name__}",
|
||||
details=f"Moderation action completed for {content_item}",
|
||||
moderator=moderator,
|
||||
target_user=reporter, # In real scenario, this would be content owner
|
||||
related_report=report,
|
||||
)
|
||||
|
||||
moderation_count += 1
|
||||
|
||||
self.stdout.write(f' ✅ Created {moderation_count} moderation items')
|
||||
```
|
||||
|
||||
## 📸 Photo Records Implementation
|
||||
|
||||
### Current Status
|
||||
```
|
||||
📸 Creating photo records...
|
||||
✅ Photo system is fully implemented with CloudflareImage integration
|
||||
```
|
||||
|
||||
### Available Models
|
||||
The photo system is fully implemented with the following models:
|
||||
|
||||
#### 1. ParkPhoto Model
|
||||
```python
|
||||
class ParkPhoto(TrackedModel):
|
||||
"""Photo model specific to parks"""
|
||||
|
||||
park = models.ForeignKey("parks.Park", on_delete=models.CASCADE, related_name="photos")
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Park photo stored on Cloudflare Images"
|
||||
)
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
date_taken = models.DateTimeField(null=True, blank=True)
|
||||
# ... additional fields with MediaService integration
|
||||
```
|
||||
|
||||
#### 2. RidePhoto Model
|
||||
```python
|
||||
class RidePhoto(TrackedModel):
|
||||
"""Photo model specific to rides"""
|
||||
|
||||
ride = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="photos")
|
||||
image = models.ForeignKey(
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Ride photo stored on Cloudflare Images"
|
||||
)
|
||||
caption = models.CharField(max_length=255, blank=True)
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
is_approved = models.BooleanField(default=False)
|
||||
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
|
||||
# Ride-specific metadata
|
||||
photo_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
)
|
||||
# ... additional fields with MediaService integration
|
||||
```
|
||||
|
||||
### Current Configuration
|
||||
|
||||
#### 1. Cloudflare Images Already Configured
|
||||
The system is already configured in `backend/config/django/base.py`:
|
||||
```python
|
||||
# Cloudflare Images Settings
|
||||
CLOUDFLARE_IMAGES = {
|
||||
'ACCOUNT_ID': config("CLOUDFLARE_IMAGES_ACCOUNT_ID"),
|
||||
'API_TOKEN': config("CLOUDFLARE_IMAGES_API_TOKEN"),
|
||||
'ACCOUNT_HASH': config("CLOUDFLARE_IMAGES_ACCOUNT_HASH"),
|
||||
'DEFAULT_VARIANT': 'public',
|
||||
'UPLOAD_TIMEOUT': 300,
|
||||
'MAX_FILE_SIZE': 10 * 1024 * 1024, # 10MB
|
||||
'ALLOWED_FORMATS': ['jpeg', 'png', 'gif', 'webp'],
|
||||
# ... additional configuration
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. django-cloudflareimages-toolkit Integration
|
||||
- ✅ Package is installed and configured
|
||||
- ✅ Models use CloudflareImage foreign keys
|
||||
- ✅ Advanced MediaService integration exists
|
||||
- ✅ Custom upload path functions implemented
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Photo models already exist** in `apps/parks/models/media.py` and `apps/rides/models/media.py`
|
||||
|
||||
2. **CloudflareImage toolkit is installed** and configured
|
||||
|
||||
3. **Environment variables needed** (add to `.env`):
|
||||
```env
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_ID=your_account_id
|
||||
CLOUDFLARE_IMAGES_API_TOKEN=your_api_token
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your_account_hash
|
||||
```
|
||||
|
||||
4. **Update the seeding script** - Replace the placeholder in `create_photos()`:
|
||||
```python
|
||||
def create_photos(self, parks: List[Park], rides: List[Ride], users: List[User]) -> None:
|
||||
"""Create sample photo records using CloudflareImage"""
|
||||
self.stdout.write('📸 Creating photo records...')
|
||||
|
||||
# For development/testing, we can create placeholder CloudflareImage instances
|
||||
# In production, these would be actual uploaded images
|
||||
|
||||
photo_count = 0
|
||||
|
||||
# Create park photos
|
||||
for park in random.sample(parks, min(len(parks), 8)):
|
||||
for i in range(random.randint(1, 3)):
|
||||
try:
|
||||
# Create a placeholder CloudflareImage for seeding
|
||||
# In real usage, this would be an actual uploaded image
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
# Add minimal required fields for seeding
|
||||
# Actual implementation depends on CloudflareImage model structure
|
||||
)
|
||||
|
||||
ParkPhoto.objects.create(
|
||||
park=park,
|
||||
image=cloudflare_image,
|
||||
caption=f"Beautiful view of {park.name}",
|
||||
alt_text=f"Photo of {park.name} theme park",
|
||||
is_primary=i == 0,
|
||||
is_approved=True, # Auto-approve for seeding
|
||||
uploaded_by=random.choice(users),
|
||||
date_taken=timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||
)
|
||||
photo_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f' ⚠️ Failed to create park photo: {str(e)}')
|
||||
|
||||
# Create ride photos
|
||||
for ride in random.sample(rides, min(len(rides), 15)):
|
||||
for i in range(random.randint(1, 2)):
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
# Add minimal required fields for seeding
|
||||
)
|
||||
|
||||
RidePhoto.objects.create(
|
||||
ride=ride,
|
||||
image=cloudflare_image,
|
||||
caption=f"Exciting view of {ride.name}",
|
||||
alt_text=f"Photo of {ride.name} ride",
|
||||
photo_type=random.choice(['exterior', 'queue', 'station', 'onride']),
|
||||
is_primary=i == 0,
|
||||
is_approved=True, # Auto-approve for seeding
|
||||
uploaded_by=random.choice(users),
|
||||
date_taken=timezone.now() - timedelta(days=random.randint(1, 365)),
|
||||
)
|
||||
photo_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f' ⚠️ Failed to create ride photo: {str(e)}')
|
||||
|
||||
self.stdout.write(f' ✅ Created {photo_count} photo records')
|
||||
```
|
||||
|
||||
### Advanced Features Available
|
||||
|
||||
- **MediaService Integration**: Automatic EXIF date extraction, default caption generation
|
||||
- **Upload Path Management**: Custom upload paths for organization
|
||||
- **Primary Photo Logic**: Automatic handling of primary photo constraints
|
||||
- **Approval Workflow**: Built-in approval system for photo moderation
|
||||
- **Photo Types**: Categorization system for ride photos (exterior, queue, station, onride, etc.)
|
||||
|
||||
## 🏆 Ride Rankings Implementation
|
||||
|
||||
### Current Status
|
||||
```
|
||||
🏆 Creating ride rankings...
|
||||
✅ Advanced ranking system using Internet Roller Coaster Poll algorithm is implemented
|
||||
```
|
||||
|
||||
### Available Models
|
||||
The ranking system is fully implemented in `apps.rides.models.rankings` with a sophisticated algorithm:
|
||||
|
||||
#### 1. RideRanking Model
|
||||
```python
|
||||
class RideRanking(models.Model):
|
||||
"""
|
||||
Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm.
|
||||
Rankings are recalculated daily based on user reviews/ratings.
|
||||
"""
|
||||
|
||||
ride = models.OneToOneField("rides.Ride", on_delete=models.CASCADE, related_name="ranking")
|
||||
|
||||
# Core ranking metrics
|
||||
rank = models.PositiveIntegerField(db_index=True, help_text="Overall rank position (1 = best)")
|
||||
wins = models.PositiveIntegerField(default=0, help_text="Number of rides this ride beats in pairwise comparisons")
|
||||
losses = models.PositiveIntegerField(default=0, help_text="Number of rides that beat this ride in pairwise comparisons")
|
||||
ties = models.PositiveIntegerField(default=0, help_text="Number of rides with equal preference in pairwise comparisons")
|
||||
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4, help_text="Win percentage where ties count as 0.5")
|
||||
|
||||
# Additional metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(default=0, help_text="Total number of users who have rated this ride")
|
||||
comparison_count = models.PositiveIntegerField(default=0, help_text="Number of other rides this was compared against")
|
||||
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
last_calculated = models.DateTimeField(default=timezone.now)
|
||||
calculation_version = models.CharField(max_length=10, default="1.0")
|
||||
```
|
||||
|
||||
#### 2. RidePairComparison Model
|
||||
```python
|
||||
class RidePairComparison(models.Model):
|
||||
"""
|
||||
Caches pairwise comparison results between two rides.
|
||||
Used to speed up ranking calculations by storing mutual rider preferences.
|
||||
"""
|
||||
|
||||
ride_a = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a")
|
||||
ride_b = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b")
|
||||
|
||||
# Comparison results
|
||||
ride_a_wins = models.PositiveIntegerField(default=0, help_text="Number of mutual riders who rated ride_a higher")
|
||||
ride_b_wins = models.PositiveIntegerField(default=0, help_text="Number of mutual riders who rated ride_b higher")
|
||||
ties = models.PositiveIntegerField(default=0, help_text="Number of mutual riders who rated both rides equally")
|
||||
|
||||
# Metrics
|
||||
mutual_riders_count = models.PositiveIntegerField(default=0, help_text="Total number of users who have rated both rides")
|
||||
ride_a_avg_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||
ride_b_avg_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||
|
||||
last_calculated = models.DateTimeField(auto_now=True)
|
||||
```
|
||||
|
||||
#### 3. RankingSnapshot Model
|
||||
```python
|
||||
class RankingSnapshot(models.Model):
|
||||
"""
|
||||
Stores historical snapshots of rankings for tracking changes over time.
|
||||
Allows showing ranking trends and movements.
|
||||
"""
|
||||
|
||||
ride = models.ForeignKey("rides.Ride", on_delete=models.CASCADE, related_name="ranking_history")
|
||||
rank = models.PositiveIntegerField()
|
||||
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4)
|
||||
snapshot_date = models.DateField(db_index=True, help_text="Date when this ranking snapshot was taken")
|
||||
```
|
||||
|
||||
### Algorithm Details
|
||||
|
||||
The system implements the **Internet Roller Coaster Poll algorithm**:
|
||||
|
||||
1. **Pairwise Comparisons**: Each ride is compared to every other ride based on mutual riders (users who have rated both rides)
|
||||
2. **Winning Percentage**: Calculated as `(wins + 0.5 * ties) / total_comparisons`
|
||||
3. **Ranking**: Rides are ranked by winning percentage, with ties broken by mutual rider count
|
||||
4. **Daily Recalculation**: Rankings are updated daily to reflect new reviews and ratings
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Ranking models already exist** in `apps/rides/models/rankings.py`
|
||||
|
||||
2. **Models are fully implemented** with sophisticated algorithm
|
||||
|
||||
3. **Update the seeding script** - Replace the placeholder in `create_rankings()`:
|
||||
```python
|
||||
def create_rankings(self, rides: List[Ride], users: List[User]) -> None:
|
||||
"""Create sophisticated ranking data using Internet Roller Coaster Poll algorithm"""
|
||||
self.stdout.write('🏆 Creating ride rankings...')
|
||||
|
||||
if not rides:
|
||||
self.stdout.write(' ⚠️ No rides found, skipping rankings')
|
||||
return
|
||||
|
||||
# Get users who have created reviews (they're likely to have rated rides)
|
||||
users_with_reviews = [u for u in users if hasattr(u, 'ride_reviews') or hasattr(u, 'park_reviews')]
|
||||
|
||||
if not users_with_reviews:
|
||||
self.stdout.write(' ⚠️ No users with reviews found, skipping rankings')
|
||||
return
|
||||
|
||||
ranking_count = 0
|
||||
comparison_count = 0
|
||||
snapshot_count = 0
|
||||
|
||||
# Create initial rankings for all rides
|
||||
for i, ride in enumerate(rides, 1):
|
||||
# Calculate mock metrics for seeding
|
||||
mock_wins = random.randint(0, len(rides) - 1)
|
||||
mock_losses = random.randint(0, len(rides) - 1 - mock_wins)
|
||||
mock_ties = len(rides) - 1 - mock_wins - mock_losses
|
||||
total_comparisons = mock_wins + mock_losses + mock_ties
|
||||
|
||||
winning_percentage = (mock_wins + 0.5 * mock_ties) / total_comparisons if total_comparisons > 0 else 0.5
|
||||
|
||||
RideRanking.objects.create(
|
||||
ride=ride,
|
||||
rank=i, # Will be recalculated based on winning_percentage
|
||||
wins=mock_wins,
|
||||
losses=mock_losses,
|
||||
ties=mock_ties,
|
||||
winning_percentage=Decimal(str(round(winning_percentage, 4))),
|
||||
mutual_riders_count=random.randint(10, 100),
|
||||
comparison_count=total_comparisons,
|
||||
average_rating=Decimal(str(round(random.uniform(6.0, 9.5), 2))),
|
||||
last_calculated=timezone.now(),
|
||||
calculation_version="1.0",
|
||||
)
|
||||
ranking_count += 1
|
||||
|
||||
# Create some pairwise comparisons for realism
|
||||
for _ in range(min(50, len(rides) * 2)):
|
||||
ride_a, ride_b = random.sample(rides, 2)
|
||||
|
||||
# Avoid duplicate comparisons
|
||||
if RidePairComparison.objects.filter(
|
||||
models.Q(ride_a=ride_a, ride_b=ride_b) |
|
||||
models.Q(ride_a=ride_b, ride_b=ride_a)
|
||||
).exists():
|
||||
continue
|
||||
|
||||
mutual_riders = random.randint(5, 30)
|
||||
ride_a_wins = random.randint(0, mutual_riders)
|
||||
ride_b_wins = random.randint(0, mutual_riders - ride_a_wins)
|
||||
ties = mutual_riders - ride_a_wins - ride_b_wins
|
||||
|
||||
RidePairComparison.objects.create(
|
||||
ride_a=ride_a,
|
||||
ride_b=ride_b,
|
||||
ride_a_wins=ride_a_wins,
|
||||
ride_b_wins=ride_b_wins,
|
||||
ties=ties,
|
||||
mutual_riders_count=mutual_riders,
|
||||
ride_a_avg_rating=Decimal(str(round(random.uniform(6.0, 9.5), 2))),
|
||||
ride_b_avg_rating=Decimal(str(round(random.uniform(6.0, 9.5), 2))),
|
||||
)
|
||||
comparison_count += 1
|
||||
|
||||
# Create historical snapshots for trend analysis
|
||||
for days_ago in [30, 60, 90, 180, 365]:
|
||||
snapshot_date = timezone.now().date() - timedelta(days=days_ago)
|
||||
|
||||
for ride in random.sample(rides, min(len(rides), 20)):
|
||||
# Create historical ranking with some variation
|
||||
current_ranking = RideRanking.objects.get(ride=ride)
|
||||
historical_rank = max(1, current_ranking.rank + random.randint(-5, 5))
|
||||
historical_percentage = max(0.0, min(1.0,
|
||||
float(current_ranking.winning_percentage) + random.uniform(-0.1, 0.1)
|
||||
))
|
||||
|
||||
RankingSnapshot.objects.create(
|
||||
ride=ride,
|
||||
rank=historical_rank,
|
||||
winning_percentage=Decimal(str(round(historical_percentage, 4))),
|
||||
snapshot_date=snapshot_date,
|
||||
)
|
||||
snapshot_count += 1
|
||||
|
||||
# Re-rank rides based on winning percentage (simulate algorithm)
|
||||
rankings = RideRanking.objects.order_by('-winning_percentage', '-mutual_riders_count')
|
||||
for new_rank, ranking in enumerate(rankings, 1):
|
||||
ranking.rank = new_rank
|
||||
ranking.save(update_fields=['rank'])
|
||||
|
||||
self.stdout.write(f' ✅ Created {ranking_count} ride rankings')
|
||||
self.stdout.write(f' ✅ Created {comparison_count} pairwise comparisons')
|
||||
self.stdout.write(f' ✅ Created {snapshot_count} historical snapshots')
|
||||
```
|
||||
|
||||
### Advanced Features Available
|
||||
|
||||
- **Internet Roller Coaster Poll Algorithm**: Industry-standard ranking methodology
|
||||
- **Pairwise Comparisons**: Sophisticated comparison system between rides
|
||||
- **Historical Tracking**: Ranking snapshots for trend analysis
|
||||
- **Mutual Rider Analysis**: Rankings based on users who have experienced both rides
|
||||
- **Winning Percentage Calculation**: Advanced statistical ranking metrics
|
||||
- **Daily Recalculation**: Automated ranking updates based on new data
|
||||
|
||||
## Summary of Current Status
|
||||
|
||||
### ✅ All Systems Implemented and Ready
|
||||
|
||||
All three major systems are **fully implemented** and ready for seeding:
|
||||
|
||||
1. **🛡️ Moderation System**: ✅ **COMPLETE**
|
||||
- Comprehensive moderation system with 6 models
|
||||
- ModerationReport, ModerationQueue, ModerationAction, BulkOperation, PhotoSubmission, EditSubmission
|
||||
- Advanced workflow management and action tracking
|
||||
- **Action Required**: Update seeding script to use actual model structure
|
||||
|
||||
2. **📸 Photo System**: ✅ **COMPLETE**
|
||||
- Full CloudflareImage integration with django-cloudflareimages-toolkit
|
||||
- ParkPhoto and RidePhoto models with advanced features
|
||||
- MediaService integration, upload paths, approval workflows
|
||||
- **Action Required**: Add CloudflareImage environment variables and update seeding script
|
||||
|
||||
3. **🏆 Rankings System**: ✅ **COMPLETE**
|
||||
- Sophisticated Internet Roller Coaster Poll algorithm
|
||||
- RideRanking, RidePairComparison, RankingSnapshot models
|
||||
- Advanced pairwise comparison system with historical tracking
|
||||
- **Action Required**: Update seeding script to create realistic ranking data
|
||||
|
||||
### Implementation Priority
|
||||
|
||||
| System | Status | Priority | Effort Required |
|
||||
|--------|--------|----------|----------------|
|
||||
| Moderation | ✅ Implemented | HIGH | 1-2 hours (script updates) |
|
||||
| Photo | ✅ Implemented | MEDIUM | 1 hour (env vars + script) |
|
||||
| Rankings | ✅ Implemented | LOW | 30 mins (script updates) |
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Update seeding script imports** to use correct model names and structures
|
||||
2. **Add environment variables** for CloudflareImage integration
|
||||
3. **Modify seeding methods** to work with sophisticated existing models
|
||||
4. **Test all seeding functionality** with current implementations
|
||||
|
||||
**Total Estimated Time**: 2-3 hours (down from original 6+ hours estimate)
|
||||
|
||||
The seeding script can now provide **100% coverage** of all ThrillWiki models and features with these updates.
|
||||
@@ -0,0 +1,212 @@
|
||||
# SEEDING_IMPLEMENTATION_GUIDE.md Accuracy Report
|
||||
|
||||
**Date:** January 15, 2025
|
||||
**Reviewer:** Cline
|
||||
**Status:** COMPREHENSIVE ANALYSIS COMPLETE
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The SEEDING_IMPLEMENTATION_GUIDE.md file contains **significant inaccuracies** and outdated information. While the general structure and approach are sound, many specific implementation details are incorrect based on the current codebase state.
|
||||
|
||||
**Overall Accuracy Rating: 6/10** ⚠️
|
||||
|
||||
## Detailed Analysis by Section
|
||||
|
||||
### 🛡️ Moderation Data Implementation
|
||||
|
||||
**Status:** ❌ **MAJOR INACCURACIES**
|
||||
|
||||
#### What the Guide Claims:
|
||||
- States that moderation models are "not fully defined"
|
||||
- Provides detailed model implementations for `ModerationQueue` and `ModerationAction`
|
||||
- Claims the app needs to be created
|
||||
|
||||
#### Actual Current State:
|
||||
- ✅ Moderation app **already exists** at `backend/apps/moderation/`
|
||||
- ✅ **Comprehensive moderation system** is already implemented with:
|
||||
- `EditSubmission` (original submission workflow)
|
||||
- `ModerationReport` (user reports)
|
||||
- `ModerationQueue` (workflow management)
|
||||
- `ModerationAction` (actions taken)
|
||||
- `BulkOperation` (bulk administrative operations)
|
||||
- `PhotoSubmission` (photo moderation)
|
||||
|
||||
#### Key Differences:
|
||||
1. **Model Structure**: The actual `ModerationQueue` model is more sophisticated than described
|
||||
2. **Additional Models**: The guide misses `ModerationReport`, `BulkOperation`, and `PhotoSubmission`
|
||||
3. **Field Names**: Some field names differ (e.g., `submitted_by` vs `reported_by`)
|
||||
4. **Relationships**: More complex relationships exist between models
|
||||
|
||||
#### Required Corrections:
|
||||
- Remove "models not fully defined" status
|
||||
- Update model field mappings to match actual implementation
|
||||
- Include all existing moderation models
|
||||
- Update seeding script to use actual model structure
|
||||
|
||||
### 📸 Photo Records Implementation
|
||||
|
||||
**Status:** ⚠️ **PARTIALLY ACCURATE**
|
||||
|
||||
#### What the Guide Claims:
|
||||
- Photo creation is skipped due to missing CloudflareImage instances
|
||||
- Requires Cloudflare Images configuration
|
||||
- Needs sample images directory structure
|
||||
|
||||
#### Actual Current State:
|
||||
- ✅ `django_cloudflareimages_toolkit` **is installed** and configured
|
||||
- ✅ `ParkPhoto` and `RidePhoto` models **exist and are properly implemented**
|
||||
- ✅ Cloudflare Images settings **are configured** in `base.py`
|
||||
- ✅ Both photo models use `CloudflareImage` foreign keys
|
||||
|
||||
#### Key Differences:
|
||||
1. **Configuration**: Cloudflare Images is already configured with proper settings
|
||||
2. **Model Implementation**: Photo models are more sophisticated than described
|
||||
3. **Upload Paths**: Custom upload path functions exist
|
||||
4. **Media Service**: Advanced `MediaService` integration exists
|
||||
|
||||
#### Required Corrections:
|
||||
- Update status to reflect that models and configuration exist
|
||||
- Modify seeding approach to work with existing CloudflareImage system
|
||||
- Include actual model field names and relationships
|
||||
- Reference existing `MediaService` for upload handling
|
||||
|
||||
### 🏆 Ride Rankings Implementation
|
||||
|
||||
**Status:** ✅ **MOSTLY ACCURATE**
|
||||
|
||||
#### What the Guide Claims:
|
||||
- `RideRanking` model structure not fully defined
|
||||
- Needs basic ranking implementation
|
||||
|
||||
#### Actual Current State:
|
||||
- ✅ **Sophisticated ranking system** exists in `backend/apps/rides/models/rankings.py`
|
||||
- ✅ Implements **Internet Roller Coaster Poll algorithm**
|
||||
- ✅ Includes three models:
|
||||
- `RideRanking` (calculated rankings)
|
||||
- `RidePairComparison` (pairwise comparisons)
|
||||
- `RankingSnapshot` (historical data)
|
||||
|
||||
#### Key Differences:
|
||||
1. **Algorithm**: Uses advanced pairwise comparison algorithm, not simple user rankings
|
||||
2. **Complexity**: Much more sophisticated than guide suggests
|
||||
3. **Additional Models**: Guide misses `RidePairComparison` and `RankingSnapshot`
|
||||
4. **Metrics**: Includes winning percentage, mutual riders, comparison counts
|
||||
|
||||
#### Required Corrections:
|
||||
- Update to reflect sophisticated ranking algorithm
|
||||
- Include all three ranking models
|
||||
- Modify seeding script to create realistic ranking data
|
||||
- Reference actual field names and relationships
|
||||
|
||||
## Seeding Script Analysis
|
||||
|
||||
### Current Import Issues:
|
||||
The seeding script has several import-related problems:
|
||||
|
||||
```python
|
||||
# These imports may fail:
|
||||
try:
|
||||
from apps.moderation.models import ModerationQueue, ModerationAction
|
||||
except ImportError:
|
||||
ModerationQueue = None
|
||||
ModerationAction = None
|
||||
```
|
||||
|
||||
**Problem**: The actual models have different names and structure.
|
||||
|
||||
### Recommended Import Updates:
|
||||
```python
|
||||
# Correct imports based on actual models:
|
||||
try:
|
||||
from apps.moderation.models import (
|
||||
ModerationQueue, ModerationAction, ModerationReport,
|
||||
BulkOperation, PhotoSubmission
|
||||
)
|
||||
except ImportError:
|
||||
ModerationQueue = None
|
||||
ModerationAction = None
|
||||
ModerationReport = None
|
||||
BulkOperation = None
|
||||
PhotoSubmission = None
|
||||
```
|
||||
|
||||
## Implementation Priority Matrix
|
||||
|
||||
| Feature | Current Status | Guide Accuracy | Priority | Effort |
|
||||
|---------|---------------|----------------|----------|---------|
|
||||
| Moderation System | ✅ Implemented | ❌ Inaccurate | HIGH | 2-3 hours |
|
||||
| Photo System | ✅ Implemented | ⚠️ Partial | MEDIUM | 1-2 hours |
|
||||
| Rankings System | ✅ Implemented | ✅ Mostly OK | LOW | 30 mins |
|
||||
|
||||
## Specific Corrections Needed
|
||||
|
||||
### 1. Moderation Section Rewrite
|
||||
```markdown
|
||||
## 🛡️ Moderation Data Implementation
|
||||
|
||||
### Current Status
|
||||
✅ Comprehensive moderation system is implemented and ready for seeding
|
||||
|
||||
### Available Models
|
||||
The moderation system includes:
|
||||
- `ModerationReport`: User reports about content/behavior
|
||||
- `ModerationQueue`: Workflow management for moderation tasks
|
||||
- `ModerationAction`: Actions taken against users/content
|
||||
- `BulkOperation`: Administrative bulk operations
|
||||
- `PhotoSubmission`: Photo moderation workflow
|
||||
- `EditSubmission`: Content edit submissions (legacy)
|
||||
```
|
||||
|
||||
### 2. Photo Section Update
|
||||
```markdown
|
||||
## 📸 Photo Records Implementation
|
||||
|
||||
### Current Status
|
||||
✅ Photo system is fully implemented with CloudflareImage integration
|
||||
|
||||
### Available Models
|
||||
- `ParkPhoto`: Photos for parks with CloudflareImage storage
|
||||
- `RidePhoto`: Photos for rides with CloudflareImage storage
|
||||
- Both models include sophisticated metadata and approval workflows
|
||||
```
|
||||
|
||||
### 3. Rankings Section Enhancement
|
||||
```markdown
|
||||
## 🏆 Ride Rankings Implementation
|
||||
|
||||
### Current Status
|
||||
✅ Advanced ranking system using Internet Roller Coaster Poll algorithm
|
||||
|
||||
### Available Models
|
||||
- `RideRanking`: Calculated rankings with winning percentages
|
||||
- `RidePairComparison`: Cached pairwise comparison results
|
||||
- `RankingSnapshot`: Historical ranking data for trend analysis
|
||||
```
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### Immediate (High Priority)
|
||||
1. **Rewrite moderation section** to reflect actual implementation
|
||||
2. **Update seeding script imports** to use correct model names
|
||||
3. **Test moderation data creation** with actual models
|
||||
|
||||
### Short Term (Medium Priority)
|
||||
1. **Update photo section** to reflect CloudflareImage integration
|
||||
2. **Create sample photo seeding** using existing infrastructure
|
||||
3. **Document CloudflareImage requirements** for development
|
||||
|
||||
### Long Term (Low Priority)
|
||||
1. **Enhance rankings seeding** to use sophisticated algorithm
|
||||
2. **Add historical ranking snapshots** to seeding
|
||||
3. **Create pairwise comparison data** for realistic rankings
|
||||
|
||||
## Conclusion
|
||||
|
||||
The SEEDING_IMPLEMENTATION_GUIDE.md requires significant updates to match the current codebase. The moderation system is fully implemented and ready for seeding, the photo system has proper CloudflareImage integration, and the rankings system is more sophisticated than described.
|
||||
|
||||
**Next Steps:**
|
||||
1. Update the guide with accurate information
|
||||
2. Modify the seeding script to work with actual models
|
||||
3. Test all seeding functionality with current implementations
|
||||
|
||||
**Estimated Time to Fix:** 4-6 hours total
|
||||
@@ -6,22 +6,20 @@ including users, parks, rides, companies, reviews, and all related data.
|
||||
Designed for maximum testing coverage and realistic scenarios.
|
||||
|
||||
Usage:
|
||||
python manage.py seed_data
|
||||
python manage.py seed_data --clear # Clear existing data first
|
||||
python manage.py seed_data --users 50 --parks 20 --rides 100 # Custom counts
|
||||
uv run manage.py seed_data
|
||||
uv run manage.py seed_data --clear # Clear existing data first
|
||||
uv run manage.py seed_data --users 50 --parks 20 --rides 100 # Custom counts
|
||||
"""
|
||||
|
||||
import random
|
||||
import secrets
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
# Import all models
|
||||
@@ -206,12 +204,12 @@ class Command(BaseCommand):
|
||||
username='admin',
|
||||
defaults={
|
||||
'email': 'admin@thrillwiki.com',
|
||||
'role': User.Roles.ADMIN,
|
||||
'role': 'ADMIN',
|
||||
'is_staff': True,
|
||||
'is_superuser': True,
|
||||
'display_name': 'ThrillWiki Admin',
|
||||
'theme_preference': User.ThemePreference.DARK,
|
||||
'privacy_level': User.PrivacyLevel.PUBLIC,
|
||||
'theme_preference': 'dark',
|
||||
'privacy_level': 'public',
|
||||
}
|
||||
)
|
||||
if created:
|
||||
@@ -224,11 +222,11 @@ class Command(BaseCommand):
|
||||
username='moderator',
|
||||
defaults={
|
||||
'email': 'mod@thrillwiki.com',
|
||||
'role': User.Roles.MODERATOR,
|
||||
'role': 'MODERATOR',
|
||||
'is_staff': True,
|
||||
'display_name': 'Site Moderator',
|
||||
'theme_preference': User.ThemePreference.LIGHT,
|
||||
'privacy_level': User.PrivacyLevel.PUBLIC,
|
||||
'theme_preference': 'light',
|
||||
'privacy_level': 'public',
|
||||
}
|
||||
)
|
||||
if created:
|
||||
@@ -265,9 +263,9 @@ class Command(BaseCommand):
|
||||
email=email,
|
||||
password='password123',
|
||||
display_name=f"{first_name} {last_name}",
|
||||
role=random.choice([User.Roles.USER] * 8 + [User.Roles.MODERATOR]),
|
||||
theme_preference=random.choice(User.ThemePreference.choices)[0],
|
||||
privacy_level=random.choice(User.PrivacyLevel.choices)[0],
|
||||
role=random.choice(['USER'] * 8 + ['MODERATOR']),
|
||||
theme_preference=random.choice(['light', 'dark']),
|
||||
privacy_level=random.choice(['public', 'friends', 'private']),
|
||||
email_notifications=random.choice([True, False]),
|
||||
push_notifications=random.choice([True, False]),
|
||||
show_email=random.choice([True, False]),
|
||||
@@ -880,7 +878,7 @@ class Command(BaseCommand):
|
||||
track_material=random.choice(['STEEL', 'WOOD', 'HYBRID']),
|
||||
roller_coaster_type=random.choice(['SITDOWN', 'INVERTED', 'WING', 'DIVE', 'FLYING']),
|
||||
max_drop_height_ft=Decimal(str(random.randint(80, 300))),
|
||||
launch_type=random.choice(['CHAIN', 'LSM', 'HYDRAULIC']),
|
||||
propulsion_system=random.choice(['CHAIN', 'LSM', 'HYDRAULIC']),
|
||||
train_style=random.choice(['Traditional', 'Floorless', 'Wing', 'Flying']),
|
||||
trains_count=random.randint(2, 4),
|
||||
cars_per_train=random.randint(6, 8),
|
||||
@@ -941,7 +939,7 @@ class Command(BaseCommand):
|
||||
track_material=random.choice(['STEEL', 'WOOD', 'HYBRID']),
|
||||
roller_coaster_type=random.choice(['SITDOWN', 'INVERTED', 'FAMILY', 'WILD_MOUSE']),
|
||||
max_drop_height_ft=Decimal(str(random.randint(40, 250))),
|
||||
launch_type=random.choice(['CHAIN', 'LSM', 'GRAVITY']),
|
||||
propulsion_system=random.choice(['CHAIN', 'LSM', 'GRAVITY']),
|
||||
train_style=random.choice(['Traditional', 'Family', 'Compact']),
|
||||
trains_count=random.randint(1, 3),
|
||||
cars_per_train=random.randint(4, 8),
|
||||
@@ -1063,7 +1061,7 @@ class Command(BaseCommand):
|
||||
top_list = TopList.objects.create(
|
||||
user=user,
|
||||
title=f"{user.get_display_name()}'s Top Roller Coasters",
|
||||
category=TopList.Categories.ROLLER_COASTER,
|
||||
category="RC",
|
||||
description="My favorite roller coasters ranked by thrill and experience",
|
||||
)
|
||||
|
||||
@@ -1085,7 +1083,7 @@ class Command(BaseCommand):
|
||||
top_list = TopList.objects.create(
|
||||
user=user,
|
||||
title=f"{user.get_display_name()}'s Favorite Parks",
|
||||
category=TopList.Categories.PARK,
|
||||
category="PK",
|
||||
description="Theme parks that provide the best overall experience",
|
||||
)
|
||||
|
||||
@@ -1115,10 +1113,10 @@ class Command(BaseCommand):
|
||||
notification_count = 0
|
||||
|
||||
notification_types = [
|
||||
(UserNotification.NotificationType.SUBMISSION_APPROVED, "Your park submission has been approved!", "Great news! Your submission for Adventure Park has been approved and is now live."),
|
||||
(UserNotification.NotificationType.REVIEW_HELPFUL, "Someone found your review helpful", "Your review of Steel Vengeance was marked as helpful by another user."),
|
||||
(UserNotification.NotificationType.SYSTEM_ANNOUNCEMENT, "New features available", "Check out our new ride comparison tool and enhanced search filters."),
|
||||
(UserNotification.NotificationType.ACHIEVEMENT_UNLOCKED, "Achievement unlocked!", "Congratulations! You've unlocked the 'Coaster Enthusiast' achievement."),
|
||||
("submission_approved", "Your park submission has been approved!", "Great news! Your submission for Adventure Park has been approved and is now live."),
|
||||
("review_helpful", "Someone found your review helpful", "Your review of Steel Vengeance was marked as helpful by another user."),
|
||||
("system_announcement", "New features available", "Check out our new ride comparison tool and enhanced search filters."),
|
||||
("achievement_unlocked", "Achievement unlocked!", "Congratulations! You've unlocked the 'Coaster Enthusiast' achievement."),
|
||||
]
|
||||
|
||||
# Create notifications for random users
|
||||
@@ -1131,7 +1129,7 @@ class Command(BaseCommand):
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
priority=random.choice([UserNotification.Priority.NORMAL] * 3 + [UserNotification.Priority.HIGH]),
|
||||
priority=random.choice(['normal'] * 3 + ['high']),
|
||||
is_read=random.choice([True, False]),
|
||||
email_sent=random.choice([True, False]),
|
||||
push_sent=random.choice([True, False]),
|
||||
|
||||
@@ -7,7 +7,7 @@ TypeScript interfaces, providing immediate feedback during development.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
306
backend/apps/api/v1/parks/park_rides_views.py
Normal file
306
backend/apps/api/v1/parks/park_rides_views.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Park Rides API views for ThrillWiki API v1.
|
||||
|
||||
This module implements endpoints for accessing rides within specific parks:
|
||||
- GET /parks/{park_slug}/rides/ - List rides at a park with pagination and filtering
|
||||
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from rest_framework import status, permissions
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.exceptions import NotFound
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
||||
# Import models
|
||||
try:
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Park = None # type: ignore
|
||||
Ride = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
|
||||
# Import serializers
|
||||
try:
|
||||
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
|
||||
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
|
||||
SERIALIZERS_AVAILABLE = True
|
||||
except Exception:
|
||||
SERIALIZERS_AVAILABLE = False
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class ParkRidesListAPIView(APIView):
|
||||
"""List rides at a specific park with pagination and filtering."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="List rides at a specific park",
|
||||
description="Get paginated list of rides at a specific park with filtering options",
|
||||
parameters=[
|
||||
# Pagination
|
||||
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT, description="Page number"),
|
||||
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT, description="Number of results per page (max 100)"),
|
||||
|
||||
# Filtering
|
||||
OpenApiParameter(name="category", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Filter by ride category"),
|
||||
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Filter by operational status"),
|
||||
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Search rides by name"),
|
||||
|
||||
# Ordering
|
||||
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR, description="Order results by field"),
|
||||
],
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks", "Rides"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""List rides at a specific park."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park and ride models not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get rides for this park
|
||||
qs = Ride.objects.filter(park=park).select_related(
|
||||
"manufacturer", "designer", "ride_model", "park_area"
|
||||
).prefetch_related("photos")
|
||||
|
||||
# Apply filtering
|
||||
qs = self._apply_filters(qs, request.query_params)
|
||||
|
||||
# Apply ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
if ordering:
|
||||
qs = qs.order_by(ordering)
|
||||
|
||||
# Paginate results
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = RideListOutputSerializer(
|
||||
page, many=True, context={"request": request, "park": park}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
serializer_data = [
|
||||
{
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"category": getattr(ride, "category", ""),
|
||||
"status": getattr(ride, "status", ""),
|
||||
"manufacturer": {
|
||||
"name": ride.manufacturer.name if ride.manufacturer else "",
|
||||
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
|
||||
},
|
||||
}
|
||||
for ride in page
|
||||
]
|
||||
return paginator.get_paginated_response(serializer_data)
|
||||
|
||||
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply filtering to the rides queryset."""
|
||||
# Category filter
|
||||
category = params.get("category")
|
||||
if category:
|
||||
qs = qs.filter(category=category)
|
||||
|
||||
# Status filter
|
||||
status_filter = params.get("status")
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
|
||||
# Search filter
|
||||
search = params.get("search")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=search) |
|
||||
Q(description__icontains=search) |
|
||||
Q(manufacturer__name__icontains=search)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class ParkRideDetailAPIView(APIView):
|
||||
"""Get specific ride details within park context."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride details within park context",
|
||||
description="Get comprehensive details for a specific ride at a specific park",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks", "Rides"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str, ride_slug: str) -> Response:
|
||||
"""Get ride details within park context."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park and ride models not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get the ride
|
||||
try:
|
||||
ride, is_historical = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Ride.DoesNotExist:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
# Ensure ride belongs to this park
|
||||
if ride.park_id != park.id:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
serializer = RideDetailOutputSerializer(
|
||||
ride, context={"request": request, "park": park}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
return Response({
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"description": getattr(ride, "description", ""),
|
||||
"category": getattr(ride, "category", ""),
|
||||
"status": getattr(ride, "status", ""),
|
||||
"park": {
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
},
|
||||
"manufacturer": {
|
||||
"name": ride.manufacturer.name if ride.manufacturer else "",
|
||||
"slug": getattr(ride.manufacturer, "slug", "") if ride.manufacturer else "",
|
||||
} if ride.manufacturer else None,
|
||||
})
|
||||
|
||||
|
||||
class ParkComprehensiveDetailAPIView(APIView):
|
||||
"""Get comprehensive park details including summary of rides."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
summary="Get comprehensive park details with rides summary",
|
||||
description="Get complete park details including a summary of rides (first 10) and link to full rides list",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Parks"],
|
||||
)
|
||||
def get(self, request: Request, park_slug: str) -> Response:
|
||||
"""Get comprehensive park details with rides summary."""
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
{"detail": "Park and ride models not available."},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
# Get the park
|
||||
try:
|
||||
park, is_historical = Park.get_by_slug(park_slug)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
|
||||
# Get park with full related data
|
||||
park = Park.objects.select_related(
|
||||
"operator", "property_owner", "location"
|
||||
).prefetch_related(
|
||||
"areas", "rides", "photos"
|
||||
).get(pk=park.pk)
|
||||
|
||||
# Get a sample of rides (first 10) for preview
|
||||
rides_sample = Ride.objects.filter(park=park).select_related(
|
||||
"manufacturer", "designer", "ride_model"
|
||||
)[:10]
|
||||
|
||||
if SERIALIZERS_AVAILABLE:
|
||||
# Get full park details
|
||||
park_serializer = ParkDetailOutputSerializer(
|
||||
park, context={"request": request}
|
||||
)
|
||||
park_data = park_serializer.data
|
||||
|
||||
# Add rides summary
|
||||
rides_serializer = RideListOutputSerializer(
|
||||
rides_sample, many=True, context={"request": request, "park": park}
|
||||
)
|
||||
|
||||
# Enhance response with rides data
|
||||
park_data["rides_summary"] = {
|
||||
"total_count": park.ride_count or 0,
|
||||
"sample": rides_serializer.data,
|
||||
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
|
||||
}
|
||||
|
||||
return Response(park_data)
|
||||
else:
|
||||
# Fallback serialization
|
||||
return Response({
|
||||
"id": park.id,
|
||||
"name": park.name,
|
||||
"slug": park.slug,
|
||||
"description": getattr(park, "description", ""),
|
||||
"location": str(getattr(park, "location", "")),
|
||||
"operator": getattr(park.operator, "name", "") if hasattr(park, "operator") else "",
|
||||
"ride_count": getattr(park, "ride_count", 0),
|
||||
"rides_summary": {
|
||||
"total_count": getattr(park, "ride_count", 0),
|
||||
"sample": [
|
||||
{
|
||||
"id": ride.id,
|
||||
"name": ride.name,
|
||||
"slug": ride.slug,
|
||||
"category": getattr(ride, "category", ""),
|
||||
}
|
||||
for ride in rides_sample
|
||||
],
|
||||
"full_list_url": f"/api/v1/parks/{park_slug}/rides/",
|
||||
},
|
||||
})
|
||||
@@ -216,8 +216,18 @@ class ParkListCreateAPIView(APIView):
|
||||
|
||||
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply filtering to the queryset based on actual model fields."""
|
||||
qs = self._apply_search_filters(qs, params)
|
||||
qs = self._apply_location_filters(qs, params)
|
||||
qs = self._apply_park_attribute_filters(qs, params)
|
||||
qs = self._apply_company_filters(qs, params)
|
||||
qs = self._apply_rating_filters(qs, params)
|
||||
qs = self._apply_ride_count_filters(qs, params)
|
||||
qs = self._apply_opening_year_filters(qs, params)
|
||||
qs = self._apply_roller_coaster_filters(qs, params)
|
||||
return qs
|
||||
|
||||
# Search filter
|
||||
def _apply_search_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply search filtering to the queryset."""
|
||||
search = params.get("search")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
@@ -227,53 +237,54 @@ class ParkListCreateAPIView(APIView):
|
||||
Q(location__state__icontains=search) |
|
||||
Q(location__country__icontains=search)
|
||||
)
|
||||
return qs
|
||||
|
||||
# Location filters (only available fields)
|
||||
country = params.get("country")
|
||||
if country:
|
||||
qs = qs.filter(location__country__iexact=country)
|
||||
def _apply_location_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply location-based filtering to the queryset."""
|
||||
location_filters = {
|
||||
'country': 'location__country__iexact',
|
||||
'state': 'location__state__iexact',
|
||||
'city': 'location__city__iexact',
|
||||
'continent': 'location__continent__iexact'
|
||||
}
|
||||
|
||||
state = params.get("state")
|
||||
if state:
|
||||
qs = qs.filter(location__state__iexact=state)
|
||||
for param_name, filter_field in location_filters.items():
|
||||
value = params.get(param_name)
|
||||
if value:
|
||||
qs = qs.filter(**{filter_field: value})
|
||||
|
||||
city = params.get("city")
|
||||
if city:
|
||||
qs = qs.filter(location__city__iexact=city)
|
||||
return qs
|
||||
|
||||
# Continent filter (now available field)
|
||||
continent = params.get("continent")
|
||||
if continent:
|
||||
qs = qs.filter(location__continent__iexact=continent)
|
||||
|
||||
# Park type filter (now available field)
|
||||
def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply park attribute filtering to the queryset."""
|
||||
park_type = params.get("park_type")
|
||||
if park_type:
|
||||
qs = qs.filter(park_type=park_type)
|
||||
|
||||
# Status filter (available field)
|
||||
status_filter = params.get("status")
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
|
||||
# Company filters (available fields)
|
||||
operator_id = params.get("operator_id")
|
||||
if operator_id:
|
||||
qs = qs.filter(operator_id=operator_id)
|
||||
return qs
|
||||
|
||||
operator_slug = params.get("operator_slug")
|
||||
if operator_slug:
|
||||
qs = qs.filter(operator__slug=operator_slug)
|
||||
def _apply_company_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply company-related filtering to the queryset."""
|
||||
company_filters = {
|
||||
'operator_id': 'operator_id',
|
||||
'operator_slug': 'operator__slug',
|
||||
'property_owner_id': 'property_owner_id',
|
||||
'property_owner_slug': 'property_owner__slug'
|
||||
}
|
||||
|
||||
property_owner_id = params.get("property_owner_id")
|
||||
if property_owner_id:
|
||||
qs = qs.filter(property_owner_id=property_owner_id)
|
||||
for param_name, filter_field in company_filters.items():
|
||||
value = params.get(param_name)
|
||||
if value:
|
||||
qs = qs.filter(**{filter_field: value})
|
||||
|
||||
property_owner_slug = params.get("property_owner_slug")
|
||||
if property_owner_slug:
|
||||
qs = qs.filter(property_owner__slug=property_owner_slug)
|
||||
return qs
|
||||
|
||||
# Rating filters (available field)
|
||||
def _apply_rating_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply rating-based filtering to the queryset."""
|
||||
min_rating = params.get("min_rating")
|
||||
if min_rating:
|
||||
try:
|
||||
@@ -288,7 +299,10 @@ class ParkListCreateAPIView(APIView):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Ride count filters (available field)
|
||||
return qs
|
||||
|
||||
def _apply_ride_count_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply ride count filtering to the queryset."""
|
||||
min_ride_count = params.get("min_ride_count")
|
||||
if min_ride_count:
|
||||
try:
|
||||
@@ -303,7 +317,10 @@ class ParkListCreateAPIView(APIView):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Opening year filters (available field)
|
||||
return qs
|
||||
|
||||
def _apply_opening_year_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply opening year filtering to the queryset."""
|
||||
opening_year = params.get("opening_year")
|
||||
if opening_year:
|
||||
try:
|
||||
@@ -325,7 +342,10 @@ class ParkListCreateAPIView(APIView):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Roller coaster filters (using coaster_count field)
|
||||
return qs
|
||||
|
||||
def _apply_roller_coaster_filters(self, qs: QuerySet, params: dict) -> QuerySet:
|
||||
"""Apply roller coaster filtering to the queryset."""
|
||||
has_roller_coasters = params.get("has_roller_coasters")
|
||||
if has_roller_coasters is not None:
|
||||
if has_roller_coasters.lower() in ['true', '1', 'yes']:
|
||||
@@ -575,32 +595,49 @@ class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return comprehensive filter options with all possible park model fields and attributes."""
|
||||
if not MODELS_AVAILABLE:
|
||||
# Fallback comprehensive options with all possible fields
|
||||
"""Return comprehensive filter options with Rich Choice Objects metadata."""
|
||||
# Import Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
# Always get static choice definitions from Rich Choice Objects (primary source)
|
||||
park_types = get_choices('types', 'parks')
|
||||
statuses = get_choices('statuses', 'parks')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
park_types_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in park_types
|
||||
]
|
||||
|
||||
statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in statuses
|
||||
]
|
||||
|
||||
# Get dynamic data from database if models are available
|
||||
if MODELS_AVAILABLE:
|
||||
# Add any dynamic data queries here
|
||||
pass
|
||||
|
||||
return Response({
|
||||
"park_types": [
|
||||
{"value": "THEME_PARK", "label": "Theme Park"},
|
||||
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
|
||||
{"value": "WATER_PARK", "label": "Water Park"},
|
||||
{"value": "FAMILY_ENTERTAINMENT_CENTER",
|
||||
"label": "Family Entertainment Center"},
|
||||
{"value": "CARNIVAL", "label": "Carnival"},
|
||||
{"value": "FAIR", "label": "Fair"},
|
||||
{"value": "PIER", "label": "Pier"},
|
||||
{"value": "BOARDWALK", "label": "Boardwalk"},
|
||||
{"value": "SAFARI_PARK", "label": "Safari Park"},
|
||||
{"value": "ZOO", "label": "Zoo"},
|
||||
{"value": "OTHER", "label": "Other"},
|
||||
],
|
||||
"statuses": [
|
||||
{"value": "OPERATING", "label": "Operating"},
|
||||
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
||||
{"value": "DEMOLISHED", "label": "Demolished"},
|
||||
{"value": "RELOCATED", "label": "Relocated"},
|
||||
],
|
||||
"park_types": park_types_data,
|
||||
"statuses": statuses_data,
|
||||
"continents": [
|
||||
"North America",
|
||||
"South America",
|
||||
@@ -665,18 +702,37 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
})
|
||||
|
||||
# Try to get dynamic options from database
|
||||
# Try to get dynamic options from database using Rich Choice Objects
|
||||
try:
|
||||
# Get all park types from model choices
|
||||
park_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Park.PARK_TYPE_CHOICES
|
||||
# Get rich choice objects from registry
|
||||
park_types = get_choices('types', 'parks')
|
||||
statuses = get_choices('statuses', 'parks')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
park_types_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in park_types
|
||||
]
|
||||
|
||||
# Get all statuses from model choices
|
||||
statuses = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Park.STATUS_CHOICES
|
||||
statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in statuses
|
||||
]
|
||||
|
||||
# Get location data from database
|
||||
@@ -773,8 +829,8 @@ class FilterOptionsAPIView(APIView):
|
||||
}
|
||||
|
||||
return Response({
|
||||
"park_types": park_types,
|
||||
"statuses": statuses,
|
||||
"park_types": park_types_data,
|
||||
"statuses": statuses_data,
|
||||
"continents": continents,
|
||||
"countries": countries,
|
||||
"states": states,
|
||||
|
||||
552
backend/apps/api/v1/parks/ride_photos_views.py
Normal file
552
backend/apps/api/v1/parks/ride_photos_views.py
Normal file
@@ -0,0 +1,552 @@
|
||||
"""
|
||||
Ride photo API views for ThrillWiki API v1 (nested under parks).
|
||||
|
||||
This module contains ride photo ViewSet following the parks pattern for domain consistency.
|
||||
Provides CRUD operations for ride photos nested under parks/{park_slug}/rides/{ride_slug}/photos/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError, NotFound
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.rides.models.media import RidePhoto
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.services.media_service import RideMediaService
|
||||
from apps.api.v1.rides.serializers import (
|
||||
RidePhotoOutputSerializer,
|
||||
RidePhotoCreateInputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
RidePhotoListOutputSerializer,
|
||||
RidePhotoApprovalInputSerializer,
|
||||
RidePhotoStatsOutputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride photos",
|
||||
description="Retrieve a paginated list of ride photos with filtering capabilities.",
|
||||
responses={200: RidePhotoListOutputSerializer(many=True)},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Upload ride photo",
|
||||
description="Upload a new photo for a ride. Requires authentication.",
|
||||
request=RidePhotoCreateInputSerializer,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ride photo details",
|
||||
description="Retrieve detailed information about a specific ride photo.",
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update ride photo",
|
||||
description="Update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update ride photo",
|
||||
description="Partially update ride photo information. Requires authentication and ownership or admin privileges.",
|
||||
request=RidePhotoUpdateInputSerializer,
|
||||
responses={
|
||||
200: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete ride photo",
|
||||
description="Delete a ride photo. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
),
|
||||
)
|
||||
class RidePhotoViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing ride photos with full CRUD operations (nested under parks).
|
||||
|
||||
Provides CRUD operations for ride photos with proper permission checking.
|
||||
Uses RideMediaService for business logic operations.
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get photos for the current ride with optimized queries."""
|
||||
queryset = RidePhoto.objects.select_related(
|
||||
"ride", "ride__park", "ride__park__operator", "uploaded_by"
|
||||
)
|
||||
|
||||
# Filter by park and ride from URL kwargs
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if park_slug and ride_slug:
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
queryset = queryset.filter(ride=ride)
|
||||
except (Park.DoesNotExist, Ride.DoesNotExist):
|
||||
# Return empty queryset if park or ride not found
|
||||
return queryset.none()
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return RidePhotoListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return RidePhotoCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return RidePhotoUpdateInputSerializer
|
||||
else:
|
||||
return RidePhotoOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new ride photo using RideMediaService."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
raise ValidationError("Park and ride slugs are required")
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
except Ride.DoesNotExist:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
try:
|
||||
# Use the service to create the photo with proper business logic
|
||||
photo = RideMediaService.upload_photo(
|
||||
ride=ride,
|
||||
image_file=serializer.validated_data["image"],
|
||||
user=self.request.user,
|
||||
caption=serializer.validated_data.get("caption", ""),
|
||||
alt_text=serializer.validated_data.get("alt_text", ""),
|
||||
photo_type=serializer.validated_data.get("photo_type", "exterior"),
|
||||
is_primary=serializer.validated_data.get("is_primary", False),
|
||||
auto_approve=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Set the instance for the serializer response
|
||||
serializer.instance = photo
|
||||
|
||||
logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride photo: {e}")
|
||||
raise ValidationError(f"Failed to create photo: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update ride photo with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own photos or be an admin.")
|
||||
|
||||
# Handle primary photo logic using service
|
||||
if serializer.validated_data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=instance.ride, photo=instance)
|
||||
# Remove is_primary from validated_data since service handles it
|
||||
if "is_primary" in serializer.validated_data:
|
||||
del serializer.validated_data["is_primary"]
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
raise ValidationError(f"Failed to set primary photo: {str(e)}")
|
||||
|
||||
try:
|
||||
serializer.save()
|
||||
logger.info(f"Updated ride photo {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride photo: {e}")
|
||||
raise ValidationError(f"Failed to update photo: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete ride photo with permission checking."""
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.uploaded_by
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only delete your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete from Cloudflare first if image exists
|
||||
if instance.image:
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(instance.image)
|
||||
logger.info(
|
||||
f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete ride photo from Cloudflare: {str(e)}")
|
||||
# Continue with database deletion even if Cloudflare deletion fails
|
||||
|
||||
RideMediaService.delete_photo(
|
||||
instance, deleted_by=self.request.user
|
||||
)
|
||||
|
||||
logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride photo: {e}")
|
||||
raise ValidationError(f"Failed to delete photo: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
summary="Set photo as primary",
|
||||
description="Set this photo as the primary photo for the ride",
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_primary(self, request, **kwargs):
|
||||
"""Set this photo as the primary photo for the ride."""
|
||||
photo = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
request.user == photo.uploaded_by
|
||||
or getattr(request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"You can only modify your own photos or be an admin."
|
||||
)
|
||||
|
||||
try:
|
||||
success = RideMediaService.set_primary_photo(ride=photo.ride, photo=photo)
|
||||
|
||||
if success:
|
||||
# Refresh the photo instance
|
||||
photo.refresh_from_db()
|
||||
serializer = self.get_serializer(photo)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": "Photo set as primary successfully",
|
||||
"photo": serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Failed to set primary photo"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to set primary photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Bulk approve/reject photos",
|
||||
description="Bulk approve or reject multiple ride photos (admin only)",
|
||||
request=RidePhotoApprovalInputSerializer,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def bulk_approve(self, request, **kwargs):
|
||||
"""Bulk approve or reject multiple photos (admin only)."""
|
||||
if not getattr(request.user, "is_staff", False):
|
||||
raise PermissionDenied("Only administrators can approve photos.")
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = getattr(serializer, "validated_data", {})
|
||||
photo_ids = validated_data.get("photo_ids")
|
||||
approve = validated_data.get("approve")
|
||||
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if photo_ids is None or approve is None:
|
||||
return Response(
|
||||
{"error": "Missing required fields: photo_ids and/or approve."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Filter photos to only those belonging to this ride
|
||||
photos_queryset = RidePhoto.objects.filter(id__in=photo_ids)
|
||||
if park_slug and ride_slug:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
photos_queryset = photos_queryset.filter(ride=ride)
|
||||
|
||||
updated_count = photos_queryset.update(is_approved=approve)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Successfully {'approved' if approve else 'rejected'} {updated_count} photos",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk photo approval: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to update photos: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride photo statistics",
|
||||
description="Get photo statistics for the ride",
|
||||
responses={
|
||||
200: RidePhotoStatsOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get photo statistics for the ride."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return Response(
|
||||
{"error": "Park and ride slugs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found at this park"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
stats = RideMediaService.get_photo_stats(ride)
|
||||
serializer = RidePhotoStatsOutputSerializer(stats)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride photo stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get photo statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Save Cloudflare image as ride photo",
|
||||
description="Save a Cloudflare image as a ride photo after direct upload to Cloudflare",
|
||||
request=OpenApiTypes.OBJECT,
|
||||
responses={
|
||||
201: RidePhotoOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Photos"],
|
||||
)
|
||||
@action(detail=False, methods=["post"])
|
||||
def save_image(self, request, **kwargs):
|
||||
"""Save a Cloudflare image as a ride photo after direct upload to Cloudflare."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return Response(
|
||||
{"error": "Park and ride slugs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found at this park"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
cloudflare_image_id = request.data.get("cloudflare_image_id")
|
||||
if not cloudflare_image_id:
|
||||
return Response(
|
||||
{"error": "cloudflare_image_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import CloudflareImage model and service
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
|
||||
# Always fetch the latest image data from Cloudflare API
|
||||
try:
|
||||
# Get image details from Cloudflare API
|
||||
service = CloudflareImagesService()
|
||||
image_data = service.get_image(cloudflare_image_id)
|
||||
|
||||
if not image_data:
|
||||
return Response(
|
||||
{"error": "Image not found in Cloudflare"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Try to find existing CloudflareImage record by cloudflare_id
|
||||
cloudflare_image = None
|
||||
try:
|
||||
cloudflare_image = CloudflareImage.objects.get(
|
||||
cloudflare_id=cloudflare_image_id)
|
||||
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = 'uploaded'
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get('meta', {})
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get(
|
||||
'result', {}).get('variants', [])
|
||||
cloudflare_image.cloudflare_metadata = image_data
|
||||
cloudflare_image.width = image_data.get('width')
|
||||
cloudflare_image.height = image_data.get('height')
|
||||
cloudflare_image.format = image_data.get('format', '')
|
||||
cloudflare_image.save()
|
||||
|
||||
except CloudflareImage.DoesNotExist:
|
||||
# Create new CloudflareImage record from API response
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=request.user,
|
||||
status='uploaded',
|
||||
upload_url='', # Not needed for uploaded images
|
||||
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
|
||||
uploaded_at=timezone.now(),
|
||||
metadata=image_data.get('meta', {}),
|
||||
# Extract variants from nested result structure
|
||||
variants=image_data.get('result', {}).get('variants', []),
|
||||
cloudflare_metadata=image_data,
|
||||
width=image_data.get('width'),
|
||||
height=image_data.get('height'),
|
||||
format=image_data.get('format', ''),
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
return Response(
|
||||
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Create the ride photo with the CloudflareImage reference
|
||||
photo = RidePhoto.objects.create(
|
||||
ride=ride,
|
||||
image=cloudflare_image,
|
||||
uploaded_by=request.user,
|
||||
caption=request.data.get("caption", ""),
|
||||
alt_text=request.data.get("alt_text", ""),
|
||||
photo_type=request.data.get("photo_type", "exterior"),
|
||||
is_primary=request.data.get("is_primary", False),
|
||||
is_approved=False, # Default to requiring approval
|
||||
)
|
||||
|
||||
# Handle primary photo logic if requested
|
||||
if request.data.get("is_primary", False):
|
||||
try:
|
||||
RideMediaService.set_primary_photo(ride=ride, photo=photo)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting primary photo: {e}")
|
||||
# Don't fail the entire operation, just log the error
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving ride photo: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to save photo: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
380
backend/apps/api/v1/parks/ride_reviews_views.py
Normal file
380
backend/apps/api/v1/parks/ride_reviews_views.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
Ride review API views for ThrillWiki API v1 (nested under parks).
|
||||
|
||||
This module contains ride review ViewSet following the parks pattern for domain consistency.
|
||||
Provides CRUD operations for ride reviews nested under parks/{park_slug}/rides/{ride_slug}/reviews/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import extend_schema_view, extend_schema
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError, NotFound
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.rides.models import Ride
|
||||
from apps.parks.models import Park
|
||||
from apps.api.v1.serializers.ride_reviews import (
|
||||
RideReviewOutputSerializer,
|
||||
RideReviewCreateInputSerializer,
|
||||
RideReviewUpdateInputSerializer,
|
||||
RideReviewListOutputSerializer,
|
||||
RideReviewStatsOutputSerializer,
|
||||
RideReviewModerationInputSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List ride reviews",
|
||||
description="Retrieve a paginated list of ride reviews with filtering capabilities.",
|
||||
responses={200: RideReviewListOutputSerializer(many=True)},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create ride review",
|
||||
description="Create a new review for a ride. Requires authentication.",
|
||||
request=RideReviewCreateInputSerializer,
|
||||
responses={
|
||||
201: RideReviewOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get ride review details",
|
||||
description="Retrieve detailed information about a specific ride review.",
|
||||
responses={
|
||||
200: RideReviewOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update ride review",
|
||||
description="Update ride review information. Requires authentication and ownership or admin privileges.",
|
||||
request=RideReviewUpdateInputSerializer,
|
||||
responses={
|
||||
200: RideReviewOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partially update ride review",
|
||||
description="Partially update ride review information. Requires authentication and ownership or admin privileges.",
|
||||
request=RideReviewUpdateInputSerializer,
|
||||
responses={
|
||||
200: RideReviewOutputSerializer,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete ride review",
|
||||
description="Delete a ride review. Requires authentication and ownership or admin privileges.",
|
||||
responses={
|
||||
204: None,
|
||||
401: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
),
|
||||
)
|
||||
class RideReviewViewSet(ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing ride reviews with full CRUD operations.
|
||||
|
||||
Provides CRUD operations for ride reviews with proper permission checking.
|
||||
Includes advanced features like bulk moderation and statistics.
|
||||
"""
|
||||
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get reviews for the current ride with optimized queries."""
|
||||
queryset = RideReview.objects.select_related(
|
||||
"ride", "ride__park", "user", "user__profile"
|
||||
)
|
||||
|
||||
# Filter by park and ride from URL kwargs
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if park_slug and ride_slug:
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
queryset = queryset.filter(ride=ride)
|
||||
except (Park.DoesNotExist, Ride.DoesNotExist):
|
||||
# Return empty queryset if park or ride not found
|
||||
return queryset.none()
|
||||
|
||||
# Filter published reviews for non-staff users
|
||||
if not (hasattr(self.request, 'user') and
|
||||
getattr(self.request.user, 'is_staff', False)):
|
||||
queryset = queryset.filter(is_published=True)
|
||||
|
||||
return queryset.order_by("-created_at")
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return appropriate serializer based on action."""
|
||||
if self.action == "list":
|
||||
return RideReviewListOutputSerializer
|
||||
elif self.action == "create":
|
||||
return RideReviewCreateInputSerializer
|
||||
elif self.action in ["update", "partial_update"]:
|
||||
return RideReviewUpdateInputSerializer
|
||||
else:
|
||||
return RideReviewOutputSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new ride review."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
raise ValidationError("Park and ride slugs are required")
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
raise NotFound("Park not found")
|
||||
except Ride.DoesNotExist:
|
||||
raise NotFound("Ride not found at this park")
|
||||
|
||||
# Check if user already has a review for this ride
|
||||
if RideReview.objects.filter(ride=ride, user=self.request.user).exists():
|
||||
raise ValidationError("You have already reviewed this ride")
|
||||
|
||||
try:
|
||||
# Save the review
|
||||
review = serializer.save(
|
||||
ride=ride,
|
||||
user=self.request.user,
|
||||
is_published=True # Auto-publish for now, can add moderation later
|
||||
)
|
||||
|
||||
logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ride review: {e}")
|
||||
raise ValidationError(f"Failed to create review: {str(e)}")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Update ride review with permission checking."""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.user
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only edit your own reviews or be an admin.")
|
||||
|
||||
try:
|
||||
serializer.save()
|
||||
logger.info(f"Updated ride review {instance.id} by user {self.request.user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating ride review: {e}")
|
||||
raise ValidationError(f"Failed to update review: {str(e)}")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Delete ride review with permission checking."""
|
||||
# Check permissions - allow owner or staff
|
||||
if not (
|
||||
self.request.user == instance.user
|
||||
or getattr(self.request.user, "is_staff", False)
|
||||
):
|
||||
raise PermissionDenied("You can only delete your own reviews or be an admin.")
|
||||
|
||||
try:
|
||||
logger.info(f"Deleting ride review {instance.id} by user {self.request.user.username}")
|
||||
instance.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting ride review: {e}")
|
||||
raise ValidationError(f"Failed to delete review: {str(e)}")
|
||||
|
||||
@extend_schema(
|
||||
summary="Get ride review statistics",
|
||||
description="Get review statistics for the ride",
|
||||
responses={
|
||||
200: RideReviewStatsOutputSerializer,
|
||||
404: OpenApiTypes.OBJECT,
|
||||
500: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
)
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request, **kwargs):
|
||||
"""Get review statistics for the ride."""
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
if not park_slug or not ride_slug:
|
||||
return Response(
|
||||
{"error": "Park and ride slugs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
except Park.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Park not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Ride.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Ride not found at this park"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get review statistics
|
||||
reviews = RideReview.objects.filter(ride=ride, is_published=True)
|
||||
|
||||
total_reviews = reviews.count()
|
||||
published_reviews = total_reviews # Since we're filtering published
|
||||
pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count()
|
||||
|
||||
# Calculate average rating
|
||||
avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating']
|
||||
|
||||
# Get rating distribution
|
||||
rating_distribution = {}
|
||||
for i in range(1, 11):
|
||||
rating_distribution[str(i)] = reviews.filter(rating=i).count()
|
||||
|
||||
# Get recent reviews count (last 30 days)
|
||||
from datetime import timedelta
|
||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||
recent_reviews = reviews.filter(created_at__gte=thirty_days_ago).count()
|
||||
|
||||
stats = {
|
||||
"total_reviews": total_reviews,
|
||||
"published_reviews": published_reviews,
|
||||
"pending_reviews": pending_reviews,
|
||||
"average_rating": round(avg_rating, 2) if avg_rating else None,
|
||||
"rating_distribution": rating_distribution,
|
||||
"recent_reviews": recent_reviews,
|
||||
}
|
||||
|
||||
serializer = RideReviewStatsOutputSerializer(stats)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ride review stats: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to get review statistics: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
summary="Bulk moderate reviews",
|
||||
description="Bulk moderate multiple ride reviews (admin only)",
|
||||
request=RideReviewModerationInputSerializer,
|
||||
responses={
|
||||
200: OpenApiTypes.OBJECT,
|
||||
400: OpenApiTypes.OBJECT,
|
||||
403: OpenApiTypes.OBJECT,
|
||||
},
|
||||
tags=["Ride Reviews"],
|
||||
)
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def moderate(self, request, **kwargs):
|
||||
"""Bulk moderate multiple reviews (admin only)."""
|
||||
if not getattr(request.user, "is_staff", False):
|
||||
raise PermissionDenied("Only administrators can moderate reviews.")
|
||||
|
||||
serializer = RideReviewModerationInputSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
review_ids = validated_data.get("review_ids")
|
||||
action_type = validated_data.get("action")
|
||||
moderation_notes = validated_data.get("moderation_notes", "")
|
||||
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
ride_slug = self.kwargs.get("ride_slug")
|
||||
|
||||
try:
|
||||
# Filter reviews to only those belonging to this ride
|
||||
reviews_queryset = RideReview.objects.filter(id__in=review_ids)
|
||||
if park_slug and ride_slug:
|
||||
park, _ = Park.get_by_slug(park_slug)
|
||||
ride, _ = Ride.get_by_slug(ride_slug, park=park)
|
||||
reviews_queryset = reviews_queryset.filter(ride=ride)
|
||||
|
||||
if action_type == "publish":
|
||||
updated_count = reviews_queryset.update(
|
||||
is_published=True,
|
||||
moderated_by=request.user,
|
||||
moderated_at=timezone.now(),
|
||||
moderation_notes=moderation_notes
|
||||
)
|
||||
message = f"Successfully published {updated_count} reviews"
|
||||
elif action_type == "unpublish":
|
||||
updated_count = reviews_queryset.update(
|
||||
is_published=False,
|
||||
moderated_by=request.user,
|
||||
moderated_at=timezone.now(),
|
||||
moderation_notes=moderation_notes
|
||||
)
|
||||
message = f"Successfully unpublished {updated_count} reviews"
|
||||
elif action_type == "delete":
|
||||
updated_count = reviews_queryset.count()
|
||||
reviews_queryset.delete()
|
||||
message = f"Successfully deleted {updated_count} reviews"
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Invalid action type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": message,
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in bulk review moderation: {e}")
|
||||
return Response(
|
||||
{"error": f"Failed to moderate reviews: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -17,12 +17,26 @@ from .park_views import (
|
||||
ParkSearchSuggestionsAPIView,
|
||||
ParkImageSettingsAPIView,
|
||||
)
|
||||
from .park_rides_views import (
|
||||
ParkRidesListAPIView,
|
||||
ParkRideDetailAPIView,
|
||||
ParkComprehensiveDetailAPIView,
|
||||
)
|
||||
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
|
||||
from .ride_photos_views import RidePhotoViewSet
|
||||
from .ride_reviews_views import RideReviewViewSet
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
router = DefaultRouter()
|
||||
router.register(r"", ParkPhotoViewSet, basename="park-photo")
|
||||
|
||||
# Create routers for nested ride endpoints
|
||||
ride_photos_router = DefaultRouter()
|
||||
ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
|
||||
|
||||
ride_reviews_router = DefaultRouter()
|
||||
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
|
||||
|
||||
app_name = "api_v1_parks"
|
||||
|
||||
urlpatterns = [
|
||||
@@ -48,6 +62,14 @@ urlpatterns = [
|
||||
),
|
||||
# Detail and action endpoints - supports both ID and slug
|
||||
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
|
||||
|
||||
# Park rides endpoints
|
||||
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"),
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/", ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
|
||||
|
||||
# Comprehensive park detail endpoint with rides summary
|
||||
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"),
|
||||
|
||||
# Park image settings endpoint
|
||||
path(
|
||||
"<int:pk>/image-settings/",
|
||||
@@ -56,4 +78,10 @@ urlpatterns = [
|
||||
),
|
||||
# Park photo endpoints - domain-specific photo management
|
||||
path("<int:park_pk>/photos/", include(router.urls)),
|
||||
|
||||
# Nested ride photo endpoints - photos for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
|
||||
|
||||
# Nested ride review endpoints - reviews for specific rides within parks
|
||||
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
@@ -109,9 +109,16 @@ class ParkPhotoViewSet(ModelViewSet):
|
||||
Includes advanced features like bulk approval and statistics.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Set permissions based on action."""
|
||||
if self.action in ['list', 'retrieve', 'stats']:
|
||||
permission_classes = [AllowAny]
|
||||
else:
|
||||
permission_classes = [IsAuthenticated]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self): # type: ignore[override]
|
||||
"""Get photos for the current park with optimized queries."""
|
||||
queryset = ParkPhoto.objects.select_related(
|
||||
|
||||
@@ -483,15 +483,111 @@ class RideModelFilterOptionsAPIView(APIView):
|
||||
tags=["Ride Models"],
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return filter options for ride models."""
|
||||
"""Return filter options for ride models with Rich Choice Objects metadata."""
|
||||
# Import Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
if not MODELS_AVAILABLE:
|
||||
return Response(
|
||||
# Use Rich Choice Objects for fallback options
|
||||
try:
|
||||
# Get rich choice objects from registry
|
||||
categories = get_choices('categories', 'rides')
|
||||
target_markets = get_choices('target_markets', 'rides')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
categories_data = [
|
||||
{
|
||||
"categories": [("RC", "Roller Coaster"), ("FR", "Flat Ride")],
|
||||
"target_markets": [("THRILL", "Thrill"), ("FAMILY", "Family")],
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard"}],
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
)
|
||||
for choice in categories
|
||||
]
|
||||
|
||||
target_markets_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in target_markets
|
||||
]
|
||||
|
||||
except Exception:
|
||||
# Ultimate fallback with basic structure
|
||||
categories_data = [
|
||||
{"value": "RC", "label": "Roller Coaster", "description": "High-speed thrill rides with tracks", "color": "red", "icon": "roller-coaster", "css_class": "bg-red-100 text-red-800", "sort_order": 1},
|
||||
{"value": "DR", "label": "Dark Ride", "description": "Indoor themed experiences", "color": "purple", "icon": "dark-ride", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2},
|
||||
{"value": "FR", "label": "Flat Ride", "description": "Spinning and rotating attractions", "color": "blue", "icon": "flat-ride", "css_class": "bg-blue-100 text-blue-800", "sort_order": 3},
|
||||
{"value": "WR", "label": "Water Ride", "description": "Water-based attractions and slides", "color": "cyan", "icon": "water-ride", "css_class": "bg-cyan-100 text-cyan-800", "sort_order": 4},
|
||||
{"value": "TR", "label": "Transport", "description": "Transportation systems within parks", "color": "green", "icon": "transport", "css_class": "bg-green-100 text-green-800", "sort_order": 5},
|
||||
{"value": "OT", "label": "Other", "description": "Miscellaneous attractions", "color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
|
||||
]
|
||||
target_markets_data = [
|
||||
{"value": "FAMILY", "label": "Family", "description": "Suitable for all family members", "color": "green", "icon": "family", "css_class": "bg-green-100 text-green-800", "sort_order": 1},
|
||||
{"value": "THRILL", "label": "Thrill", "description": "High-intensity thrill experience", "color": "orange", "icon": "thrill", "css_class": "bg-orange-100 text-orange-800", "sort_order": 2},
|
||||
{"value": "EXTREME", "label": "Extreme", "description": "Maximum intensity experience", "color": "red", "icon": "extreme", "css_class": "bg-red-100 text-red-800", "sort_order": 3},
|
||||
{"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4},
|
||||
{"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
|
||||
]
|
||||
|
||||
return Response({
|
||||
"categories": categories_data,
|
||||
"target_markets": target_markets_data,
|
||||
"manufacturers": [{"id": 1, "name": "Bolliger & Mabillard", "slug": "bolliger-mabillard"}],
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name A-Z"},
|
||||
{"value": "-name", "label": "Name Z-A"},
|
||||
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
|
||||
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
|
||||
{"value": "first_installation_year", "label": "Oldest First"},
|
||||
{"value": "-first_installation_year", "label": "Newest First"},
|
||||
{"value": "total_installations", "label": "Fewest Installations"},
|
||||
{"value": "-total_installations", "label": "Most Installations"},
|
||||
],
|
||||
})
|
||||
|
||||
# Get static choice definitions from Rich Choice Objects (primary source)
|
||||
# Get dynamic data from database queries
|
||||
|
||||
# Get rich choice objects from registry
|
||||
categories = get_choices('categories', 'rides')
|
||||
target_markets = get_choices('target_markets', 'rides')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
categories_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in categories
|
||||
]
|
||||
|
||||
target_markets_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in target_markets
|
||||
]
|
||||
|
||||
# Get actual data from database
|
||||
manufacturers = (
|
||||
@@ -502,48 +598,22 @@ class RideModelFilterOptionsAPIView(APIView):
|
||||
.values("id", "name", "slug")
|
||||
)
|
||||
|
||||
(
|
||||
RideModel.objects.exclude(category="")
|
||||
.values_list("category", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
(
|
||||
RideModel.objects.exclude(target_market="")
|
||||
.values_list("target_market", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"categories": [
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
"target_markets": [
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
return Response({
|
||||
"categories": categories_data,
|
||||
"target_markets": target_markets_data,
|
||||
"manufacturers": list(manufacturers),
|
||||
"ordering_options": [
|
||||
("name", "Name A-Z"),
|
||||
("-name", "Name Z-A"),
|
||||
("manufacturer__name", "Manufacturer A-Z"),
|
||||
("-manufacturer__name", "Manufacturer Z-A"),
|
||||
("first_installation_year", "Oldest First"),
|
||||
("-first_installation_year", "Newest First"),
|
||||
("total_installations", "Fewest Installations"),
|
||||
("-total_installations", "Most Installations"),
|
||||
{"value": "name", "label": "Name A-Z"},
|
||||
{"value": "-name", "label": "Name Z-A"},
|
||||
{"value": "manufacturer__name", "label": "Manufacturer A-Z"},
|
||||
{"value": "-manufacturer__name", "label": "Manufacturer Z-A"},
|
||||
{"value": "first_installation_year", "label": "Oldest First"},
|
||||
{"value": "-first_installation_year", "label": "Newest First"},
|
||||
{"value": "total_installations", "label": "Fewest Installations"},
|
||||
{"value": "-total_installations", "label": "Most Installations"},
|
||||
],
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
|
||||
# === RIDE MODEL STATISTICS ===
|
||||
|
||||
@@ -304,7 +304,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
coaster_track_material = serializers.SerializerMethodField()
|
||||
coaster_roller_coaster_type = serializers.SerializerMethodField()
|
||||
coaster_max_drop_height_ft = serializers.SerializerMethodField()
|
||||
coaster_launch_type = serializers.SerializerMethodField()
|
||||
coaster_propulsion_system = serializers.SerializerMethodField()
|
||||
coaster_train_style = serializers.SerializerMethodField()
|
||||
coaster_trains_count = serializers.SerializerMethodField()
|
||||
coaster_cars_per_train = serializers.SerializerMethodField()
|
||||
@@ -439,11 +439,11 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_coaster_launch_type(self, obj):
|
||||
"""Get roller coaster launch type."""
|
||||
def get_coaster_propulsion_system(self, obj):
|
||||
"""Get roller coaster propulsion system."""
|
||||
try:
|
||||
if hasattr(obj, 'coaster_stats') and obj.coaster_stats:
|
||||
return obj.coaster_stats.launch_type
|
||||
return obj.coaster_stats.propulsion_system
|
||||
return None
|
||||
except AttributeError:
|
||||
return None
|
||||
@@ -561,7 +561,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
"coaster_track_material",
|
||||
"coaster_roller_coaster_type",
|
||||
"coaster_max_drop_height_ft",
|
||||
"coaster_launch_type",
|
||||
"coaster_propulsion_system",
|
||||
"coaster_train_style",
|
||||
"coaster_trains_count",
|
||||
"coaster_cars_per_train",
|
||||
|
||||
@@ -13,7 +13,7 @@ Notes:
|
||||
are not present, they return a clear 501 response explaining what to wire up.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
@@ -38,7 +38,6 @@ from apps.api.v1.serializers.rides import (
|
||||
)
|
||||
|
||||
# Import hybrid filtering components
|
||||
from apps.api.v1.rides.serializers import HybridRideSerializer
|
||||
from apps.rides.services.hybrid_loader import SmartRideLoader
|
||||
|
||||
# Create smart loader instance
|
||||
@@ -47,12 +46,14 @@ smart_ride_loader = SmartRideLoader()
|
||||
# Attempt to import model-level helpers; fall back gracefully if not present.
|
||||
try:
|
||||
from apps.rides.models import Ride, RideModel
|
||||
from apps.rides.models.rides import RollerCoasterStats
|
||||
from apps.parks.models import Park, Company
|
||||
|
||||
MODELS_AVAILABLE = True
|
||||
except Exception:
|
||||
Ride = None # type: ignore
|
||||
RideModel = None # type: ignore
|
||||
RollerCoasterStats = None # type: ignore
|
||||
Company = None # type: ignore
|
||||
Park = None # type: ignore
|
||||
MODELS_AVAILABLE = False
|
||||
@@ -172,10 +173,10 @@ class RideListCreateAPIView(APIView):
|
||||
description="Filter roller coasters by track material (STEEL, WOOD, HYBRID)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="launch_type",
|
||||
name="propulsion_system",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter roller coasters by launch type (CHAIN, LSM, HYDRAULIC, etc.)",
|
||||
description="Filter roller coasters by propulsion system (CHAIN, LSM, HYDRAULIC, etc.)",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="min_rating",
|
||||
@@ -307,181 +308,233 @@ class RideListCreateAPIView(APIView):
|
||||
.prefetch_related("coaster_stats")
|
||||
) # type: ignore
|
||||
|
||||
# Text search
|
||||
search = request.query_params.get("search")
|
||||
# Apply comprehensive filtering
|
||||
qs = self._apply_filters(qs, request.query_params)
|
||||
|
||||
# Apply ordering
|
||||
qs = self._apply_ordering(qs, request.query_params)
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
serializer = RideListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
def _apply_filters(self, qs, params):
|
||||
"""Apply all filtering to the queryset."""
|
||||
qs = self._apply_search_filters(qs, params)
|
||||
qs = self._apply_park_filters(qs, params)
|
||||
qs = self._apply_category_status_filters(qs, params)
|
||||
qs = self._apply_company_filters(qs, params)
|
||||
qs = self._apply_ride_model_filters(qs, params)
|
||||
qs = self._apply_rating_filters(qs, params)
|
||||
qs = self._apply_height_requirement_filters(qs, params)
|
||||
qs = self._apply_capacity_filters(qs, params)
|
||||
qs = self._apply_opening_year_filters(qs, params)
|
||||
qs = self._apply_roller_coaster_filters(qs, params)
|
||||
return qs
|
||||
|
||||
def _apply_search_filters(self, qs, params):
|
||||
"""Apply text search filtering."""
|
||||
search = params.get("search")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
models.Q(name__icontains=search)
|
||||
| models.Q(description__icontains=search)
|
||||
| models.Q(park__name__icontains=search)
|
||||
)
|
||||
return qs
|
||||
|
||||
# Park filters
|
||||
park_slug = request.query_params.get("park_slug")
|
||||
def _apply_park_filters(self, qs, params):
|
||||
"""Apply park-related filtering."""
|
||||
park_slug = params.get("park_slug")
|
||||
if park_slug:
|
||||
qs = qs.filter(park__slug=park_slug)
|
||||
|
||||
park_id = request.query_params.get("park_id")
|
||||
park_id = params.get("park_id")
|
||||
if park_id:
|
||||
try:
|
||||
qs = qs.filter(park_id=int(park_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Category filters (multiple values supported)
|
||||
categories = request.query_params.getlist("category")
|
||||
return qs
|
||||
|
||||
def _apply_category_status_filters(self, qs, params):
|
||||
"""Apply category and status filtering."""
|
||||
categories = params.getlist("category")
|
||||
if categories:
|
||||
qs = qs.filter(category__in=categories)
|
||||
|
||||
# Status filters (multiple values supported)
|
||||
statuses = request.query_params.getlist("status")
|
||||
statuses = params.getlist("status")
|
||||
if statuses:
|
||||
qs = qs.filter(status__in=statuses)
|
||||
|
||||
# Manufacturer filters
|
||||
manufacturer_id = request.query_params.get("manufacturer_id")
|
||||
return qs
|
||||
|
||||
def _apply_company_filters(self, qs, params):
|
||||
"""Apply manufacturer and designer filtering."""
|
||||
manufacturer_id = params.get("manufacturer_id")
|
||||
if manufacturer_id:
|
||||
try:
|
||||
qs = qs.filter(manufacturer_id=int(manufacturer_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
manufacturer_slug = request.query_params.get("manufacturer_slug")
|
||||
manufacturer_slug = params.get("manufacturer_slug")
|
||||
if manufacturer_slug:
|
||||
qs = qs.filter(manufacturer__slug=manufacturer_slug)
|
||||
|
||||
# Designer filters
|
||||
designer_id = request.query_params.get("designer_id")
|
||||
designer_id = params.get("designer_id")
|
||||
if designer_id:
|
||||
try:
|
||||
qs = qs.filter(designer_id=int(designer_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
designer_slug = request.query_params.get("designer_slug")
|
||||
designer_slug = params.get("designer_slug")
|
||||
if designer_slug:
|
||||
qs = qs.filter(designer__slug=designer_slug)
|
||||
|
||||
# Ride model filters
|
||||
ride_model_id = request.query_params.get("ride_model_id")
|
||||
return qs
|
||||
|
||||
def _apply_ride_model_filters(self, qs, params):
|
||||
"""Apply ride model filtering."""
|
||||
ride_model_id = params.get("ride_model_id")
|
||||
if ride_model_id:
|
||||
try:
|
||||
qs = qs.filter(ride_model_id=int(ride_model_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
ride_model_slug = request.query_params.get("ride_model_slug")
|
||||
manufacturer_slug_for_model = request.query_params.get("manufacturer_slug")
|
||||
ride_model_slug = params.get("ride_model_slug")
|
||||
manufacturer_slug_for_model = params.get("manufacturer_slug")
|
||||
if ride_model_slug and manufacturer_slug_for_model:
|
||||
qs = qs.filter(
|
||||
ride_model__slug=ride_model_slug,
|
||||
ride_model__manufacturer__slug=manufacturer_slug_for_model,
|
||||
)
|
||||
|
||||
# Rating filters
|
||||
min_rating = request.query_params.get("min_rating")
|
||||
return qs
|
||||
|
||||
def _apply_rating_filters(self, qs, params):
|
||||
"""Apply rating-based filtering."""
|
||||
min_rating = params.get("min_rating")
|
||||
if min_rating:
|
||||
try:
|
||||
qs = qs.filter(average_rating__gte=float(min_rating))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_rating = request.query_params.get("max_rating")
|
||||
max_rating = params.get("max_rating")
|
||||
if max_rating:
|
||||
try:
|
||||
qs = qs.filter(average_rating__lte=float(max_rating))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Height requirement filters
|
||||
min_height_req = request.query_params.get("min_height_requirement")
|
||||
return qs
|
||||
|
||||
def _apply_height_requirement_filters(self, qs, params):
|
||||
"""Apply height requirement filtering."""
|
||||
min_height_req = params.get("min_height_requirement")
|
||||
if min_height_req:
|
||||
try:
|
||||
qs = qs.filter(min_height_in__gte=int(min_height_req))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_height_req = request.query_params.get("max_height_requirement")
|
||||
max_height_req = params.get("max_height_requirement")
|
||||
if max_height_req:
|
||||
try:
|
||||
qs = qs.filter(max_height_in__lte=int(max_height_req))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Capacity filters
|
||||
min_capacity = request.query_params.get("min_capacity")
|
||||
return qs
|
||||
|
||||
def _apply_capacity_filters(self, qs, params):
|
||||
"""Apply capacity filtering."""
|
||||
min_capacity = params.get("min_capacity")
|
||||
if min_capacity:
|
||||
try:
|
||||
qs = qs.filter(capacity_per_hour__gte=int(min_capacity))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_capacity = request.query_params.get("max_capacity")
|
||||
max_capacity = params.get("max_capacity")
|
||||
if max_capacity:
|
||||
try:
|
||||
qs = qs.filter(capacity_per_hour__lte=int(max_capacity))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Opening year filters
|
||||
opening_year = request.query_params.get("opening_year")
|
||||
return qs
|
||||
|
||||
def _apply_opening_year_filters(self, qs, params):
|
||||
"""Apply opening year filtering."""
|
||||
opening_year = params.get("opening_year")
|
||||
if opening_year:
|
||||
try:
|
||||
qs = qs.filter(opening_date__year=int(opening_year))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
min_opening_year = request.query_params.get("min_opening_year")
|
||||
min_opening_year = params.get("min_opening_year")
|
||||
if min_opening_year:
|
||||
try:
|
||||
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_opening_year = request.query_params.get("max_opening_year")
|
||||
max_opening_year = params.get("max_opening_year")
|
||||
if max_opening_year:
|
||||
try:
|
||||
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Roller coaster specific filters
|
||||
roller_coaster_type = request.query_params.get("roller_coaster_type")
|
||||
return qs
|
||||
|
||||
def _apply_roller_coaster_filters(self, qs, params):
|
||||
"""Apply roller coaster specific filtering."""
|
||||
roller_coaster_type = params.get("roller_coaster_type")
|
||||
if roller_coaster_type:
|
||||
qs = qs.filter(coaster_stats__roller_coaster_type=roller_coaster_type)
|
||||
|
||||
track_material = request.query_params.get("track_material")
|
||||
track_material = params.get("track_material")
|
||||
if track_material:
|
||||
qs = qs.filter(coaster_stats__track_material=track_material)
|
||||
|
||||
launch_type = request.query_params.get("launch_type")
|
||||
if launch_type:
|
||||
qs = qs.filter(coaster_stats__launch_type=launch_type)
|
||||
propulsion_system = params.get("propulsion_system")
|
||||
if propulsion_system:
|
||||
qs = qs.filter(coaster_stats__propulsion_system=propulsion_system)
|
||||
|
||||
# Roller coaster height filters
|
||||
min_height_ft = request.query_params.get("min_height_ft")
|
||||
# Height filters
|
||||
min_height_ft = params.get("min_height_ft")
|
||||
if min_height_ft:
|
||||
try:
|
||||
qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_height_ft = request.query_params.get("max_height_ft")
|
||||
max_height_ft = params.get("max_height_ft")
|
||||
if max_height_ft:
|
||||
try:
|
||||
qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Roller coaster speed filters
|
||||
min_speed_mph = request.query_params.get("min_speed_mph")
|
||||
# Speed filters
|
||||
min_speed_mph = params.get("min_speed_mph")
|
||||
if min_speed_mph:
|
||||
try:
|
||||
qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_speed_mph = request.query_params.get("max_speed_mph")
|
||||
max_speed_mph = params.get("max_speed_mph")
|
||||
if max_speed_mph:
|
||||
try:
|
||||
qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph))
|
||||
@@ -489,29 +542,32 @@ class RideListCreateAPIView(APIView):
|
||||
pass
|
||||
|
||||
# Inversion filters
|
||||
min_inversions = request.query_params.get("min_inversions")
|
||||
min_inversions = params.get("min_inversions")
|
||||
if min_inversions:
|
||||
try:
|
||||
qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
max_inversions = request.query_params.get("max_inversions")
|
||||
max_inversions = params.get("max_inversions")
|
||||
if max_inversions:
|
||||
try:
|
||||
qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
has_inversions = request.query_params.get("has_inversions")
|
||||
has_inversions = params.get("has_inversions")
|
||||
if has_inversions is not None:
|
||||
if has_inversions.lower() in ["true", "1", "yes"]:
|
||||
qs = qs.filter(coaster_stats__inversions__gt=0)
|
||||
elif has_inversions.lower() in ["false", "0", "no"]:
|
||||
qs = qs.filter(coaster_stats__inversions=0)
|
||||
|
||||
# Ordering
|
||||
ordering = request.query_params.get("ordering", "name")
|
||||
return qs
|
||||
|
||||
def _apply_ordering(self, qs, params):
|
||||
"""Apply ordering to the queryset."""
|
||||
ordering = params.get("ordering", "name")
|
||||
valid_orderings = [
|
||||
"name",
|
||||
"-name",
|
||||
@@ -539,12 +595,7 @@ class RideListCreateAPIView(APIView):
|
||||
else:
|
||||
qs = qs.order_by(ordering)
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
serializer = RideListOutputSerializer(
|
||||
page, many=True, context={"request": request}
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
return qs
|
||||
|
||||
@extend_schema(
|
||||
summary="Create a new ride",
|
||||
@@ -698,28 +749,169 @@ class FilterOptionsAPIView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Return comprehensive filter options with all possible ride model fields and attributes."""
|
||||
"""Return comprehensive filter options with Rich Choice Objects metadata."""
|
||||
# Import Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
if not MODELS_AVAILABLE:
|
||||
# Comprehensive fallback options with all possible fields
|
||||
# Use Rich Choice Objects for fallback options
|
||||
try:
|
||||
# Get rich choice objects from registry
|
||||
categories = get_choices('categories', 'rides')
|
||||
statuses = get_choices('statuses', 'rides')
|
||||
post_closing_statuses = get_choices('post_closing_statuses', 'rides')
|
||||
track_materials = get_choices('track_materials', 'rides')
|
||||
coaster_types = get_choices('coaster_types', 'rides')
|
||||
propulsion_systems = get_choices('propulsion_systems', 'rides')
|
||||
target_markets = get_choices('target_markets', 'rides')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
categories_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in categories
|
||||
]
|
||||
|
||||
statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in statuses
|
||||
]
|
||||
|
||||
post_closing_statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in post_closing_statuses
|
||||
]
|
||||
|
||||
track_materials_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in track_materials
|
||||
]
|
||||
|
||||
coaster_types_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in coaster_types
|
||||
]
|
||||
|
||||
propulsion_systems_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in propulsion_systems
|
||||
]
|
||||
|
||||
target_markets_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in target_markets
|
||||
]
|
||||
|
||||
except Exception:
|
||||
# Ultimate fallback with basic structure
|
||||
categories_data = [
|
||||
{"value": "RC", "label": "Roller Coaster", "description": "High-speed thrill rides with tracks", "color": "red", "icon": "roller-coaster", "css_class": "bg-red-100 text-red-800", "sort_order": 1},
|
||||
{"value": "DR", "label": "Dark Ride", "description": "Indoor themed experiences", "color": "purple", "icon": "dark-ride", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2},
|
||||
{"value": "FR", "label": "Flat Ride", "description": "Spinning and rotating attractions", "color": "blue", "icon": "flat-ride", "css_class": "bg-blue-100 text-blue-800", "sort_order": 3},
|
||||
{"value": "WR", "label": "Water Ride", "description": "Water-based attractions and slides", "color": "cyan", "icon": "water-ride", "css_class": "bg-cyan-100 text-cyan-800", "sort_order": 4},
|
||||
{"value": "TR", "label": "Transport", "description": "Transportation systems within parks", "color": "green", "icon": "transport", "css_class": "bg-green-100 text-green-800", "sort_order": 5},
|
||||
{"value": "OT", "label": "Other", "description": "Miscellaneous attractions", "color": "gray", "icon": "other", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
|
||||
]
|
||||
statuses_data = [
|
||||
{"value": "OPERATING", "label": "Operating", "description": "Ride is currently open and operating", "color": "green", "icon": "check-circle", "css_class": "bg-green-100 text-green-800", "sort_order": 1},
|
||||
{"value": "CLOSED_TEMP", "label": "Temporarily Closed", "description": "Ride is temporarily closed for maintenance", "color": "yellow", "icon": "pause-circle", "css_class": "bg-yellow-100 text-yellow-800", "sort_order": 2},
|
||||
{"value": "SBNO", "label": "Standing But Not Operating", "description": "Ride exists but is not operational", "color": "orange", "icon": "stop-circle", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3},
|
||||
{"value": "CLOSING", "label": "Closing", "description": "Ride is scheduled to close permanently", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 4},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed", "description": "Ride has been permanently closed", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 5},
|
||||
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction", "description": "Ride is currently being built", "color": "blue", "icon": "tool", "css_class": "bg-blue-100 text-blue-800", "sort_order": 6},
|
||||
{"value": "DEMOLISHED", "label": "Demolished", "description": "Ride has been completely removed", "color": "gray", "icon": "trash", "css_class": "bg-gray-100 text-gray-800", "sort_order": 7},
|
||||
{"value": "RELOCATED", "label": "Relocated", "description": "Ride has been moved to another location", "color": "purple", "icon": "arrow-right", "css_class": "bg-purple-100 text-purple-800", "sort_order": 8},
|
||||
]
|
||||
post_closing_statuses_data = [
|
||||
{"value": "SBNO", "label": "Standing But Not Operating", "description": "Ride exists but is not operational", "color": "orange", "icon": "stop-circle", "css_class": "bg-orange-100 text-orange-800", "sort_order": 1},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed", "description": "Ride has been permanently closed", "color": "red", "icon": "x-circle", "css_class": "bg-red-100 text-red-800", "sort_order": 2},
|
||||
]
|
||||
track_materials_data = [
|
||||
{"value": "STEEL", "label": "Steel", "description": "Modern steel track construction", "color": "gray", "icon": "steel", "css_class": "bg-gray-100 text-gray-800", "sort_order": 1},
|
||||
{"value": "WOOD", "label": "Wood", "description": "Traditional wooden track construction", "color": "amber", "icon": "wood", "css_class": "bg-amber-100 text-amber-800", "sort_order": 2},
|
||||
{"value": "HYBRID", "label": "Hybrid", "description": "Steel track on wooden structure", "color": "orange", "icon": "hybrid", "css_class": "bg-orange-100 text-orange-800", "sort_order": 3},
|
||||
]
|
||||
coaster_types_data = [
|
||||
{"value": "SITDOWN", "label": "Sit Down", "description": "Traditional seated roller coaster", "color": "blue", "icon": "sitdown", "css_class": "bg-blue-100 text-blue-800", "sort_order": 1},
|
||||
{"value": "INVERTED", "label": "Inverted", "description": "Track above riders, feet dangle", "color": "purple", "icon": "inverted", "css_class": "bg-purple-100 text-purple-800", "sort_order": 2},
|
||||
{"value": "FLYING", "label": "Flying", "description": "Riders positioned face-down", "color": "sky", "icon": "flying", "css_class": "bg-sky-100 text-sky-800", "sort_order": 3},
|
||||
{"value": "STANDUP", "label": "Stand Up", "description": "Riders stand during the ride", "color": "green", "icon": "standup", "css_class": "bg-green-100 text-green-800", "sort_order": 4},
|
||||
{"value": "WING", "label": "Wing", "description": "Seats extend beyond track sides", "color": "indigo", "icon": "wing", "css_class": "bg-indigo-100 text-indigo-800", "sort_order": 5},
|
||||
{"value": "DIVE", "label": "Dive", "description": "Features steep vertical drops", "color": "red", "icon": "dive", "css_class": "bg-red-100 text-red-800", "sort_order": 6},
|
||||
]
|
||||
propulsion_systems_data = [
|
||||
{"value": "CHAIN", "label": "Chain Lift", "description": "Traditional chain lift hill", "color": "gray", "icon": "chain", "css_class": "bg-gray-100 text-gray-800", "sort_order": 1},
|
||||
{"value": "LSM", "label": "LSM Launch", "description": "Linear synchronous motor launch", "color": "blue", "icon": "lightning", "css_class": "bg-blue-100 text-blue-800", "sort_order": 2},
|
||||
{"value": "HYDRAULIC", "label": "Hydraulic Launch", "description": "High-pressure hydraulic launch", "color": "red", "icon": "hydraulic", "css_class": "bg-red-100 text-red-800", "sort_order": 3},
|
||||
{"value": "GRAVITY", "label": "Gravity", "description": "Gravity-powered ride", "color": "green", "icon": "gravity", "css_class": "bg-green-100 text-green-800", "sort_order": 4},
|
||||
]
|
||||
target_markets_data = [
|
||||
{"value": "FAMILY", "label": "Family", "description": "Suitable for all family members", "color": "green", "icon": "family", "css_class": "bg-green-100 text-green-800", "sort_order": 1},
|
||||
{"value": "THRILL", "label": "Thrill", "description": "High-intensity thrill experience", "color": "orange", "icon": "thrill", "css_class": "bg-orange-100 text-orange-800", "sort_order": 2},
|
||||
{"value": "EXTREME", "label": "Extreme", "description": "Maximum intensity experience", "color": "red", "icon": "extreme", "css_class": "bg-red-100 text-red-800", "sort_order": 3},
|
||||
{"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4},
|
||||
{"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
|
||||
]
|
||||
|
||||
# Comprehensive fallback options with Rich Choice Objects metadata
|
||||
return Response({
|
||||
"categories": [
|
||||
{"value": "RC", "label": "Roller Coaster"},
|
||||
{"value": "DR", "label": "Dark Ride"},
|
||||
{"value": "FR", "label": "Flat Ride"},
|
||||
{"value": "WR", "label": "Water Ride"},
|
||||
{"value": "TR", "label": "Transport"},
|
||||
{"value": "OT", "label": "Other"},
|
||||
],
|
||||
"statuses": [
|
||||
{"value": "OPERATING", "label": "Operating"},
|
||||
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
||||
{"value": "SBNO", "label": "Standing But Not Operating"},
|
||||
{"value": "CLOSING", "label": "Closing"},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
||||
{"value": "DEMOLISHED", "label": "Demolished"},
|
||||
{"value": "RELOCATED", "label": "Relocated"},
|
||||
],
|
||||
"categories": categories_data,
|
||||
"statuses": statuses_data,
|
||||
"post_closing_statuses": [
|
||||
{"value": "SBNO", "label": "Standing But Not Operating"},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||
@@ -742,7 +934,7 @@ class FilterOptionsAPIView(APIView):
|
||||
{"value": "WOOD", "label": "Wood"},
|
||||
{"value": "HYBRID", "label": "Hybrid"},
|
||||
],
|
||||
"launch_types": [
|
||||
"propulsion_systems": [
|
||||
{"value": "CHAIN", "label": "Chain Lift"},
|
||||
{"value": "LSM", "label": "LSM Launch"},
|
||||
{"value": "HYDRAULIC", "label": "Hydraulic Launch"},
|
||||
@@ -818,49 +1010,108 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
})
|
||||
|
||||
# Try to get dynamic options from database
|
||||
try:
|
||||
# Get all ride categories from model choices
|
||||
categories = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Ride.CATEGORY_CHOICES if choice[0] # Skip empty choice
|
||||
# Get static choice definitions from Rich Choice Objects (primary source)
|
||||
# Get dynamic data from database queries
|
||||
|
||||
# Get rich choice objects from registry
|
||||
categories = get_choices('categories', 'rides')
|
||||
statuses = get_choices('statuses', 'rides')
|
||||
post_closing_statuses = get_choices('post_closing_statuses', 'rides')
|
||||
track_materials = get_choices('track_materials', 'rides')
|
||||
coaster_types = get_choices('coaster_types', 'rides')
|
||||
propulsion_systems = get_choices('propulsion_systems', 'rides')
|
||||
target_markets = get_choices('target_markets', 'rides')
|
||||
|
||||
# Convert Rich Choice Objects to frontend format with metadata
|
||||
categories_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in categories
|
||||
]
|
||||
|
||||
# Get all ride statuses from model choices
|
||||
statuses = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Ride.STATUS_CHOICES if choice[0] # Skip empty choice
|
||||
statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in statuses
|
||||
]
|
||||
|
||||
# Get post-closing statuses from model choices
|
||||
post_closing_statuses = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in Ride.POST_CLOSING_STATUS_CHOICES
|
||||
post_closing_statuses_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in post_closing_statuses
|
||||
]
|
||||
|
||||
# Get roller coaster types from model choices
|
||||
from apps.rides.models.rides import RollerCoasterStats
|
||||
roller_coaster_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in RollerCoasterStats.COASTER_TYPE_CHOICES
|
||||
track_materials_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in track_materials
|
||||
]
|
||||
|
||||
# Get track materials from model choices
|
||||
track_materials = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in RollerCoasterStats.TRACK_MATERIAL_CHOICES
|
||||
coaster_types_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in coaster_types
|
||||
]
|
||||
|
||||
# Get launch types from model choices
|
||||
launch_types = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in RollerCoasterStats.LAUNCH_CHOICES
|
||||
propulsion_systems_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in propulsion_systems
|
||||
]
|
||||
|
||||
# Get ride model target markets from model choices
|
||||
ride_model_target_markets = [
|
||||
{"value": choice[0], "label": choice[1]}
|
||||
for choice in RideModel._meta.get_field('target_market').choices
|
||||
target_markets_data = [
|
||||
{
|
||||
"value": choice.value,
|
||||
"label": choice.label,
|
||||
"description": choice.description,
|
||||
"color": choice.metadata.get('color'),
|
||||
"icon": choice.metadata.get('icon'),
|
||||
"css_class": choice.metadata.get('css_class'),
|
||||
"sort_order": choice.metadata.get('sort_order', 0)
|
||||
}
|
||||
for choice in target_markets
|
||||
]
|
||||
|
||||
# Get parks data from database
|
||||
@@ -1018,13 +1269,13 @@ class FilterOptionsAPIView(APIView):
|
||||
}
|
||||
|
||||
return Response({
|
||||
"categories": categories,
|
||||
"statuses": statuses,
|
||||
"post_closing_statuses": post_closing_statuses,
|
||||
"roller_coaster_types": roller_coaster_types,
|
||||
"track_materials": track_materials,
|
||||
"launch_types": launch_types,
|
||||
"ride_model_target_markets": ride_model_target_markets,
|
||||
"categories": categories_data,
|
||||
"statuses": statuses_data,
|
||||
"post_closing_statuses": post_closing_statuses_data,
|
||||
"roller_coaster_types": coaster_types_data,
|
||||
"track_materials": track_materials_data,
|
||||
"propulsion_systems": propulsion_systems_data,
|
||||
"ride_model_target_markets": target_markets_data,
|
||||
"parks": parks,
|
||||
"park_areas": park_areas,
|
||||
"manufacturers": manufacturers,
|
||||
@@ -1072,124 +1323,6 @@ class FilterOptionsAPIView(APIView):
|
||||
],
|
||||
})
|
||||
|
||||
except Exception:
|
||||
# Fallback to static options if database query fails
|
||||
return Response({
|
||||
"categories": [
|
||||
{"value": "RC", "label": "Roller Coaster"},
|
||||
{"value": "DR", "label": "Dark Ride"},
|
||||
{"value": "FR", "label": "Flat Ride"},
|
||||
{"value": "WR", "label": "Water Ride"},
|
||||
{"value": "TR", "label": "Transport"},
|
||||
{"value": "OT", "label": "Other"},
|
||||
],
|
||||
"statuses": [
|
||||
{"value": "OPERATING", "label": "Operating"},
|
||||
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
||||
{"value": "SBNO", "label": "Standing But Not Operating"},
|
||||
{"value": "CLOSING", "label": "Closing"},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
||||
{"value": "DEMOLISHED", "label": "Demolished"},
|
||||
{"value": "RELOCATED", "label": "Relocated"},
|
||||
],
|
||||
"post_closing_statuses": [
|
||||
{"value": "SBNO", "label": "Standing But Not Operating"},
|
||||
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||
],
|
||||
"roller_coaster_types": [
|
||||
{"value": "SITDOWN", "label": "Sit Down"},
|
||||
{"value": "INVERTED", "label": "Inverted"},
|
||||
{"value": "FLYING", "label": "Flying"},
|
||||
{"value": "STANDUP", "label": "Stand Up"},
|
||||
{"value": "WING", "label": "Wing"},
|
||||
{"value": "DIVE", "label": "Dive"},
|
||||
{"value": "FAMILY", "label": "Family"},
|
||||
{"value": "WILD_MOUSE", "label": "Wild Mouse"},
|
||||
{"value": "SPINNING", "label": "Spinning"},
|
||||
{"value": "FOURTH_DIMENSION", "label": "4th Dimension"},
|
||||
{"value": "OTHER", "label": "Other"},
|
||||
],
|
||||
"track_materials": [
|
||||
{"value": "STEEL", "label": "Steel"},
|
||||
{"value": "WOOD", "label": "Wood"},
|
||||
{"value": "HYBRID", "label": "Hybrid"},
|
||||
],
|
||||
"launch_types": [
|
||||
{"value": "CHAIN", "label": "Chain Lift"},
|
||||
{"value": "LSM", "label": "LSM Launch"},
|
||||
{"value": "HYDRAULIC", "label": "Hydraulic Launch"},
|
||||
{"value": "GRAVITY", "label": "Gravity"},
|
||||
{"value": "OTHER", "label": "Other"},
|
||||
],
|
||||
"ride_model_target_markets": [
|
||||
{"value": "FAMILY", "label": "Family"},
|
||||
{"value": "THRILL", "label": "Thrill"},
|
||||
{"value": "EXTREME", "label": "Extreme"},
|
||||
{"value": "KIDDIE", "label": "Kiddie"},
|
||||
{"value": "ALL_AGES", "label": "All Ages"},
|
||||
],
|
||||
"parks": [],
|
||||
"park_areas": [],
|
||||
"manufacturers": [],
|
||||
"designers": [],
|
||||
"ride_models": [],
|
||||
"ranges": {
|
||||
"rating": {"min": 1, "max": 10, "step": 0.1, "unit": "stars"},
|
||||
"height_requirement": {"min": 30, "max": 90, "step": 1, "unit": "inches"},
|
||||
"capacity": {"min": 0, "max": 5000, "step": 50, "unit": "riders/hour"},
|
||||
"ride_duration": {"min": 0, "max": 600, "step": 10, "unit": "seconds"},
|
||||
"height_ft": {"min": 0, "max": 500, "step": 5, "unit": "feet"},
|
||||
"length_ft": {"min": 0, "max": 10000, "step": 100, "unit": "feet"},
|
||||
"speed_mph": {"min": 0, "max": 150, "step": 5, "unit": "mph"},
|
||||
"inversions": {"min": 0, "max": 20, "step": 1, "unit": "inversions"},
|
||||
"ride_time": {"min": 0, "max": 600, "step": 10, "unit": "seconds"},
|
||||
"max_drop_height_ft": {"min": 0, "max": 500, "step": 10, "unit": "feet"},
|
||||
"trains_count": {"min": 1, "max": 10, "step": 1, "unit": "trains"},
|
||||
"cars_per_train": {"min": 1, "max": 20, "step": 1, "unit": "cars"},
|
||||
"seats_per_car": {"min": 1, "max": 8, "step": 1, "unit": "seats"},
|
||||
"opening_year": {"min": 1800, "max": 2030, "step": 1, "unit": "year"},
|
||||
},
|
||||
"boolean_filters": [
|
||||
{"key": "has_inversions", "label": "Has Inversions",
|
||||
"description": "Filter roller coasters with or without inversions"},
|
||||
{"key": "has_coordinates", "label": "Has Location Coordinates",
|
||||
"description": "Filter rides with GPS coordinates"},
|
||||
{"key": "has_ride_model", "label": "Has Ride Model",
|
||||
"description": "Filter rides with specified ride model"},
|
||||
{"key": "has_manufacturer", "label": "Has Manufacturer",
|
||||
"description": "Filter rides with specified manufacturer"},
|
||||
{"key": "has_designer", "label": "Has Designer",
|
||||
"description": "Filter rides with specified designer"},
|
||||
],
|
||||
"ordering_options": [
|
||||
{"value": "name", "label": "Name (A-Z)"},
|
||||
{"value": "-name", "label": "Name (Z-A)"},
|
||||
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
|
||||
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
|
||||
{"value": "average_rating", "label": "Rating (Lowest First)"},
|
||||
{"value": "-average_rating", "label": "Rating (Highest First)"},
|
||||
{"value": "capacity_per_hour", "label": "Capacity (Lowest First)"},
|
||||
{"value": "-capacity_per_hour",
|
||||
"label": "Capacity (Highest First)"},
|
||||
{"value": "ride_duration_seconds",
|
||||
"label": "Duration (Shortest First)"},
|
||||
{"value": "-ride_duration_seconds",
|
||||
"label": "Duration (Longest First)"},
|
||||
{"value": "height_ft", "label": "Height (Shortest First)"},
|
||||
{"value": "-height_ft", "label": "Height (Tallest First)"},
|
||||
{"value": "length_ft", "label": "Length (Shortest First)"},
|
||||
{"value": "-length_ft", "label": "Length (Longest First)"},
|
||||
{"value": "speed_mph", "label": "Speed (Slowest First)"},
|
||||
{"value": "-speed_mph", "label": "Speed (Fastest First)"},
|
||||
{"value": "inversions", "label": "Inversions (Fewest First)"},
|
||||
{"value": "-inversions", "label": "Inversions (Most First)"},
|
||||
{"value": "created_at", "label": "Date Added (Oldest First)"},
|
||||
{"value": "-created_at", "label": "Date Added (Newest First)"},
|
||||
{"value": "updated_at", "label": "Last Updated (Oldest First)"},
|
||||
{"value": "-updated_at", "label": "Last Updated (Newest First)"},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
# --- Company search (autocomplete) -----------------------------------------
|
||||
@@ -1366,7 +1499,7 @@ class RideImageSettingsAPIView(APIView):
|
||||
OpenApiParameter("capacity_max", OpenApiTypes.INT, description="Maximum hourly capacity"),
|
||||
OpenApiParameter("roller_coaster_type", OpenApiTypes.STR, description="Filter by roller coaster type (comma-separated for multiple)"),
|
||||
OpenApiParameter("track_material", OpenApiTypes.STR, description="Filter by track material (comma-separated for multiple)"),
|
||||
OpenApiParameter("launch_type", OpenApiTypes.STR, description="Filter by launch type (comma-separated for multiple)"),
|
||||
OpenApiParameter("propulsion_system", OpenApiTypes.STR, description="Filter by propulsion system (comma-separated for multiple)"),
|
||||
OpenApiParameter("height_ft_min", OpenApiTypes.NUMBER, description="Minimum roller coaster height in feet"),
|
||||
OpenApiParameter("height_ft_max", OpenApiTypes.NUMBER, description="Maximum roller coaster height in feet"),
|
||||
OpenApiParameter("speed_mph_min", OpenApiTypes.NUMBER, description="Minimum roller coaster speed in mph"),
|
||||
@@ -1477,7 +1610,7 @@ class HybridRideAPIView(APIView):
|
||||
filters = {}
|
||||
|
||||
# Handle comma-separated list parameters
|
||||
list_params = ['category', 'status', 'manufacturer', 'designer', 'ride_model', 'roller_coaster_type', 'track_material', 'launch_type']
|
||||
list_params = ['category', 'status', 'manufacturer', 'designer', 'ride_model', 'roller_coaster_type', 'track_material', 'propulsion_system']
|
||||
for param in list_params:
|
||||
value = query_params.get(param)
|
||||
if value:
|
||||
@@ -1559,7 +1692,7 @@ class HybridRideAPIView(APIView):
|
||||
"statuses": {"type": "array", "items": {"type": "string"}},
|
||||
"roller_coaster_types": {"type": "array", "items": {"type": "string"}},
|
||||
"track_materials": {"type": "array", "items": {"type": "string"}},
|
||||
"launch_types": {"type": "array", "items": {"type": "string"}},
|
||||
"propulsion_systems": {"type": "array", "items": {"type": "string"}},
|
||||
"parks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -18,6 +18,7 @@ from apps.accounts.models import (
|
||||
UserNotification,
|
||||
NotificationPreference,
|
||||
)
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
@@ -190,8 +191,10 @@ class CompleteUserSerializer(serializers.ModelSerializer):
|
||||
class UserPreferencesSerializer(serializers.Serializer):
|
||||
"""Serializer for user preferences and settings."""
|
||||
|
||||
theme_preference = serializers.ChoiceField(
|
||||
choices=User.ThemePreference.choices, help_text="User's theme preference"
|
||||
theme_preference = RichChoiceFieldSerializer(
|
||||
choice_group="theme_preferences",
|
||||
domain="accounts",
|
||||
help_text="User's theme preference"
|
||||
)
|
||||
email_notifications = serializers.BooleanField(
|
||||
default=True, help_text="Whether to receive email notifications"
|
||||
@@ -199,12 +202,9 @@ class UserPreferencesSerializer(serializers.Serializer):
|
||||
push_notifications = serializers.BooleanField(
|
||||
default=False, help_text="Whether to receive push notifications"
|
||||
)
|
||||
privacy_level = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
privacy_level = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="public",
|
||||
help_text="Profile visibility level",
|
||||
)
|
||||
@@ -321,12 +321,9 @@ class NotificationSettingsSerializer(serializers.Serializer):
|
||||
class PrivacySettingsSerializer(serializers.Serializer):
|
||||
"""Serializer for privacy and visibility settings."""
|
||||
|
||||
profile_visibility = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
profile_visibility = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="public",
|
||||
help_text="Overall profile visibility",
|
||||
)
|
||||
@@ -363,12 +360,9 @@ class PrivacySettingsSerializer(serializers.Serializer):
|
||||
search_visibility = serializers.BooleanField(
|
||||
default=True, help_text="Allow profile to appear in search results"
|
||||
)
|
||||
activity_visibility = serializers.ChoiceField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("friends", "Friends Only"),
|
||||
("private", "Private"),
|
||||
],
|
||||
activity_visibility = RichChoiceFieldSerializer(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
default="friends",
|
||||
help_text="Who can see your activity feed",
|
||||
)
|
||||
|
||||
@@ -12,7 +12,8 @@ from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
)
|
||||
|
||||
from .shared import CATEGORY_CHOICES, ModelChoices
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === COMPANY SERIALIZERS ===
|
||||
@@ -111,7 +112,10 @@ class RideModelDetailOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = serializers.SerializerMethodField()
|
||||
@@ -136,7 +140,7 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(choices=CATEGORY_CHOICES, required=False)
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
@@ -145,5 +149,5 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(choices=CATEGORY_CHOICES, required=False)
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices(), required=False)
|
||||
manufacturer_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
@@ -9,6 +9,8 @@ from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_field,
|
||||
)
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === STATISTICS SERIALIZERS ===
|
||||
@@ -90,7 +92,10 @@ class ParkReviewOutputSerializer(serializers.Serializer):
|
||||
class HealthCheckOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for health check responses."""
|
||||
|
||||
status = serializers.ChoiceField(choices=["healthy", "unhealthy"])
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="health_statuses",
|
||||
domain="core"
|
||||
)
|
||||
timestamp = serializers.DateTimeField()
|
||||
version = serializers.CharField()
|
||||
environment = serializers.CharField()
|
||||
@@ -111,6 +116,9 @@ class PerformanceMetricsOutputSerializer(serializers.Serializer):
|
||||
class SimpleHealthOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for simple health check."""
|
||||
|
||||
status = serializers.ChoiceField(choices=["ok", "error"])
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="simple_health_statuses",
|
||||
domain="core"
|
||||
)
|
||||
timestamp = serializers.DateTimeField()
|
||||
error = serializers.CharField(required=False)
|
||||
|
||||
@@ -15,6 +15,7 @@ from config.django import base as settings
|
||||
|
||||
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
|
||||
from apps.core.services.media_url_service import MediaURLService
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === PARK SERIALIZERS ===
|
||||
@@ -51,7 +52,10 @@ class ParkListOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="parks"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Statistics
|
||||
@@ -141,7 +145,10 @@ class ParkDetailOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="parks"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Details
|
||||
|
||||
@@ -14,6 +14,7 @@ from drf_spectacular.utils import (
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
# Use dynamic imports to avoid circular import issues
|
||||
|
||||
@@ -132,14 +133,20 @@ class RideModelListOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Manufacturer info
|
||||
manufacturer = RideModelManufacturerOutputSerializer(allow_null=True)
|
||||
|
||||
# Market info
|
||||
target_market = serializers.CharField()
|
||||
target_market = RichChoiceFieldSerializer(
|
||||
choice_group="target_markets",
|
||||
domain="rides"
|
||||
)
|
||||
is_discontinued = serializers.BooleanField()
|
||||
total_installations = serializers.IntegerField()
|
||||
first_installation_year = serializers.IntegerField(allow_null=True)
|
||||
@@ -386,15 +393,9 @@ class RideModelCreateInputSerializer(serializers.Serializer):
|
||||
# Design features
|
||||
notable_features = serializers.CharField(allow_blank=True, default="")
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
choices=ModelChoices.get_target_market_choices(),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -496,13 +497,7 @@ class RideModelUpdateInputSerializer(serializers.Serializer):
|
||||
# Design features
|
||||
notable_features = serializers.CharField(allow_blank=True, required=False)
|
||||
target_market = serializers.ChoiceField(
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
choices=ModelChoices.get_target_market_choices(),
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
)
|
||||
@@ -565,13 +560,7 @@ class RideModelFilterInputSerializer(serializers.Serializer):
|
||||
|
||||
# Market filter
|
||||
target_market = serializers.MultipleChoiceField(
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
choices=ModelChoices.get_target_market_choices(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
@@ -724,16 +713,7 @@ class RideModelTechnicalSpecCreateInputSerializer(serializers.Serializer):
|
||||
|
||||
ride_model_id = serializers.IntegerField()
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
choices=ModelChoices.get_technical_spec_category_choices()
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100)
|
||||
spec_value = serializers.CharField(max_length=255)
|
||||
@@ -745,16 +725,7 @@ class RideModelTechnicalSpecUpdateInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for updating ride model technical specifications."""
|
||||
|
||||
spec_category = serializers.ChoiceField(
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
choices=ModelChoices.get_technical_spec_category_choices(),
|
||||
required=False,
|
||||
)
|
||||
spec_name = serializers.CharField(max_length=100, required=False)
|
||||
@@ -774,13 +745,7 @@ class RideModelPhotoCreateInputSerializer(serializers.Serializer):
|
||||
caption = serializers.CharField(max_length=500, allow_blank=True, default="")
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
choices=ModelChoices.get_photo_type_choices(),
|
||||
default="PROMOTIONAL",
|
||||
)
|
||||
is_primary = serializers.BooleanField(default=False)
|
||||
@@ -795,13 +760,7 @@ class RideModelPhotoUpdateInputSerializer(serializers.Serializer):
|
||||
caption = serializers.CharField(max_length=500, allow_blank=True, required=False)
|
||||
alt_text = serializers.CharField(max_length=255, allow_blank=True, required=False)
|
||||
photo_type = serializers.ChoiceField(
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
choices=ModelChoices.get_photo_type_choices(),
|
||||
required=False,
|
||||
)
|
||||
is_primary = serializers.BooleanField(required=False)
|
||||
|
||||
219
backend/apps/api/v1/serializers/ride_reviews.py
Normal file
219
backend/apps/api/v1/serializers/ride_reviews.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Serializers for ride review API endpoints.
|
||||
|
||||
This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
|
||||
from apps.rides.models.reviews import RideReview
|
||||
from apps.accounts.models import User
|
||||
from apps.core.choices.serializers import RichChoiceSerializer
|
||||
|
||||
|
||||
class ReviewUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user information in ride reviews."""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "display_name", "avatar_url"]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(serializers.URLField(allow_null=True))
|
||||
def get_avatar_url(self, obj):
|
||||
"""Get the user's avatar URL."""
|
||||
if hasattr(obj, "profile") and obj.profile:
|
||||
return obj.profile.get_avatar_url()
|
||||
return "/static/images/default-avatar.png"
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display_name(self, obj):
|
||||
"""Get the user's display name."""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
name="Complete Ride Review",
|
||||
summary="Full ride review response",
|
||||
description="Example response showing all fields for a ride review",
|
||||
value={
|
||||
"id": 123,
|
||||
"title": "Amazing roller coaster experience!",
|
||||
"content": "This ride was absolutely incredible. The inversions were smooth and the theming was top-notch.",
|
||||
"rating": 9,
|
||||
"visit_date": "2023-06-15",
|
||||
"created_at": "2023-06-16T10:30:00Z",
|
||||
"updated_at": "2023-06-16T10:30:00Z",
|
||||
"is_published": True,
|
||||
"user": {
|
||||
"id": 456,
|
||||
"username": "coaster_fan",
|
||||
"display_name": "Coaster Fan",
|
||||
"avatar_url": "https://example.com/avatar.jpg"
|
||||
},
|
||||
"ride": {
|
||||
"id": 789,
|
||||
"name": "Steel Vengeance",
|
||||
"slug": "steel-vengeance"
|
||||
},
|
||||
"park": {
|
||||
"id": 101,
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point"
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
)
|
||||
class RideReviewOutputSerializer(serializers.ModelSerializer):
|
||||
"""Output serializer for ride reviews."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
|
||||
# Ride information
|
||||
ride = serializers.SerializerMethodField()
|
||||
park = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"ride",
|
||||
"park",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"ride",
|
||||
"park",
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_ride(self, obj):
|
||||
"""Get ride information."""
|
||||
return {
|
||||
"id": obj.ride.id,
|
||||
"name": obj.ride.name,
|
||||
"slug": obj.ride.slug,
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.DictField())
|
||||
def get_park(self, obj):
|
||||
"""Get park information."""
|
||||
return {
|
||||
"id": obj.ride.park.id,
|
||||
"name": obj.ride.park.name,
|
||||
"slug": obj.ride.park.slug,
|
||||
}
|
||||
|
||||
|
||||
class RideReviewCreateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for creating ride reviews."""
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewUpdateInputSerializer(serializers.ModelSerializer):
|
||||
"""Input serializer for updating ride reviews."""
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"title",
|
||||
"content",
|
||||
"rating",
|
||||
"visit_date",
|
||||
]
|
||||
|
||||
def validate_rating(self, value):
|
||||
"""Validate rating is between 1 and 10."""
|
||||
if not (1 <= value <= 10):
|
||||
raise serializers.ValidationError("Rating must be between 1 and 10.")
|
||||
return value
|
||||
|
||||
|
||||
class RideReviewListOutputSerializer(serializers.ModelSerializer):
|
||||
"""Simplified output serializer for ride review lists."""
|
||||
|
||||
user = ReviewUserSerializer(read_only=True)
|
||||
ride_name = serializers.CharField(source="ride.name", read_only=True)
|
||||
park_name = serializers.CharField(source="ride.park.name", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RideReview
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"rating",
|
||||
"visit_date",
|
||||
"created_at",
|
||||
"is_published",
|
||||
"user",
|
||||
"ride_name",
|
||||
"park_name",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RideReviewStatsOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for ride review statistics."""
|
||||
|
||||
total_reviews = serializers.IntegerField()
|
||||
published_reviews = serializers.IntegerField()
|
||||
pending_reviews = serializers.IntegerField()
|
||||
average_rating = serializers.FloatField(allow_null=True)
|
||||
rating_distribution = serializers.DictField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="Count of reviews by rating (1-10)"
|
||||
)
|
||||
recent_reviews = serializers.IntegerField()
|
||||
|
||||
|
||||
class RideReviewModerationInputSerializer(serializers.Serializer):
|
||||
"""Input serializer for review moderation operations."""
|
||||
|
||||
review_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text="List of review IDs to moderate"
|
||||
)
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
("publish", "Publish"),
|
||||
("unpublish", "Unpublish"),
|
||||
("delete", "Delete"),
|
||||
],
|
||||
help_text="Moderation action to perform"
|
||||
)
|
||||
moderation_notes = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
help_text="Optional notes about the moderation action"
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from drf_spectacular.utils import (
|
||||
)
|
||||
from config.django import base as settings
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === RIDE SERIALIZERS ===
|
||||
@@ -24,6 +25,12 @@ class RideParkOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
@extend_schema_field(serializers.URLField())
|
||||
def get_url(self, obj) -> str:
|
||||
"""Generate the frontend URL for this park."""
|
||||
return f"{settings.FRONTEND_DOMAIN}/parks/{obj.slug}/"
|
||||
|
||||
|
||||
class RideModelOutputSerializer(serializers.Serializer):
|
||||
@@ -73,8 +80,14 @@ class RideListOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="rides"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
@@ -164,9 +177,19 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
post_closing_status = serializers.CharField(allow_null=True)
|
||||
category = RichChoiceFieldSerializer(
|
||||
choice_group="categories",
|
||||
domain="rides"
|
||||
)
|
||||
status = RichChoiceFieldSerializer(
|
||||
choice_group="statuses",
|
||||
domain="rides"
|
||||
)
|
||||
post_closing_status = RichChoiceFieldSerializer(
|
||||
choice_group="post_closing_statuses",
|
||||
domain="rides",
|
||||
allow_null=True
|
||||
)
|
||||
description = serializers.CharField()
|
||||
|
||||
# Park info
|
||||
@@ -449,10 +472,10 @@ class RideCreateInputSerializer(serializers.Serializer):
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
category = serializers.ChoiceField(choices=[]) # Choices set dynamically
|
||||
category = serializers.ChoiceField(choices=ModelChoices.get_ride_category_choices())
|
||||
status = serializers.ChoiceField(
|
||||
choices=[], default="OPERATING"
|
||||
) # Choices set dynamically
|
||||
choices=ModelChoices.get_ride_status_choices(), default="OPERATING"
|
||||
)
|
||||
|
||||
# Required park
|
||||
park_id = serializers.IntegerField()
|
||||
@@ -531,11 +554,11 @@ class RideUpdateInputSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
category = serializers.ChoiceField(
|
||||
choices=[], required=False
|
||||
) # Choices set dynamically
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
status = serializers.ChoiceField(
|
||||
choices=[], required=False
|
||||
) # Choices set dynamically
|
||||
choices=ModelChoices.get_ride_status_choices(), required=False
|
||||
)
|
||||
post_closing_status = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_ride_post_closing_choices(),
|
||||
required=False,
|
||||
@@ -603,13 +626,13 @@ class RideFilterInputSerializer(serializers.Serializer):
|
||||
|
||||
# Category filter
|
||||
category = serializers.MultipleChoiceField(
|
||||
choices=[], required=False
|
||||
) # Choices set dynamically
|
||||
choices=ModelChoices.get_ride_category_choices(), required=False
|
||||
)
|
||||
|
||||
# Status filter
|
||||
status = serializers.MultipleChoiceField(
|
||||
choices=[],
|
||||
required=False, # Choices set dynamically
|
||||
choices=ModelChoices.get_ride_status_choices(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Park filter
|
||||
@@ -674,7 +697,7 @@ class RideFilterInputSerializer(serializers.Serializer):
|
||||
"ride_time_seconds": 150,
|
||||
"track_material": "HYBRID",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -695,12 +718,21 @@ class RollerCoasterStatsOutputSerializer(serializers.Serializer):
|
||||
inversions = serializers.IntegerField()
|
||||
ride_time_seconds = serializers.IntegerField(allow_null=True)
|
||||
track_type = serializers.CharField()
|
||||
track_material = serializers.CharField()
|
||||
roller_coaster_type = serializers.CharField()
|
||||
track_material = RichChoiceFieldSerializer(
|
||||
choice_group="track_materials",
|
||||
domain="rides"
|
||||
)
|
||||
roller_coaster_type = RichChoiceFieldSerializer(
|
||||
choice_group="coaster_types",
|
||||
domain="rides"
|
||||
)
|
||||
max_drop_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, allow_null=True
|
||||
)
|
||||
launch_type = serializers.CharField()
|
||||
propulsion_system = RichChoiceFieldSerializer(
|
||||
choice_group="propulsion_systems",
|
||||
domain="rides"
|
||||
)
|
||||
train_style = serializers.CharField()
|
||||
trains_count = serializers.IntegerField(allow_null=True)
|
||||
cars_per_train = serializers.IntegerField(allow_null=True)
|
||||
@@ -743,8 +775,8 @@ class RollerCoasterStatsCreateInputSerializer(serializers.Serializer):
|
||||
max_drop_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
launch_type = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_launch_choices(), default="CHAIN"
|
||||
propulsion_system = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_propulsion_system_choices(), default="CHAIN"
|
||||
)
|
||||
train_style = serializers.CharField(max_length=255, allow_blank=True, default="")
|
||||
trains_count = serializers.IntegerField(required=False, allow_null=True)
|
||||
@@ -776,8 +808,8 @@ class RollerCoasterStatsUpdateInputSerializer(serializers.Serializer):
|
||||
max_drop_height_ft = serializers.DecimalField(
|
||||
max_digits=6, decimal_places=2, required=False, allow_null=True
|
||||
)
|
||||
launch_type = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_launch_choices(), required=False
|
||||
propulsion_system = serializers.ChoiceField(
|
||||
choices=ModelChoices.get_propulsion_system_choices(), required=False
|
||||
)
|
||||
train_style = serializers.CharField(
|
||||
max_length=255, allow_blank=True, required=False
|
||||
|
||||
@@ -6,6 +6,8 @@ and other search functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from ..shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
|
||||
|
||||
# === CORE ENTITY SEARCH SERIALIZERS ===
|
||||
@@ -16,7 +18,9 @@ class EntitySearchInputSerializer(serializers.Serializer):
|
||||
|
||||
query = serializers.CharField(max_length=255, help_text="Search query string")
|
||||
entity_types = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=["park", "ride", "company", "user"]),
|
||||
child=serializers.ChoiceField(
|
||||
choices=ModelChoices.get_entity_type_choices()
|
||||
),
|
||||
required=False,
|
||||
help_text="Types of entities to search for",
|
||||
)
|
||||
@@ -34,7 +38,10 @@ class EntitySearchResultSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
type = serializers.CharField()
|
||||
type = RichChoiceFieldSerializer(
|
||||
choice_group="entity_types",
|
||||
domain="core"
|
||||
)
|
||||
description = serializers.CharField()
|
||||
relevance_score = serializers.FloatField()
|
||||
|
||||
|
||||
@@ -147,7 +147,12 @@ class ModerationSubmissionSerializer(serializers.Serializer):
|
||||
"""Serializer for moderation submissions."""
|
||||
|
||||
submission_type = serializers.ChoiceField(
|
||||
choices=["EDIT", "PHOTO", "REVIEW"], help_text="Type of submission"
|
||||
choices=[
|
||||
("EDIT", "Edit Submission"),
|
||||
("PHOTO", "Photo Submission"),
|
||||
("REVIEW", "Review Submission"),
|
||||
],
|
||||
help_text="Type of submission"
|
||||
)
|
||||
content_type = serializers.CharField(help_text="Content type being modified")
|
||||
object_id = serializers.IntegerField(help_text="ID of object being modified")
|
||||
|
||||
@@ -9,7 +9,7 @@ for common data structures used throughout the API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
class FilterOptionSerializer(serializers.Serializer):
|
||||
@@ -316,107 +316,131 @@ class CompanyOutputSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
# Category choices for ride models
|
||||
CATEGORY_CHOICES = [
|
||||
('RC', 'Roller Coaster'),
|
||||
('DR', 'Dark Ride'),
|
||||
('FR', 'Flat Ride'),
|
||||
('WR', 'Water Ride'),
|
||||
('TR', 'Transport Ride'),
|
||||
]
|
||||
|
||||
|
||||
class ModelChoices:
|
||||
"""
|
||||
Utility class to provide model choices for serializers.
|
||||
This prevents circular imports while providing access to model choices.
|
||||
Utility class to provide model choices for serializers using Rich Choice Objects.
|
||||
This prevents circular imports while providing access to model choices from the registry.
|
||||
|
||||
NO FALLBACKS - All choices must be properly defined in Rich Choice Objects.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_park_status_choices():
|
||||
"""Get park status choices."""
|
||||
return [
|
||||
('OPERATING', 'Operating'),
|
||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||
('PLANNED', 'Planned'),
|
||||
]
|
||||
"""Get park status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("statuses", "parks")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_status_choices():
|
||||
"""Get ride status choices."""
|
||||
return [
|
||||
('OPERATING', 'Operating'),
|
||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
||||
('CLOSED_PERM', 'Permanently Closed'),
|
||||
('SBNO', 'Standing But Not Operating'),
|
||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||
('RELOCATED', 'Relocated'),
|
||||
('DEMOLISHED', 'Demolished'),
|
||||
]
|
||||
"""Get ride status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("statuses", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_company_role_choices():
|
||||
"""Get company role choices."""
|
||||
return [
|
||||
('MANUFACTURER', 'Manufacturer'),
|
||||
('OPERATOR', 'Operator'),
|
||||
('DESIGNER', 'Designer'),
|
||||
('PROPERTY_OWNER', 'Property Owner'),
|
||||
]
|
||||
"""Get company role choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
# Get rides domain company roles (MANUFACTURER, DESIGNER)
|
||||
rides_choices = get_choices("company_roles", "rides")
|
||||
# Get parks domain company roles (OPERATOR, PROPERTY_OWNER)
|
||||
parks_choices = get_choices("company_roles", "parks")
|
||||
all_choices = list(rides_choices) + list(parks_choices)
|
||||
return [(choice.value, choice.label) for choice in all_choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_category_choices():
|
||||
"""Get ride category choices."""
|
||||
return CATEGORY_CHOICES
|
||||
"""Get ride category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_ride_post_closing_choices():
|
||||
"""Get ride post-closing status choices."""
|
||||
return [
|
||||
('RELOCATED', 'Relocated'),
|
||||
('DEMOLISHED', 'Demolished'),
|
||||
('STORED', 'Stored'),
|
||||
('UNKNOWN', 'Unknown'),
|
||||
]
|
||||
"""Get ride post-closing status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("post_closing_statuses", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_track_choices():
|
||||
"""Get coaster track type choices."""
|
||||
return [
|
||||
('STEEL', 'Steel'),
|
||||
('WOOD', 'Wood'),
|
||||
('HYBRID', 'Hybrid'),
|
||||
]
|
||||
"""Get coaster track material choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("track_materials", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_coaster_type_choices():
|
||||
"""Get coaster type choices."""
|
||||
return [
|
||||
('SIT_DOWN', 'Sit Down'),
|
||||
('INVERTED', 'Inverted'),
|
||||
('FLOORLESS', 'Floorless'),
|
||||
('FLYING', 'Flying'),
|
||||
('STAND_UP', 'Stand Up'),
|
||||
('SPINNING', 'Spinning'),
|
||||
('WING', 'Wing'),
|
||||
('DIVE', 'Dive'),
|
||||
('LAUNCHED', 'Launched'),
|
||||
]
|
||||
"""Get coaster type choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("coaster_types", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_launch_choices():
|
||||
"""Get launch system choices."""
|
||||
return [
|
||||
('NONE', 'None'),
|
||||
('LIM', 'Linear Induction Motor'),
|
||||
('LSM', 'Linear Synchronous Motor'),
|
||||
('HYDRAULIC', 'Hydraulic'),
|
||||
('PNEUMATIC', 'Pneumatic'),
|
||||
('CABLE', 'Cable'),
|
||||
('FLYWHEEL', 'Flywheel'),
|
||||
]
|
||||
"""Get launch system choices from Rich Choice registry (legacy method)."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("propulsion_systems", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_propulsion_system_choices():
|
||||
"""Get propulsion system choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("propulsion_systems", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_photo_type_choices():
|
||||
"""Get photo type choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("photo_types", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_spec_category_choices():
|
||||
"""Get technical specification category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("spec_categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_technical_spec_category_choices():
|
||||
"""Get technical specification category choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("spec_categories", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_target_market_choices():
|
||||
"""Get target market choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("target_markets", "rides")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_entity_type_choices():
|
||||
"""Get entity type choices for search functionality."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("entity_types", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_health_status_choices():
|
||||
"""Get health check status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("health_statuses", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
@staticmethod
|
||||
def get_simple_health_status_choices():
|
||||
"""Get simple health check status choices from Rich Choice registry."""
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("simple_health_statuses", "core")
|
||||
return [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
|
||||
class EntityReferenceSerializer(serializers.Serializer):
|
||||
@@ -593,12 +617,12 @@ def ensure_filter_option_format(options: List[Any]) -> List[Dict[str, Any]]:
|
||||
'count': option.get('count'),
|
||||
'selected': option.get('selected', False)
|
||||
}
|
||||
elif isinstance(option, (list, tuple)) and len(option) >= 2:
|
||||
# Tuple format: (value, label) or (value, label, count)
|
||||
elif hasattr(option, 'value') and hasattr(option, 'label'):
|
||||
# RichChoice object format
|
||||
standardized_option = {
|
||||
'value': str(option[0]),
|
||||
'label': str(option[1]),
|
||||
'count': option[2] if len(option) > 2 else None,
|
||||
'value': str(option.value),
|
||||
'label': str(option.label),
|
||||
'count': None,
|
||||
'selected': False
|
||||
}
|
||||
else:
|
||||
|
||||
@@ -5,12 +5,8 @@ These tests verify that API responses match frontend TypeScript interfaces exact
|
||||
preventing runtime errors and ensuring type safety.
|
||||
"""
|
||||
|
||||
import json
|
||||
from django.test import TestCase, Client
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from apps.parks.services.hybrid_loader import smart_park_loader
|
||||
from apps.rides.services.hybrid_loader import SmartRideLoader
|
||||
|
||||
@@ -14,9 +14,7 @@ from rest_framework.serializers import Serializer
|
||||
from django.conf import settings
|
||||
|
||||
from apps.api.v1.serializers.shared import (
|
||||
validate_filter_metadata_contract,
|
||||
ApiResponseSerializer,
|
||||
ErrorResponseSerializer
|
||||
validate_filter_metadata_contract
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -250,7 +250,10 @@ class StatsAPIView(APIView):
|
||||
"RELOCATED": "relocated_parks",
|
||||
}
|
||||
|
||||
status_name = status_names.get(status_code, f"status_{status_code.lower()}")
|
||||
if status_code in status_names:
|
||||
status_name = status_names[status_code]
|
||||
else:
|
||||
raise ValueError(f"Unknown park status: {status_code}")
|
||||
park_status_stats[status_name] = status_count
|
||||
|
||||
# Ride status counts
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
default_app_config = "apps.core.apps.CoreConfig"
|
||||
"""
|
||||
Core Django App
|
||||
|
||||
This app handles core system functionality including health checks,
|
||||
system status, and other foundational features.
|
||||
"""
|
||||
|
||||
# Import core choices to ensure they are registered with the global registry
|
||||
from .choices import core_choices
|
||||
|
||||
# Ensure choices are registered on app startup
|
||||
__all__ = ['core_choices']
|
||||
|
||||
32
backend/apps/core/choices/__init__.py
Normal file
32
backend/apps/core/choices/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Rich Choice Objects System
|
||||
|
||||
This module provides a comprehensive system for managing choice fields throughout
|
||||
the ThrillWiki application. It replaces simple tuple-based choices with rich
|
||||
dataclass objects that support metadata, descriptions, categories, and deprecation.
|
||||
|
||||
Key Components:
|
||||
- RichChoice: Base dataclass for choice objects
|
||||
- ChoiceRegistry: Centralized management of all choice definitions
|
||||
- RichChoiceField: Django model field for rich choices
|
||||
- RichChoiceSerializer: DRF serializer for API responses
|
||||
"""
|
||||
|
||||
from .base import RichChoice, ChoiceCategory, ChoiceGroup
|
||||
from .registry import ChoiceRegistry, register_choices
|
||||
from .fields import RichChoiceField
|
||||
from .serializers import RichChoiceSerializer, RichChoiceOptionSerializer
|
||||
from .utils import validate_choice_value, get_choice_display
|
||||
|
||||
__all__ = [
|
||||
'RichChoice',
|
||||
'ChoiceCategory',
|
||||
'ChoiceGroup',
|
||||
'ChoiceRegistry',
|
||||
'register_choices',
|
||||
'RichChoiceField',
|
||||
'RichChoiceSerializer',
|
||||
'RichChoiceOptionSerializer',
|
||||
'validate_choice_value',
|
||||
'get_choice_display',
|
||||
]
|
||||
154
backend/apps/core/choices/base.py
Normal file
154
backend/apps/core/choices/base.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Base Rich Choice Objects
|
||||
|
||||
This module defines the core dataclass structures for rich choice objects.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ChoiceCategory(Enum):
|
||||
"""Categories for organizing choice types"""
|
||||
STATUS = "status"
|
||||
TYPE = "type"
|
||||
CLASSIFICATION = "classification"
|
||||
PREFERENCE = "preference"
|
||||
PERMISSION = "permission"
|
||||
PRIORITY = "priority"
|
||||
ACTION = "action"
|
||||
NOTIFICATION = "notification"
|
||||
MODERATION = "moderation"
|
||||
TECHNICAL = "technical"
|
||||
BUSINESS = "business"
|
||||
SECURITY = "security"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RichChoice:
|
||||
"""
|
||||
Rich choice object with metadata support.
|
||||
|
||||
This replaces simple tuple choices with a comprehensive object that can
|
||||
carry additional information like descriptions, colors, icons, and custom metadata.
|
||||
|
||||
Attributes:
|
||||
value: The stored value (equivalent to first element of tuple choice)
|
||||
label: Human-readable display name (equivalent to second element of tuple choice)
|
||||
description: Detailed description of what this choice means
|
||||
metadata: Dictionary of additional properties (colors, icons, etc.)
|
||||
deprecated: Whether this choice is deprecated and should not be used for new entries
|
||||
category: Category for organizing related choices
|
||||
"""
|
||||
value: str
|
||||
label: str
|
||||
description: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
deprecated: bool = False
|
||||
category: ChoiceCategory = ChoiceCategory.OTHER
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate the choice object after initialization"""
|
||||
if not self.value:
|
||||
raise ValueError("Choice value cannot be empty")
|
||||
if not self.label:
|
||||
raise ValueError("Choice label cannot be empty")
|
||||
|
||||
@property
|
||||
def color(self) -> Optional[str]:
|
||||
"""Get the color from metadata if available"""
|
||||
return self.metadata.get('color')
|
||||
|
||||
@property
|
||||
def icon(self) -> Optional[str]:
|
||||
"""Get the icon from metadata if available"""
|
||||
return self.metadata.get('icon')
|
||||
|
||||
@property
|
||||
def css_class(self) -> Optional[str]:
|
||||
"""Get the CSS class from metadata if available"""
|
||||
return self.metadata.get('css_class')
|
||||
|
||||
@property
|
||||
def sort_order(self) -> int:
|
||||
"""Get the sort order from metadata, defaulting to 0"""
|
||||
return self.metadata.get('sort_order', 0)
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation for API serialization"""
|
||||
return {
|
||||
'value': self.value,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
'metadata': self.metadata,
|
||||
'deprecated': self.deprecated,
|
||||
'category': self.category.value,
|
||||
'color': self.color,
|
||||
'icon': self.icon,
|
||||
'css_class': self.css_class,
|
||||
'sort_order': self.sort_order,
|
||||
}
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.label
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RichChoice(value='{self.value}', label='{self.label}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceGroup:
|
||||
"""
|
||||
A group of related choices with shared metadata.
|
||||
|
||||
This allows for organizing choices into logical groups with
|
||||
common properties and behaviors.
|
||||
"""
|
||||
name: str
|
||||
choices: list[RichChoice]
|
||||
description: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate the choice group after initialization"""
|
||||
if not self.name:
|
||||
raise ValueError("Choice group name cannot be empty")
|
||||
if not self.choices:
|
||||
raise ValueError("Choice group must contain at least one choice")
|
||||
|
||||
# Validate that all choice values are unique within the group
|
||||
values = [choice.value for choice in self.choices]
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError("All choice values within a group must be unique")
|
||||
|
||||
def get_choice(self, value: str) -> Optional[RichChoice]:
|
||||
"""Get a choice by its value"""
|
||||
for choice in self.choices:
|
||||
if choice.value == value:
|
||||
return choice
|
||||
return None
|
||||
|
||||
def get_choices_by_category(self, category: ChoiceCategory) -> list[RichChoice]:
|
||||
"""Get all choices in a specific category"""
|
||||
return [choice for choice in self.choices if choice.category == category]
|
||||
|
||||
def get_active_choices(self) -> list[RichChoice]:
|
||||
"""Get all non-deprecated choices"""
|
||||
return [choice for choice in self.choices if not choice.deprecated]
|
||||
|
||||
def to_tuple_choices(self) -> list[tuple[str, str]]:
|
||||
"""Convert to legacy tuple choices format"""
|
||||
return [(choice.value, choice.label) for choice in self.choices]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation for API serialization"""
|
||||
return {
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'metadata': self.metadata,
|
||||
'choices': [choice.to_dict() for choice in self.choices]
|
||||
}
|
||||
158
backend/apps/core/choices/core_choices.py
Normal file
158
backend/apps/core/choices/core_choices.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Core System Rich Choice Objects
|
||||
|
||||
This module defines all choice objects for core system functionality,
|
||||
including health checks, API statuses, and other system-level choices.
|
||||
"""
|
||||
|
||||
from .base import RichChoice, ChoiceCategory
|
||||
from .registry import register_choices
|
||||
|
||||
|
||||
# Health Check Status Choices
|
||||
HEALTH_STATUSES = [
|
||||
RichChoice(
|
||||
value="healthy",
|
||||
label="Healthy",
|
||||
description="System is operating normally with no issues detected",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1,
|
||||
'http_status': 200
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="unhealthy",
|
||||
label="Unhealthy",
|
||||
description="System has detected issues that may affect functionality",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2,
|
||||
'http_status': 503
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Simple Health Check Status Choices
|
||||
SIMPLE_HEALTH_STATUSES = [
|
||||
RichChoice(
|
||||
value="ok",
|
||||
label="OK",
|
||||
description="Basic health check passed",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1,
|
||||
'http_status': 200
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="error",
|
||||
label="Error",
|
||||
description="Basic health check failed",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2,
|
||||
'http_status': 500
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Entity Type Choices for Search
|
||||
ENTITY_TYPES = [
|
||||
RichChoice(
|
||||
value="park",
|
||||
label="Park",
|
||||
description="Theme parks and amusement parks",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'map-pin',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1,
|
||||
'search_weight': 1.0
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="ride",
|
||||
label="Ride",
|
||||
description="Individual rides and attractions",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'activity',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 2,
|
||||
'search_weight': 1.0
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="company",
|
||||
label="Company",
|
||||
description="Manufacturers, operators, and designers",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'building',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 3,
|
||||
'search_weight': 0.8
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="user",
|
||||
label="User",
|
||||
description="User profiles and accounts",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'user',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 4,
|
||||
'search_weight': 0.5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_core_choices():
|
||||
"""Register all core system choices with the global registry"""
|
||||
|
||||
register_choices(
|
||||
name="health_statuses",
|
||||
choices=HEALTH_STATUSES,
|
||||
domain="core",
|
||||
description="Health check status options",
|
||||
metadata={'domain': 'core', 'type': 'health_status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="simple_health_statuses",
|
||||
choices=SIMPLE_HEALTH_STATUSES,
|
||||
domain="core",
|
||||
description="Simple health check status options",
|
||||
metadata={'domain': 'core', 'type': 'simple_health_status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="entity_types",
|
||||
choices=ENTITY_TYPES,
|
||||
domain="core",
|
||||
description="Entity type classifications for search functionality",
|
||||
metadata={'domain': 'core', 'type': 'entity_type'}
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_core_choices()
|
||||
198
backend/apps/core/choices/fields.py
Normal file
198
backend/apps/core/choices/fields.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Django Model Fields for Rich Choices
|
||||
|
||||
This module provides Django model field implementations for rich choice objects.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import ChoiceField
|
||||
from .base import RichChoice
|
||||
from .registry import registry
|
||||
|
||||
|
||||
class RichChoiceField(models.CharField):
|
||||
"""
|
||||
Django model field for rich choice objects.
|
||||
|
||||
This field stores the choice value as a CharField but provides
|
||||
rich choice functionality through the registry system.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
max_length: int = 50,
|
||||
allow_deprecated: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the RichChoiceField.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
max_length: Maximum length for the stored value
|
||||
allow_deprecated: Whether to allow deprecated choices
|
||||
**kwargs: Additional arguments passed to CharField
|
||||
"""
|
||||
self.choice_group = choice_group
|
||||
self.domain = domain
|
||||
self.allow_deprecated = allow_deprecated
|
||||
|
||||
# Set choices from registry for Django admin and forms
|
||||
if self.allow_deprecated:
|
||||
choices_list = registry.get_choices(choice_group, domain)
|
||||
else:
|
||||
choices_list = registry.get_active_choices(choice_group, domain)
|
||||
|
||||
choices = [(choice.value, choice.label) for choice in choices_list]
|
||||
|
||||
kwargs['choices'] = choices
|
||||
kwargs['max_length'] = max_length
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate(self, value: Any, model_instance: Any) -> None:
|
||||
"""Validate the choice value"""
|
||||
super().validate(value, model_instance)
|
||||
|
||||
if value is None or value == '':
|
||||
return
|
||||
|
||||
# Check if choice exists in registry
|
||||
choice = registry.get_choice(self.choice_group, value, self.domain)
|
||||
if choice is None:
|
||||
raise ValidationError(
|
||||
f"'{value}' is not a valid choice for {self.choice_group}"
|
||||
)
|
||||
|
||||
# Check if deprecated choices are allowed
|
||||
if choice.deprecated and not self.allow_deprecated:
|
||||
raise ValidationError(
|
||||
f"'{value}' is deprecated and cannot be used for new entries"
|
||||
)
|
||||
|
||||
def get_rich_choice(self, value: str) -> Optional[RichChoice]:
|
||||
"""Get the RichChoice object for a value"""
|
||||
return registry.get_choice(self.choice_group, value, self.domain)
|
||||
|
||||
def get_choice_display(self, value: str) -> str:
|
||||
"""Get the display label for a choice value"""
|
||||
return registry.get_choice_display(self.choice_group, value, self.domain)
|
||||
|
||||
def contribute_to_class(self, cls: Any, name: str, private_only: bool = False, **kwargs: Any) -> None:
|
||||
"""Add helper methods to the model class (signature compatible with Django Field)"""
|
||||
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
|
||||
|
||||
# Add get_FOO_rich_choice method
|
||||
def get_rich_choice_method(instance):
|
||||
value = getattr(instance, name)
|
||||
return self.get_rich_choice(value) if value else None
|
||||
|
||||
setattr(cls, f'get_{name}_rich_choice', get_rich_choice_method)
|
||||
|
||||
# Add get_FOO_display method (Django provides this, but we enhance it)
|
||||
def get_display_method(instance):
|
||||
value = getattr(instance, name)
|
||||
return self.get_choice_display(value) if value else ''
|
||||
|
||||
setattr(cls, f'get_{name}_display', get_display_method)
|
||||
|
||||
def deconstruct(self):
|
||||
"""Support for Django migrations"""
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
kwargs['choice_group'] = self.choice_group
|
||||
kwargs['domain'] = self.domain
|
||||
kwargs['allow_deprecated'] = self.allow_deprecated
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
class RichChoiceFormField(ChoiceField):
|
||||
"""
|
||||
Form field for rich choices with enhanced functionality.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
allow_deprecated: bool = False,
|
||||
show_descriptions: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the form field.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
allow_deprecated: Whether to allow deprecated choices
|
||||
show_descriptions: Whether to show descriptions in choice labels
|
||||
**kwargs: Additional arguments passed to ChoiceField
|
||||
"""
|
||||
self.choice_group = choice_group
|
||||
self.domain = domain
|
||||
self.allow_deprecated = allow_deprecated
|
||||
self.show_descriptions = show_descriptions
|
||||
|
||||
# Get choices from registry
|
||||
if allow_deprecated:
|
||||
choices_list = registry.get_choices(choice_group, domain)
|
||||
else:
|
||||
choices_list = registry.get_active_choices(choice_group, domain)
|
||||
|
||||
# Format choices for display
|
||||
choices = []
|
||||
for choice in choices_list:
|
||||
label = choice.label
|
||||
if show_descriptions and choice.description:
|
||||
label = f"{choice.label} - {choice.description}"
|
||||
choices.append((choice.value, label))
|
||||
|
||||
kwargs['choices'] = choices
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate(self, value: Any) -> None:
|
||||
"""Validate the choice value"""
|
||||
super().validate(value)
|
||||
|
||||
if value is None or value == '':
|
||||
return
|
||||
|
||||
# Check if choice exists in registry
|
||||
choice = registry.get_choice(self.choice_group, value, self.domain)
|
||||
if choice is None:
|
||||
raise ValidationError(
|
||||
f"'{value}' is not a valid choice for {self.choice_group}"
|
||||
)
|
||||
|
||||
# Check if deprecated choices are allowed
|
||||
if choice.deprecated and not self.allow_deprecated:
|
||||
raise ValidationError(
|
||||
f"'{value}' is deprecated and cannot be used"
|
||||
)
|
||||
|
||||
|
||||
def create_rich_choice_field(
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
max_length: int = 50,
|
||||
allow_deprecated: bool = False,
|
||||
**kwargs
|
||||
) -> RichChoiceField:
|
||||
"""
|
||||
Factory function to create a RichChoiceField.
|
||||
|
||||
This is useful for creating fields with consistent settings
|
||||
across multiple models.
|
||||
"""
|
||||
return RichChoiceField(
|
||||
choice_group=choice_group,
|
||||
domain=domain,
|
||||
max_length=max_length,
|
||||
allow_deprecated=allow_deprecated,
|
||||
**kwargs
|
||||
)
|
||||
197
backend/apps/core/choices/registry.py
Normal file
197
backend/apps/core/choices/registry.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Choice Registry
|
||||
|
||||
Centralized registry for managing all choice definitions across the application.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Any
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from .base import RichChoice, ChoiceGroup
|
||||
|
||||
|
||||
class ChoiceRegistry:
|
||||
"""
|
||||
Centralized registry for managing all choice definitions.
|
||||
|
||||
This provides a single source of truth for all choice objects
|
||||
throughout the application, with support for namespacing by domain.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._choices: Dict[str, ChoiceGroup] = {}
|
||||
self._domains: Dict[str, List[str]] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
choices: List[RichChoice],
|
||||
domain: str = "core",
|
||||
description: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> ChoiceGroup:
|
||||
"""
|
||||
Register a group of choices.
|
||||
|
||||
Args:
|
||||
name: Unique name for the choice group
|
||||
choices: List of RichChoice objects
|
||||
domain: Domain namespace (e.g., 'rides', 'parks', 'accounts')
|
||||
description: Description of the choice group
|
||||
metadata: Additional metadata for the group
|
||||
|
||||
Returns:
|
||||
The registered ChoiceGroup
|
||||
|
||||
Raises:
|
||||
ImproperlyConfigured: If name is already registered with different choices
|
||||
"""
|
||||
full_name = f"{domain}.{name}"
|
||||
|
||||
if full_name in self._choices:
|
||||
# Check if the existing registration is identical
|
||||
existing_group = self._choices[full_name]
|
||||
existing_values = [choice.value for choice in existing_group.choices]
|
||||
new_values = [choice.value for choice in choices]
|
||||
|
||||
if existing_values == new_values:
|
||||
# Same choices, return existing group (allow duplicate registration)
|
||||
return existing_group
|
||||
else:
|
||||
# Different choices, this is an error
|
||||
raise ImproperlyConfigured(
|
||||
f"Choice group '{full_name}' is already registered with different choices. "
|
||||
f"Existing: {existing_values}, New: {new_values}"
|
||||
)
|
||||
|
||||
choice_group = ChoiceGroup(
|
||||
name=full_name,
|
||||
choices=choices,
|
||||
description=description,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
self._choices[full_name] = choice_group
|
||||
|
||||
# Track domain
|
||||
if domain not in self._domains:
|
||||
self._domains[domain] = []
|
||||
self._domains[domain].append(name)
|
||||
|
||||
return choice_group
|
||||
|
||||
def get(self, name: str, domain: str = "core") -> Optional[ChoiceGroup]:
|
||||
"""Get a choice group by name and domain"""
|
||||
full_name = f"{domain}.{name}"
|
||||
return self._choices.get(full_name)
|
||||
|
||||
def get_choice(self, group_name: str, value: str, domain: str = "core") -> Optional[RichChoice]:
|
||||
"""Get a specific choice by group name, value, and domain"""
|
||||
choice_group = self.get(group_name, domain)
|
||||
if choice_group:
|
||||
return choice_group.get_choice(value)
|
||||
return None
|
||||
|
||||
def get_choices(self, name: str, domain: str = "core") -> List[RichChoice]:
|
||||
"""Get all choices in a group"""
|
||||
choice_group = self.get(name, domain)
|
||||
return choice_group.choices if choice_group else []
|
||||
|
||||
def get_active_choices(self, name: str, domain: str = "core") -> List[RichChoice]:
|
||||
"""Get all non-deprecated choices in a group"""
|
||||
choice_group = self.get(name, domain)
|
||||
return choice_group.get_active_choices() if choice_group else []
|
||||
|
||||
|
||||
def get_domains(self) -> List[str]:
|
||||
"""Get all registered domains"""
|
||||
return list(self._domains.keys())
|
||||
|
||||
def get_domain_choices(self, domain: str) -> Dict[str, ChoiceGroup]:
|
||||
"""Get all choice groups for a specific domain"""
|
||||
if domain not in self._domains:
|
||||
return {}
|
||||
|
||||
return {
|
||||
name: self._choices[f"{domain}.{name}"]
|
||||
for name in self._domains[domain]
|
||||
}
|
||||
|
||||
def list_all(self) -> Dict[str, ChoiceGroup]:
|
||||
"""Get all registered choice groups"""
|
||||
return self._choices.copy()
|
||||
|
||||
def validate_choice(self, group_name: str, value: str, domain: str = "core") -> bool:
|
||||
"""Validate that a choice value exists in a group"""
|
||||
choice = self.get_choice(group_name, value, domain)
|
||||
return choice is not None and not choice.deprecated
|
||||
|
||||
def get_choice_display(self, group_name: str, value: str, domain: str = "core") -> str:
|
||||
"""Get the display label for a choice value"""
|
||||
choice = self.get_choice(group_name, value, domain)
|
||||
if choice:
|
||||
return choice.label
|
||||
else:
|
||||
raise ValueError(f"Choice value '{value}' not found in group '{group_name}' for domain '{domain}'")
|
||||
|
||||
def clear_domain(self, domain: str) -> None:
|
||||
"""Clear all choices for a specific domain (useful for testing)"""
|
||||
if domain in self._domains:
|
||||
for name in self._domains[domain]:
|
||||
full_name = f"{domain}.{name}"
|
||||
if full_name in self._choices:
|
||||
del self._choices[full_name]
|
||||
del self._domains[domain]
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Clear all registered choices (useful for testing)"""
|
||||
self._choices.clear()
|
||||
self._domains.clear()
|
||||
|
||||
|
||||
# Global registry instance
|
||||
registry = ChoiceRegistry()
|
||||
|
||||
|
||||
def register_choices(
|
||||
name: str,
|
||||
choices: List[RichChoice],
|
||||
domain: str = "core",
|
||||
description: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> ChoiceGroup:
|
||||
"""
|
||||
Convenience function to register choices with the global registry.
|
||||
|
||||
Args:
|
||||
name: Unique name for the choice group
|
||||
choices: List of RichChoice objects
|
||||
domain: Domain namespace
|
||||
description: Description of the choice group
|
||||
metadata: Additional metadata for the group
|
||||
|
||||
Returns:
|
||||
The registered ChoiceGroup
|
||||
"""
|
||||
return registry.register(name, choices, domain, description, metadata)
|
||||
|
||||
|
||||
def get_choices(name: str, domain: str = "core") -> List[RichChoice]:
|
||||
"""Get choices from the global registry"""
|
||||
return registry.get_choices(name, domain)
|
||||
|
||||
|
||||
def get_choice(group_name: str, value: str, domain: str = "core") -> Optional[RichChoice]:
|
||||
"""Get a specific choice from the global registry"""
|
||||
return registry.get_choice(group_name, value, domain)
|
||||
|
||||
|
||||
|
||||
|
||||
def validate_choice(group_name: str, value: str, domain: str = "core") -> bool:
|
||||
"""Validate a choice value using the global registry"""
|
||||
return registry.validate_choice(group_name, value, domain)
|
||||
|
||||
|
||||
def get_choice_display(group_name: str, value: str, domain: str = "core") -> str:
|
||||
"""Get choice display label using the global registry"""
|
||||
return registry.get_choice_display(group_name, value, domain)
|
||||
275
backend/apps/core/choices/serializers.py
Normal file
275
backend/apps/core/choices/serializers.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
DRF Serializers for Rich Choices
|
||||
|
||||
This module provides Django REST Framework serializer implementations
|
||||
for rich choice objects.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from rest_framework import serializers
|
||||
from .base import RichChoice, ChoiceGroup
|
||||
from .registry import registry
|
||||
|
||||
|
||||
class RichChoiceSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for individual RichChoice objects.
|
||||
|
||||
This provides a consistent API representation for choice objects
|
||||
with all their metadata.
|
||||
"""
|
||||
value = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
metadata = serializers.DictField()
|
||||
deprecated = serializers.BooleanField()
|
||||
category = serializers.CharField()
|
||||
color = serializers.CharField(allow_null=True)
|
||||
icon = serializers.CharField(allow_null=True)
|
||||
css_class = serializers.CharField(allow_null=True)
|
||||
sort_order = serializers.IntegerField()
|
||||
|
||||
def to_representation(self, instance: RichChoice) -> Dict[str, Any]:
|
||||
"""Convert RichChoice to dictionary representation"""
|
||||
return instance.to_dict()
|
||||
|
||||
|
||||
class RichChoiceOptionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for choice options in filter endpoints.
|
||||
|
||||
This replaces the legacy FilterOptionSerializer with rich choice support.
|
||||
"""
|
||||
value = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
description = serializers.CharField(allow_blank=True)
|
||||
count = serializers.IntegerField(required=False, allow_null=True)
|
||||
selected = serializers.BooleanField(default=False)
|
||||
deprecated = serializers.BooleanField(default=False)
|
||||
color = serializers.CharField(allow_null=True, required=False)
|
||||
icon = serializers.CharField(allow_null=True, required=False)
|
||||
css_class = serializers.CharField(allow_null=True, required=False)
|
||||
metadata = serializers.DictField(required=False)
|
||||
|
||||
def to_representation(self, instance) -> Dict[str, Any]:
|
||||
"""Convert choice option to dictionary representation"""
|
||||
if isinstance(instance, RichChoice):
|
||||
# Convert RichChoice to option format
|
||||
return {
|
||||
'value': instance.value,
|
||||
'label': instance.label,
|
||||
'description': instance.description,
|
||||
'count': None,
|
||||
'selected': False,
|
||||
'deprecated': instance.deprecated,
|
||||
'color': instance.color,
|
||||
'icon': instance.icon,
|
||||
'css_class': instance.css_class,
|
||||
'metadata': instance.metadata,
|
||||
}
|
||||
elif isinstance(instance, dict):
|
||||
# Handle dictionary input (for backwards compatibility)
|
||||
return {
|
||||
'value': instance.get('value', ''),
|
||||
'label': instance.get('label', ''),
|
||||
'description': instance.get('description', ''),
|
||||
'count': instance.get('count'),
|
||||
'selected': instance.get('selected', False),
|
||||
'deprecated': instance.get('deprecated', False),
|
||||
'color': instance.get('color'),
|
||||
'icon': instance.get('icon'),
|
||||
'css_class': instance.get('css_class'),
|
||||
'metadata': instance.get('metadata', {}),
|
||||
}
|
||||
else:
|
||||
return super().to_representation(instance)
|
||||
|
||||
|
||||
class ChoiceGroupSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for ChoiceGroup objects.
|
||||
|
||||
This provides API representation for entire choice groups
|
||||
with all their choices and metadata.
|
||||
"""
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
metadata = serializers.DictField()
|
||||
choices = RichChoiceSerializer(many=True)
|
||||
|
||||
def to_representation(self, instance: ChoiceGroup) -> Dict[str, Any]:
|
||||
"""Convert ChoiceGroup to dictionary representation"""
|
||||
return instance.to_dict()
|
||||
|
||||
|
||||
class RichChoiceFieldSerializer(serializers.CharField):
|
||||
"""
|
||||
Serializer field for rich choice values.
|
||||
|
||||
This field serializes the choice value but can optionally
|
||||
include rich choice metadata in the response.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
include_metadata: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the serializer field.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
include_metadata: Whether to include rich choice metadata
|
||||
**kwargs: Additional arguments passed to CharField
|
||||
"""
|
||||
self.choice_group = choice_group
|
||||
self.domain = domain
|
||||
self.include_metadata = include_metadata
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_representation(self, value: str) -> Any:
|
||||
"""Convert choice value to representation"""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if self.include_metadata:
|
||||
# Return rich choice object
|
||||
choice = registry.get_choice(self.choice_group, value, self.domain)
|
||||
if choice:
|
||||
return RichChoiceSerializer(choice).data
|
||||
else:
|
||||
# Fallback for unknown values
|
||||
return {
|
||||
'value': value,
|
||||
'label': value,
|
||||
'description': '',
|
||||
'metadata': {},
|
||||
'deprecated': False,
|
||||
'category': 'other',
|
||||
'color': None,
|
||||
'icon': None,
|
||||
'css_class': None,
|
||||
'sort_order': 0,
|
||||
}
|
||||
else:
|
||||
# Return just the value
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data: Any) -> str:
|
||||
"""Convert input data to choice value"""
|
||||
if isinstance(data, dict) and 'value' in data:
|
||||
# Handle rich choice object input
|
||||
return data['value']
|
||||
else:
|
||||
# Handle string input
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
def create_choice_options_serializer(
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
include_counts: bool = False,
|
||||
queryset=None,
|
||||
count_field: str = 'id'
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Create choice options for filter endpoints.
|
||||
|
||||
This function generates choice options with optional counts
|
||||
for use in filter metadata endpoints.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
include_counts: Whether to include counts for each option
|
||||
queryset: QuerySet to count against (required if include_counts=True)
|
||||
count_field: Field to filter on for counting (default: 'id')
|
||||
|
||||
Returns:
|
||||
List of choice option dictionaries
|
||||
"""
|
||||
choices = registry.get_active_choices(choice_group, domain)
|
||||
options = []
|
||||
|
||||
for choice in choices:
|
||||
option_data = {
|
||||
'value': choice.value,
|
||||
'label': choice.label,
|
||||
'description': choice.description,
|
||||
'selected': False,
|
||||
'deprecated': choice.deprecated,
|
||||
'color': choice.color,
|
||||
'icon': choice.icon,
|
||||
'css_class': choice.css_class,
|
||||
'metadata': choice.metadata,
|
||||
}
|
||||
|
||||
if include_counts and queryset is not None:
|
||||
# Count items for this choice
|
||||
try:
|
||||
count = queryset.filter(**{count_field: choice.value}).count()
|
||||
option_data['count'] = count
|
||||
except Exception:
|
||||
# If counting fails, set count to None
|
||||
option_data['count'] = None
|
||||
else:
|
||||
option_data['count'] = None
|
||||
|
||||
options.append(option_data)
|
||||
|
||||
# Sort by sort_order, then by label
|
||||
options.sort(key=lambda x: (
|
||||
(lambda c: c.sort_order if (c is not None and hasattr(c, 'sort_order')) else 0)(
|
||||
registry.get_choice(choice_group, x['value'], domain)
|
||||
),
|
||||
x['label']
|
||||
))
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def serialize_choice_value(
|
||||
value: str,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
include_metadata: bool = False
|
||||
) -> Any:
|
||||
"""
|
||||
Serialize a single choice value.
|
||||
|
||||
Args:
|
||||
value: The choice value to serialize
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
include_metadata: Whether to include rich choice metadata
|
||||
|
||||
Returns:
|
||||
Serialized choice value (string or rich object)
|
||||
"""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if include_metadata:
|
||||
choice = registry.get_choice(choice_group, value, domain)
|
||||
if choice:
|
||||
return RichChoiceSerializer(choice).data
|
||||
else:
|
||||
# Fallback for unknown values
|
||||
return {
|
||||
'value': value,
|
||||
'label': value,
|
||||
'description': '',
|
||||
'metadata': {},
|
||||
'deprecated': False,
|
||||
'category': 'other',
|
||||
'color': None,
|
||||
'icon': None,
|
||||
'css_class': None,
|
||||
'sort_order': 0,
|
||||
}
|
||||
else:
|
||||
return value
|
||||
318
backend/apps/core/choices/utils.py
Normal file
318
backend/apps/core/choices/utils.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Utility Functions for Rich Choices
|
||||
|
||||
This module provides utility functions for working with rich choice objects.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from .base import RichChoice, ChoiceCategory
|
||||
from .registry import registry
|
||||
|
||||
|
||||
def validate_choice_value(
|
||||
value: str,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
allow_deprecated: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that a choice value is valid for a given choice group.
|
||||
|
||||
Args:
|
||||
value: The choice value to validate
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
allow_deprecated: Whether to allow deprecated choices
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not value:
|
||||
return True # Allow empty values (handled by field's null/blank settings)
|
||||
|
||||
choice = registry.get_choice(choice_group, value, domain)
|
||||
if choice is None:
|
||||
return False
|
||||
|
||||
if choice.deprecated and not allow_deprecated:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_choice_display(
|
||||
value: str,
|
||||
choice_group: str,
|
||||
domain: str = "core"
|
||||
) -> str:
|
||||
"""
|
||||
Get the display label for a choice value.
|
||||
|
||||
Args:
|
||||
value: The choice value
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
|
||||
Returns:
|
||||
Display label for the choice
|
||||
|
||||
Raises:
|
||||
ValueError: If the choice value is not found in the registry
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
choice = registry.get_choice(choice_group, value, domain)
|
||||
if choice:
|
||||
return choice.label
|
||||
else:
|
||||
raise ValueError(f"Choice value '{value}' not found in group '{choice_group}' for domain '{domain}'")
|
||||
|
||||
|
||||
|
||||
|
||||
def create_status_choices(
|
||||
statuses: Dict[str, Dict[str, Any]],
|
||||
category: ChoiceCategory = ChoiceCategory.STATUS
|
||||
) -> List[RichChoice]:
|
||||
"""
|
||||
Create status choices with consistent color coding.
|
||||
|
||||
Args:
|
||||
statuses: Dictionary mapping status value to config dict
|
||||
category: Choice category (defaults to STATUS)
|
||||
|
||||
Returns:
|
||||
List of RichChoice objects for statuses
|
||||
"""
|
||||
choices = []
|
||||
|
||||
for value, config in statuses.items():
|
||||
metadata = config.get('metadata', {})
|
||||
|
||||
# Add default status colors if not specified
|
||||
if 'color' not in metadata:
|
||||
if 'operating' in value.lower() or 'active' in value.lower():
|
||||
metadata['color'] = 'green'
|
||||
elif 'closed' in value.lower() or 'inactive' in value.lower():
|
||||
metadata['color'] = 'red'
|
||||
elif 'temp' in value.lower() or 'pending' in value.lower():
|
||||
metadata['color'] = 'yellow'
|
||||
elif 'construction' in value.lower():
|
||||
metadata['color'] = 'blue'
|
||||
else:
|
||||
metadata['color'] = 'gray'
|
||||
|
||||
choice = RichChoice(
|
||||
value=value,
|
||||
label=config['label'],
|
||||
description=config.get('description', ''),
|
||||
metadata=metadata,
|
||||
deprecated=config.get('deprecated', False),
|
||||
category=category
|
||||
)
|
||||
choices.append(choice)
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def create_type_choices(
|
||||
types: Dict[str, Dict[str, Any]],
|
||||
category: ChoiceCategory = ChoiceCategory.TYPE
|
||||
) -> List[RichChoice]:
|
||||
"""
|
||||
Create type/classification choices.
|
||||
|
||||
Args:
|
||||
types: Dictionary mapping type value to config dict
|
||||
category: Choice category (defaults to TYPE)
|
||||
|
||||
Returns:
|
||||
List of RichChoice objects for types
|
||||
"""
|
||||
choices = []
|
||||
|
||||
for value, config in types.items():
|
||||
choice = RichChoice(
|
||||
value=value,
|
||||
label=config['label'],
|
||||
description=config.get('description', ''),
|
||||
metadata=config.get('metadata', {}),
|
||||
deprecated=config.get('deprecated', False),
|
||||
category=category
|
||||
)
|
||||
choices.append(choice)
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def merge_choice_metadata(
|
||||
base_metadata: Dict[str, Any],
|
||||
override_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge choice metadata dictionaries.
|
||||
|
||||
Args:
|
||||
base_metadata: Base metadata dictionary
|
||||
override_metadata: Override metadata dictionary
|
||||
|
||||
Returns:
|
||||
Merged metadata dictionary
|
||||
"""
|
||||
merged = base_metadata.copy()
|
||||
merged.update(override_metadata)
|
||||
return merged
|
||||
|
||||
|
||||
def filter_choices_by_category(
|
||||
choices: List[RichChoice],
|
||||
category: ChoiceCategory
|
||||
) -> List[RichChoice]:
|
||||
"""
|
||||
Filter choices by category.
|
||||
|
||||
Args:
|
||||
choices: List of RichChoice objects
|
||||
category: Category to filter by
|
||||
|
||||
Returns:
|
||||
Filtered list of choices
|
||||
"""
|
||||
return [choice for choice in choices if choice.category == category]
|
||||
|
||||
|
||||
def sort_choices(
|
||||
choices: List[RichChoice],
|
||||
sort_by: str = "sort_order"
|
||||
) -> List[RichChoice]:
|
||||
"""
|
||||
Sort choices by specified criteria.
|
||||
|
||||
Args:
|
||||
choices: List of RichChoice objects
|
||||
sort_by: Sort criteria ("sort_order", "label", "value")
|
||||
|
||||
Returns:
|
||||
Sorted list of choices
|
||||
"""
|
||||
if sort_by == "sort_order":
|
||||
return sorted(choices, key=lambda x: (x.sort_order, x.label))
|
||||
elif sort_by == "label":
|
||||
return sorted(choices, key=lambda x: x.label)
|
||||
elif sort_by == "value":
|
||||
return sorted(choices, key=lambda x: x.value)
|
||||
else:
|
||||
return choices
|
||||
|
||||
|
||||
def get_choice_colors(
|
||||
choice_group: str,
|
||||
domain: str = "core"
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Get a mapping of choice values to their colors.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
|
||||
Returns:
|
||||
Dictionary mapping choice values to colors
|
||||
"""
|
||||
choices = registry.get_choices(choice_group, domain)
|
||||
return {
|
||||
choice.value: choice.color
|
||||
for choice in choices
|
||||
if choice.color
|
||||
}
|
||||
|
||||
|
||||
def validate_choice_group_data(
|
||||
name: str,
|
||||
choices: List[RichChoice],
|
||||
domain: str = "core"
|
||||
) -> List[str]:
|
||||
"""
|
||||
Validate choice group data and return list of errors.
|
||||
|
||||
Args:
|
||||
name: Choice group name
|
||||
choices: List of RichChoice objects
|
||||
domain: Domain namespace
|
||||
|
||||
Returns:
|
||||
List of validation error messages
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if not name:
|
||||
errors.append("Choice group name cannot be empty")
|
||||
|
||||
if not choices:
|
||||
errors.append("Choice group must contain at least one choice")
|
||||
return errors
|
||||
|
||||
# Check for duplicate values
|
||||
values = [choice.value for choice in choices]
|
||||
if len(values) != len(set(values)):
|
||||
duplicates = [v for v in values if values.count(v) > 1]
|
||||
errors.append(f"Duplicate choice values found: {', '.join(set(duplicates))}")
|
||||
|
||||
# Validate individual choices
|
||||
for i, choice in enumerate(choices):
|
||||
try:
|
||||
# This will trigger __post_init__ validation
|
||||
RichChoice(
|
||||
value=choice.value,
|
||||
label=choice.label,
|
||||
description=choice.description,
|
||||
metadata=choice.metadata,
|
||||
deprecated=choice.deprecated,
|
||||
category=choice.category
|
||||
)
|
||||
except ValueError as e:
|
||||
errors.append(f"Choice {i}: {str(e)}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def create_choice_from_config(config: Dict[str, Any]) -> RichChoice:
|
||||
"""
|
||||
Create a RichChoice from a configuration dictionary.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with choice data
|
||||
|
||||
Returns:
|
||||
RichChoice object
|
||||
"""
|
||||
return RichChoice(
|
||||
value=config['value'],
|
||||
label=config['label'],
|
||||
description=config.get('description', ''),
|
||||
metadata=config.get('metadata', {}),
|
||||
deprecated=config.get('deprecated', False),
|
||||
category=ChoiceCategory(config.get('category', 'other'))
|
||||
)
|
||||
|
||||
|
||||
def export_choices_to_dict(
|
||||
choice_group: str,
|
||||
domain: str = "core"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Export a choice group to a dictionary format.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the choice group
|
||||
"""
|
||||
group = registry.get(choice_group, domain)
|
||||
if not group:
|
||||
return {}
|
||||
|
||||
return group.to_dict()
|
||||
@@ -2,7 +2,7 @@
|
||||
Django management command to calculate new content.
|
||||
|
||||
This replaces the Celery task for calculating new content.
|
||||
Run with: python manage.py calculate_new_content
|
||||
Run with: uv run manage.py calculate_new_content
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Django management command to calculate trending content.
|
||||
|
||||
This replaces the Celery task for calculating trending content.
|
||||
Run with: python manage.py calculate_trending
|
||||
Run with: uv run manage.py calculate_trending
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -94,7 +94,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
# Check if migrations are up to date
|
||||
result = subprocess.run(
|
||||
[sys.executable, "manage.py", "migrate", "--check"],
|
||||
["uv", "run", "manage.py", "migrate", "--check"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
@@ -106,7 +106,7 @@ class Command(BaseCommand):
|
||||
else:
|
||||
self.stdout.write("🔄 Running database migrations...")
|
||||
subprocess.run(
|
||||
[sys.executable, "manage.py", "migrate", "--noinput"], check=True
|
||||
["uv", "run", "manage.py", "migrate", "--noinput"], check=True
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ Database migrations completed")
|
||||
@@ -123,7 +123,7 @@ class Command(BaseCommand):
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "manage.py", "seed_sample_data"], check=True
|
||||
["uv", "run", "manage.py", "seed_sample_data"], check=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Sample data seeded"))
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -163,7 +163,7 @@ class Command(BaseCommand):
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "manage.py", "collectstatic", "--noinput", "--clear"],
|
||||
["uv", "run", "manage.py", "collectstatic", "--noinput", "--clear"],
|
||||
check=True,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Static files collected"))
|
||||
@@ -182,7 +182,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Build Tailwind CSS
|
||||
subprocess.run(
|
||||
[sys.executable, "manage.py", "tailwind", "build"], check=True
|
||||
["uv", "run", "manage.py", "tailwind", "build"], check=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("✅ Tailwind CSS built"))
|
||||
|
||||
@@ -198,7 +198,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("🔍 Running system checks...")
|
||||
|
||||
try:
|
||||
subprocess.run([sys.executable, "manage.py", "check"], check=True)
|
||||
subprocess.run(["uv", "run", "manage.py", "check"], check=True)
|
||||
self.stdout.write(self.style.SUCCESS("✅ System checks passed"))
|
||||
except subprocess.CalledProcessError:
|
||||
self.stdout.write(
|
||||
@@ -220,5 +220,5 @@ class Command(BaseCommand):
|
||||
self.stdout.write(" - API Documentation: http://localhost:8000/api/docs/")
|
||||
self.stdout.write("")
|
||||
self.stdout.write("🌟 Ready to start development server with:")
|
||||
self.stdout.write(" python manage.py runserver")
|
||||
self.stdout.write(" uv run manage.py runserver_plus")
|
||||
self.stdout.write("")
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Import choices to trigger auto-registration with the global registry
|
||||
from . import choices # noqa: F401
|
||||
|
||||
935
backend/apps/moderation/choices.py
Normal file
935
backend/apps/moderation/choices.py
Normal file
@@ -0,0 +1,935 @@
|
||||
"""
|
||||
Rich Choice Objects for Moderation Domain
|
||||
|
||||
This module defines all choice options for the moderation system using the Rich Choice Objects pattern.
|
||||
All choices include rich metadata for UI styling, business logic, and enhanced functionality.
|
||||
"""
|
||||
|
||||
from apps.core.choices.base import RichChoice, ChoiceCategory
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
# ============================================================================
|
||||
# EditSubmission Choices
|
||||
# ============================================================================
|
||||
|
||||
EDIT_SUBMISSION_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending",
|
||||
description="Submission awaiting moderator review",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="APPROVED",
|
||||
label="Approved",
|
||||
description="Submission has been approved and changes applied",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': [],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="REJECTED",
|
||||
label="Rejected",
|
||||
description="Submission has been rejected and will not be applied",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="ESCALATED",
|
||||
label="Escalated",
|
||||
description="Submission has been escalated for higher-level review",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'arrow-up',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': ['APPROVED', 'REJECTED'],
|
||||
'requires_moderator': True,
|
||||
'is_actionable': True,
|
||||
'escalation_level': 'admin'
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
SUBMISSION_TYPES = [
|
||||
RichChoice(
|
||||
value="EDIT",
|
||||
label="Edit Existing",
|
||||
description="Modification to existing content",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'pencil',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 1,
|
||||
'requires_existing_object': True,
|
||||
'complexity_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CREATE",
|
||||
label="Create New",
|
||||
description="Creation of new content",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'plus-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 2,
|
||||
'requires_existing_object': False,
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ModerationReport Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_REPORT_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending Review",
|
||||
description="Report awaiting initial moderator review",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['UNDER_REVIEW', 'DISMISSED'],
|
||||
'requires_assignment': False,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="UNDER_REVIEW",
|
||||
label="Under Review",
|
||||
description="Report is actively being investigated by a moderator",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'eye',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': ['RESOLVED', 'DISMISSED'],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RESOLVED",
|
||||
label="Resolved",
|
||||
description="Report has been resolved with appropriate action taken",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="DISMISSED",
|
||||
label="Dismissed",
|
||||
description="Report was reviewed but no action was necessary",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
PRIORITY_LEVELS = [
|
||||
RichChoice(
|
||||
value="LOW",
|
||||
label="Low",
|
||||
description="Low priority - can be handled in regular workflow",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'arrow-down',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 1,
|
||||
'sla_hours': 168, # 7 days
|
||||
'escalation_threshold': 240, # 10 days
|
||||
'urgency_level': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="MEDIUM",
|
||||
label="Medium",
|
||||
description="Medium priority - standard response time expected",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'minus',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 2,
|
||||
'sla_hours': 72, # 3 days
|
||||
'escalation_threshold': 120, # 5 days
|
||||
'urgency_level': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="HIGH",
|
||||
label="High",
|
||||
description="High priority - requires prompt attention",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'arrow-up',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 3,
|
||||
'sla_hours': 24, # 1 day
|
||||
'escalation_threshold': 48, # 2 days
|
||||
'urgency_level': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="URGENT",
|
||||
label="Urgent",
|
||||
description="Urgent priority - immediate attention required",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'sla_hours': 4, # 4 hours
|
||||
'escalation_threshold': 8, # 8 hours
|
||||
'urgency_level': 4,
|
||||
'requires_immediate_notification': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
REPORT_TYPES = [
|
||||
RichChoice(
|
||||
value="SPAM",
|
||||
label="Spam",
|
||||
description="Unwanted or repetitive content",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'ban',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'default_priority': 'MEDIUM',
|
||||
'auto_actions': ['content_review'],
|
||||
'severity_level': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="HARASSMENT",
|
||||
label="Harassment",
|
||||
description="Targeted harassment or bullying behavior",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'shield-exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 2,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['user_review', 'content_review'],
|
||||
'severity_level': 4,
|
||||
'requires_user_action': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="INAPPROPRIATE_CONTENT",
|
||||
label="Inappropriate Content",
|
||||
description="Content that violates community guidelines",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'exclamation-triangle',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 3,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['content_review'],
|
||||
'severity_level': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="MISINFORMATION",
|
||||
label="Misinformation",
|
||||
description="False or misleading information",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'information-circle',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 4,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['content_review', 'fact_check'],
|
||||
'severity_level': 3,
|
||||
'requires_expert_review': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="COPYRIGHT",
|
||||
label="Copyright Violation",
|
||||
description="Unauthorized use of copyrighted material",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'document-duplicate',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 5,
|
||||
'default_priority': 'HIGH',
|
||||
'auto_actions': ['content_review', 'legal_review'],
|
||||
'severity_level': 4,
|
||||
'requires_legal_review': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="PRIVACY",
|
||||
label="Privacy Violation",
|
||||
description="Unauthorized sharing of private information",
|
||||
metadata={
|
||||
'color': 'pink',
|
||||
'icon': 'lock-closed',
|
||||
'css_class': 'bg-pink-100 text-pink-800 border-pink-200',
|
||||
'sort_order': 6,
|
||||
'default_priority': 'URGENT',
|
||||
'auto_actions': ['content_removal', 'user_review'],
|
||||
'severity_level': 5,
|
||||
'requires_immediate_action': True
|
||||
},
|
||||
category=ChoiceCategory.SECURITY
|
||||
),
|
||||
RichChoice(
|
||||
value="HATE_SPEECH",
|
||||
label="Hate Speech",
|
||||
description="Content promoting hatred or discrimination",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'fire',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 7,
|
||||
'default_priority': 'URGENT',
|
||||
'auto_actions': ['content_removal', 'user_suspension'],
|
||||
'severity_level': 5,
|
||||
'requires_immediate_action': True,
|
||||
'zero_tolerance': True
|
||||
},
|
||||
category=ChoiceCategory.SECURITY
|
||||
),
|
||||
RichChoice(
|
||||
value="VIOLENCE",
|
||||
label="Violence or Threats",
|
||||
description="Content containing violence or threatening behavior",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 8,
|
||||
'default_priority': 'URGENT',
|
||||
'auto_actions': ['content_removal', 'user_ban', 'law_enforcement_notification'],
|
||||
'severity_level': 5,
|
||||
'requires_immediate_action': True,
|
||||
'zero_tolerance': True,
|
||||
'requires_law_enforcement': True
|
||||
},
|
||||
category=ChoiceCategory.SECURITY
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other issues not covered by specific categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 9,
|
||||
'default_priority': 'MEDIUM',
|
||||
'auto_actions': ['manual_review'],
|
||||
'severity_level': 1,
|
||||
'requires_manual_categorization': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ModerationQueue Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_QUEUE_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending",
|
||||
description="Queue item awaiting assignment or action",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['IN_PROGRESS', 'CANCELLED'],
|
||||
'requires_assignment': False,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="IN_PROGRESS",
|
||||
label="In Progress",
|
||||
description="Queue item is actively being worked on",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'play',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': ['COMPLETED', 'CANCELLED'],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="COMPLETED",
|
||||
label="Completed",
|
||||
description="Queue item has been successfully completed",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': True,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CANCELLED",
|
||||
label="Cancelled",
|
||||
description="Queue item was cancelled and will not be completed",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'requires_assignment': False,
|
||||
'is_actionable': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
QUEUE_ITEM_TYPES = [
|
||||
RichChoice(
|
||||
value="CONTENT_REVIEW",
|
||||
label="Content Review",
|
||||
description="Review of user-submitted content for policy compliance",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'document-text',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 1,
|
||||
'estimated_time_minutes': 15,
|
||||
'required_permissions': ['content_moderation'],
|
||||
'complexity_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_REVIEW",
|
||||
label="User Review",
|
||||
description="Review of user account or behavior",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'user',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 2,
|
||||
'estimated_time_minutes': 30,
|
||||
'required_permissions': ['user_moderation'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="BULK_ACTION",
|
||||
label="Bulk Action",
|
||||
description="Large-scale administrative operation",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'collection',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 3,
|
||||
'estimated_time_minutes': 60,
|
||||
'required_permissions': ['bulk_operations'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="POLICY_VIOLATION",
|
||||
label="Policy Violation",
|
||||
description="Investigation of potential policy violations",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'shield-exclamation',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'estimated_time_minutes': 45,
|
||||
'required_permissions': ['policy_enforcement'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="APPEAL",
|
||||
label="Appeal",
|
||||
description="Review of user appeal against moderation action",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'scale',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 5,
|
||||
'estimated_time_minutes': 30,
|
||||
'required_permissions': ['appeal_review'],
|
||||
'complexity_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other moderation tasks not covered by specific types",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 6,
|
||||
'estimated_time_minutes': 20,
|
||||
'required_permissions': ['general_moderation'],
|
||||
'complexity_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ModerationAction Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_ACTION_TYPES = [
|
||||
RichChoice(
|
||||
value="WARNING",
|
||||
label="Warning",
|
||||
description="Formal warning issued to user",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'exclamation-triangle',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'severity_level': 1,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'escalation_path': ['USER_SUSPENSION']
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_SUSPENSION",
|
||||
label="User Suspension",
|
||||
description="Temporary suspension of user account",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'pause',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 2,
|
||||
'severity_level': 3,
|
||||
'is_temporary': True,
|
||||
'affects_privileges': True,
|
||||
'requires_duration': True,
|
||||
'escalation_path': ['USER_BAN']
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_BAN",
|
||||
label="User Ban",
|
||||
description="Permanent ban of user account",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'ban',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 3,
|
||||
'severity_level': 5,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': True,
|
||||
'is_permanent': True,
|
||||
'requires_admin_approval': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CONTENT_REMOVAL",
|
||||
label="Content Removal",
|
||||
description="Removal of specific content",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'severity_level': 2,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'is_content_action': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CONTENT_EDIT",
|
||||
label="Content Edit",
|
||||
description="Modification of content to comply with policies",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'pencil',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 5,
|
||||
'severity_level': 1,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'is_content_action': True,
|
||||
'preserves_content': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CONTENT_RESTRICTION",
|
||||
label="Content Restriction",
|
||||
description="Restriction of content visibility or access",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'eye-off',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 6,
|
||||
'severity_level': 2,
|
||||
'is_temporary': True,
|
||||
'affects_privileges': False,
|
||||
'is_content_action': True,
|
||||
'requires_duration': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="ACCOUNT_RESTRICTION",
|
||||
label="Account Restriction",
|
||||
description="Restriction of specific account privileges",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'lock-closed',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 7,
|
||||
'severity_level': 3,
|
||||
'is_temporary': True,
|
||||
'affects_privileges': True,
|
||||
'requires_duration': True,
|
||||
'escalation_path': ['USER_SUSPENSION']
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other moderation actions not covered by specific types",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 8,
|
||||
'severity_level': 1,
|
||||
'is_temporary': False,
|
||||
'affects_privileges': False,
|
||||
'requires_manual_review': True
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# BulkOperation Choices
|
||||
# ============================================================================
|
||||
|
||||
BULK_OPERATION_STATUSES = [
|
||||
RichChoice(
|
||||
value="PENDING",
|
||||
label="Pending",
|
||||
description="Operation is queued and waiting to start",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'clock',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
'sort_order': 1,
|
||||
'can_transition_to': ['RUNNING', 'CANCELLED'],
|
||||
'is_actionable': True,
|
||||
'can_cancel': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RUNNING",
|
||||
label="Running",
|
||||
description="Operation is currently executing",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'play',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'can_transition_to': ['COMPLETED', 'FAILED', 'CANCELLED'],
|
||||
'is_actionable': True,
|
||||
'can_cancel': True,
|
||||
'shows_progress': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="COMPLETED",
|
||||
label="Completed",
|
||||
description="Operation completed successfully",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 3,
|
||||
'can_transition_to': [],
|
||||
'is_actionable': False,
|
||||
'can_cancel': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="FAILED",
|
||||
label="Failed",
|
||||
description="Operation failed with errors",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 4,
|
||||
'can_transition_to': [],
|
||||
'is_actionable': False,
|
||||
'can_cancel': False,
|
||||
'is_final': True,
|
||||
'requires_investigation': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CANCELLED",
|
||||
label="Cancelled",
|
||||
description="Operation was cancelled before completion",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'stop',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 5,
|
||||
'can_transition_to': [],
|
||||
'is_actionable': False,
|
||||
'can_cancel': False,
|
||||
'is_final': True
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
BULK_OPERATION_TYPES = [
|
||||
RichChoice(
|
||||
value="UPDATE_PARKS",
|
||||
label="Update Parks",
|
||||
description="Bulk update operations on park data",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'map',
|
||||
'css_class': 'bg-green-100 text-green-800 border-green-200',
|
||||
'sort_order': 1,
|
||||
'estimated_duration_minutes': 30,
|
||||
'required_permissions': ['bulk_park_operations'],
|
||||
'affects_data': ['parks'],
|
||||
'risk_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="UPDATE_RIDES",
|
||||
label="Update Rides",
|
||||
description="Bulk update operations on ride data",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'cog',
|
||||
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
'sort_order': 2,
|
||||
'estimated_duration_minutes': 45,
|
||||
'required_permissions': ['bulk_ride_operations'],
|
||||
'affects_data': ['rides'],
|
||||
'risk_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="IMPORT_DATA",
|
||||
label="Import Data",
|
||||
description="Import data from external sources",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'download',
|
||||
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
'sort_order': 3,
|
||||
'estimated_duration_minutes': 60,
|
||||
'required_permissions': ['data_import'],
|
||||
'affects_data': ['parks', 'rides', 'users'],
|
||||
'risk_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="EXPORT_DATA",
|
||||
label="Export Data",
|
||||
description="Export data for backup or analysis",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'upload',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800 border-indigo-200',
|
||||
'sort_order': 4,
|
||||
'estimated_duration_minutes': 20,
|
||||
'required_permissions': ['data_export'],
|
||||
'affects_data': [],
|
||||
'risk_level': 'low'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="MODERATE_CONTENT",
|
||||
label="Moderate Content",
|
||||
description="Bulk moderation actions on content",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'shield-check',
|
||||
'css_class': 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'sort_order': 5,
|
||||
'estimated_duration_minutes': 40,
|
||||
'required_permissions': ['bulk_moderation'],
|
||||
'affects_data': ['content', 'users'],
|
||||
'risk_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="USER_ACTIONS",
|
||||
label="User Actions",
|
||||
description="Bulk actions on user accounts",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'users',
|
||||
'css_class': 'bg-red-100 text-red-800 border-red-200',
|
||||
'sort_order': 6,
|
||||
'estimated_duration_minutes': 50,
|
||||
'required_permissions': ['bulk_user_operations'],
|
||||
'affects_data': ['users'],
|
||||
'risk_level': 'high'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="CLEANUP",
|
||||
label="Cleanup",
|
||||
description="System cleanup and maintenance operations",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 7,
|
||||
'estimated_duration_minutes': 25,
|
||||
'required_permissions': ['system_maintenance'],
|
||||
'affects_data': ['system'],
|
||||
'risk_level': 'low'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other bulk operations not covered by specific types",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'dots-horizontal',
|
||||
'css_class': 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
'sort_order': 8,
|
||||
'estimated_duration_minutes': 30,
|
||||
'required_permissions': ['general_operations'],
|
||||
'affects_data': [],
|
||||
'risk_level': 'medium'
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# PhotoSubmission Choices (Shared with EditSubmission)
|
||||
# ============================================================================
|
||||
|
||||
# PhotoSubmission uses the same STATUS_CHOICES as EditSubmission
|
||||
PHOTO_SUBMISSION_STATUSES = EDIT_SUBMISSION_STATUSES
|
||||
|
||||
# ============================================================================
|
||||
# Choice Registration
|
||||
# ============================================================================
|
||||
|
||||
# Register all choice groups with the global registry
|
||||
register_choices("edit_submission_statuses", EDIT_SUBMISSION_STATUSES, "moderation", "Edit submission status options")
|
||||
register_choices("submission_types", SUBMISSION_TYPES, "moderation", "Submission type classifications")
|
||||
register_choices("moderation_report_statuses", MODERATION_REPORT_STATUSES, "moderation", "Moderation report status options")
|
||||
register_choices("priority_levels", PRIORITY_LEVELS, "moderation", "Priority level classifications")
|
||||
register_choices("report_types", REPORT_TYPES, "moderation", "Report type classifications")
|
||||
register_choices("moderation_queue_statuses", MODERATION_QUEUE_STATUSES, "moderation", "Moderation queue status options")
|
||||
register_choices("queue_item_types", QUEUE_ITEM_TYPES, "moderation", "Queue item type classifications")
|
||||
register_choices("moderation_action_types", MODERATION_ACTION_TYPES, "moderation", "Moderation action type classifications")
|
||||
register_choices("bulk_operation_statuses", BULK_OPERATION_STATUSES, "moderation", "Bulk operation status options")
|
||||
register_choices("bulk_operation_types", BULK_OPERATION_TYPES, "moderation", "Bulk operation type classifications")
|
||||
register_choices("photo_submission_statuses", PHOTO_SUBMISSION_STATUSES, "moderation", "Photo submission status options")
|
||||
@@ -17,6 +17,7 @@ from .models import (
|
||||
ModerationAction,
|
||||
BulkOperation,
|
||||
)
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -26,17 +27,20 @@ class ModerationReportFilter(django_filters.FilterSet):
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.STATUS_CHOICES, help_text="Filter by report status"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")],
|
||||
help_text="Filter by report status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.PRIORITY_CHOICES, help_text="Filter by report priority"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
|
||||
help_text="Filter by report priority"
|
||||
)
|
||||
|
||||
# Report type filters
|
||||
report_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationReport.REPORT_TYPE_CHOICES, help_text="Filter by report type"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("report_types", "moderation")],
|
||||
help_text="Filter by report type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
@@ -125,7 +129,11 @@ class ModerationReportFilter(django_filters.FilterSet):
|
||||
overdue_ids = []
|
||||
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
|
||||
hours_since_created = (now - report.created_at).total_seconds() / 3600
|
||||
if hours_since_created > sla_hours.get(report.priority, 24):
|
||||
if report.priority in sla_hours:
|
||||
threshold = sla_hours[report.priority]
|
||||
else:
|
||||
raise ValueError(f"Unknown priority level: {report.priority}")
|
||||
if hours_since_created > threshold:
|
||||
overdue_ids.append(report.id)
|
||||
|
||||
return queryset.filter(id__in=overdue_ids)
|
||||
@@ -146,18 +154,20 @@ class ModerationQueueFilter(django_filters.FilterSet):
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.STATUS_CHOICES, help_text="Filter by queue item status"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_queue_statuses", "moderation")],
|
||||
help_text="Filter by queue item status"
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.PRIORITY_CHOICES,
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
|
||||
help_text="Filter by queue item priority",
|
||||
)
|
||||
|
||||
# Item type filters
|
||||
item_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationQueue.ITEM_TYPE_CHOICES, help_text="Filter by queue item type"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("queue_item_types", "moderation")],
|
||||
help_text="Filter by queue item type"
|
||||
)
|
||||
|
||||
# Assignment filters
|
||||
@@ -236,7 +246,8 @@ class ModerationActionFilter(django_filters.FilterSet):
|
||||
|
||||
# Action type filters
|
||||
action_type = django_filters.ChoiceFilter(
|
||||
choices=ModerationAction.ACTION_TYPE_CHOICES, help_text="Filter by action type"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("moderation_action_types", "moderation")],
|
||||
help_text="Filter by action type"
|
||||
)
|
||||
|
||||
# User filters
|
||||
@@ -332,18 +343,20 @@ class BulkOperationFilter(django_filters.FilterSet):
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.STATUS_CHOICES, help_text="Filter by operation status"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_statuses", "moderation")],
|
||||
help_text="Filter by operation status"
|
||||
)
|
||||
|
||||
# Operation type filters
|
||||
operation_type = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.OPERATION_TYPE_CHOICES,
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("bulk_operation_types", "moderation")],
|
||||
help_text="Filter by operation type",
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
priority = django_filters.ChoiceFilter(
|
||||
choices=BulkOperation.PRIORITY_CHOICES, help_text="Filter by operation priority"
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("priority_levels", "moderation")],
|
||||
help_text="Filter by operation priority"
|
||||
)
|
||||
|
||||
# User filters
|
||||
|
||||
@@ -165,7 +165,7 @@ class Command(BaseCommand):
|
||||
"length_ft": 4500,
|
||||
"speed_mph": 80,
|
||||
"inversions": 5,
|
||||
"launch_type": "LSM",
|
||||
"propulsion_system": "LSM",
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"trains_count": 3,
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0005_remove_photosubmission_insert_insert_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="operation_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="bulk_operation_types",
|
||||
choices=[
|
||||
("UPDATE_PARKS", "Update Parks"),
|
||||
("UPDATE_RIDES", "Update Rides"),
|
||||
("IMPORT_DATA", "Import Data"),
|
||||
("EXPORT_DATA", "Export Data"),
|
||||
("MODERATE_CONTENT", "Moderate Content"),
|
||||
("USER_ACTIONS", "User Actions"),
|
||||
("CLEANUP", "Cleanup"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="priority_levels",
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperation",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="bulk_operation_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("RUNNING", "Running"),
|
||||
("COMPLETED", "Completed"),
|
||||
("FAILED", "Failed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="operation_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="bulk_operation_types",
|
||||
choices=[
|
||||
("UPDATE_PARKS", "Update Parks"),
|
||||
("UPDATE_RIDES", "Update Rides"),
|
||||
("IMPORT_DATA", "Import Data"),
|
||||
("EXPORT_DATA", "Export Data"),
|
||||
("MODERATE_CONTENT", "Moderate Content"),
|
||||
("USER_ACTIONS", "User Actions"),
|
||||
("CLEANUP", "Cleanup"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="priority_levels",
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bulkoperationevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="bulk_operation_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("RUNNING", "Running"),
|
||||
("COMPLETED", "Completed"),
|
||||
("FAILED", "Failed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmission",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="edit_submission_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmission",
|
||||
name="submission_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="submission_types",
|
||||
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
|
||||
default="EDIT",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmissionevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="edit_submission_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="editsubmissionevent",
|
||||
name="submission_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="submission_types",
|
||||
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
|
||||
default="EDIT",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationaction",
|
||||
name="action_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_action_types",
|
||||
choices=[
|
||||
("WARNING", "Warning"),
|
||||
("USER_SUSPENSION", "User Suspension"),
|
||||
("USER_BAN", "User Ban"),
|
||||
("CONTENT_REMOVAL", "Content Removal"),
|
||||
("CONTENT_EDIT", "Content Edit"),
|
||||
("CONTENT_RESTRICTION", "Content Restriction"),
|
||||
("ACCOUNT_RESTRICTION", "Account Restriction"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationactionevent",
|
||||
name="action_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_action_types",
|
||||
choices=[
|
||||
("WARNING", "Warning"),
|
||||
("USER_SUSPENSION", "User Suspension"),
|
||||
("USER_BAN", "User Ban"),
|
||||
("CONTENT_REMOVAL", "Content Removal"),
|
||||
("CONTENT_EDIT", "Content Edit"),
|
||||
("CONTENT_RESTRICTION", "Content Restriction"),
|
||||
("ACCOUNT_RESTRICTION", "Account Restriction"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="item_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="queue_item_types",
|
||||
choices=[
|
||||
("CONTENT_REVIEW", "Content Review"),
|
||||
("USER_REVIEW", "User Review"),
|
||||
("BULK_ACTION", "Bulk Action"),
|
||||
("POLICY_VIOLATION", "Policy Violation"),
|
||||
("APPEAL", "Appeal"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="priority_levels",
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueue",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_queue_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("IN_PROGRESS", "In Progress"),
|
||||
("COMPLETED", "Completed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="item_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="queue_item_types",
|
||||
choices=[
|
||||
("CONTENT_REVIEW", "Content Review"),
|
||||
("USER_REVIEW", "User Review"),
|
||||
("BULK_ACTION", "Bulk Action"),
|
||||
("POLICY_VIOLATION", "Policy Violation"),
|
||||
("APPEAL", "Appeal"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="priority_levels",
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationqueueevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_queue_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("IN_PROGRESS", "In Progress"),
|
||||
("COMPLETED", "Completed"),
|
||||
("CANCELLED", "Cancelled"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="priority_levels",
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="report_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="report_types",
|
||||
choices=[
|
||||
("SPAM", "Spam"),
|
||||
("HARASSMENT", "Harassment"),
|
||||
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
|
||||
("MISINFORMATION", "Misinformation"),
|
||||
("COPYRIGHT", "Copyright Violation"),
|
||||
("PRIVACY", "Privacy Violation"),
|
||||
("HATE_SPEECH", "Hate Speech"),
|
||||
("VIOLENCE", "Violence or Threats"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreport",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_report_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending Review"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("RESOLVED", "Resolved"),
|
||||
("DISMISSED", "Dismissed"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="priority",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="priority_levels",
|
||||
choices=[
|
||||
("LOW", "Low"),
|
||||
("MEDIUM", "Medium"),
|
||||
("HIGH", "High"),
|
||||
("URGENT", "Urgent"),
|
||||
],
|
||||
default="MEDIUM",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="report_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="report_types",
|
||||
choices=[
|
||||
("SPAM", "Spam"),
|
||||
("HARASSMENT", "Harassment"),
|
||||
("INAPPROPRIATE_CONTENT", "Inappropriate Content"),
|
||||
("MISINFORMATION", "Misinformation"),
|
||||
("COPYRIGHT", "Copyright Violation"),
|
||||
("PRIVACY", "Privacy Violation"),
|
||||
("HATE_SPEECH", "Hate Speech"),
|
||||
("VIOLENCE", "Violence or Threats"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="moderationreportevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_report_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending Review"),
|
||||
("UNDER_REVIEW", "Under Review"),
|
||||
("RESOLVED", "Resolved"),
|
||||
("DISMISSED", "Dismissed"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="photosubmission",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_submission_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="photosubmissionevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_submission_statuses",
|
||||
choices=[
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
],
|
||||
default="PENDING",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from datetime import timedelta
|
||||
import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||
|
||||
@@ -33,17 +34,6 @@ UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class EditSubmission(TrackedModel):
|
||||
STATUS_CHOICES = [
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
]
|
||||
|
||||
SUBMISSION_TYPE_CHOICES = [
|
||||
("EDIT", "Edit Existing"),
|
||||
("CREATE", "Create New"),
|
||||
]
|
||||
|
||||
# Who submitted the edit
|
||||
user = models.ForeignKey(
|
||||
@@ -60,8 +50,11 @@ class EditSubmission(TrackedModel):
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Type of submission
|
||||
submission_type = models.CharField(
|
||||
max_length=10, choices=SUBMISSION_TYPE_CHOICES, default="EDIT"
|
||||
submission_type = RichChoiceField(
|
||||
choice_group="submission_types",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default="EDIT"
|
||||
)
|
||||
|
||||
# The actual changes/data
|
||||
@@ -81,7 +74,12 @@ class EditSubmission(TrackedModel):
|
||||
source = models.TextField(
|
||||
blank=True, help_text="Source of information (if applicable)"
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
|
||||
status = RichChoiceField(
|
||||
choice_group="edit_submission_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default="PENDING"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
@@ -124,11 +122,11 @@ class EditSubmission(TrackedModel):
|
||||
field = model_class._meta.get_field(field_name)
|
||||
if isinstance(field, models.ForeignKey) and value is not None:
|
||||
try:
|
||||
related_obj = field.related_model.objects.get(pk=value)
|
||||
related_obj = field.related_model.objects.get(pk=value) # type: ignore
|
||||
resolved_data[field_name] = related_obj
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(
|
||||
f"Related object {field.related_model.__name__} with pk={value} does not exist"
|
||||
f"Related object {field.related_model.__name__} with pk={value} does not exist" # type: ignore
|
||||
)
|
||||
except FieldDoesNotExist:
|
||||
# Field doesn't exist on model, skip it
|
||||
@@ -258,37 +256,24 @@ class ModerationReport(TrackedModel):
|
||||
or behavior that needs moderator attention.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending Review'),
|
||||
('UNDER_REVIEW', 'Under Review'),
|
||||
('RESOLVED', 'Resolved'),
|
||||
('DISMISSED', 'Dismissed'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
REPORT_TYPE_CHOICES = [
|
||||
('SPAM', 'Spam'),
|
||||
('HARASSMENT', 'Harassment'),
|
||||
('INAPPROPRIATE_CONTENT', 'Inappropriate Content'),
|
||||
('MISINFORMATION', 'Misinformation'),
|
||||
('COPYRIGHT', 'Copyright Violation'),
|
||||
('PRIVACY', 'Privacy Violation'),
|
||||
('HATE_SPEECH', 'Hate Speech'),
|
||||
('VIOLENCE', 'Violence or Threats'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Report details
|
||||
report_type = models.CharField(max_length=50, choices=REPORT_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
report_type = RichChoiceField(
|
||||
choice_group="report_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="moderation_report_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default='PENDING'
|
||||
)
|
||||
priority = RichChoiceField(
|
||||
choice_group="priority_levels",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default='MEDIUM'
|
||||
)
|
||||
|
||||
# What is being reported
|
||||
reported_entity_type = models.CharField(
|
||||
@@ -339,7 +324,7 @@ class ModerationReport(TrackedModel):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_report_type_display()} report by {self.reported_by.username}"
|
||||
return f"{self.get_report_type_display()} report by {self.reported_by.username}" # type: ignore
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
@@ -351,34 +336,24 @@ class ModerationQueue(TrackedModel):
|
||||
separate from the initial reports.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('IN_PROGRESS', 'In Progress'),
|
||||
('COMPLETED', 'Completed'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
ITEM_TYPE_CHOICES = [
|
||||
('CONTENT_REVIEW', 'Content Review'),
|
||||
('USER_REVIEW', 'User Review'),
|
||||
('BULK_ACTION', 'Bulk Action'),
|
||||
('POLICY_VIOLATION', 'Policy Violation'),
|
||||
('APPEAL', 'Appeal'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Queue item details
|
||||
item_type = models.CharField(max_length=50, choices=ITEM_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
item_type = RichChoiceField(
|
||||
choice_group="queue_item_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="moderation_queue_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default='PENDING'
|
||||
)
|
||||
priority = RichChoiceField(
|
||||
choice_group="priority_levels",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default='MEDIUM'
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200, help_text="Brief title for the queue item")
|
||||
description = models.TextField(
|
||||
@@ -439,7 +414,7 @@ class ModerationQueue(TrackedModel):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_item_type_display()}: {self.title}"
|
||||
return f"{self.get_item_type_display()}: {self.title}" # type: ignore
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
@@ -451,19 +426,12 @@ class ModerationAction(TrackedModel):
|
||||
warnings, suspensions, content removal, etc.
|
||||
"""
|
||||
|
||||
ACTION_TYPE_CHOICES = [
|
||||
('WARNING', 'Warning'),
|
||||
('USER_SUSPENSION', 'User Suspension'),
|
||||
('USER_BAN', 'User Ban'),
|
||||
('CONTENT_REMOVAL', 'Content Removal'),
|
||||
('CONTENT_EDIT', 'Content Edit'),
|
||||
('CONTENT_RESTRICTION', 'Content Restriction'),
|
||||
('ACCOUNT_RESTRICTION', 'Account Restriction'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Action details
|
||||
action_type = models.CharField(max_length=50, choices=ACTION_TYPE_CHOICES)
|
||||
action_type = RichChoiceField(
|
||||
choice_group="moderation_action_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
reason = models.CharField(max_length=200, help_text="Brief reason for the action")
|
||||
details = models.TextField(help_text="Detailed explanation of the action")
|
||||
|
||||
@@ -513,7 +481,7 @@ class ModerationAction(TrackedModel):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}"
|
||||
return f"{self.get_action_type_display()} against {self.target_user.username} by {self.moderator.username}" # type: ignore
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Set expiration time if duration is provided
|
||||
@@ -531,37 +499,24 @@ class BulkOperation(TrackedModel):
|
||||
imports, exports, or mass moderation actions.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('PENDING', 'Pending'),
|
||||
('RUNNING', 'Running'),
|
||||
('COMPLETED', 'Completed'),
|
||||
('FAILED', 'Failed'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('LOW', 'Low'),
|
||||
('MEDIUM', 'Medium'),
|
||||
('HIGH', 'High'),
|
||||
('URGENT', 'Urgent'),
|
||||
]
|
||||
|
||||
OPERATION_TYPE_CHOICES = [
|
||||
('UPDATE_PARKS', 'Update Parks'),
|
||||
('UPDATE_RIDES', 'Update Rides'),
|
||||
('IMPORT_DATA', 'Import Data'),
|
||||
('EXPORT_DATA', 'Export Data'),
|
||||
('MODERATE_CONTENT', 'Moderate Content'),
|
||||
('USER_ACTIONS', 'User Actions'),
|
||||
('CLEANUP', 'Cleanup'),
|
||||
('OTHER', 'Other'),
|
||||
]
|
||||
|
||||
# Operation details
|
||||
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||
priority = models.CharField(
|
||||
max_length=10, choices=PRIORITY_CHOICES, default='MEDIUM')
|
||||
operation_type = RichChoiceField(
|
||||
choice_group="bulk_operation_types",
|
||||
domain="moderation",
|
||||
max_length=50
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="bulk_operation_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default='PENDING'
|
||||
)
|
||||
priority = RichChoiceField(
|
||||
choice_group="priority_levels",
|
||||
domain="moderation",
|
||||
max_length=10,
|
||||
default='MEDIUM'
|
||||
)
|
||||
description = models.TextField(help_text="Description of what this operation does")
|
||||
|
||||
# Operation parameters and results
|
||||
@@ -614,7 +569,7 @@ class BulkOperation(TrackedModel):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_operation_type_display()}: {self.description[:50]}"
|
||||
return f"{self.get_operation_type_display()}: {self.description[:50]}" # type: ignore
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
@@ -626,12 +581,6 @@ class BulkOperation(TrackedModel):
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(TrackedModel):
|
||||
STATUS_CHOICES = [
|
||||
("PENDING", "Pending"),
|
||||
("APPROVED", "Approved"),
|
||||
("REJECTED", "Rejected"),
|
||||
("ESCALATED", "Escalated"),
|
||||
]
|
||||
|
||||
# Who submitted the photo
|
||||
user = models.ForeignKey(
|
||||
@@ -655,7 +604,12 @@ class PhotoSubmission(TrackedModel):
|
||||
date_taken = models.DateField(null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
|
||||
status = RichChoiceField(
|
||||
choice_group="photo_submission_statuses",
|
||||
domain="moderation",
|
||||
max_length=20,
|
||||
default="PENDING"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
|
||||
@@ -128,7 +128,12 @@ class ModerationReportSerializer(serializers.ModelSerializer):
|
||||
# Define SLA hours by priority
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
|
||||
return hours_since_created > sla_hours.get(obj.priority, 24)
|
||||
if obj.priority in sla_hours:
|
||||
threshold = sla_hours[obj.priority]
|
||||
else:
|
||||
raise ValueError(f"Unknown priority level: {obj.priority}")
|
||||
|
||||
return hours_since_created > threshold
|
||||
|
||||
def get_time_since_created(self, obj) -> str:
|
||||
"""Human-readable time since creation."""
|
||||
@@ -345,12 +350,12 @@ class CompleteQueueItemSerializer(serializers.Serializer):
|
||||
|
||||
action = serializers.ChoiceField(
|
||||
choices=[
|
||||
"NO_ACTION",
|
||||
"CONTENT_REMOVED",
|
||||
"CONTENT_EDITED",
|
||||
"USER_WARNING",
|
||||
"USER_SUSPENDED",
|
||||
"USER_BANNED",
|
||||
("NO_ACTION", "No Action Required"),
|
||||
("CONTENT_REMOVED", "Content Removed"),
|
||||
("CONTENT_EDITED", "Content Edited"),
|
||||
("USER_WARNING", "User Warning Issued"),
|
||||
("USER_SUSPENDED", "User Suspended"),
|
||||
("USER_BANNED", "User Banned"),
|
||||
]
|
||||
)
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
@@ -722,7 +727,14 @@ class UserModerationProfileSerializer(serializers.Serializer):
|
||||
active_restrictions = serializers.IntegerField()
|
||||
|
||||
# Risk assessment
|
||||
risk_level = serializers.ChoiceField(choices=["LOW", "MEDIUM", "HIGH", "CRITICAL"])
|
||||
risk_level = serializers.ChoiceField(
|
||||
choices=[
|
||||
("LOW", "Low Risk"),
|
||||
("MEDIUM", "Medium Risk"),
|
||||
("HIGH", "High Risk"),
|
||||
("CRITICAL", "Critical Risk"),
|
||||
]
|
||||
)
|
||||
risk_factors = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
# Recent activity
|
||||
|
||||
@@ -181,7 +181,11 @@ class ModerationReportViewSet(viewsets.ModelViewSet):
|
||||
for report in queryset.filter(status__in=["PENDING", "UNDER_REVIEW"]):
|
||||
sla_hours = {"URGENT": 2, "HIGH": 8, "MEDIUM": 24, "LOW": 72}
|
||||
hours_since_created = (now - report.created_at).total_seconds() / 3600
|
||||
if hours_since_created > sla_hours.get(report.priority, 24):
|
||||
if report.priority in sla_hours:
|
||||
threshold = sla_hours[report.priority]
|
||||
else:
|
||||
raise ValueError(f"Unknown priority level: {report.priority}")
|
||||
if hours_since_created > threshold:
|
||||
overdue_reports += 1
|
||||
|
||||
# Reports by priority and type
|
||||
|
||||
288
backend/apps/parks/choices.py
Normal file
288
backend/apps/parks/choices.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Rich Choice Objects for Parks Domain
|
||||
|
||||
This module defines all choice objects for the parks domain, replacing
|
||||
the legacy tuple-based choices with rich choice objects.
|
||||
"""
|
||||
|
||||
from apps.core.choices import RichChoice, ChoiceCategory
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
|
||||
# Park Status Choices
|
||||
PARK_STATUSES = [
|
||||
RichChoice(
|
||||
value="OPERATING",
|
||||
label="Operating",
|
||||
description="Park is currently open and operating normally",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_TEMP",
|
||||
label="Temporarily Closed",
|
||||
description="Park is temporarily closed for maintenance, weather, or seasonal reasons",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'pause-circle',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_PERM",
|
||||
label="Permanently Closed",
|
||||
description="Park has been permanently closed and will not reopen",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="UNDER_CONSTRUCTION",
|
||||
label="Under Construction",
|
||||
description="Park is currently being built or undergoing major renovation",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'tool',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="DEMOLISHED",
|
||||
label="Demolished",
|
||||
description="Park has been completely demolished and removed",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RELOCATED",
|
||||
label="Relocated",
|
||||
description="Park has been moved to a different location",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'arrow-right',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Park Type Choices
|
||||
PARK_TYPES = [
|
||||
RichChoice(
|
||||
value="THEME_PARK",
|
||||
label="Theme Park",
|
||||
description="Large-scale amusement park with themed areas and attractions",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'castle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="AMUSEMENT_PARK",
|
||||
label="Amusement Park",
|
||||
description="Traditional amusement park with rides and games",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'ferris-wheel',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WATER_PARK",
|
||||
label="Water Park",
|
||||
description="Park featuring water-based attractions and activities",
|
||||
metadata={
|
||||
'color': 'cyan',
|
||||
'icon': 'water',
|
||||
'css_class': 'bg-cyan-100 text-cyan-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FAMILY_ENTERTAINMENT_CENTER",
|
||||
label="Family Entertainment Center",
|
||||
description="Indoor entertainment facility with games and family attractions",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'family',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CARNIVAL",
|
||||
label="Carnival",
|
||||
description="Traveling amusement show with rides, games, and entertainment",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'carnival',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FAIR",
|
||||
label="Fair",
|
||||
description="Temporary event featuring rides, games, and agricultural exhibits",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'fair',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="PIER",
|
||||
label="Pier",
|
||||
description="Seaside entertainment pier with rides and attractions",
|
||||
metadata={
|
||||
'color': 'teal',
|
||||
'icon': 'pier',
|
||||
'css_class': 'bg-teal-100 text-teal-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="BOARDWALK",
|
||||
label="Boardwalk",
|
||||
description="Waterfront entertainment area with rides and attractions",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'boardwalk',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="SAFARI_PARK",
|
||||
label="Safari Park",
|
||||
description="Wildlife park with drive-through animal experiences",
|
||||
metadata={
|
||||
'color': 'emerald',
|
||||
'icon': 'safari',
|
||||
'css_class': 'bg-emerald-100 text-emerald-800',
|
||||
'sort_order': 9
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="ZOO",
|
||||
label="Zoo",
|
||||
description="Zoological park with animal exhibits and educational programs",
|
||||
metadata={
|
||||
'color': 'lime',
|
||||
'icon': 'zoo',
|
||||
'css_class': 'bg-lime-100 text-lime-800',
|
||||
'sort_order': 10
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Park type that doesn't fit into standard categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 11
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Company Role Choices for Parks Domain (OPERATOR and PROPERTY_OWNER only)
|
||||
PARKS_COMPANY_ROLES = [
|
||||
RichChoice(
|
||||
value="OPERATOR",
|
||||
label="Park Operator",
|
||||
description="Company that operates and manages theme parks and amusement facilities",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'building-office',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1,
|
||||
'domain': 'parks',
|
||||
'permissions': ['manage_parks', 'view_operations'],
|
||||
'url_pattern': '/parks/operators/{slug}/'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="PROPERTY_OWNER",
|
||||
label="Property Owner",
|
||||
description="Company that owns the land and property where parks are located",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'home',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 2,
|
||||
'domain': 'parks',
|
||||
'permissions': ['manage_property', 'view_ownership'],
|
||||
'url_pattern': '/parks/owners/{slug}/'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_parks_choices():
|
||||
"""Register all parks domain choices with the global registry"""
|
||||
|
||||
register_choices(
|
||||
name="statuses",
|
||||
choices=PARK_STATUSES,
|
||||
domain="parks",
|
||||
description="Park operational status options",
|
||||
metadata={'domain': 'parks', 'type': 'status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="types",
|
||||
choices=PARK_TYPES,
|
||||
domain="parks",
|
||||
description="Park type and category classifications",
|
||||
metadata={'domain': 'parks', 'type': 'park_type'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="company_roles",
|
||||
choices=PARKS_COMPANY_ROLES,
|
||||
domain="parks",
|
||||
description="Company role classifications for parks domain (OPERATOR and PROPERTY_OWNER only)",
|
||||
metadata={'domain': 'parks', 'type': 'company_role'}
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_parks_choices()
|
||||
@@ -15,6 +15,7 @@ from django_filters import (
|
||||
)
|
||||
from .models import Park, Company
|
||||
from .querysets import get_base_park_queryset
|
||||
from apps.core.choices.registry import get_choices
|
||||
import requests
|
||||
|
||||
|
||||
@@ -46,7 +47,7 @@ class ParkFilter(FilterSet):
|
||||
# Status filter with clearer label
|
||||
status = ChoiceFilter(
|
||||
field_name="status",
|
||||
choices=Park.STATUS_CHOICES,
|
||||
choices=lambda: [(choice.value, choice.label) for choice in get_choices("park_statuses", "parks")],
|
||||
empty_label=_("Any status"),
|
||||
label=_("Operating Status"),
|
||||
help_text=_("Filter parks by their current operating status"),
|
||||
|
||||
@@ -658,7 +658,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 300,
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
"trains_count": 3,
|
||||
"cars_per_train": 9,
|
||||
"seats_per_car": 4,
|
||||
@@ -681,7 +681,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 400,
|
||||
"launch_type": "HYDRAULIC",
|
||||
"propulsion_system": "HYDRAULIC",
|
||||
"trains_count": 1,
|
||||
"cars_per_train": 1,
|
||||
"seats_per_car": 16,
|
||||
@@ -705,7 +705,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 197,
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
"trains_count": 2,
|
||||
"cars_per_train": 10,
|
||||
"seats_per_car": 2,
|
||||
@@ -729,7 +729,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 98,
|
||||
"launch_type": "HYDRAULIC",
|
||||
"propulsion_system": "HYDRAULIC",
|
||||
"trains_count": 2,
|
||||
"cars_per_train": 5,
|
||||
"seats_per_car": 4,
|
||||
@@ -752,7 +752,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 150,
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
"trains_count": 2,
|
||||
"cars_per_train": 6,
|
||||
"seats_per_car": 2,
|
||||
@@ -775,7 +775,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 128,
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
"trains_count": 3,
|
||||
"cars_per_train": 5,
|
||||
"seats_per_car": 4,
|
||||
@@ -798,7 +798,7 @@ class Command(BaseCommand):
|
||||
"track_material": "STEEL",
|
||||
"roller_coaster_type": "WILD_MOUSE",
|
||||
"max_drop_height_ft": 100,
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
"trains_count": 2,
|
||||
"cars_per_train": 4,
|
||||
"seats_per_car": 4,
|
||||
@@ -822,7 +822,7 @@ class Command(BaseCommand):
|
||||
"track_material": "HYBRID",
|
||||
"roller_coaster_type": "SITDOWN",
|
||||
"max_drop_height_ft": 155,
|
||||
"launch_type": "CHAIN",
|
||||
"propulsion_system": "CHAIN",
|
||||
"trains_count": 2,
|
||||
"cars_per_train": 6,
|
||||
"seats_per_car": 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-14 19:12
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0020_fix_pghistory_update_timezone"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="park",
|
||||
name="park_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="types",
|
||||
choices=[
|
||||
("THEME_PARK", "Theme Park"),
|
||||
("AMUSEMENT_PARK", "Amusement Park"),
|
||||
("WATER_PARK", "Water Park"),
|
||||
("FAMILY_ENTERTAINMENT_CENTER", "Family Entertainment Center"),
|
||||
("CARNIVAL", "Carnival"),
|
||||
("FAIR", "Fair"),
|
||||
("PIER", "Pier"),
|
||||
("BOARDWALK", "Boardwalk"),
|
||||
("SAFARI_PARK", "Safari Park"),
|
||||
("ZOO", "Zoo"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
db_index=True,
|
||||
default="THEME_PARK",
|
||||
domain="parks",
|
||||
help_text="Type/category of the park",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="park",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="statuses",
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parkevent",
|
||||
name="park_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="types",
|
||||
choices=[
|
||||
("THEME_PARK", "Theme Park"),
|
||||
("AMUSEMENT_PARK", "Amusement Park"),
|
||||
("WATER_PARK", "Water Park"),
|
||||
("FAMILY_ENTERTAINMENT_CENTER", "Family Entertainment Center"),
|
||||
("CARNIVAL", "Carnival"),
|
||||
("FAIR", "Fair"),
|
||||
("PIER", "Pier"),
|
||||
("BOARDWALK", "Boardwalk"),
|
||||
("SAFARI_PARK", "Safari Park"),
|
||||
("ZOO", "Zoo"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="THEME_PARK",
|
||||
domain="parks",
|
||||
help_text="Type/category of the park",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parkevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="statuses",
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 18:07
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0021_alter_park_park_type_alter_park_status_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_roles",
|
||||
choices=[
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_roles",
|
||||
choices=[
|
||||
("OPERATOR", "Park Operator"),
|
||||
("PROPERTY_OWNER", "Property Owner"),
|
||||
],
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,9 @@ from .reviews import ParkReview
|
||||
from .companies import Company, CompanyHeadquarters
|
||||
from .media import ParkPhoto
|
||||
|
||||
# Import choices to trigger registration
|
||||
from ..choices import *
|
||||
|
||||
# Alias Company as Operator for clarity
|
||||
Operator = Company
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
import pghistory
|
||||
|
||||
|
||||
@@ -12,14 +13,10 @@ class Company(TrackedModel):
|
||||
|
||||
objects = CompanyManager()
|
||||
|
||||
class CompanyRole(models.TextChoices):
|
||||
OPERATOR = "OPERATOR", "Park Operator"
|
||||
PROPERTY_OWNER = "PROPERTY_OWNER", "Property Owner"
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
roles = ArrayField(
|
||||
models.CharField(max_length=20, choices=CompanyRole.choices),
|
||||
RichChoiceField(choice_group="company_roles", domain="parks", max_length=20),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from config.django import base as settings
|
||||
from typing import Optional, Any, TYPE_CHECKING, List
|
||||
import pghistory
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -20,39 +21,21 @@ class Park(TrackedModel):
|
||||
|
||||
objects = ParkManager()
|
||||
id: int # Type hint for Django's automatic id field
|
||||
STATUS_CHOICES = [
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||
status = RichChoiceField(
|
||||
choice_group="statuses",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
default="OPERATING",
|
||||
)
|
||||
|
||||
PARK_TYPE_CHOICES = [
|
||||
("THEME_PARK", "Theme Park"),
|
||||
("AMUSEMENT_PARK", "Amusement Park"),
|
||||
("WATER_PARK", "Water Park"),
|
||||
("FAMILY_ENTERTAINMENT_CENTER", "Family Entertainment Center"),
|
||||
("CARNIVAL", "Carnival"),
|
||||
("FAIR", "Fair"),
|
||||
("PIER", "Pier"),
|
||||
("BOARDWALK", "Boardwalk"),
|
||||
("SAFARI_PARK", "Safari Park"),
|
||||
("ZOO", "Zoo"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
|
||||
park_type = models.CharField(
|
||||
park_type = RichChoiceField(
|
||||
choice_group="types",
|
||||
domain="parks",
|
||||
max_length=30,
|
||||
choices=PARK_TYPE_CHOICES,
|
||||
default="THEME_PARK",
|
||||
db_index=True,
|
||||
help_text="Type/category of the park"
|
||||
@@ -291,7 +274,10 @@ class Park(TrackedModel):
|
||||
"DEMOLISHED": "bg-gray-100 text-gray-800",
|
||||
"RELOCATED": "bg-purple-100 text-purple-800",
|
||||
}
|
||||
return status_colors.get(self.status, "bg-gray-100 text-gray-500")
|
||||
if self.status in status_colors:
|
||||
return status_colors[self.status]
|
||||
else:
|
||||
raise ValueError(f"Unknown park status: {self.status}")
|
||||
|
||||
@property
|
||||
def formatted_location(self) -> str:
|
||||
|
||||
@@ -5,7 +5,7 @@ This module provides intelligent data loading capabilities for the hybrid filter
|
||||
optimizing database queries and implementing progressive loading strategies.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from typing import Dict, Optional, Any
|
||||
from django.db import models
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
@@ -392,7 +392,10 @@ class SmartParkLoader:
|
||||
'CLOSED_PERM': 'Permanently Closed',
|
||||
'UNDER_CONSTRUCTION': 'Under Construction',
|
||||
}
|
||||
return status_labels.get(status, status)
|
||||
if status in status_labels:
|
||||
return status_labels[status]
|
||||
else:
|
||||
raise ValueError(f"Unknown park status: {status}")
|
||||
|
||||
def _generate_cache_key(self, operation: str, filters: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Generate cache key for the given operation and filters."""
|
||||
|
||||
@@ -15,6 +15,7 @@ app_name = "parks"
|
||||
urlpatterns = [
|
||||
# Park views with autocomplete search
|
||||
path("", views.ParkListView.as_view(), name="park_list"),
|
||||
path("operators/", views.OperatorListView.as_view(), name="operator_list"),
|
||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||
# Add park button endpoint (moved before park detail pattern)
|
||||
path("add-park-button/", views.add_park_button, name="add_park_button"),
|
||||
@@ -65,25 +66,25 @@ urlpatterns = [
|
||||
),
|
||||
# Park-specific category URLs
|
||||
path(
|
||||
"<slug:park_slug>/roller_coasters/",
|
||||
"<slug:park_slug>/roller-coasters/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "RC"},
|
||||
name="park_roller_coasters",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/dark_rides/",
|
||||
"<slug:park_slug>/dark-rides/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "DR"},
|
||||
name="park_dark_rides",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/flat_rides/",
|
||||
"<slug:park_slug>/flat-rides/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "FR"},
|
||||
name="park_flat_rides",
|
||||
),
|
||||
path(
|
||||
"<slug:park_slug>/water_rides/",
|
||||
"<slug:park_slug>/water-rides/",
|
||||
ParkSingleCategoryListView.as_view(),
|
||||
{"category": "WR"},
|
||||
name="park_water_rides",
|
||||
|
||||
@@ -849,3 +849,28 @@ class ParkAreaDetailView(
|
||||
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||
area = cast(ParkArea, self.object)
|
||||
return {"park_slug": area.park.slug, "area_slug": area.slug}
|
||||
|
||||
|
||||
class OperatorListView(ListView):
|
||||
"""View for displaying a list of park operators"""
|
||||
|
||||
template_name = "operators/operator_list.html"
|
||||
context_object_name = "operators"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get companies that are operators"""
|
||||
from .models.companies import Company
|
||||
from django.db.models import Count
|
||||
|
||||
return (
|
||||
Company.objects.filter(roles__contains=["OPERATOR"])
|
||||
.annotate(park_count=Count("operated_parks"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["total_operators"] = self.get_queryset().count()
|
||||
return context
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Rides Django App
|
||||
|
||||
This app handles all ride-related functionality including ride models,
|
||||
companies, rankings, and search functionality.
|
||||
"""
|
||||
|
||||
# Import choices to ensure they are registered with the global registry
|
||||
from . import choices
|
||||
|
||||
# Ensure choices are registered on app startup
|
||||
__all__ = ['choices']
|
||||
|
||||
@@ -94,7 +94,7 @@ class RollerCoasterStatsInline(admin.StackedInline):
|
||||
fields = (
|
||||
("height_ft", "length_ft", "speed_mph"),
|
||||
("track_material", "roller_coaster_type"),
|
||||
("launch_type", "inversions"),
|
||||
("propulsion_system", "inversions"),
|
||||
("max_drop_height_ft", "ride_time_seconds"),
|
||||
("train_style", "trains_count"),
|
||||
("cars_per_train", "seats_per_car"),
|
||||
@@ -197,9 +197,11 @@ class RideAdmin(admin.ModelAdmin):
|
||||
@admin.display(description="Category")
|
||||
def category_display(self, obj):
|
||||
"""Display category with full name"""
|
||||
return dict(obj._meta.get_field("category").choices).get(
|
||||
obj.category, obj.category
|
||||
)
|
||||
choices_dict = dict(obj._meta.get_field("category").choices)
|
||||
if obj.category in choices_dict:
|
||||
return choices_dict[obj.category]
|
||||
else:
|
||||
raise ValueError(f"Unknown category: {obj.category}")
|
||||
|
||||
|
||||
@admin.register(RideModel)
|
||||
@@ -240,9 +242,11 @@ class RideModelAdmin(admin.ModelAdmin):
|
||||
@admin.display(description="Category")
|
||||
def category_display(self, obj):
|
||||
"""Display category with full name"""
|
||||
return dict(obj._meta.get_field("category").choices).get(
|
||||
obj.category, obj.category
|
||||
)
|
||||
choices_dict = dict(obj._meta.get_field("category").choices)
|
||||
if obj.category in choices_dict:
|
||||
return choices_dict[obj.category]
|
||||
else:
|
||||
raise ValueError(f"Unknown category: {obj.category}")
|
||||
|
||||
@admin.display(description="Installations")
|
||||
def ride_count(self, obj):
|
||||
@@ -266,7 +270,7 @@ class RollerCoasterStatsAdmin(admin.ModelAdmin):
|
||||
list_filter = (
|
||||
"track_material",
|
||||
"roller_coaster_type",
|
||||
"launch_type",
|
||||
"propulsion_system",
|
||||
"inversions",
|
||||
)
|
||||
search_fields = (
|
||||
@@ -297,7 +301,7 @@ class RollerCoasterStatsAdmin(admin.ModelAdmin):
|
||||
"track_material",
|
||||
"track_type",
|
||||
"roller_coaster_type",
|
||||
"launch_type",
|
||||
"propulsion_system",
|
||||
"inversions",
|
||||
)
|
||||
},
|
||||
|
||||
804
backend/apps/rides/choices.py
Normal file
804
backend/apps/rides/choices.py
Normal file
@@ -0,0 +1,804 @@
|
||||
"""
|
||||
Rich Choice Objects for Rides Domain
|
||||
|
||||
This module defines all choice objects for the rides domain, replacing
|
||||
the legacy tuple-based choices with rich choice objects.
|
||||
"""
|
||||
|
||||
from apps.core.choices import RichChoice, ChoiceCategory
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
|
||||
# Ride Category Choices
|
||||
RIDE_CATEGORIES = [
|
||||
RichChoice(
|
||||
value="RC",
|
||||
label="Roller Coaster",
|
||||
description="Thrill rides with tracks featuring hills, loops, and high speeds",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'roller-coaster',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="DR",
|
||||
label="Dark Ride",
|
||||
description="Indoor rides with themed environments and storytelling",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'dark-ride',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FR",
|
||||
label="Flat Ride",
|
||||
description="Rides that move along a generally flat plane with spinning, swinging, or bouncing motions",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'flat-ride',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WR",
|
||||
label="Water Ride",
|
||||
description="Rides that incorporate water elements like splashing, floating, or getting wet",
|
||||
metadata={
|
||||
'color': 'cyan',
|
||||
'icon': 'water-ride',
|
||||
'css_class': 'bg-cyan-100 text-cyan-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="TR",
|
||||
label="Transport Ride",
|
||||
description="Rides primarily designed for transportation around the park",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'transport',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OT",
|
||||
label="Other",
|
||||
description="Rides that don't fit into standard categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Ride Status Choices
|
||||
RIDE_STATUSES = [
|
||||
RichChoice(
|
||||
value="OPERATING",
|
||||
label="Operating",
|
||||
description="Ride is currently open and operating normally",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'check-circle',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_TEMP",
|
||||
label="Temporarily Closed",
|
||||
description="Ride is temporarily closed for maintenance, weather, or other short-term reasons",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'pause-circle',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="SBNO",
|
||||
label="Standing But Not Operating",
|
||||
description="Ride structure remains but is not currently operating",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'stop-circle',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSING",
|
||||
label="Closing",
|
||||
description="Ride is scheduled to close permanently",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_PERM",
|
||||
label="Permanently Closed",
|
||||
description="Ride has been permanently closed and will not reopen",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="UNDER_CONSTRUCTION",
|
||||
label="Under Construction",
|
||||
description="Ride is currently being built or undergoing major renovation",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'tool',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="DEMOLISHED",
|
||||
label="Demolished",
|
||||
description="Ride has been completely removed and demolished",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'trash',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="RELOCATED",
|
||||
label="Relocated",
|
||||
description="Ride has been moved to a different location",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'arrow-right',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Post-Closing Status Choices
|
||||
POST_CLOSING_STATUSES = [
|
||||
RichChoice(
|
||||
value="SBNO",
|
||||
label="Standing But Not Operating",
|
||||
description="Ride structure remains but is not operating after closure",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'stop-circle',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
RichChoice(
|
||||
value="CLOSED_PERM",
|
||||
label="Permanently Closed",
|
||||
description="Ride has been permanently closed after the closing date",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'x-circle',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
]
|
||||
|
||||
# Roller Coaster Track Material Choices
|
||||
TRACK_MATERIALS = [
|
||||
RichChoice(
|
||||
value="STEEL",
|
||||
label="Steel",
|
||||
description="Modern steel track construction providing smooth rides and complex layouts",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'steel',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="WOOD",
|
||||
label="Wood",
|
||||
description="Traditional wooden track construction providing classic coaster experience",
|
||||
metadata={
|
||||
'color': 'amber',
|
||||
'icon': 'wood',
|
||||
'css_class': 'bg-amber-100 text-amber-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="HYBRID",
|
||||
label="Hybrid",
|
||||
description="Combination of steel and wooden construction elements",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'hybrid',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# Roller Coaster Type Choices
|
||||
COASTER_TYPES = [
|
||||
RichChoice(
|
||||
value="SITDOWN",
|
||||
label="Sit Down",
|
||||
description="Traditional seated roller coaster with riders sitting upright",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'sitdown',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="INVERTED",
|
||||
label="Inverted",
|
||||
description="Coaster where riders' feet dangle freely below the track",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'inverted',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FLYING",
|
||||
label="Flying",
|
||||
description="Riders lie face-down in a flying position",
|
||||
metadata={
|
||||
'color': 'sky',
|
||||
'icon': 'flying',
|
||||
'css_class': 'bg-sky-100 text-sky-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="STANDUP",
|
||||
label="Stand Up",
|
||||
description="Riders stand upright during the ride",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'standup',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WING",
|
||||
label="Wing",
|
||||
description="Riders sit on either side of the track with nothing above or below",
|
||||
metadata={
|
||||
'color': 'indigo',
|
||||
'icon': 'wing',
|
||||
'css_class': 'bg-indigo-100 text-indigo-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="DIVE",
|
||||
label="Dive",
|
||||
description="Features a vertical or near-vertical drop as the main element",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'dive',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FAMILY",
|
||||
label="Family",
|
||||
description="Designed for riders of all ages with moderate thrills",
|
||||
metadata={
|
||||
'color': 'emerald',
|
||||
'icon': 'family',
|
||||
'css_class': 'bg-emerald-100 text-emerald-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="WILD_MOUSE",
|
||||
label="Wild Mouse",
|
||||
description="Compact coaster with sharp turns and sudden drops",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'mouse',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="SPINNING",
|
||||
label="Spinning",
|
||||
description="Cars rotate freely during the ride",
|
||||
metadata={
|
||||
'color': 'pink',
|
||||
'icon': 'spinning',
|
||||
'css_class': 'bg-pink-100 text-pink-800',
|
||||
'sort_order': 9
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="FOURTH_DIMENSION",
|
||||
label="4th Dimension",
|
||||
description="Seats rotate independently of the track direction",
|
||||
metadata={
|
||||
'color': 'violet',
|
||||
'icon': '4d',
|
||||
'css_class': 'bg-violet-100 text-violet-800',
|
||||
'sort_order': 10
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Coaster type that doesn't fit standard classifications",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 11
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Propulsion System Choices
|
||||
PROPULSION_SYSTEMS = [
|
||||
RichChoice(
|
||||
value="CHAIN",
|
||||
label="Chain Lift",
|
||||
description="Traditional chain lift system to pull trains up the lift hill",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'chain',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="LSM",
|
||||
label="LSM Launch",
|
||||
description="Linear Synchronous Motor launch system using magnetic propulsion",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'lightning',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="HYDRAULIC",
|
||||
label="Hydraulic Launch",
|
||||
description="High-pressure hydraulic launch system for rapid acceleration",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'hydraulic',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="GRAVITY",
|
||||
label="Gravity",
|
||||
description="Uses gravity and momentum without mechanical lift systems",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'gravity',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Propulsion system that doesn't fit standard categories",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# Ride Model Target Market Choices
|
||||
TARGET_MARKETS = [
|
||||
RichChoice(
|
||||
value="FAMILY",
|
||||
label="Family",
|
||||
description="Designed for families with children, moderate thrills",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'family',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="THRILL",
|
||||
label="Thrill",
|
||||
description="High-intensity rides for thrill seekers",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'thrill',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="EXTREME",
|
||||
label="Extreme",
|
||||
description="Maximum intensity rides for extreme thrill seekers",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'extreme',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="KIDDIE",
|
||||
label="Kiddie",
|
||||
description="Gentle rides designed specifically for young children",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'kiddie',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="ALL_AGES",
|
||||
label="All Ages",
|
||||
description="Suitable for riders of all ages and thrill preferences",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'all-ages',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Ride Model Photo Type Choices
|
||||
PHOTO_TYPES = [
|
||||
RichChoice(
|
||||
value="PROMOTIONAL",
|
||||
label="Promotional",
|
||||
description="Marketing and promotional photos of the ride model",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'camera',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="TECHNICAL",
|
||||
label="Technical Drawing",
|
||||
description="Technical drawings and engineering diagrams",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'blueprint',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="INSTALLATION",
|
||||
label="Installation Example",
|
||||
description="Photos of actual installations of this ride model",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'installation',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="RENDERING",
|
||||
label="3D Rendering",
|
||||
description="Computer-generated 3D renderings of the ride model",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'cube',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="CATALOG",
|
||||
label="Catalog Image",
|
||||
description="Official catalog and brochure images",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'catalog',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
# Technical Specification Category Choices
|
||||
SPEC_CATEGORIES = [
|
||||
RichChoice(
|
||||
value="DIMENSIONS",
|
||||
label="Dimensions",
|
||||
description="Physical dimensions and measurements",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'ruler',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="PERFORMANCE",
|
||||
label="Performance",
|
||||
description="Performance specifications and capabilities",
|
||||
metadata={
|
||||
'color': 'red',
|
||||
'icon': 'speedometer',
|
||||
'css_class': 'bg-red-100 text-red-800',
|
||||
'sort_order': 2
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="CAPACITY",
|
||||
label="Capacity",
|
||||
description="Rider capacity and throughput specifications",
|
||||
metadata={
|
||||
'color': 'green',
|
||||
'icon': 'users',
|
||||
'css_class': 'bg-green-100 text-green-800',
|
||||
'sort_order': 3
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="SAFETY",
|
||||
label="Safety Features",
|
||||
description="Safety systems and features",
|
||||
metadata={
|
||||
'color': 'yellow',
|
||||
'icon': 'shield',
|
||||
'css_class': 'bg-yellow-100 text-yellow-800',
|
||||
'sort_order': 4
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="ELECTRICAL",
|
||||
label="Electrical Requirements",
|
||||
description="Power and electrical system requirements",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'lightning',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 5
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="FOUNDATION",
|
||||
label="Foundation Requirements",
|
||||
description="Foundation and structural requirements",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'foundation',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 6
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="MAINTENANCE",
|
||||
label="Maintenance",
|
||||
description="Maintenance requirements and procedures",
|
||||
metadata={
|
||||
'color': 'orange',
|
||||
'icon': 'wrench',
|
||||
'css_class': 'bg-orange-100 text-orange-800',
|
||||
'sort_order': 7
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
RichChoice(
|
||||
value="OTHER",
|
||||
label="Other",
|
||||
description="Other technical specifications",
|
||||
metadata={
|
||||
'color': 'gray',
|
||||
'icon': 'other',
|
||||
'css_class': 'bg-gray-100 text-gray-800',
|
||||
'sort_order': 8
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL
|
||||
),
|
||||
]
|
||||
|
||||
# Company Role Choices for Rides Domain (MANUFACTURER and DESIGNER only)
|
||||
RIDES_COMPANY_ROLES = [
|
||||
RichChoice(
|
||||
value="MANUFACTURER",
|
||||
label="Ride Manufacturer",
|
||||
description="Company that designs and builds ride hardware and systems",
|
||||
metadata={
|
||||
'color': 'blue',
|
||||
'icon': 'factory',
|
||||
'css_class': 'bg-blue-100 text-blue-800',
|
||||
'sort_order': 1,
|
||||
'domain': 'rides',
|
||||
'permissions': ['manage_ride_models', 'view_manufacturing'],
|
||||
'url_pattern': '/rides/manufacturers/{slug}/'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
RichChoice(
|
||||
value="DESIGNER",
|
||||
label="Ride Designer",
|
||||
description="Company that specializes in ride design, layout, and engineering",
|
||||
metadata={
|
||||
'color': 'purple',
|
||||
'icon': 'design',
|
||||
'css_class': 'bg-purple-100 text-purple-800',
|
||||
'sort_order': 2,
|
||||
'domain': 'rides',
|
||||
'permissions': ['manage_ride_designs', 'view_design_specs'],
|
||||
'url_pattern': '/rides/designers/{slug}/'
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_rides_choices():
|
||||
"""Register all rides domain choices with the global registry"""
|
||||
|
||||
register_choices(
|
||||
name="categories",
|
||||
choices=RIDE_CATEGORIES,
|
||||
domain="rides",
|
||||
description="Ride category classifications",
|
||||
metadata={'domain': 'rides', 'type': 'category'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="statuses",
|
||||
choices=RIDE_STATUSES,
|
||||
domain="rides",
|
||||
description="Ride operational status options",
|
||||
metadata={'domain': 'rides', 'type': 'status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="post_closing_statuses",
|
||||
choices=POST_CLOSING_STATUSES,
|
||||
domain="rides",
|
||||
description="Status options after ride closure",
|
||||
metadata={'domain': 'rides', 'type': 'post_closing_status'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="track_materials",
|
||||
choices=TRACK_MATERIALS,
|
||||
domain="rides",
|
||||
description="Roller coaster track material types",
|
||||
metadata={'domain': 'rides', 'type': 'track_material', 'applies_to': 'roller_coasters'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="coaster_types",
|
||||
choices=COASTER_TYPES,
|
||||
domain="rides",
|
||||
description="Roller coaster type classifications",
|
||||
metadata={'domain': 'rides', 'type': 'coaster_type', 'applies_to': 'roller_coasters'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="propulsion_systems",
|
||||
choices=PROPULSION_SYSTEMS,
|
||||
domain="rides",
|
||||
description="Roller coaster propulsion and lift systems",
|
||||
metadata={'domain': 'rides', 'type': 'propulsion_system', 'applies_to': 'roller_coasters'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="target_markets",
|
||||
choices=TARGET_MARKETS,
|
||||
domain="rides",
|
||||
description="Target market classifications for ride models",
|
||||
metadata={'domain': 'rides', 'type': 'target_market', 'applies_to': 'ride_models'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="photo_types",
|
||||
choices=PHOTO_TYPES,
|
||||
domain="rides",
|
||||
description="Photo type classifications for ride model images",
|
||||
metadata={'domain': 'rides', 'type': 'photo_type', 'applies_to': 'ride_model_photos'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="spec_categories",
|
||||
choices=SPEC_CATEGORIES,
|
||||
domain="rides",
|
||||
description="Technical specification category classifications",
|
||||
metadata={'domain': 'rides', 'type': 'spec_category', 'applies_to': 'ride_model_specs'}
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="company_roles",
|
||||
choices=RIDES_COMPANY_ROLES,
|
||||
domain="rides",
|
||||
description="Company role classifications for rides domain (MANUFACTURER and DESIGNER only)",
|
||||
metadata={'domain': 'rides', 'type': 'company_role'}
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_rides_choices()
|
||||
@@ -25,17 +25,21 @@ def get_ride_display_changes(changes: Dict) -> Dict:
|
||||
|
||||
# Format specific fields
|
||||
if field == "status":
|
||||
from .models import Ride
|
||||
from .choices import RIDE_STATUSES
|
||||
|
||||
choices = dict(Ride.STATUS_CHOICES)
|
||||
old_value = choices.get(old_value, old_value)
|
||||
new_value = choices.get(new_value, new_value)
|
||||
choices = {choice.value: choice.label for choice in RIDE_STATUSES}
|
||||
if old_value in choices:
|
||||
old_value = choices[old_value]
|
||||
if new_value in choices:
|
||||
new_value = choices[new_value]
|
||||
elif field == "post_closing_status":
|
||||
from .models import Ride
|
||||
from .choices import POST_CLOSING_STATUSES
|
||||
|
||||
choices = dict(Ride.POST_CLOSING_STATUS_CHOICES)
|
||||
old_value = choices.get(old_value, old_value)
|
||||
new_value = choices.get(new_value, new_value)
|
||||
choices = {choice.value: choice.label for choice in POST_CLOSING_STATUSES}
|
||||
if old_value in choices:
|
||||
old_value = choices[old_value]
|
||||
if new_value in choices:
|
||||
new_value = choices[new_value]
|
||||
|
||||
display_changes[field_names[field]] = {
|
||||
"old": old_value,
|
||||
@@ -61,11 +65,13 @@ def get_ride_model_display_changes(changes: Dict) -> Dict:
|
||||
|
||||
# Format category field
|
||||
if field == "category":
|
||||
from .models import CATEGORY_CHOICES
|
||||
from .choices import RIDE_CATEGORIES
|
||||
|
||||
choices = dict(CATEGORY_CHOICES)
|
||||
old_value = choices.get(old_value, old_value)
|
||||
new_value = choices.get(new_value, new_value)
|
||||
choices = {choice.value: choice.label for choice in RIDE_CATEGORIES}
|
||||
if old_value in choices:
|
||||
old_value = choices[old_value]
|
||||
if new_value in choices:
|
||||
new_value = choices[new_value]
|
||||
|
||||
display_changes[field_names[field]] = {
|
||||
"old": old_value,
|
||||
|
||||
@@ -93,36 +93,28 @@ class SearchTextForm(BaseFilterForm):
|
||||
class BasicInfoForm(BaseFilterForm):
|
||||
"""Form for basic ride information filters."""
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
("", "All Categories"),
|
||||
("roller_coaster", "Roller Coaster"),
|
||||
("water_ride", "Water Ride"),
|
||||
("flat_ride", "Flat Ride"),
|
||||
("dark_ride", "Dark Ride"),
|
||||
("kiddie_ride", "Kiddie Ride"),
|
||||
("transport", "Transport"),
|
||||
("show", "Show"),
|
||||
("other", "Other"),
|
||||
]
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("", "All Statuses"),
|
||||
("operating", "Operating"),
|
||||
("closed_temporary", "Temporarily Closed"),
|
||||
("closed_permanent", "Permanently Closed"),
|
||||
("under_construction", "Under Construction"),
|
||||
("announced", "Announced"),
|
||||
("rumored", "Rumored"),
|
||||
]
|
||||
# Get choices from Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
# Get choices - let exceptions propagate if registry fails
|
||||
category_choices = [(choice.value, choice.label) for choice in get_choices("categories", "rides")]
|
||||
status_choices = [(choice.value, choice.label) for choice in get_choices("statuses", "rides")]
|
||||
|
||||
# Update field choices dynamically
|
||||
self.fields['category'].choices = category_choices
|
||||
self.fields['status'].choices = status_choices
|
||||
|
||||
category = forms.MultipleChoiceField(
|
||||
choices=CATEGORY_CHOICES[1:], # Exclude "All Categories"
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grid grid-cols-2 gap-2"}),
|
||||
)
|
||||
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=STATUS_CHOICES[1:], # Exclude "All Statuses"
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
@@ -346,35 +338,21 @@ class RelationshipsForm(BaseFilterForm):
|
||||
class RollerCoasterForm(BaseFilterForm):
|
||||
"""Form for roller coaster specific filters."""
|
||||
|
||||
TRACK_MATERIAL_CHOICES = [
|
||||
("steel", "Steel"),
|
||||
("wood", "Wood"),
|
||||
("hybrid", "Hybrid"),
|
||||
]
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
COASTER_TYPE_CHOICES = [
|
||||
("sit_down", "Sit Down"),
|
||||
("inverted", "Inverted"),
|
||||
("floorless", "Floorless"),
|
||||
("flying", "Flying"),
|
||||
("suspended", "Suspended"),
|
||||
("stand_up", "Stand Up"),
|
||||
("spinning", "Spinning"),
|
||||
("launched", "Launched"),
|
||||
("hypercoaster", "Hypercoaster"),
|
||||
("giga_coaster", "Giga Coaster"),
|
||||
("strata_coaster", "Strata Coaster"),
|
||||
]
|
||||
# Get choices from Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
LAUNCH_TYPE_CHOICES = [
|
||||
("none", "No Launch"),
|
||||
("lim", "LIM (Linear Induction Motor)"),
|
||||
("lsm", "LSM (Linear Synchronous Motor)"),
|
||||
("hydraulic", "Hydraulic"),
|
||||
("pneumatic", "Pneumatic"),
|
||||
("cable", "Cable"),
|
||||
("flywheel", "Flywheel"),
|
||||
]
|
||||
# Get choices - let exceptions propagate if registry fails
|
||||
track_material_choices = [(choice.value, choice.label) for choice in get_choices("track_materials", "rides")]
|
||||
coaster_type_choices = [(choice.value, choice.label) for choice in get_choices("coaster_types", "rides")]
|
||||
propulsion_system_choices = [(choice.value, choice.label) for choice in get_choices("propulsion_systems", "rides")]
|
||||
|
||||
# Update field choices dynamically
|
||||
self.fields['track_material'].choices = track_material_choices
|
||||
self.fields['coaster_type'].choices = coaster_type_choices
|
||||
self.fields['propulsion_system'].choices = propulsion_system_choices
|
||||
|
||||
height_ft_range = NumberRangeField(
|
||||
min_val=0, max_val=500, step=1, required=False, label="Height (feet)"
|
||||
@@ -393,21 +371,21 @@ class RollerCoasterForm(BaseFilterForm):
|
||||
)
|
||||
|
||||
track_material = forms.MultipleChoiceField(
|
||||
choices=TRACK_MATERIAL_CHOICES,
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
coaster_type = forms.MultipleChoiceField(
|
||||
choices=COASTER_TYPE_CHOICES,
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "grid grid-cols-2 gap-2 max-h-48 overflow-y-auto"}
|
||||
),
|
||||
)
|
||||
|
||||
launch_type = forms.MultipleChoiceField(
|
||||
choices=LAUNCH_TYPE_CHOICES,
|
||||
propulsion_system = forms.MultipleChoiceField(
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "space-y-2 max-h-48 overflow-y-auto"}
|
||||
@@ -418,20 +396,29 @@ class RollerCoasterForm(BaseFilterForm):
|
||||
class CompanyForm(BaseFilterForm):
|
||||
"""Form for company-related filters."""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
("MANUFACTURER", "Manufacturer"),
|
||||
("DESIGNER", "Designer"),
|
||||
("OPERATOR", "Operator"),
|
||||
]
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Get choices from Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
|
||||
# Get both rides and parks company roles - let exceptions propagate if registry fails
|
||||
rides_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "rides")]
|
||||
parks_roles = [(choice.value, choice.label) for choice in get_choices("company_roles", "parks")]
|
||||
role_choices = rides_roles + parks_roles
|
||||
|
||||
# Update field choices dynamically
|
||||
self.fields['manufacturer_roles'].choices = role_choices
|
||||
self.fields['designer_roles'].choices = role_choices
|
||||
|
||||
manufacturer_roles = forms.MultipleChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
|
||||
designer_roles = forms.MultipleChoiceField(
|
||||
choices=ROLE_CHOICES,
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "space-y-2"}),
|
||||
)
|
||||
@@ -444,7 +431,12 @@ class CompanyForm(BaseFilterForm):
|
||||
class SortingForm(BaseFilterForm):
|
||||
"""Form for sorting options."""
|
||||
|
||||
SORT_CHOICES = [
|
||||
# Static sorting choices - these are UI-specific and don't need Rich Choice Objects
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Static sort choices for UI functionality
|
||||
sort_choices = [
|
||||
("relevance", "Relevance"),
|
||||
("name_asc", "Name (A-Z)"),
|
||||
("name_desc", "Name (Z-A)"),
|
||||
@@ -460,8 +452,10 @@ class SortingForm(BaseFilterForm):
|
||||
("capacity_desc", "Capacity (Highest)"),
|
||||
]
|
||||
|
||||
self.fields['sort_by'].choices = sort_choices
|
||||
|
||||
sort_by = forms.ChoiceField(
|
||||
choices=SORT_CHOICES,
|
||||
choices=[], # Will be populated in __init__
|
||||
required=False,
|
||||
initial="relevance",
|
||||
widget=forms.Select(
|
||||
@@ -555,21 +549,6 @@ class MasterFilterForm(BaseFilterForm):
|
||||
if not self.is_valid():
|
||||
return active_filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
"""Alias for get_filter_summary for backward compatibility."""
|
||||
return self.get_filter_summary()
|
||||
|
||||
def has_active_filters(self) -> bool:
|
||||
"""Check if any filters are currently active."""
|
||||
if not self.is_valid():
|
||||
return False
|
||||
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # If any field has a value, we have active filters
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Group filters by category
|
||||
categories = {
|
||||
"Search": ["global_search", "name_search", "description_search"],
|
||||
@@ -585,7 +564,7 @@ class MasterFilterForm(BaseFilterForm):
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"launch_type",
|
||||
"propulsion_system",
|
||||
],
|
||||
"Company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
@@ -608,3 +587,18 @@ class MasterFilterForm(BaseFilterForm):
|
||||
active_filters[category] = category_filters
|
||||
|
||||
return active_filters
|
||||
|
||||
def get_active_filters_summary(self) -> Dict[str, Any]:
|
||||
"""Alias for get_filter_summary for backward compatibility."""
|
||||
return self.get_filter_summary()
|
||||
|
||||
def has_active_filters(self) -> bool:
|
||||
"""Check if any filters are currently active."""
|
||||
if not self.is_valid():
|
||||
return False
|
||||
|
||||
for field_name, value in self.cleaned_data.items():
|
||||
if value: # If any field has a value, we have active filters
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -271,7 +271,7 @@ class RollerCoasterStatsQuerySet(BaseQuerySet):
|
||||
|
||||
def launched_coasters(self):
|
||||
"""Filter for launched coasters."""
|
||||
return self.exclude(launch_type="NONE")
|
||||
return self.exclude(propulsion_system="NONE")
|
||||
|
||||
def by_track_type(self, *, track_type: str):
|
||||
"""Filter by track type."""
|
||||
|
||||
@@ -14,7 +14,7 @@ Index Strategy:
|
||||
Performance Target: <100ms for most filter combinations
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0020_add_hybrid_filtering_indexes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Ride category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="post_closing_status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="post_closing_statuses",
|
||||
choices=[
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Status to change to after closing date",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="statuses",
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
domain="rides",
|
||||
help_text="Current operational status of the ride",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rideevent",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Ride category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rideevent",
|
||||
name="post_closing_status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="post_closing_statuses",
|
||||
choices=[
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Status to change to after closing date",
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rideevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="statuses",
|
||||
choices=[
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
domain="rides",
|
||||
help_text="Current operational status of the ride",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Primary category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="target_market",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="target_markets",
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Primary target market for this ride model",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelevent",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
default="",
|
||||
domain="rides",
|
||||
help_text="Primary category classification",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelevent",
|
||||
name="target_market",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="target_markets",
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Primary target market for this ride model",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridephoto",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridephotoevent",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstats",
|
||||
name="launch_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="launch_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Launch or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstats",
|
||||
name="roller_coaster_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="coaster_types",
|
||||
choices=[
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="SITDOWN",
|
||||
domain="rides",
|
||||
help_text="Roller coaster type classification",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstats",
|
||||
name="track_material",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="track_materials",
|
||||
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
|
||||
default="STEEL",
|
||||
domain="rides",
|
||||
help_text="Track construction material type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="launch_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="launch_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Launch or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="roller_coaster_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="coaster_types",
|
||||
choices=[
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="SITDOWN",
|
||||
domain="rides",
|
||||
help_text="Roller coaster type classification",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="track_material",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="track_materials",
|
||||
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
|
||||
default="STEEL",
|
||||
domain="rides",
|
||||
help_text="Track construction material type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 18:07
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0021_alter_company_roles_alter_companyevent_roles_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_roles",
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="roles",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_roles",
|
||||
choices=[
|
||||
("MANUFACTURER", "Ride Manufacturer"),
|
||||
("DESIGNER", "Ride Designer"),
|
||||
],
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-15 19:06
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0022_alter_company_roles_alter_companyevent_roles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelphoto",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default="PROMOTIONAL",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodelphotoevent",
|
||||
name="photo_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="photo_types",
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default="PROMOTIONAL",
|
||||
domain="rides",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodeltechnicalspec",
|
||||
name="spec_category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="spec_categories",
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Category of technical specification",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodeltechnicalspecevent",
|
||||
name="spec_category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="spec_categories",
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Category of technical specification",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-17 01:25
|
||||
|
||||
import apps.core.choices.fields
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0023_alter_ridemodelphoto_photo_type_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="rollercoasterstats",
|
||||
name="launch_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="launch_type",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstats",
|
||||
name="propulsion_system",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="propulsion_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Propulsion or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rollercoasterstatsevent",
|
||||
name="propulsion_system",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="propulsion_systems",
|
||||
choices=[
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
default="CHAIN",
|
||||
domain="rides",
|
||||
help_text="Propulsion or lift system type",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "propulsion_system", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."propulsion_system", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
|
||||
hash="89e2bb56c0befa025a9961f8df34a8a02c09f188",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_96f8b",
|
||||
table="rides_rollercoasterstats",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="rollercoasterstats",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "rides_rollercoasterstatsevent" ("cars_per_train", "height_ft", "id", "inversions", "length_ft", "max_drop_height_ft", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "propulsion_system", "ride_id", "ride_time_seconds", "roller_coaster_type", "seats_per_car", "speed_mph", "track_material", "track_type", "train_style", "trains_count") VALUES (NEW."cars_per_train", NEW."height_ft", NEW."id", NEW."inversions", NEW."length_ft", NEW."max_drop_height_ft", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."propulsion_system", NEW."ride_id", NEW."ride_time_seconds", NEW."roller_coaster_type", NEW."seats_per_car", NEW."speed_mph", NEW."track_material", NEW."track_type", NEW."train_style", NEW."trains_count"); RETURN NULL;',
|
||||
hash="047cc99ae3282202b6dc43c8dbe07690076d5068",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_24e8a",
|
||||
table="rides_rollercoasterstats",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,7 +8,7 @@ The Company model is aliased as Manufacturer to clarify its role as ride manufac
|
||||
while maintaining backward compatibility through the Company alias.
|
||||
"""
|
||||
|
||||
from .rides import Ride, RideModel, RollerCoasterStats, Categories, CATEGORY_CHOICES
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .company import Company
|
||||
from .location import RideLocation
|
||||
from .reviews import RideReview
|
||||
@@ -28,7 +28,4 @@ __all__ = [
|
||||
"RideRanking",
|
||||
"RidePairComparison",
|
||||
"RankingSnapshot",
|
||||
# Shared constants
|
||||
"Categories",
|
||||
"CATEGORY_CHOICES",
|
||||
]
|
||||
|
||||
@@ -7,20 +7,15 @@ from django.conf import settings
|
||||
|
||||
from apps.core.history import HistoricalSlug
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Company(TrackedModel):
|
||||
class CompanyRole(models.TextChoices):
|
||||
MANUFACTURER = "MANUFACTURER", "Ride Manufacturer"
|
||||
DESIGNER = "DESIGNER", "Ride Designer"
|
||||
OPERATOR = "OPERATOR", "Park Operator"
|
||||
PROPERTY_OWNER = "PROPERTY_OWNER", "Property Owner"
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
roles = ArrayField(
|
||||
models.CharField(max_length=20, choices=CompanyRole.choices),
|
||||
RichChoiceField(choice_group="company_roles", domain="rides", max_length=20),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
@@ -64,7 +59,6 @@ class Company(TrackedModel):
|
||||
def get_absolute_url(self):
|
||||
# This will need to be updated to handle different roles
|
||||
return reverse("companies:detail", kwargs={"slug": self.slug})
|
||||
return "#"
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug):
|
||||
@@ -73,7 +67,9 @@ class Company(TrackedModel):
|
||||
return cls.objects.get(slug=slug), False
|
||||
except cls.DoesNotExist:
|
||||
# Check pghistory first
|
||||
history_model = cls.get_history_model()
|
||||
try:
|
||||
from django.apps import apps
|
||||
history_model = apps.get_model('rides', f'{cls.__name__}Event')
|
||||
history_entry = (
|
||||
history_model.objects.filter(slug=slug)
|
||||
.order_by("-pgh_created_at")
|
||||
@@ -81,6 +77,9 @@ class Company(TrackedModel):
|
||||
)
|
||||
if history_entry:
|
||||
return cls.objects.get(id=history_entry.pgh_obj_id), True
|
||||
except LookupError:
|
||||
# History model doesn't exist, skip pghistory check
|
||||
pass
|
||||
|
||||
# Check manual slug history as fallback
|
||||
try:
|
||||
@@ -91,7 +90,7 @@ class Company(TrackedModel):
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist("No company found with this slug")
|
||||
|
||||
class Meta:
|
||||
class Meta(TrackedModel.Meta):
|
||||
app_label = "rides"
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "Companies"
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Optional, List, cast
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from apps.core.history import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.services.media_service import MediaService
|
||||
import pghistory
|
||||
|
||||
@@ -48,17 +49,12 @@ class RidePhoto(TrackedModel):
|
||||
is_approved = models.BooleanField(default=False)
|
||||
|
||||
# Ride-specific metadata
|
||||
photo_type = models.CharField(
|
||||
photo_type = RichChoiceField(
|
||||
choice_group="photo_types",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
choices=[
|
||||
("exterior", "Exterior View"),
|
||||
("queue", "Queue Area"),
|
||||
("station", "Station"),
|
||||
("onride", "On-Ride"),
|
||||
("construction", "Construction"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="exterior",
|
||||
help_text="Type of photo for categorization and display purposes"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
|
||||
@@ -40,7 +40,7 @@ class RideReview(TrackedModel):
|
||||
)
|
||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-created_at"]
|
||||
unique_together = ["ride", "user"]
|
||||
constraints = [
|
||||
|
||||
@@ -2,22 +2,14 @@ from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from config.django import base as settings
|
||||
from apps.core.models import TrackedModel
|
||||
from apps.core.choices import RichChoiceField
|
||||
from .company import Company
|
||||
import pghistory
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Shared choices that will be used by multiple models
|
||||
CATEGORY_CHOICES = [
|
||||
("", "Select ride type"),
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport"),
|
||||
("OT", "Other"),
|
||||
]
|
||||
if TYPE_CHECKING:
|
||||
from .rides import RollerCoasterStats
|
||||
|
||||
# Legacy alias for backward compatibility
|
||||
Categories = CATEGORY_CHOICES
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
@@ -46,9 +38,10 @@ class RideModel(TrackedModel):
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Detailed description of the ride model"
|
||||
)
|
||||
category = models.CharField(
|
||||
category = RichChoiceField(
|
||||
choice_group="categories",
|
||||
domain="rides",
|
||||
max_length=2,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Primary category classification",
|
||||
@@ -137,16 +130,11 @@ class RideModel(TrackedModel):
|
||||
blank=True,
|
||||
help_text="Notable design features or innovations (JSON or comma-separated)",
|
||||
)
|
||||
target_market = models.CharField(
|
||||
target_market = RichChoiceField(
|
||||
choice_group="target_markets",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
blank=True,
|
||||
choices=[
|
||||
("FAMILY", "Family"),
|
||||
("THRILL", "Thrill"),
|
||||
("EXTREME", "Extreme"),
|
||||
("KIDDIE", "Kiddie"),
|
||||
("ALL_AGES", "All Ages"),
|
||||
],
|
||||
help_text="Primary target market for this ride model",
|
||||
)
|
||||
|
||||
@@ -371,16 +359,12 @@ class RideModelPhoto(TrackedModel):
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# Photo metadata
|
||||
photo_type = models.CharField(
|
||||
photo_type = RichChoiceField(
|
||||
choice_group="photo_types",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
choices=[
|
||||
("PROMOTIONAL", "Promotional"),
|
||||
("TECHNICAL", "Technical Drawing"),
|
||||
("INSTALLATION", "Installation Example"),
|
||||
("RENDERING", "3D Rendering"),
|
||||
("CATALOG", "Catalog Image"),
|
||||
],
|
||||
default="PROMOTIONAL",
|
||||
help_text="Type of photo for categorization and display purposes",
|
||||
)
|
||||
|
||||
is_primary = models.BooleanField(
|
||||
@@ -418,18 +402,11 @@ class RideModelTechnicalSpec(TrackedModel):
|
||||
RideModel, on_delete=models.CASCADE, related_name="technical_specs"
|
||||
)
|
||||
|
||||
spec_category = models.CharField(
|
||||
spec_category = RichChoiceField(
|
||||
choice_group="spec_categories",
|
||||
domain="rides",
|
||||
max_length=50,
|
||||
choices=[
|
||||
("DIMENSIONS", "Dimensions"),
|
||||
("PERFORMANCE", "Performance"),
|
||||
("CAPACITY", "Capacity"),
|
||||
("SAFETY", "Safety Features"),
|
||||
("ELECTRICAL", "Electrical Requirements"),
|
||||
("FOUNDATION", "Foundation Requirements"),
|
||||
("MAINTENANCE", "Maintenance"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
help_text="Category of technical specification",
|
||||
)
|
||||
|
||||
spec_name = models.CharField(max_length=100, help_text="Name of the specification")
|
||||
@@ -460,22 +437,8 @@ class Ride(TrackedModel):
|
||||
jobs. Use selectors or annotations for real-time calculations if needed.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("", "Select status"),
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
]
|
||||
|
||||
POST_CLOSING_STATUS_CHOICES = [
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
]
|
||||
if TYPE_CHECKING:
|
||||
coaster_stats: 'RollerCoasterStats'
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255)
|
||||
@@ -490,8 +453,13 @@ class Ride(TrackedModel):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
category = models.CharField(
|
||||
max_length=2, choices=CATEGORY_CHOICES, default="", blank=True
|
||||
category = RichChoiceField(
|
||||
choice_group="categories",
|
||||
domain="rides",
|
||||
max_length=2,
|
||||
default="",
|
||||
blank=True,
|
||||
help_text="Ride category classification"
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
@@ -517,12 +485,17 @@ class Ride(TrackedModel):
|
||||
blank=True,
|
||||
help_text="The specific model/type of this ride",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||
)
|
||||
post_closing_status = models.CharField(
|
||||
status = RichChoiceField(
|
||||
choice_group="statuses",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="OPERATING",
|
||||
help_text="Current operational status of the ride"
|
||||
)
|
||||
post_closing_status = RichChoiceField(
|
||||
choice_group="post_closing_statuses",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
choices=POST_CLOSING_STATUS_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Status to change to after closing date",
|
||||
@@ -654,6 +627,14 @@ class Ride(TrackedModel):
|
||||
# Clear park_area if it doesn't belong to the new park
|
||||
self.park_area = None
|
||||
|
||||
# Sync manufacturer with ride model's manufacturer
|
||||
if self.ride_model and self.ride_model.manufacturer:
|
||||
self.manufacturer = self.ride_model.manufacturer
|
||||
elif self.ride_model and not self.ride_model.manufacturer:
|
||||
# If ride model has no manufacturer, clear the ride's manufacturer
|
||||
# to maintain consistency
|
||||
self.manufacturer = None
|
||||
|
||||
# Generate frontend URLs
|
||||
if self.park:
|
||||
frontend_domain = getattr(
|
||||
@@ -701,15 +682,15 @@ class Ride(TrackedModel):
|
||||
|
||||
# Category
|
||||
if self.category:
|
||||
category_display = dict(CATEGORY_CHOICES).get(self.category, '')
|
||||
if category_display:
|
||||
search_parts.append(category_display)
|
||||
category_choice = self.get_category_rich_choice()
|
||||
if category_choice:
|
||||
search_parts.append(category_choice.label)
|
||||
|
||||
# Status
|
||||
if self.status:
|
||||
status_display = dict(self.STATUS_CHOICES).get(self.status, '')
|
||||
if status_display:
|
||||
search_parts.append(status_display)
|
||||
status_choice = self.get_status_rich_choice()
|
||||
if status_choice:
|
||||
search_parts.append(status_choice.label)
|
||||
|
||||
# Companies
|
||||
if self.manufacturer:
|
||||
@@ -730,17 +711,17 @@ class Ride(TrackedModel):
|
||||
if stats.track_type:
|
||||
search_parts.append(stats.track_type)
|
||||
if stats.track_material:
|
||||
material_display = dict(RollerCoasterStats.TRACK_MATERIAL_CHOICES).get(stats.track_material, '')
|
||||
if material_display:
|
||||
search_parts.append(material_display)
|
||||
material_choice = stats.get_track_material_rich_choice()
|
||||
if material_choice:
|
||||
search_parts.append(material_choice.label)
|
||||
if stats.roller_coaster_type:
|
||||
type_display = dict(RollerCoasterStats.COASTER_TYPE_CHOICES).get(stats.roller_coaster_type, '')
|
||||
if type_display:
|
||||
search_parts.append(type_display)
|
||||
if stats.launch_type:
|
||||
launch_display = dict(RollerCoasterStats.LAUNCH_CHOICES).get(stats.launch_type, '')
|
||||
if launch_display:
|
||||
search_parts.append(launch_display)
|
||||
type_choice = stats.get_roller_coaster_type_rich_choice()
|
||||
if type_choice:
|
||||
search_parts.append(type_choice.label)
|
||||
if stats.propulsion_system:
|
||||
propulsion_choice = stats.get_propulsion_system_rich_choice()
|
||||
if propulsion_choice:
|
||||
search_parts.append(propulsion_choice.label)
|
||||
if stats.train_style:
|
||||
search_parts.append(stats.train_style)
|
||||
except Exception:
|
||||
@@ -815,38 +796,53 @@ class Ride(TrackedModel):
|
||||
|
||||
return changes
|
||||
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str, park=None) -> tuple["Ride", bool]:
|
||||
"""Get ride by current or historical slug, optionally within a specific park"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from apps.core.history import HistoricalSlug
|
||||
|
||||
# Build base query
|
||||
base_query = cls.objects
|
||||
if park:
|
||||
base_query = base_query.filter(park=park)
|
||||
|
||||
try:
|
||||
ride = base_query.get(slug=slug)
|
||||
return ride, False
|
||||
except cls.DoesNotExist:
|
||||
# Try historical slugs in HistoricalSlug model
|
||||
content_type = ContentType.objects.get_for_model(cls)
|
||||
historical_query = HistoricalSlug.objects.filter(
|
||||
content_type=content_type, slug=slug
|
||||
).order_by("-created_at")
|
||||
|
||||
for historical in historical_query:
|
||||
try:
|
||||
ride = base_query.get(pk=historical.object_id)
|
||||
return ride, True
|
||||
except cls.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Try pghistory events
|
||||
event_model = getattr(cls, "event_model", None)
|
||||
if event_model:
|
||||
historical_events = event_model.objects.filter(slug=slug).order_by("-pgh_created_at")
|
||||
|
||||
for historical_event in historical_events:
|
||||
try:
|
||||
ride = base_query.get(pk=historical_event.pgh_obj_id)
|
||||
return ride, True
|
||||
except cls.DoesNotExist:
|
||||
continue
|
||||
|
||||
raise cls.DoesNotExist("No ride found with this slug")
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RollerCoasterStats(models.Model):
|
||||
"""Model for tracking roller coaster specific statistics"""
|
||||
|
||||
TRACK_MATERIAL_CHOICES = [
|
||||
("STEEL", "Steel"),
|
||||
("WOOD", "Wood"),
|
||||
("HYBRID", "Hybrid"),
|
||||
]
|
||||
|
||||
COASTER_TYPE_CHOICES = [
|
||||
("SITDOWN", "Sit Down"),
|
||||
("INVERTED", "Inverted"),
|
||||
("FLYING", "Flying"),
|
||||
("STANDUP", "Stand Up"),
|
||||
("WING", "Wing"),
|
||||
("DIVE", "Dive"),
|
||||
("FAMILY", "Family"),
|
||||
("WILD_MOUSE", "Wild Mouse"),
|
||||
("SPINNING", "Spinning"),
|
||||
("FOURTH_DIMENSION", "4th Dimension"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
|
||||
LAUNCH_CHOICES = [
|
||||
("CHAIN", "Chain Lift"),
|
||||
("LSM", "LSM Launch"),
|
||||
("HYDRAULIC", "Hydraulic Launch"),
|
||||
("GRAVITY", "Gravity"),
|
||||
("OTHER", "Other"),
|
||||
]
|
||||
|
||||
ride = models.OneToOneField(
|
||||
Ride, on_delete=models.CASCADE, related_name="coaster_stats"
|
||||
@@ -863,23 +859,31 @@ class RollerCoasterStats(models.Model):
|
||||
inversions = models.PositiveIntegerField(default=0)
|
||||
ride_time_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||
track_type = models.CharField(max_length=255, blank=True)
|
||||
track_material = models.CharField(
|
||||
track_material = RichChoiceField(
|
||||
choice_group="track_materials",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
choices=TRACK_MATERIAL_CHOICES,
|
||||
default="STEEL",
|
||||
blank=True,
|
||||
help_text="Track construction material type"
|
||||
)
|
||||
roller_coaster_type = models.CharField(
|
||||
roller_coaster_type = RichChoiceField(
|
||||
choice_group="coaster_types",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
choices=COASTER_TYPE_CHOICES,
|
||||
default="SITDOWN",
|
||||
blank=True,
|
||||
help_text="Roller coaster type classification"
|
||||
)
|
||||
max_drop_height_ft = models.DecimalField(
|
||||
max_digits=6, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
launch_type = models.CharField(
|
||||
max_length=20, choices=LAUNCH_CHOICES, default="CHAIN"
|
||||
propulsion_system = RichChoiceField(
|
||||
choice_group="propulsion_systems",
|
||||
domain="rides",
|
||||
max_length=20,
|
||||
default="CHAIN",
|
||||
help_text="Propulsion or lift system type"
|
||||
)
|
||||
train_style = models.CharField(max_length=255, blank=True)
|
||||
trains_count = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
@@ -8,7 +8,8 @@ from django.db.models import QuerySet, Q, Count, Avg, Prefetch
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
from .models import Ride, RideModel, RideReview, CATEGORY_CHOICES
|
||||
from .models import Ride, RideModel, RideReview
|
||||
from .choices import RIDE_CATEGORIES
|
||||
|
||||
|
||||
def ride_list_for_display(
|
||||
@@ -275,10 +276,10 @@ def ride_statistics_by_category() -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
stats = {}
|
||||
for category_code, category_name in CATEGORY_CHOICES:
|
||||
if category_code: # Skip empty choice
|
||||
count = Ride.objects.filter(category=category_code).count()
|
||||
stats[category_code] = {"name": category_name, "count": count}
|
||||
for category in RIDE_CATEGORIES:
|
||||
if category.value: # Skip empty choice
|
||||
count = Ride.objects.filter(category=category.value).count()
|
||||
stats[category.value] = {"name": category.label, "count": count}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@@ -17,12 +17,10 @@ Architecture:
|
||||
- Hybrid: Combine both approaches based on data characteristics
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from typing import Dict, List, Any, Optional
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.models import Q, Count, Min, Max, Avg
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.db.models import Q, Min, Max
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -67,7 +65,6 @@ class SmartRideLoader:
|
||||
- has_more: Whether more data is available
|
||||
- filter_metadata: Available filter options
|
||||
"""
|
||||
from apps.rides.models import Ride
|
||||
|
||||
# Get total count for strategy decision
|
||||
total_count = self._get_total_count(filters)
|
||||
@@ -89,7 +86,6 @@ class SmartRideLoader:
|
||||
Returns:
|
||||
Dict containing additional ride records
|
||||
"""
|
||||
from apps.rides.models import Ride
|
||||
|
||||
# Build queryset with filters
|
||||
queryset = self._build_filtered_queryset(filters)
|
||||
@@ -294,8 +290,8 @@ class SmartRideLoader:
|
||||
if 'track_material' in filters and filters['track_material']:
|
||||
q_objects &= Q(coaster_stats__track_material__in=filters['track_material'])
|
||||
|
||||
if 'launch_type' in filters and filters['launch_type']:
|
||||
q_objects &= Q(coaster_stats__launch_type__in=filters['launch_type'])
|
||||
if 'propulsion_system' in filters and filters['propulsion_system']:
|
||||
q_objects &= Q(coaster_stats__propulsion_system__in=filters['propulsion_system'])
|
||||
|
||||
# Roller coaster height filters
|
||||
if 'min_height_ft' in filters and filters['min_height_ft']:
|
||||
@@ -433,7 +429,7 @@ class SmartRideLoader:
|
||||
'track_material': stats.track_material,
|
||||
'roller_coaster_type': stats.roller_coaster_type,
|
||||
'max_drop_height_ft': float(stats.max_drop_height_ft) if stats.max_drop_height_ft else None,
|
||||
'launch_type': stats.launch_type,
|
||||
'propulsion_system': stats.propulsion_system,
|
||||
'train_style': stats.train_style,
|
||||
'trains_count': stats.trains_count,
|
||||
'cars_per_train': stats.cars_per_train,
|
||||
@@ -499,9 +495,9 @@ class SmartRideLoader:
|
||||
count=models.Count('ride')
|
||||
).exclude(track_material__isnull=True).order_by('track_material'))
|
||||
|
||||
launch_types_data = list(RollerCoasterStats.objects.values('launch_type').annotate(
|
||||
propulsion_systems_data = list(RollerCoasterStats.objects.values('propulsion_system').annotate(
|
||||
count=models.Count('ride')
|
||||
).exclude(launch_type__isnull=True).order_by('launch_type'))
|
||||
).exclude(propulsion_system__isnull=True).order_by('propulsion_system'))
|
||||
|
||||
# Convert to frontend-expected format with value/label/count
|
||||
categories = [
|
||||
@@ -540,13 +536,13 @@ class SmartRideLoader:
|
||||
for item in track_materials_data
|
||||
]
|
||||
|
||||
launch_types = [
|
||||
propulsion_systems = [
|
||||
{
|
||||
'value': item['launch_type'],
|
||||
'label': self._get_launch_type_label(item['launch_type']),
|
||||
'value': item['propulsion_system'],
|
||||
'label': self._get_propulsion_system_label(item['propulsion_system']),
|
||||
'count': item['count']
|
||||
}
|
||||
for item in launch_types_data
|
||||
for item in propulsion_systems_data
|
||||
]
|
||||
|
||||
# Convert other data to expected format
|
||||
@@ -637,7 +633,7 @@ class SmartRideLoader:
|
||||
'statuses': statuses,
|
||||
'roller_coaster_types': roller_coaster_types,
|
||||
'track_materials': track_materials,
|
||||
'launch_types': launch_types,
|
||||
'propulsion_systems': propulsion_systems,
|
||||
'parks': parks,
|
||||
'park_areas': park_areas,
|
||||
'manufacturers': manufacturers,
|
||||
@@ -713,7 +709,10 @@ class SmartRideLoader:
|
||||
'TR': 'Transport Ride',
|
||||
'OT': 'Other',
|
||||
}
|
||||
return category_labels.get(category, category)
|
||||
if category in category_labels:
|
||||
return category_labels[category]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category: {category}")
|
||||
|
||||
def _get_status_label(self, status: str) -> str:
|
||||
"""Convert status code to human-readable label."""
|
||||
@@ -727,7 +726,10 @@ class SmartRideLoader:
|
||||
'DEMOLISHED': 'Demolished',
|
||||
'RELOCATED': 'Relocated',
|
||||
}
|
||||
return status_labels.get(status, status)
|
||||
if status in status_labels:
|
||||
return status_labels[status]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride status: {status}")
|
||||
|
||||
def _get_rc_type_label(self, rc_type: str) -> str:
|
||||
"""Convert roller coaster type to human-readable label."""
|
||||
@@ -744,8 +746,12 @@ class SmartRideLoader:
|
||||
'BOBSLED': 'Bobsled',
|
||||
'PIPELINE': 'Pipeline',
|
||||
'FOURTH_DIMENSION': '4th Dimension',
|
||||
'FAMILY': 'Family',
|
||||
}
|
||||
return rc_type_labels.get(rc_type, rc_type.replace('_', ' ').title())
|
||||
if rc_type in rc_type_labels:
|
||||
return rc_type_labels[rc_type]
|
||||
else:
|
||||
raise ValueError(f"Unknown roller coaster type: {rc_type}")
|
||||
|
||||
def _get_track_material_label(self, material: str) -> str:
|
||||
"""Convert track material to human-readable label."""
|
||||
@@ -754,11 +760,14 @@ class SmartRideLoader:
|
||||
'WOOD': 'Wood',
|
||||
'HYBRID': 'Hybrid (Steel/Wood)',
|
||||
}
|
||||
return material_labels.get(material, material)
|
||||
if material in material_labels:
|
||||
return material_labels[material]
|
||||
else:
|
||||
raise ValueError(f"Unknown track material: {material}")
|
||||
|
||||
def _get_launch_type_label(self, launch_type: str) -> str:
|
||||
"""Convert launch type to human-readable label."""
|
||||
launch_labels = {
|
||||
def _get_propulsion_system_label(self, propulsion_system: str) -> str:
|
||||
"""Convert propulsion system to human-readable label."""
|
||||
propulsion_labels = {
|
||||
'CHAIN': 'Chain Lift',
|
||||
'LSM': 'Linear Synchronous Motor',
|
||||
'LIM': 'Linear Induction Motor',
|
||||
@@ -766,6 +775,10 @@ class SmartRideLoader:
|
||||
'PNEUMATIC': 'Pneumatic Launch',
|
||||
'CABLE': 'Cable Lift',
|
||||
'FLYWHEEL': 'Flywheel Launch',
|
||||
'NONE': 'No Launch System',
|
||||
'GRAVITY': 'Gravity',
|
||||
'NONE': 'No Propulsion System',
|
||||
}
|
||||
return launch_labels.get(launch_type, launch_type.replace('_', ' ').title())
|
||||
if propulsion_system in propulsion_labels:
|
||||
return propulsion_labels[propulsion_system]
|
||||
else:
|
||||
raise ValueError(f"Unknown propulsion system: {propulsion_system}")
|
||||
|
||||
@@ -60,7 +60,7 @@ class RideSearchService:
|
||||
"inversions_range",
|
||||
"track_material",
|
||||
"coaster_type",
|
||||
"launch_type",
|
||||
"propulsion_system",
|
||||
],
|
||||
"company": ["manufacturer_roles", "designer_roles", "founded_date_range"],
|
||||
}
|
||||
@@ -434,14 +434,14 @@ class RideSearchService:
|
||||
rollercoasterstats__roller_coaster_type__in=types
|
||||
)
|
||||
|
||||
# Launch type filter (multi-select)
|
||||
if filters.get("launch_type"):
|
||||
launch_types = (
|
||||
filters["launch_type"]
|
||||
if isinstance(filters["launch_type"], list)
|
||||
else [filters["launch_type"]]
|
||||
# Propulsion system filter (multi-select)
|
||||
if filters.get("propulsion_system"):
|
||||
propulsion_systems = (
|
||||
filters["propulsion_system"]
|
||||
if isinstance(filters["propulsion_system"], list)
|
||||
else [filters["propulsion_system"]]
|
||||
)
|
||||
queryset = queryset.filter(rollercoasterstats__launch_type__in=launch_types)
|
||||
queryset = queryset.filter(rollercoasterstats__propulsion_system__in=propulsion_systems)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -589,13 +589,16 @@ class RideSearchService:
|
||||
"inversions_range": "Inversions",
|
||||
"track_material": "Track Material",
|
||||
"coaster_type": "Coaster Type",
|
||||
"launch_type": "Launch Type",
|
||||
"propulsion_system": "Propulsion System",
|
||||
"manufacturer_roles": "Manufacturer Roles",
|
||||
"designer_roles": "Designer Roles",
|
||||
"founded_date_range": "Founded Date",
|
||||
}
|
||||
|
||||
return display_names.get(filter_key, filter_key.replace("_", " ").title())
|
||||
if filter_key in display_names:
|
||||
return display_names[filter_key]
|
||||
else:
|
||||
raise ValueError(f"Unknown filter key: {filter_key}")
|
||||
|
||||
def get_search_suggestions(
|
||||
self, query: str, limit: int = 10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
from ..models.rides import Categories
|
||||
from ..choices import RIDE_CATEGORIES
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -16,7 +16,10 @@ def get_ride_placeholder_image(category):
|
||||
"TR": "images/placeholders/transport.jpg",
|
||||
"OT": "images/placeholders/other-ride.jpg",
|
||||
}
|
||||
return static(category_images.get(category, "images/placeholders/default-ride.jpg"))
|
||||
if category in category_images:
|
||||
return static(category_images[category])
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category: {category}")
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@@ -28,4 +31,8 @@ def get_park_placeholder_image():
|
||||
@register.filter
|
||||
def get_category_display(code):
|
||||
"""Convert category code to display name"""
|
||||
return dict(Categories).get(code, code)
|
||||
choices = {choice.value: choice.label for choice in RIDE_CATEGORIES}
|
||||
if code in choices:
|
||||
return choices[code]
|
||||
else:
|
||||
raise ValueError(f"Unknown ride category code: {code}")
|
||||
|
||||
@@ -8,25 +8,25 @@ urlpatterns = [
|
||||
path("", views.RideListView.as_view(), name="global_ride_list"),
|
||||
# Global category views
|
||||
path(
|
||||
"roller_coasters/",
|
||||
"roller-coasters/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "RC"},
|
||||
name="global_roller_coasters",
|
||||
),
|
||||
path(
|
||||
"dark_rides/",
|
||||
"dark-rides/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "DR"},
|
||||
name="global_dark_rides",
|
||||
),
|
||||
path(
|
||||
"flat_rides/",
|
||||
"flat-rides/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "FR"},
|
||||
name="global_flat_rides",
|
||||
),
|
||||
path(
|
||||
"water_rides/",
|
||||
"water-rides/",
|
||||
views.SingleCategoryListView.as_view(),
|
||||
{"category": "WR"},
|
||||
name="global_water_rides",
|
||||
@@ -70,6 +70,9 @@ urlpatterns = [
|
||||
views.ranking_comparisons,
|
||||
name="ranking_comparisons",
|
||||
),
|
||||
# Company list views
|
||||
path("manufacturers/", views.ManufacturerListView.as_view(), name="manufacturer_list"),
|
||||
path("designers/", views.DesignerListView.as_view(), name="designer_list"),
|
||||
# API endpoints moved to centralized backend/api/v1/rides/ structure
|
||||
# Frontend requests to /api/ are proxied to /api/v1/ by Vite
|
||||
# Park-specific URLs
|
||||
|
||||
@@ -5,7 +5,8 @@ from django.db.models import Q
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse, Http404
|
||||
from django.db.models import Count
|
||||
from .models.rides import Ride, RideModel, Categories
|
||||
from .models.rides import Ride, RideModel
|
||||
from .choices import RIDE_CATEGORIES
|
||||
from .models.company import Company
|
||||
from .forms import RideForm, RideSearchForm
|
||||
from .forms.search import MasterFilterForm
|
||||
@@ -241,21 +242,21 @@ class RideListView(ListView):
|
||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
||||
park = self.park
|
||||
|
||||
if filter_form.is_valid():
|
||||
# Use advanced search service
|
||||
queryset = search_service.search_rides(
|
||||
filters=filter_form.get_filter_dict(), park=park
|
||||
)
|
||||
else:
|
||||
# Fallback to basic queryset with park filter
|
||||
# For now, use a simpler approach until we can properly integrate the search service
|
||||
queryset = (
|
||||
Ride.objects.all()
|
||||
.select_related("park", "ride_model", "ride_model__manufacturer")
|
||||
.prefetch_related("photos")
|
||||
)
|
||||
|
||||
if park:
|
||||
queryset = queryset.filter(park=park)
|
||||
|
||||
# Apply basic search if provided
|
||||
search_query = self.request.GET.get('search', '').strip()
|
||||
if search_query:
|
||||
queryset = queryset.filter(name__icontains=search_query)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_template_names(self):
|
||||
@@ -276,7 +277,10 @@ class RideListView(ListView):
|
||||
# Add filter form
|
||||
filter_form = MasterFilterForm(self.request.GET)
|
||||
context["filter_form"] = filter_form
|
||||
context["category_choices"] = Categories
|
||||
# Use Rich Choice registry directly
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
|
||||
|
||||
# Add filter summary for display
|
||||
if filter_form.is_valid():
|
||||
@@ -324,7 +328,11 @@ class SingleCategoryListView(ListView):
|
||||
if hasattr(self, "park"):
|
||||
context["park"] = self.park
|
||||
context["park_slug"] = self.kwargs["park_slug"]
|
||||
context["category"] = dict(Categories).get(self.kwargs["category"])
|
||||
# Find the category choice by value using Rich Choice registry
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
category_choice = next((choice for choice in choices if choice.value == self.kwargs["category"]), None)
|
||||
context["category"] = category_choice.label if category_choice else "Unknown"
|
||||
return context
|
||||
|
||||
|
||||
@@ -419,14 +427,16 @@ def get_search_suggestions(request: HttpRequest) -> HttpResponse:
|
||||
)
|
||||
|
||||
# Add category matches
|
||||
for code, name in Categories:
|
||||
if query in name.lower():
|
||||
ride_count = Ride.objects.filter(category=code).count()
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
for choice in choices:
|
||||
if query in choice.label.lower():
|
||||
ride_count = Ride.objects.filter(category=choice.value).count()
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "category",
|
||||
"code": code,
|
||||
"text": name,
|
||||
"code": choice.value,
|
||||
"text": choice.label,
|
||||
"count": ride_count,
|
||||
}
|
||||
)
|
||||
@@ -517,7 +527,10 @@ class RideRankingsView(ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context for rankings view."""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["category_choices"] = Categories
|
||||
# Use Rich Choice registry directly
|
||||
from apps.core.choices.registry import get_choices
|
||||
choices = get_choices("categories", "rides")
|
||||
context["category_choices"] = [(choice.value, choice.label) for choice in choices]
|
||||
context["selected_category"] = self.request.GET.get("category", "all")
|
||||
context["min_riders"] = self.request.GET.get("min_riders", "")
|
||||
|
||||
@@ -639,3 +652,49 @@ def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse:
|
||||
"rides/partials/ranking_comparisons.html",
|
||||
{"comparisons": comparison_data, "ride": ride},
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerListView(ListView):
|
||||
"""View for displaying a list of ride manufacturers"""
|
||||
|
||||
model = Company
|
||||
template_name = "manufacturers/manufacturer_list.html"
|
||||
context_object_name = "manufacturers"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get companies that are manufacturers"""
|
||||
return (
|
||||
Company.objects.filter(roles__contains=["MANUFACTURER"])
|
||||
.annotate(ride_count=Count("manufactured_rides"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["total_manufacturers"] = self.get_queryset().count()
|
||||
return context
|
||||
|
||||
|
||||
class DesignerListView(ListView):
|
||||
"""View for displaying a list of ride designers"""
|
||||
|
||||
model = Company
|
||||
template_name = "designers/designer_list.html"
|
||||
context_object_name = "designers"
|
||||
paginate_by = 24
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get companies that are designers"""
|
||||
return (
|
||||
Company.objects.filter(roles__contains=["DESIGNER"])
|
||||
.annotate(ride_count=Count("designed_rides"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add context data"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["total_designers"] = self.get_queryset().count()
|
||||
return context
|
||||
|
||||
@@ -13,12 +13,12 @@ from decouple import config
|
||||
|
||||
DEBUG = config("DEBUG", default=True)
|
||||
SECRET_KEY = config("SECRET_KEY")
|
||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS")
|
||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
|
||||
DATABASE_URL = config("DATABASE_URL")
|
||||
CACHE_URL = config("CACHE_URL", default="locmem://")
|
||||
EMAIL_URL = config("EMAIL_URL", default="console://")
|
||||
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default=[])
|
||||
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
|
||||
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
|
||||
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
|
||||
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300)
|
||||
@@ -43,13 +43,12 @@ if apps_dir.exists() and str(apps_dir) not in sys.path:
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = config("SECRET_KEY")
|
||||
|
||||
# Allowed hosts
|
||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS")
|
||||
# Allowed hosts (already configured above)
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS = config(
|
||||
"CSRF_TRUSTED_ORIGINS", default=[]
|
||||
) # type: ignore[arg-type]
|
||||
"CSRF_TRUSTED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()]
|
||||
)
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
@@ -147,7 +146,7 @@ if TEMPLATES_ENABLED:
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"moderation.context_processors.moderation_access",
|
||||
"apps.moderation.context_processors.moderation_access",
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -165,7 +164,7 @@ else:
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"moderation.context_processors.moderation_access",
|
||||
"apps.moderation.context_processors.moderation_access",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ def main():
|
||||
"""Run administrative tasks."""
|
||||
# Auto-detect environment based on command line arguments and environment variables
|
||||
settings_module = detect_settings_module()
|
||||
config("DJANGO_SETTINGS_MODULE", settings_module)
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
|
||||
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
@@ -21,7 +21,7 @@ def main():
|
||||
"Did you forget to activate a virtual environment?"
|
||||
)
|
||||
print("\nTo set up your development environment, try:")
|
||||
print(" python manage.py setup_dev")
|
||||
print(" uv run manage.py setup_dev")
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script to set up social authentication providers for development.
|
||||
Run this with: python manage.py shell < setup_social_providers.py
|
||||
Run this with: uv run manage.py shell < setup_social_providers.py
|
||||
"""
|
||||
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
574
backend/static/css/components.css
Normal file
574
backend/static/css/components.css
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* ThrillWiki Component Styles
|
||||
* Enhanced CSS matching shadcn/ui design system from React frontend
|
||||
*/
|
||||
|
||||
/* CSS Variables for Design System */
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 262.1 83.3% 57.8%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 262.1 83.3% 57.8%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Component Classes */
|
||||
.bg-background { background-color: hsl(var(--background)); }
|
||||
.bg-foreground { background-color: hsl(var(--foreground)); }
|
||||
.bg-card { background-color: hsl(var(--card)); }
|
||||
.bg-card-foreground { background-color: hsl(var(--card-foreground)); }
|
||||
.bg-popover { background-color: hsl(var(--popover)); }
|
||||
.bg-popover-foreground { background-color: hsl(var(--popover-foreground)); }
|
||||
.bg-primary { background-color: hsl(var(--primary)); }
|
||||
.bg-primary-foreground { background-color: hsl(var(--primary-foreground)); }
|
||||
.bg-secondary { background-color: hsl(var(--secondary)); }
|
||||
.bg-secondary-foreground { background-color: hsl(var(--secondary-foreground)); }
|
||||
.bg-muted { background-color: hsl(var(--muted)); }
|
||||
.bg-muted-foreground { background-color: hsl(var(--muted-foreground)); }
|
||||
.bg-accent { background-color: hsl(var(--accent)); }
|
||||
.bg-accent-foreground { background-color: hsl(var(--accent-foreground)); }
|
||||
.bg-destructive { background-color: hsl(var(--destructive)); }
|
||||
.bg-destructive-foreground { background-color: hsl(var(--destructive-foreground)); }
|
||||
|
||||
.text-background { color: hsl(var(--background)); }
|
||||
.text-foreground { color: hsl(var(--foreground)); }
|
||||
.text-card { color: hsl(var(--card)); }
|
||||
.text-card-foreground { color: hsl(var(--card-foreground)); }
|
||||
.text-popover { color: hsl(var(--popover)); }
|
||||
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
|
||||
.text-primary { color: hsl(var(--primary)); }
|
||||
.text-primary-foreground { color: hsl(var(--primary-foreground)); }
|
||||
.text-secondary { color: hsl(var(--secondary)); }
|
||||
.text-secondary-foreground { color: hsl(var(--secondary-foreground)); }
|
||||
.text-muted { color: hsl(var(--muted)); }
|
||||
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
|
||||
.text-accent { color: hsl(var(--accent)); }
|
||||
.text-accent-foreground { color: hsl(var(--accent-foreground)); }
|
||||
.text-destructive { color: hsl(var(--destructive)); }
|
||||
.text-destructive-foreground { color: hsl(var(--destructive-foreground)); }
|
||||
|
||||
.border-background { border-color: hsl(var(--background)); }
|
||||
.border-foreground { border-color: hsl(var(--foreground)); }
|
||||
.border-card { border-color: hsl(var(--card)); }
|
||||
.border-card-foreground { border-color: hsl(var(--card-foreground)); }
|
||||
.border-popover { border-color: hsl(var(--popover)); }
|
||||
.border-popover-foreground { border-color: hsl(var(--popover-foreground)); }
|
||||
.border-primary { border-color: hsl(var(--primary)); }
|
||||
.border-primary-foreground { border-color: hsl(var(--primary-foreground)); }
|
||||
.border-secondary { border-color: hsl(var(--secondary)); }
|
||||
.border-secondary-foreground { border-color: hsl(var(--secondary-foreground)); }
|
||||
.border-muted { border-color: hsl(var(--muted)); }
|
||||
.border-muted-foreground { border-color: hsl(var(--muted-foreground)); }
|
||||
.border-accent { border-color: hsl(var(--accent)); }
|
||||
.border-accent-foreground { border-color: hsl(var(--accent-foreground)); }
|
||||
.border-destructive { border-color: hsl(var(--destructive)); }
|
||||
.border-destructive-foreground { border-color: hsl(var(--destructive-foreground)); }
|
||||
.border-input { border-color: hsl(var(--input)); }
|
||||
|
||||
.ring-background { --tw-ring-color: hsl(var(--background)); }
|
||||
.ring-foreground { --tw-ring-color: hsl(var(--foreground)); }
|
||||
.ring-card { --tw-ring-color: hsl(var(--card)); }
|
||||
.ring-card-foreground { --tw-ring-color: hsl(var(--card-foreground)); }
|
||||
.ring-popover { --tw-ring-color: hsl(var(--popover)); }
|
||||
.ring-popover-foreground { --tw-ring-color: hsl(var(--popover-foreground)); }
|
||||
.ring-primary { --tw-ring-color: hsl(var(--primary)); }
|
||||
.ring-primary-foreground { --tw-ring-color: hsl(var(--primary-foreground)); }
|
||||
.ring-secondary { --tw-ring-color: hsl(var(--secondary)); }
|
||||
.ring-secondary-foreground { --tw-ring-color: hsl(var(--secondary-foreground)); }
|
||||
.ring-muted { --tw-ring-color: hsl(var(--muted)); }
|
||||
.ring-muted-foreground { --tw-ring-color: hsl(var(--muted-foreground)); }
|
||||
.ring-accent { --tw-ring-color: hsl(var(--accent)); }
|
||||
.ring-accent-foreground { --tw-ring-color: hsl(var(--accent-foreground)); }
|
||||
.ring-destructive { --tw-ring-color: hsl(var(--destructive)); }
|
||||
.ring-destructive-foreground { --tw-ring-color: hsl(var(--destructive-foreground)); }
|
||||
.ring-ring { --tw-ring-color: hsl(var(--ring)); }
|
||||
|
||||
.ring-offset-background { --tw-ring-offset-color: hsl(var(--background)); }
|
||||
|
||||
/* Enhanced Button Styles */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
@apply bg-primary text-primary-foreground hover:bg-primary/90;
|
||||
}
|
||||
|
||||
.btn-destructive {
|
||||
@apply bg-destructive text-destructive-foreground hover:bg-destructive/90;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-input bg-background hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply h-9 rounded-md px-3;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply h-11 rounded-md px-8;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@apply h-10 w-10;
|
||||
}
|
||||
|
||||
/* Enhanced Card Styles */
|
||||
.card {
|
||||
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply flex flex-col space-y-1.5 p-6;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply text-2xl font-semibold leading-none tracking-tight;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6 pt-0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply flex items-center p-6 pt-0;
|
||||
}
|
||||
|
||||
/* Enhanced Input Styles */
|
||||
.input {
|
||||
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
/* Enhanced Form Styles */
|
||||
.form-group {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply text-sm font-medium text-destructive;
|
||||
}
|
||||
|
||||
.form-description {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
/* Enhanced Navigation Styles */
|
||||
.nav-link {
|
||||
@apply flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
@apply bg-accent text-accent-foreground;
|
||||
}
|
||||
|
||||
/* Enhanced Dropdown Styles */
|
||||
.dropdown-content {
|
||||
@apply z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50;
|
||||
}
|
||||
|
||||
.dropdown-separator {
|
||||
@apply -mx-1 my-1 h-px bg-muted;
|
||||
}
|
||||
|
||||
/* Enhanced Modal Styles */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50 bg-background/80 backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex flex-col space-y-1.5 text-center sm:text-left;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@apply text-lg font-semibold leading-none tracking-tight;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2;
|
||||
}
|
||||
|
||||
/* Enhanced Alert Styles */
|
||||
.alert {
|
||||
@apply relative w-full rounded-lg border p-4;
|
||||
}
|
||||
|
||||
.alert-default {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
.alert-destructive {
|
||||
@apply border-destructive/50 text-destructive dark:border-destructive;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
@apply mb-1 font-medium leading-none tracking-tight;
|
||||
}
|
||||
|
||||
.alert-description {
|
||||
@apply text-sm opacity-90;
|
||||
}
|
||||
|
||||
/* Enhanced Badge Styles */
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
@apply border-transparent bg-primary text-primary-foreground hover:bg-primary/80;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@apply border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||
}
|
||||
|
||||
.badge-destructive {
|
||||
@apply border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80;
|
||||
}
|
||||
|
||||
.badge-outline {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
/* Enhanced Table Styles */
|
||||
.table {
|
||||
@apply w-full caption-bottom text-sm;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply border-b;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
@apply divide-y;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply border-b transition-colors hover:bg-muted/50;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
@apply h-12 px-4 text-left align-middle font-medium text-muted-foreground;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
@apply p-4 align-middle;
|
||||
}
|
||||
|
||||
/* Enhanced Skeleton Styles */
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded-md bg-muted;
|
||||
}
|
||||
|
||||
/* Enhanced Separator Styles */
|
||||
.separator {
|
||||
@apply shrink-0 bg-border;
|
||||
}
|
||||
|
||||
.separator-horizontal {
|
||||
@apply h-[1px] w-full;
|
||||
}
|
||||
|
||||
.separator-vertical {
|
||||
@apply h-full w-[1px];
|
||||
}
|
||||
|
||||
/* Enhanced Avatar Styles */
|
||||
.avatar {
|
||||
@apply relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
@apply aspect-square h-full w-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
@apply flex h-full w-full items-center justify-center rounded-full bg-muted;
|
||||
}
|
||||
|
||||
/* Enhanced Progress Styles */
|
||||
.progress {
|
||||
@apply relative h-4 w-full overflow-hidden rounded-full bg-secondary;
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
@apply h-full w-full flex-1 bg-primary transition-all;
|
||||
}
|
||||
|
||||
/* Enhanced Scroll Area Styles */
|
||||
.scroll-area {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.scroll-viewport {
|
||||
@apply h-full w-full rounded-[inherit];
|
||||
}
|
||||
|
||||
.scroll-bar {
|
||||
@apply flex touch-none select-none transition-colors;
|
||||
}
|
||||
|
||||
.scroll-thumb {
|
||||
@apply relative flex-1 rounded-full bg-border;
|
||||
}
|
||||
|
||||
/* Enhanced Tabs Styles */
|
||||
.tabs-list {
|
||||
@apply inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground;
|
||||
}
|
||||
|
||||
.tabs-trigger {
|
||||
@apply inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm;
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
@apply mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
|
||||
}
|
||||
|
||||
/* Enhanced Tooltip Styles */
|
||||
.tooltip-content {
|
||||
@apply z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95;
|
||||
}
|
||||
|
||||
/* Enhanced Switch Styles */
|
||||
.switch {
|
||||
@apply peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input;
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
@apply pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0;
|
||||
}
|
||||
|
||||
/* Enhanced Checkbox Styles */
|
||||
.checkbox {
|
||||
@apply peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground;
|
||||
}
|
||||
|
||||
/* Enhanced Radio Styles */
|
||||
.radio {
|
||||
@apply aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
/* Enhanced Select Styles */
|
||||
.select-trigger {
|
||||
@apply flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.select-content {
|
||||
@apply relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md;
|
||||
}
|
||||
|
||||
.select-item {
|
||||
@apply relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scaleOut {
|
||||
from { transform: scale(1); opacity: 1; }
|
||||
to { transform: scale(0.95); opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fadeOut 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-out {
|
||||
animation: slideOut 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-out {
|
||||
animation: scaleOut 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive Design Helpers */
|
||||
@media (max-width: 640px) {
|
||||
.modal-content {
|
||||
@apply w-[95vw] max-w-none;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
@apply w-screen max-w-none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Specific Adjustments */
|
||||
.dark .shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark .shadow-md {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Focus Visible Improvements */
|
||||
.focus-visible\:ring-2:focus-visible {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
/* High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
.border {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.input {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.transition-colors,
|
||||
.transition-all,
|
||||
.transition-transform {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.animate-fade-in,
|
||||
.animate-fade-out,
|
||||
.animate-slide-in,
|
||||
.animate-slide-out,
|
||||
.animate-scale-in,
|
||||
.animate-scale-out {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
711
backend/static/js/alpine-components.js
Normal file
711
backend/static/js/alpine-components.js
Normal file
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* Alpine.js Components for ThrillWiki
|
||||
* Enhanced components matching React frontend functionality
|
||||
*/
|
||||
|
||||
// Theme Toggle Component
|
||||
Alpine.data('themeToggle', () => ({
|
||||
theme: localStorage.getItem('theme') || 'system',
|
||||
|
||||
init() {
|
||||
this.updateTheme();
|
||||
|
||||
// Watch for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (this.theme === 'system') {
|
||||
this.updateTheme();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
const themes = ['light', 'dark', 'system'];
|
||||
const currentIndex = themes.indexOf(this.theme);
|
||||
this.theme = themes[(currentIndex + 1) % themes.length];
|
||||
localStorage.setItem('theme', this.theme);
|
||||
this.updateTheme();
|
||||
},
|
||||
|
||||
updateTheme() {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (this.theme === 'dark' ||
|
||||
(this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Search Component
|
||||
Alpine.data('searchComponent', () => ({
|
||||
query: '',
|
||||
results: [],
|
||||
loading: false,
|
||||
showResults: false,
|
||||
|
||||
async search() {
|
||||
if (this.query.length < 2) {
|
||||
this.results = [];
|
||||
this.showResults = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search/?q=${encodeURIComponent(this.query)}`);
|
||||
const data = await response.json();
|
||||
this.results = data.results || [];
|
||||
this.showResults = this.results.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
this.results = [];
|
||||
this.showResults = false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
selectResult(result) {
|
||||
window.location.href = result.url;
|
||||
this.showResults = false;
|
||||
this.query = '';
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
this.showResults = false;
|
||||
}
|
||||
}));
|
||||
|
||||
// Browse Menu Component
|
||||
Alpine.data('browseMenu', () => ({
|
||||
open: false,
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
}
|
||||
}));
|
||||
|
||||
// Mobile Menu Component
|
||||
Alpine.data('mobileMenu', () => ({
|
||||
open: false,
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
|
||||
// Prevent body scroll when menu is open
|
||||
if (this.open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}));
|
||||
|
||||
// User Menu Component
|
||||
Alpine.data('userMenu', () => ({
|
||||
open: false,
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
}
|
||||
}));
|
||||
|
||||
// Modal Component
|
||||
Alpine.data('modal', (initialOpen = false) => ({
|
||||
open: initialOpen,
|
||||
|
||||
show() {
|
||||
this.open = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
hide() {
|
||||
this.open = false;
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
|
||||
toggle() {
|
||||
if (this.open) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Dropdown Component
|
||||
Alpine.data('dropdown', (initialOpen = false) => ({
|
||||
open: initialOpen,
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
show() {
|
||||
this.open = true;
|
||||
}
|
||||
}));
|
||||
|
||||
// Tabs Component
|
||||
Alpine.data('tabs', (defaultTab = 0) => ({
|
||||
activeTab: defaultTab,
|
||||
|
||||
setTab(index) {
|
||||
this.activeTab = index;
|
||||
},
|
||||
|
||||
isActive(index) {
|
||||
return this.activeTab === index;
|
||||
}
|
||||
}));
|
||||
|
||||
// Accordion Component
|
||||
Alpine.data('accordion', (allowMultiple = false) => ({
|
||||
openItems: [],
|
||||
|
||||
toggle(index) {
|
||||
if (this.isOpen(index)) {
|
||||
this.openItems = this.openItems.filter(item => item !== index);
|
||||
} else {
|
||||
if (allowMultiple) {
|
||||
this.openItems.push(index);
|
||||
} else {
|
||||
this.openItems = [index];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isOpen(index) {
|
||||
return this.openItems.includes(index);
|
||||
},
|
||||
|
||||
open(index) {
|
||||
if (!this.isOpen(index)) {
|
||||
if (allowMultiple) {
|
||||
this.openItems.push(index);
|
||||
} else {
|
||||
this.openItems = [index];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
close(index) {
|
||||
this.openItems = this.openItems.filter(item => item !== index);
|
||||
}
|
||||
}));
|
||||
|
||||
// Form Component with Validation
|
||||
Alpine.data('form', (initialData = {}) => ({
|
||||
data: initialData,
|
||||
errors: {},
|
||||
loading: false,
|
||||
|
||||
setField(field, value) {
|
||||
this.data[field] = value;
|
||||
// Clear error when user starts typing
|
||||
if (this.errors[field]) {
|
||||
delete this.errors[field];
|
||||
}
|
||||
},
|
||||
|
||||
setError(field, message) {
|
||||
this.errors[field] = message;
|
||||
},
|
||||
|
||||
clearErrors() {
|
||||
this.errors = {};
|
||||
},
|
||||
|
||||
hasError(field) {
|
||||
return !!this.errors[field];
|
||||
},
|
||||
|
||||
getError(field) {
|
||||
return this.errors[field] || '';
|
||||
},
|
||||
|
||||
async submit(url, options = {}) {
|
||||
this.loading = true;
|
||||
this.clearErrors();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
|
||||
...options.headers
|
||||
},
|
||||
body: JSON.stringify(this.data),
|
||||
...options
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (result.errors) {
|
||||
this.errors = result.errors;
|
||||
}
|
||||
throw new Error(result.message || 'Form submission failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Pagination Component
|
||||
Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({
|
||||
currentPage: initialPage,
|
||||
totalPages: totalPages,
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
this.goToPage(this.currentPage + 1);
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
this.goToPage(this.currentPage - 1);
|
||||
},
|
||||
|
||||
hasNext() {
|
||||
return this.currentPage < this.totalPages;
|
||||
},
|
||||
|
||||
hasPrev() {
|
||||
return this.currentPage > 1;
|
||||
},
|
||||
|
||||
getPages() {
|
||||
const pages = [];
|
||||
const start = Math.max(1, this.currentPage - 2);
|
||||
const end = Math.min(this.totalPages, this.currentPage + 2);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}));
|
||||
|
||||
// Toast/Alert Component
|
||||
Alpine.data('toast', () => ({
|
||||
toasts: [],
|
||||
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now();
|
||||
const toast = { id, message, type, visible: true };
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.hide(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300); // Wait for animation
|
||||
}
|
||||
},
|
||||
|
||||
success(message, duration) {
|
||||
return this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
error(message, duration) {
|
||||
return this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
warning(message, duration) {
|
||||
return this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
info(message, duration) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Authentication Modal Component
|
||||
Alpine.data('authModal', (defaultMode = 'login') => ({
|
||||
open: false,
|
||||
mode: defaultMode, // 'login' or 'register'
|
||||
showPassword: false,
|
||||
socialProviders: [],
|
||||
socialLoading: true,
|
||||
|
||||
// Login form data
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
loginLoading: false,
|
||||
loginError: '',
|
||||
|
||||
// Register form data
|
||||
registerForm: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
},
|
||||
registerLoading: false,
|
||||
registerError: '',
|
||||
|
||||
init() {
|
||||
this.fetchSocialProviders();
|
||||
|
||||
// Listen for auth modal events
|
||||
this.$watch('open', (value) => {
|
||||
if (value) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
this.resetForms();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async fetchSocialProviders() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/social-providers/');
|
||||
const data = await response.json();
|
||||
this.socialProviders = data.available_providers || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch social providers:', error);
|
||||
this.socialProviders = [];
|
||||
} finally {
|
||||
this.socialLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
show(mode = 'login') {
|
||||
this.mode = mode;
|
||||
this.open = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
switchToLogin() {
|
||||
this.mode = 'login';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
switchToRegister() {
|
||||
this.mode = 'register';
|
||||
this.resetForms();
|
||||
},
|
||||
|
||||
resetForms() {
|
||||
this.loginForm = { username: '', password: '' };
|
||||
this.registerForm = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password1: '',
|
||||
password2: ''
|
||||
};
|
||||
this.loginError = '';
|
||||
this.registerError = '';
|
||||
this.showPassword = false;
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
if (!this.loginForm.username || !this.loginForm.password) {
|
||||
this.loginError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/login/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login: this.loginForm.username,
|
||||
password: this.loginForm.password
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Login successful - reload page to update auth state
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.loginError = data.message || 'Login failed. Please check your credentials.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
this.loginError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
if (!this.registerForm.first_name || !this.registerForm.last_name ||
|
||||
!this.registerForm.email || !this.registerForm.username ||
|
||||
!this.registerForm.password1 || !this.registerForm.password2) {
|
||||
this.registerError = 'Please fill in all fields';
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registerForm.password1 !== this.registerForm.password2) {
|
||||
this.registerError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerLoading = true;
|
||||
this.registerError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/accounts/signup/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': this.getCSRFToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
first_name: this.registerForm.first_name,
|
||||
last_name: this.registerForm.last_name,
|
||||
email: this.registerForm.email,
|
||||
username: this.registerForm.username,
|
||||
password1: this.registerForm.password1,
|
||||
password2: this.registerForm.password2
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Registration successful
|
||||
this.close();
|
||||
// Show success message or redirect
|
||||
Alpine.store('toast').success('Account created successfully! Please check your email to verify your account.');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
this.registerError = data.message || 'Registration failed. Please try again.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.registerError = 'An error occurred. Please try again.';
|
||||
} finally {
|
||||
this.registerLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleSocialLogin(providerId) {
|
||||
const provider = this.socialProviders.find(p => p.id === providerId);
|
||||
if (!provider) {
|
||||
Alpine.store('toast').error(`Social provider ${providerId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to social auth URL
|
||||
window.location.href = provider.auth_url;
|
||||
},
|
||||
|
||||
getCSRFToken() {
|
||||
const token = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
|
||||
document.querySelector('meta[name=csrf-token]')?.getAttribute('content') ||
|
||||
document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
||||
return token || '';
|
||||
}
|
||||
}));
|
||||
|
||||
// Enhanced Toast Component with Better UX
|
||||
Alpine.data('toast', () => ({
|
||||
toasts: [],
|
||||
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
progress: 100
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
// Animate progress bar
|
||||
const interval = setInterval(() => {
|
||||
toast.progress -= (100 / (duration / 100));
|
||||
if (toast.progress <= 0) {
|
||||
clearInterval(interval);
|
||||
this.hide(id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
success(message, duration = 5000) {
|
||||
return this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
error(message, duration = 7000) {
|
||||
return this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
warning(message, duration = 6000) {
|
||||
return this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
info(message, duration = 5000) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
}));
|
||||
|
||||
// Global Store for App State
|
||||
Alpine.store('app', {
|
||||
user: null,
|
||||
theme: 'system',
|
||||
searchQuery: '',
|
||||
notifications: [],
|
||||
|
||||
setUser(user) {
|
||||
this.user = user;
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
},
|
||||
|
||||
addNotification(notification) {
|
||||
this.notifications.push({
|
||||
id: Date.now(),
|
||||
...notification
|
||||
});
|
||||
},
|
||||
|
||||
removeNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
}
|
||||
});
|
||||
|
||||
// Global Toast Store
|
||||
Alpine.store('toast', {
|
||||
toasts: [],
|
||||
|
||||
show(message, type = 'info', duration = 5000) {
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
progress: 100
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
const interval = setInterval(() => {
|
||||
toast.progress -= (100 / (duration / 100));
|
||||
if (toast.progress <= 0) {
|
||||
clearInterval(interval);
|
||||
this.hide(id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
hide(id) {
|
||||
const toast = this.toasts.find(t => t.id === id);
|
||||
if (toast) {
|
||||
toast.visible = false;
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
success(message, duration = 5000) {
|
||||
return this.show(message, 'success', duration);
|
||||
},
|
||||
|
||||
error(message, duration = 7000) {
|
||||
return this.show(message, 'error', duration);
|
||||
},
|
||||
|
||||
warning(message, duration = 6000) {
|
||||
return this.show(message, 'warning', duration);
|
||||
},
|
||||
|
||||
info(message, duration = 5000) {
|
||||
return this.show(message, 'info', duration);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Alpine.js when DOM is ready
|
||||
document.addEventListener('alpine:init', () => {
|
||||
console.log('Alpine.js components initialized');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user