feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -1,354 +1,207 @@
# ThrillWiki Gap Analysis Matrix # Gap Analysis Matrix - Deep Logic Audit
**Generated:** 2025-12-27 | **Audit Level:** Maximum Thoroughness (Line-by-Line)
> **Generated:** 2025-12-27 | **Source:** Fresh ground-zero audit of `source_docs/` vs. actual codebase
This matrix documents every requirement extracted from the 5 source documentation files and their verification status against the Django backend (`backend/apps/`) and Nuxt frontend (`frontend/app/`).
**Legend:**
-**[OK]** - Implemented as specified
- ⚠️ **[DEVIATION]** - Implemented but differs from spec
-**[MISSING]** - Not implemented
---
## 1. SITE_OVERVIEW.md
| Feature | Source Doc | Current Status | Action Required |
|---------|------------|----------------|-----------------|
| Homepage with Hero Search | SITE_OVERVIEW.md §Homepage | ✅ [OK] | `frontend/app/pages/index.vue` has hero search |
| Discovery Tabs (11 categories) | SITE_OVERVIEW.md §Homepage | ✅ [OK] | `frontend/app/pages/discover.vue` implements tabs |
| Recent Changes Feed | SITE_OVERVIEW.md §Homepage | ✅ [OK] | Backend `apps/core/history` provides timeline data |
| Global Search | SITE_OVERVIEW.md §Core Features | ✅ [OK] | `GlobalSearch.vue` component exists |
| Parks Nearby with Map | SITE_OVERVIEW.md §Core Features | ✅ [OK] | `pages/parks/nearby.vue` with Leaflet |
| Advanced Filters | SITE_OVERVIEW.md §Core Features | ✅ [OK] | Filter components on parks/rides pages |
| Trending Content | SITE_OVERVIEW.md §Core Features | ⚠️ [DEVIATION] | Backend has `trending_parks` endpoint but no dedicated "Trending" UI section |
| Detailed Park Pages with Tabs | SITE_OVERVIEW.md §Parks | ✅ [OK] | Overview/Rides/Reviews/Photos/History tabs |
| Ride Specifications | SITE_OVERVIEW.md §Rides | ✅ [OK] | `Ride` model has full spec fields |
| Company Profiles | SITE_OVERVIEW.md §Companies | ✅ [OK] | `/manufacturers`, `/operators`, `/designers`, `/owners` pages |
| Ride Models | SITE_OVERVIEW.md §Ride Models | ✅ [OK] | `RideModel` model + `/ride-models` pages |
| Photo Galleries | SITE_OVERVIEW.md §Photos | ✅ [OK] | `PhotoGallery.vue` + `GalleryUploader.vue` |
| Version History / Historical Records | SITE_OVERVIEW.md §History | ✅ [OK] | `pghistory` tracking on models + History tab |
| Reviews & Ratings | SITE_OVERVIEW.md §Community | ✅ [OK] | `apps/reviews` + Review components |
| Ride Credits | SITE_OVERVIEW.md §Community | ✅ [OK] | `RideCredit` model + `/my-credits` page |
| Personal Lists | SITE_OVERVIEW.md §Community | ✅ [OK] | `apps/lists` + `/lists` pages |
| Leaderboards | SITE_OVERVIEW.md §Community | ❌ [MISSING] | No leaderboard page or backend endpoint exists |
| Badges / Achievement System | SITE_OVERVIEW.md §Community | ⚠️ [DEVIATION] | `User.badges` field exists in model but no UI to display/earn badges |
| Submit New Content | SITE_OVERVIEW.md §Contribution | ✅ [OK] | `/submit/park`, `/submit/ride`, `/submit/company` pages |
| Moderation Queue | SITE_OVERVIEW.md §Moderation | ✅ [OK] | `/moderation` dashboard with queue |
| Admin Dashboard | SITE_OVERVIEW.md §Admin | ⚠️ [DEVIATION] | Only `/admin/system.vue` exists; no full user management UI |
| Terms of Service | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `/terms.vue` |
| Privacy Policy | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `/privacy.vue` |
| Community Guidelines | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `/guidelines.vue` |
| Contact Form | SITE_OVERVIEW.md §Static Pages | ❌ [MISSING] | No `/contact` page exists |
| Blog | SITE_OVERVIEW.md §Static Pages | ✅ [OK] | `apps/blog` + `/blog` pages |
| Full keyboard navigation | SITE_OVERVIEW.md §Accessibility | ⚠️ [DEVIATION] | Components use Nuxt UI which has ARIA support but not explicitly tested |
| Screen reader compatible | SITE_OVERVIEW.md §Accessibility | ⚠️ [DEVIATION] | Uses semantic HTML but no specific ARIA implementation |
| High contrast support | SITE_OVERVIEW.md §Accessibility | ⚠️ [DEVIATION] | Dark mode exists but no specific high-contrast mode |
| Reduced motion preferences | SITE_OVERVIEW.md §Accessibility | ❌ [MISSING] | Animations don't check `prefers-reduced-motion` |
| Metric/Imperial toggle | SITE_OVERVIEW.md §Internationalization | ✅ [OK] | `unit_system` in User model + `useUnits.ts` composable |
---
## 2. PAGES.md
| Feature | Source Doc | Current Status | Action Required |
|---------|------------|----------------|-----------------|
| Homepage Hero Search | PAGES.md §Homepage | ✅ [OK] | Large search input with autocomplete |
| Homepage Discovery Tabs (11) | PAGES.md §Homepage | ✅ [OK] | Tabs implemented in `discover.vue` |
| Homepage Recent Changes Feed | PAGES.md §Homepage | ✅ [OK] | Timeline component exists |
| Parks Listing with Filters | PAGES.md §Parks Listing | ✅ [OK] | `/parks/index.vue` with filters |
| Parks Listing Grid/List View Toggle | PAGES.md §Parks Listing | ❌ [MISSING] | Only grid view, no list view toggle |
| Parks Nearby with Map | PAGES.md §Parks Nearby | ✅ [OK] | Leaflet map + radius slider |
| Parks Nearby Unit Toggle (mi/km) | PAGES.md §Parks Nearby | ✅ [OK] | Unit toggle in nearby page |
| Park Detail Hero Banner | PAGES.md §Park Detail | ✅ [OK] | Hero with banner image |
| Park Detail Quick Stats (Rides/Reviews/Rating/Status/Est.) | PAGES.md §Park Detail | ✅ [OK] | Stats displayed in hero area |
| Park Detail Overview Tab | PAGES.md §Park Detail | ✅ [OK] | Description, location, contact |
| Park Detail Rides Tab | PAGES.md §Park Detail | ✅ [OK] | Filterable ride list |
| Park Detail Reviews Tab | PAGES.md §Park Detail | ✅ [OK] | Review list with ratings |
| Park Detail Photos Tab | PAGES.md §Park Detail | ✅ [OK] | Photo gallery |
| Park Detail History Tab | PAGES.md §Park Detail | ✅ [OK] | Version history/timeline |
| Park Detail Location Map | PAGES.md §Park Detail Overview | ❌ [MISSING] | No inline map on Overview tab |
| Park Detail Contact Info | PAGES.md §Park Detail Overview | ⚠️ [DEVIATION] | Website link exists but no full contact section |
| Park Detail Operator/Owner Links | PAGES.md §Park Detail Overview | ⚠️ [DEVIATION] | Not prominently displayed |
| Rides Listing with Filters | PAGES.md §Rides Listing | ✅ [OK] | `/rides/index.vue` with filters |
| Rides Advanced Filters (Speed/Height/Inversions) | PAGES.md §Rides Listing | ⚠️ [DEVIATION] | Basic filters only, no slider ranges |
| Ride Detail Nested URL (/parks/{park}/rides/{ride}) | PAGES.md §Ride Detail | ✅ [OK] | Nested routing implemented |
| Ride Detail Hero Banner | PAGES.md §Ride Detail | ✅ [OK] | Hero with banner image |
| Ride Detail Quick Stats (Speed/Height/Length/Inv/Rating) | PAGES.md §Ride Detail | ✅ [OK] | Stats displayed |
| Ride Detail Overview Tab | PAGES.md §Ride Detail | ✅ [OK] | Description + key info |
| Ride Detail Specifications Tab | PAGES.md §Ride Detail | ✅ [OK] | Full specs by category |
| Ride Detail Reviews Tab | PAGES.md §Ride Detail | ✅ [OK] | Review list |
| Ride Detail Photos Tab | PAGES.md §Ride Detail | ✅ [OK] | Photo gallery |
| Ride Detail History Tab | PAGES.md §Ride Detail | ✅ [OK] | Version history |
| Coaster Spec: Speed/Height/Length/Drop | PAGES.md §Ride Specs | ✅ [OK] | All fields in `Ride` model |
| Coaster Spec: Inversions/G-Force | PAGES.md §Ride Specs | ✅ [OK] | Fields exist |
| Coaster Spec: Duration/Capacity | PAGES.md §Ride Specs | ✅ [OK] | Fields exist |
| Coaster Spec: Track Material/Seating Type | PAGES.md §Ride Specs | ✅ [OK] | Fields exist |
| Flat Ride Specs | PAGES.md §Ride Specs | ⚠️ [DEVIATION] | Uses same `Ride` model but not all flat-specific fields |
| Water Ride Specs (Wetness Level, Splash Height) | PAGES.md §Ride Specs | ❌ [MISSING] | No wetness_level or splash_height fields |
| Dark Ride Specs (Scenes Count, Animatronics) | PAGES.md §Ride Specs | ❌ [MISSING] | No scenes_count or animatronics fields |
| Manufacturers Listing | PAGES.md §Company Pages | ✅ [OK] | `/manufacturers/index.vue` |
| Designers Listing | PAGES.md §Company Pages | ✅ [OK] | `/designers/index.vue` |
| Operators Listing | PAGES.md §Company Pages | ✅ [OK] | `/operators/index.vue` |
| Owners Listing | PAGES.md §Company Pages | ✅ [OK] | `/owners/index.vue` |
| Company Detail Tabs (Overview/Rides/Models/History) | PAGES.md §Company Detail | ⚠️ [DEVIATION] | Only index listing exists, no detail pages with tabs |
| Ride Models Listing | PAGES.md §Ride Models | ✅ [OK] | `/ride-models/index.vue` |
| Ride Model Detail with Installations | PAGES.md §Ride Models | ✅ [OK] | `/ride-models/[slug].vue` |
| Search Page with Type Tabs | PAGES.md §Search | ⚠️ [DEVIATION] | `/search.vue` exists but minimal implementation |
| Instant Search Results | PAGES.md §Search | ✅ [OK] | `GlobalSearch.vue` has debounced search |
| Recent Searches History | PAGES.md §Search | ❌ [MISSING] | No search history feature |
| Auth Page Sign In | PAGES.md §Authentication | ✅ [OK] | `/auth/login.vue` |
| Auth Page Sign Up | PAGES.md §Authentication | ✅ [OK] | `/auth/signup.vue` |
| Auth Email/Password Login | PAGES.md §Authentication | ✅ [OK] | Standard login form |
| Auth Magic Link (Passwordless) | PAGES.md §Authentication | ❌ [MISSING] | No magic link implementation |
| Auth Google OAuth | PAGES.md §Authentication | ✅ [OK] | Social auth configured |
| Auth Discord OAuth | PAGES.md §Authentication | ✅ [OK] | Discord social auth exists |
| Auth CAPTCHA Verification | PAGES.md §Authentication | ❌ [MISSING] | No CAPTCHA on forms |
| Auth Email Confirmation | PAGES.md §Authentication | ✅ [OK] | `EmailVerification` model exists |
| Auth MFA/TOTP Support | PAGES.md §Authentication | ❌ [MISSING] | No MFA implementation |
| Auth Session Management | PAGES.md §Authentication | ✅ [OK] | Django sessions + JWT |
| Auth Ban Check on Login | PAGES.md §Authentication | ✅ [OK] | `is_banned` field checked |
| User Profile Page | PAGES.md §User Profile | ✅ [OK] | `/profile/[username].vue` |
| Profile Activity Tab | PAGES.md §User Profile | ⚠️ [DEVIATION] | Overview tab but not full activity feed |
| Profile Reviews Tab | PAGES.md §User Profile | ✅ [OK] | Reviews tab exists |
| Profile Lists Tab | PAGES.md §User Profile | ❌ [MISSING] | No lists tab on profile |
| Profile Ride Credits Tab | PAGES.md §User Profile | ✅ [OK] | Credits tab exists |
| Profile Stats Display | PAGES.md §User Profile | ✅ [OK] | Total credits, unique rides, member since |
| Profile Badges Display | PAGES.md §User Profile | ❌ [MISSING] | No badges display on profile |
| Settings Page Account Section | PAGES.md §User Settings | ✅ [OK] | `/settings.vue` has account settings |
| Settings Security (Password) | PAGES.md §User Settings | ✅ [OK] | Change password modal |
| Settings Privacy | PAGES.md §User Settings | ⚠️ [DEVIATION] | Minimal privacy options |
| Settings Notifications | PAGES.md §User Settings | ✅ [OK] | Notification preferences exist |
| Settings Location & Units | PAGES.md §User Settings | ✅ [OK] | Unit system + home location |
| Settings Data Export | PAGES.md §User Settings | ⚠️ [DEVIATION] | Export service exists but no UI button |
| Settings Login History View | PAGES.md §User Settings | ❌ [MISSING] | No login history UI |
| Ride Credits Page (/my-credits) | PAGES.md §Ride Credits | ✅ [OK] | `/my-credits.vue` |
| Credits Statistics Panel | PAGES.md §Ride Credits | ✅ [OK] | Stats displayed |
| Credits Add/Edit/Delete | PAGES.md §Ride Credits | ✅ [OK] | `RideCreditModal.vue` |
| Credits Quick Increment (+/-) | PAGES.md §Ride Credits | ✅ [OK] | Quick increment on cards |
| Credits Drag Reorder | PAGES.md §Ride Credits | ❌ [MISSING] | No drag reorder functionality |
| User Lists Page (/my-lists) | PAGES.md §User Lists | ⚠️ [DEVIATION] | Uses `/lists` not `/my-lists` |
| Lists Create/Edit/Delete | PAGES.md §User Lists | ✅ [OK] | CRUD operations work |
| Lists Public/Private Toggle | PAGES.md §User Lists | ✅ [OK] | Privacy setting exists |
| Review Writing Form | PAGES.md §Reviews | ✅ [OK] | `ReviewForm.vue` |
| Review Star Rating | PAGES.md §Reviews | ✅ [OK] | `StarRating.vue` |
| Review Like/Dislike (Voting) | PAGES.md §Reviews | ⚠️ [DEVIATION] | Backend has votes but frontend UI minimal |
| Review Reply System | PAGES.md §Reviews | ⚠️ [DEVIATION] | Backend has replies but no frontend UI |
| Review Report Button | PAGES.md §Reviews | ❌ [MISSING] | No report functionality in UI |
| Photo Upload Interface | PAGES.md §Photo System | ✅ [OK] | `PhotoUpload.vue` + modal |
| Photo Drag & Drop | PAGES.md §Photo System | ✅ [OK] | Drag-drop in uploader |
| Photo Gallery Lightbox | PAGES.md §Photo System | ⚠️ [DEVIATION] | `PhotoGallery.vue` exists but no full lightbox |
| Photo Zoom/Download in Lightbox | PAGES.md §Photo System | ❌ [MISSING] | No zoom/download in gallery |
| Submission Multi-Step Wizard | PAGES.md §Submission Forms | ❌ [MISSING] | Single-page forms, no step wizard |
| Submission Auto-Save Drafts | PAGES.md §Submission Forms | ❌ [MISSING] | No draft auto-save |
| Submission Unit Toggle (m/ft) | PAGES.md §Submission Forms | ⚠️ [DEVIATION] | No inline unit toggle on forms |
| Moderation Queue Dashboard | PAGES.md §Moderation | ✅ [OK] | `/moderation/index.vue` |
| Moderation Filters | PAGES.md §Moderation | ✅ [OK] | Type, status, priority filters |
| Moderation Claim/Unclaim | PAGES.md §Moderation | ✅ [OK] | Claim functionality implemented |
| Moderation Side-by-Side Diff | PAGES.md §Moderation | ✅ [OK] | `DiffView.vue` component |
| Moderation Approve/Reject/Request Changes | PAGES.md §Moderation | ✅ [OK] | All actions available |
| Admin Dashboard with Stats | PAGES.md §Admin Dashboard | ⚠️ [DEVIATION] | Only `/admin/system.vue` with health checks |
| Admin User Management | PAGES.md §Admin | ⚠️ [DEVIATION] | `/moderation/users.vue` exists for user moderation |
| Admin Change User Role | PAGES.md §Admin | ✅ [OK] | Role change in user moderation |
| Admin Ban/Unban User | PAGES.md §Admin | ✅ [OK] | Ban functionality exists |
| Admin Delete User | PAGES.md §Admin | ⚠️ [DEVIATION] | User deletion request exists but no admin delete |
| Contact Page with Category Select | PAGES.md §Contact | ❌ [MISSING] | No contact page |
| Contact CAPTCHA | PAGES.md §Contact | ❌ [MISSING] | No contact page |
---
## 3. COMPONENTS.md
| Feature | Source Doc | Current Status | Action Required |
|---------|------------|----------------|-----------------|
| Header Component | COMPONENTS.md §Layout | ✅ [OK] | `AppHeader.vue` |
| Header Logo/Brand Link | COMPONENTS.md §Header | ✅ [OK] | Links to homepage |
| Header Primary Navigation | COMPONENTS.md §Header | ✅ [OK] | Main nav links |
| Header Search Button | COMPONENTS.md §Header | ✅ [OK] | Search trigger in header |
| Header User Menu (Avatar Dropdown) | COMPONENTS.md §Header | ✅ [OK] | User dropdown menu |
| Header Notification Bell | COMPONENTS.md §Header | ❌ [MISSING] | No notification bell in header |
| Header Mobile Hamburger Menu | COMPONENTS.md §Header | ✅ [OK] | Mobile responsive menu |
| Header Minimal Variant (Auth Pages) | COMPONENTS.md §Header | ⚠️ [DEVIATION] | Same header on all pages |
| Footer Component | COMPONENTS.md §Layout | ✅ [OK] | `AppFooter.vue` |
| Footer Navigation Columns | COMPONENTS.md §Footer | ✅ [OK] | Link sections |
| Footer Social Links | COMPONENTS.md §Footer | ✅ [OK] | Social media links |
| Footer Copyright | COMPONENTS.md §Footer | ✅ [OK] | Copyright text |
| PageContainer Component | COMPONENTS.md §Layout | ⚠️ [DEVIATION] | Uses `layouts/default.vue` instead |
| Sidebar Component (Admin/Settings) | COMPONENTS.md §Layout | ⚠️ [DEVIATION] | Settings has tabs, no separate sidebar |
| MainNav with Dropdowns | COMPONENTS.md §Navigation | ✅ [OK] | Dropdown navigation |
| TabNav Component | COMPONENTS.md §Navigation | ✅ [OK] | Uses Nuxt UI `UTabs` |
| Breadcrumbs Component | COMPONENTS.md §Navigation | ✅ [OK] | `Breadcrumbs.vue` exists |
| Breadcrumbs Schema.org Markup | COMPONENTS.md §Breadcrumbs | ⚠️ [DEVIATION] | No structured data markup |
| Pagination Component | COMPONENTS.md §Navigation | ✅ [OK] | Uses Nuxt UI `UPagination` |
| Card Component (Default/Elevated/Interactive/Glass) | COMPONENTS.md §Display | ✅ [OK] | Uses Nuxt UI `UCard` |
| Badge Component | COMPONENTS.md §Display | ✅ [OK] | `StatusBadge.vue` + `EntityStatusBadge.vue` |
| Avatar Component | COMPONENTS.md §Display | ✅ [OK] | Uses Nuxt UI `UAvatar` |
| Image Component with Lazy Loading | COMPONENTS.md §Display | ⚠️ [DEVIATION] | Standard `<img>` tags without lazy loading component |
| Image Blur Placeholder | COMPONENTS.md §Display | ❌ [MISSING] | No blur-up placeholder |
| Input Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UInput` |
| Select Component with Search | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `USelect` |
| Checkbox Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UCheckbox` |
| Radio Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI radio |
| Switch Component | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UToggle` |
| Button Component (All Variants) | COMPONENTS.md §Forms | ✅ [OK] | Uses Nuxt UI `UButton` |
| DatePicker Component | COMPONENTS.md §Forms | ⚠️ [DEVIATION] | Uses HTML date input, no custom DatePicker |
| DatePicker Date Precision Selector | COMPONENTS.md §Forms | ❌ [MISSING] | No date precision selection |
| Slider Component | COMPONENTS.md §Forms | ❌ [MISSING] | No slider/range component |
| Toast Component | COMPONENTS.md §Feedback | ✅ [OK] | Uses Nuxt UI `useToast` |
| Alert Component | COMPONENTS.md §Feedback | ✅ [OK] | Uses Nuxt UI `UAlert` |
| Modal/Dialog Component | COMPONENTS.md §Feedback | ✅ [OK] | Multiple modals exist |
| Loading Spinner | COMPONENTS.md §Feedback | ✅ [OK] | Uses icon spinners |
| Skeleton Loading | COMPONENTS.md §Feedback | ⚠️ [DEVIATION] | Uses spinners, not skeleton loaders |
| Progress Bar | COMPONENTS.md §Feedback | ⚠️ [DEVIATION] | No progress bar component |
| Empty State Component | COMPONENTS.md §Feedback | ✅ [OK] | Empty states with icons/messages |
| Table Component | COMPONENTS.md §Data Display | ✅ [OK] | Uses Nuxt UI `UTable` |
| Table Sortable Columns | COMPONENTS.md §Table | ✅ [OK] | Sorting available |
| Table Row Selection | COMPONENTS.md §Table | ⚠️ [DEVIATION] | Not all tables have selection |
| Stats Card Component | COMPONENTS.md §Data Display | ✅ [OK] | `BentoCard.vue` + stat displays |
| Rating Display Component | COMPONENTS.md §Data Display | ✅ [OK] | `StarRating.vue` |
| ParkCard Component | COMPONENTS.md §Entity Components | ❌ [MISSING] | No dedicated `ParkCard.vue` (uses inline cards) |
| RideCard Component | COMPONENTS.md §Entity Components | ❌ [MISSING] | No dedicated `RideCard.vue` (uses inline cards) |
| ReviewCard Component | COMPONENTS.md §Entity Components | ✅ [OK] | `ReviewCard.vue` exists |
| CreditCard Component | COMPONENTS.md §Entity Components | ✅ [OK] | `CreditCard.vue` exists |
| UnitDisplay Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | Logic in `useUnits.ts` but no dedicated component |
| Map Component (Leaflet) | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | Inline in pages, no reusable `Map.vue` |
| Map Marker Clusters | COMPONENTS.md §Map | ❌ [MISSING] | No marker clustering |
| Map Full-Screen Toggle | COMPONENTS.md §Map | ❌ [MISSING] | No full-screen map option |
| Timeline Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | History tab has timeline but no reusable component |
| Diff Viewer Component | COMPONENTS.md §Specialty | ✅ [OK] | `DiffView.vue` |
| ImageGallery Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | `PhotoGallery.vue` exists but limited functionality |
| ImageGallery Lightbox with Navigation | COMPONENTS.md §ImageGallery | ⚠️ [DEVIATION] | Basic lightbox, no prev/next |
| ImageGallery Zoom/Download | COMPONENTS.md §ImageGallery | ❌ [MISSING] | No zoom or download |
| SearchAutocomplete Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | `GlobalSearch.vue` has autocomplete inline |
| Tooltip Component | COMPONENTS.md §Specialty | ⚠️ [DEVIATION] | Uses Nuxt UI tooltips, no custom component |
| HoverCard Component | COMPONENTS.md §Specialty | ❌ [MISSING] | No hover card previews |
---
## 4. DESIGN_SYSTEM.md
| Feature | Source Doc | Current Status | Action Required |
|---------|------------|----------------|-----------------|
| Brand Name/Tagline | DESIGN_SYSTEM.md §Brand | ✅ [OK] | "ThrillWiki" used consistently |
| Light Mode Color Palette | DESIGN_SYSTEM.md §Colors | ✅ [OK] | Light mode theme exists |
| Dark Mode Color Palette | DESIGN_SYSTEM.md §Colors | ✅ [OK] | Dark mode with toggle |
| Semantic Colors (Primary/Secondary/Muted) | DESIGN_SYSTEM.md §Colors | ✅ [OK] | CSS variables defined |
| Gradients (Primary/Glow/Subtle) | DESIGN_SYSTEM.md §Colors | ⚠️ [DEVIATION] | Some gradients, not full spec |
| Typography: Inter Font | DESIGN_SYSTEM.md §Typography | ✅ [OK] | Inter font configured |
| Type Scale (12-48px) | DESIGN_SYSTEM.md §Typography | ✅ [OK] | Font sizes match scale |
| Spacing System (4px base) | DESIGN_SYSTEM.md §Spacing | ✅ [OK] | Tailwind spacing used |
| Border Radius Tokens | DESIGN_SYSTEM.md §Border Radius | ✅ [OK] | Tailwind rounded utilities |
| Shadows (Light Mode) | DESIGN_SYSTEM.md §Shadows | ✅ [OK] | Shadow utilities used |
| Glow Effects (Dark Mode) | DESIGN_SYSTEM.md §Shadows | ⚠️ [DEVIATION] | Limited glow implementation |
| Animation Timing Functions | DESIGN_SYSTEM.md §Animation | ⚠️ [DEVIATION] | Uses default transitions |
| Animation Durations (150-500ms) | DESIGN_SYSTEM.md §Animation | ✅ [OK] | Transitions within spec |
| Fade/Slide/Scale Animations | DESIGN_SYSTEM.md §Animation | ⚠️ [DEVIATION] | Basic transitions only |
| Button Variants (Primary/Secondary/Outline/Ghost/Destructive) | DESIGN_SYSTEM.md §Components | ✅ [OK] | All variants via Nuxt UI |
| Card Variants (Default/Interactive/Glass) | DESIGN_SYSTEM.md §Components | ⚠️ [DEVIATION] | Glass cards on dark mode but not complete |
| Input States (Default/Focused/Error/Disabled) | DESIGN_SYSTEM.md §Components | ✅ [OK] | All states via Nuxt UI |
| Responsive Breakpoints (sm/md/lg/xl/2xl) | DESIGN_SYSTEM.md §Responsive | ✅ [OK] | Tailwind breakpoints |
| Color Contrast (4.5:1 minimum) | DESIGN_SYSTEM.md §Accessibility | ⚠️ [DEVIATION] | Not explicitly verified |
| Focus Ring on Interactive Elements | DESIGN_SYSTEM.md §Accessibility | ✅ [OK] | Nuxt UI provides focus rings |
| Respect prefers-reduced-motion | DESIGN_SYSTEM.md §Accessibility | ❌ [MISSING] | Not implemented |
| Dark Mode: Reduce Contrast | DESIGN_SYSTEM.md §Dark Mode | ✅ [OK] | Off-white text colors used |
| Dark Mode: Subtle Borders | DESIGN_SYSTEM.md §Dark Mode | ✅ [OK] | Semi-transparent borders |
| Lucide Icons | DESIGN_SYSTEM.md §Icons | ⚠️ [DEVIATION] | Uses Heroicons, not Lucide |
---
## 5. USER_FLOWS.md
| Feature | Source Doc | Current Status | Action Required |
|---------|------------|----------------|-----------------|
| Homepage Discovery Journey | USER_FLOWS.md §Discovery | ✅ [OK] | Search → Browse → Detail flow works |
| Search Flow with Debounce (300ms) | USER_FLOWS.md §Search Flow | ✅ [OK] | Debounced search implemented |
| Search Keyboard Navigation | USER_FLOWS.md §Search Flow | ⚠️ [DEVIATION] | Basic, not full arrow key nav |
| Parks Nearby Location Detection | USER_FLOWS.md §Nearby Flow | ✅ [OK] | Geolocation request |
| Parks Nearby Manual Location Entry | USER_FLOWS.md §Nearby Flow | ⚠️ [DEVIATION] | Can set home location in settings but not on nearby page |
| Sign Up Email/Password Flow | USER_FLOWS.md §Auth Flows | ✅ [OK] | Email + password signup |
| Sign Up Magic Link Flow | USER_FLOWS.md §Auth Flows | ❌ [MISSING] | No magic link |
| Sign Up OAuth Flow | USER_FLOWS.md §Auth Flows | ✅ [OK] | Google + Discord OAuth |
| Sign Up CAPTCHA Verification | USER_FLOWS.md §Auth Flows | ❌ [MISSING] | No CAPTCHA |
| Sign Up Email Confirmation | USER_FLOWS.md §Auth Flows | ✅ [OK] | Verification email sent |
| Sign Up Redirect to Profile Setup | USER_FLOWS.md §Auth Flows | ⚠️ [DEVIATION] | Redirects to home, not profile setup |
| Sign In Validation | USER_FLOWS.md §Sign In Flow | ✅ [OK] | Credential validation |
| Sign In Rate Limiting/Lockout | USER_FLOWS.md §Sign In Flow | ⚠️ [DEVIATION] | Backend may have, not explicit |
| Sign In MFA Check | USER_FLOWS.md §Sign In Flow | ❌ [MISSING] | No MFA |
| Sign In Ban Status Check | USER_FLOWS.md §Sign In Flow | ✅ [OK] | Ban check exists |
| Park Page Tab Navigation | USER_FLOWS.md §Park Journey | ✅ [OK] | All tabs functional |
| Park Page Click Ride → Ride Page | USER_FLOWS.md §Park Journey | ✅ [OK] | Links work |
| Park Page Lightbox for Photos | USER_FLOWS.md §Park Journey | ⚠️ [DEVIATION] | Basic lightbox only |
| Park Page Actions (Edit/Review/Photo/Credit) | USER_FLOWS.md §Park Journey | ✅ [OK] | All action buttons present |
| The Sacred Pipeline (Submission → Moderation → Approval) | USER_FLOWS.md §Contribution | ✅ [OK] | Full moderation pipeline |
| Submission Multi-Step Wizard | USER_FLOWS.md §Contribution | ❌ [MISSING] | No step wizard |
| Submission Auto-Save Drafts | USER_FLOWS.md §Contribution | ❌ [MISSING] | No auto-save |
| Moderator Claims Lock Item (30 min) | USER_FLOWS.md §Moderation | ✅ [OK] | Claim timeout exists |
| Moderator Side-by-Side Diff | USER_FLOWS.md §Moderation | ✅ [OK] | `DiffView.vue` |
| Moderator Approve/Reject/Request Changes | USER_FLOWS.md §Moderation | ✅ [OK] | All actions available |
| Write Review Flow | USER_FLOWS.md §Engagement | ✅ [OK] | Review form works |
| Review Check Existing (Edit Mode) | USER_FLOWS.md §Engagement | ⚠️ [DEVIATION] | Creates new, may not detect existing |
| Review No Moderation by Default | USER_FLOWS.md §Engagement | ✅ [OK] | Reviews post immediately |
| Log Credit Flow | USER_FLOWS.md §Credits Flow | ✅ [OK] | Credit logging works |
| Credit Quick Increment | USER_FLOWS.md §Credits Flow | ✅ [OK] | Plus/minus buttons |
| Photo Upload Direct to CloudFlare | USER_FLOWS.md §Photo Upload | ⚠️ [DEVIATION] | Uses backend upload, not direct CF |
| Photo Upload Progress Display | USER_FLOWS.md §Photo Upload | ⚠️ [DEVIATION] | Basic loading, no progress bar |
| Admin User Search/Filter | USER_FLOWS.md §Admin Flow | ✅ [OK] | In moderation/users page |
| Admin View User Profile | USER_FLOWS.md §Admin Flow | ✅ [OK] | Profile view works |
| Admin Change Role | USER_FLOWS.md §Admin Flow | ✅ [OK] | Role change available |
| Admin Ban User with Reason | USER_FLOWS.md §Admin Flow | ✅ [OK] | Ban with reason |
| Admin Action Audit Trail | USER_FLOWS.md §Admin Flow | ✅ [OK] | pghistory tracking |
| Notification Event Triggers | USER_FLOWS.md §Notifications | ⚠️ [DEVIATION] | Backend signals exist but not full Novu |
| Notification Check User Preferences | USER_FLOWS.md §Notifications | ✅ [OK] | `NotificationPreference` model |
| Notification In-App via Novu | USER_FLOWS.md §Notifications | ⚠️ [DEVIATION] | Novu partial integration |
| Notification Bell Badge | USER_FLOWS.md §Notifications | ❌ [MISSING] | No notification bell in UI |
| Notification Feed (Mark as Read) | USER_FLOWS.md §Notifications | ❌ [MISSING] | No notification feed UI |
---
## Summary Statistics ## Summary Statistics
| Category | ✅ OK | ⚠️ DEVIATION | ❌ MISSING | Total |
|----------|-------|--------------|-----------|-------|
| Field Fidelity | 18 | 2 | 1 | 21 |
| State Logic | 12 | 1 | 0 | 13 |
| UI States | 14 | 3 | 0 | 17 |
| Permissions | 8 | 0 | 0 | 8 |
| Entity Forms | 10 | 0 | 0 | 10 |
| Entity CRUD API | 6 | 0 | 0 | 6 |
| **TOTAL** | **68** | **6** | **1** | **75** |
| Category | Total | ✅ OK | ⚠️ Deviation | ❌ Missing |
|----------|-------|-------|--------------|------------|
| SITE_OVERVIEW.md | 32 | 22 | 7 | 3 |
| PAGES.md | 88 | 54 | 19 | 15 |
| COMPONENTS.md | 58 | 31 | 17 | 10 |
| DESIGN_SYSTEM.md | 24 | 16 | 7 | 1 |
| USER_FLOWS.md | 43 | 27 | 10 | 6 |
| **TOTAL** | **245** | **150 (61%)** | **60 (24%)** | **35 (15%)** |
--- ---
## Top Priority Missing Features ## 1. Field Fidelity Audit
### Critical (User-Facing Features) ### Ride Statistics Models
1. **Contact Page** (`/contact`) - Static page requirement
2. **Leaderboard Page** - Community engagement feature
3. **Notification Bell + Feed** - User engagement/retention
4. **CAPTCHA on Forms** - Security requirement
5. **MFA/TOTP Support** - Security requirement
6. **Magic Link Authentication** - UX enhancement
### High Priority (UX Components) | Requirement | File | Status | Notes |
7. **Multi-Step Submission Wizard** - UX for complex forms |-------------|------|--------|-------|
8. **ParkCard / RideCard Components** - Reusable entity cards | `height_ft` as Decimal(6,2) | `rides/models/rides.py:1000` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
9. **HoverCard Previews** - Rich preview on hover | `length_ft` as Decimal(7,2) | `rides/models/rides.py:1007` | ✅ OK | `DecimalField(max_digits=7, decimal_places=2)` |
10. **ImageGallery Lightbox (Zoom/Download)** - Photo viewing | `speed_mph` as Decimal(5,2) | `rides/models/rides.py:1014` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
11. **Grid/List View Toggle** on listings | `max_drop_height_ft` | `rides/models/rides.py:1046` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
12. **Search History** feature | `g_force` field for coasters | `rides/models/rides.py` | ❌ MISSING | Spec mentions G-forces but `RollerCoasterStats` lacks this field |
| `inversions` as Integer | `rides/models/rides.py:1021` | ✅ OK | `PositiveIntegerField(default=0)` |
### Medium Priority (Polish) ### Water/Dark/Flat Ride Stats
13. **Reduced Motion Support** - Accessibility
14. **Skeleton Loading States** - Better perceived performance
15. **Profile Badges Display** - Community engagement
16. **Profile Lists Tab** - Feature visibility
17. **Company Detail Pages with Tabs** - Content depth
18. **Slider/Range Components** for advanced filters
19. **Map Marker Clustering** - Performance
20. **Breadcrumbs Schema.org Markup** - SEO
### Low Priority (Nice to Have) | Requirement | File | Status | Notes |
21. **Login History View** in settings |-------------|------|--------|-------|
22. **Data Export Button** in UI | `WaterRideStats.splash_height_ft` | `rides/models/stats.py:59` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
23. **Ride Drag Reorder** for credits | `WaterRideStats.wetness_level` | `rides/models/stats.py:52` | ✅ OK | CharField with choices |
24. **Water/Dark Ride Specific Specs** - Content completeness | `DarkRideStats.scene_count` | `rides/models/stats.py:112` | ✅ OK | PositiveIntegerField |
25. **Date Precision Selector** - Data entry accuracy | `DarkRideStats.animatronic_count` | `rides/models/stats.py:117` | ✅ OK | PositiveIntegerField |
| `FlatRideStats.max_height_ft` | `rides/models/stats.py:172` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
| `FlatRideStats.rotation_speed_rpm` | `rides/models/stats.py:180` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
| `FlatRideStats.max_g_force` | `rides/models/stats.py:213` | ✅ OK | `DecimalField(max_digits=4, decimal_places=2)` |
### RideModel Technical Specs
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `typical_height_range_*_ft` | `rides/models/rides.py:54-67` | ✅ OK | Both min/max as DecimalField |
| `typical_speed_range_*_mph` | `rides/models/rides.py:68-81` | ✅ OK | Both min/max as DecimalField |
| Height range constraint | `rides/models/rides.py:184-194` | ✅ OK | CheckConstraint validates min ≤ max |
| Speed range constraint | `rides/models/rides.py:196-206` | ✅ OK | CheckConstraint validates min ≤ max |
### Park Model Fields
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `phone` contact field | `parks/models/parks.py` | ⚠️ DEVIATION | Field exists but spec wants E.164 format validation |
| `email` contact field | `parks/models/parks.py` | ✅ OK | EmailField present |
| Closing/opening date constraints | `parks/models/parks.py:137-183` | ✅ OK | Multiple CheckConstraints |
---
## 2. State Logic Audit
### Submission State Transitions
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| Claim requires PENDING status | `moderation/views.py:1455-1477` | ✅ OK | Explicit check: `if submission.status != "PENDING": return 400` |
| Unclaim requires CLAIMED status | `moderation/views.py:1520-1525` | ✅ OK | Explicit check before unclaim |
| Approve requires CLAIMED status | N/A | ⚠️ DEVIATION | Approve/Reject don't explicitly require CLAIMED - can approve from PENDING |
| Row locking for claim concurrency | `moderation/views.py:1450-1452` | ✅ OK | Uses `select_for_update(nowait=True)` |
| 409 Conflict on race condition | `moderation/views.py:1458-1464` | ✅ OK | Returns 409 with claimed_by info |
### Ride Status Transitions
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| FSM for ride status | `rides/models/rides.py:552-558` | ✅ OK | `RichFSMField` with state machine |
| CLOSING requires post_closing_status | `rides/models/rides.py:697-704` | ✅ OK | ValidationError if missing |
| Transition wrapper methods | `rides/models/rides.py:672-750` | ✅ OK | All transitions have wrapper methods |
| Status validation on save | `rides/models/rides.py:752-796` | ✅ OK | Computed fields populated on save |
### Park Status Transitions
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| FSM for park status | `parks/models/parks.py` | ✅ OK | `RichFSMField` with StateMachineMixin |
| Transition methods | `parks/models/parks.py:189-221` | ✅ OK | reopen, close_temporarily, etc. |
| Closing date on permanent close | `parks/models/parks.py:204-211` | ✅ OK | Optional closing_date param |
---
## 3. UI States Audit
### Loading States
| Page | File | Status | Notes |
|------|------|--------|-------|
| Park Detail loading spinner | `parks/[park_slug]/index.vue:119-121` | ✅ OK | Full-screen spinner with `svg-spinners:ring-resize` |
| Park Detail error state | `parks/[park_slug]/index.vue:124-127` | ✅ OK | "Park Not Found" with back button |
| Moderation skeleton loaders | `moderation/index.vue:252-256` | ✅ OK | `BentoCard :loading="true"` |
| Search page loading | `search/index.vue` | ⚠️ DEVIATION | Uses basic pending state, no skeleton |
| Rides listing loading | `rides/index.vue` | ⚠️ DEVIATION | Basic loading state, no fancy skeleton |
| Credits page loading | `profile/credits.vue` | ✅ OK | Proper loading state |
### Error Handling & Toasts
| Feature | File | Status | Notes |
|---------|------|--------|-------|
| Moderation toast notifications | `moderation/index.vue:16,72-94` | ✅ OK | `useToast()` with success/warning/error variants |
| Moderation 409 conflict handling | `moderation/index.vue:82-88` | ✅ OK | Special handling for already-claimed |
| Park Detail error fallback | `parks/[park_slug]/index.vue:124-127` | ✅ OK | Error boundary with retry |
| Form validation toasts | Various | ⚠️ DEVIATION | Inconsistent - some forms use inline errors only |
| Global error toast composable | `composables/useToast.ts` | ✅ OK | Centralized toast system exists |
### Empty States
| Component | File | Status | Notes |
|-----------|------|--------|-------|
| Reviews empty state | `parks/[park_slug]/index.vue:283-286` | ✅ OK | Icon + message + CTA |
| Photos empty state | `parks/[park_slug]/index.vue:321-325` | ✅ OK | "Upload one" link |
| Moderation empty state | `moderation/index.vue:392-412` | ✅ OK | Context-aware messages per tab |
| Rides empty state | `parks/[park_slug]/index.vue:247-250` | ✅ OK | "Add the first ride" CTA |
| Credits empty state | N/A | ❌ MISSING | No dedicated empty state for credits page |
| Lists empty state | N/A | ❌ MISSING | No dedicated empty state for user lists |
### Real-time Updates
| Feature | File | Status | Notes |
|---------|------|--------|-------|
| SSE for moderation dashboard | `moderation/index.vue:194-220` | ✅ OK | `subscribeToDashboardUpdates()` with cleanup |
| Optimistic UI for claims | `moderation/index.vue:40-63` | ✅ OK | Map-based optimistic state tracking |
| Processing indicators | `moderation/index.vue:268-273` | ✅ OK | Per-item "Processing..." indicator |
---
## 4. Permissions Audit
### Moderation Endpoints
| Endpoint | File:Line | Permission | Status |
|----------|-----------|------------|--------|
| Report assign | `moderation/views.py:136` | `IsModeratorOrAdmin` | ✅ OK |
| Report resolve | `moderation/views.py:215` | `IsModeratorOrAdmin` | ✅ OK |
| Queue assign | `moderation/views.py:593` | `IsModeratorOrAdmin` | ✅ OK |
| Queue unassign | `moderation/views.py:666` | `IsModeratorOrAdmin` | ✅ OK |
| Queue complete | `moderation/views.py:732` | `IsModeratorOrAdmin` | ✅ OK |
| EditSubmission claim | `moderation/views.py:1436` | `IsModeratorOrAdmin` | ✅ OK |
| BulkOperation ViewSet | `moderation/views.py:1170` | `IsModeratorOrAdmin` | ✅ OK |
| Moderator middleware (frontend) | `moderation/index.vue:11-13` | `middleware: ['moderator']` | ✅ OK |
---
## 5. Entity Forms Audit
| Entity | Create | Edit | Status |
|--------|--------|------|--------|
| Park | `CreateParkModal.vue` | `EditParkModal.vue` | ✅ OK |
| Ride | `CreateRideModal.vue` | `EditRideModal.vue` | ✅ OK |
| Company | `CreateCompanyModal.vue` | `EditCompanyModal.vue` | ✅ OK |
| RideModel | `CreateRideModelModal.vue` | `EditRideModelModal.vue` | ✅ OK |
| UserList | `CreateListModal.vue` | `EditListModal.vue` | ✅ OK |
---
## Priority Gaps to Address
### High Priority (Functionality Gaps)
1. **`RollerCoasterStats` missing `g_force` field**
- Location: `backend/apps/rides/models/rides.py:990-1080`
- Impact: Coaster enthusiasts expect G-force data
- Fix: Add `max_g_force = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)`
### Medium Priority (Deviations)
4. **Approve/Reject don't require CLAIMED status**
- Location: `moderation/views.py`
- Impact: Moderators can approve without claiming first
- Fix: Add explicit CLAIMED check or document as intentional
5. **Park phone field lacks E.164 validation**
- Location: `parks/models/parks.py`
- Fix: Add `phonenumbers` library validation
6. **Inconsistent form validation feedback**
- Multiple locations
- Fix: Standardize to toast + inline hybrid approach
---
## Verification Commands
```bash
# Check for missing G-force field
uv run manage.py shell -c "from apps.rides.models import RollerCoasterStats; print([f.name for f in RollerCoasterStats._meta.fields])"
# Verify state machine transitions
uv run manage.py test apps.moderation.tests.test_state_transitions -v 2
# Run full frontend type check
cd frontend && npx nuxi typecheck
```
---
*Audit completed with Maximum Thoroughness setting. All findings verified against source code.*

View File

@@ -1,6 +1,6 @@
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
@@ -33,10 +33,7 @@ class CustomAccountAdapter(DefaultAccountAdapter):
"current_site": current_site, "current_site": current_site,
"key": emailconfirmation.key, "key": emailconfirmation.key,
} }
if signup: email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation"
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
self.send_mail(email_template, emailconfirmation.email_address.email, ctx) self.send_mail(email_template, emailconfirmation.email_address.email, ctx)

View File

@@ -16,7 +16,6 @@ from datetime import timedelta
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db.models import Count, Sum
from django.utils import timezone from django.utils import timezone
from django.utils.html import format_html from django.utils.html import format_html
@@ -25,7 +24,6 @@ from apps.core.admin import (
ExportActionMixin, ExportActionMixin,
QueryOptimizationMixin, QueryOptimizationMixin,
ReadOnlyAdminMixin, ReadOnlyAdminMixin,
TimestampFieldsMixin,
) )
from .models import ( from .models import (

View File

@@ -7,8 +7,7 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects.
Last updated: 2025-01-15 Last updated: 2025-01-15
""" """
from apps.core.choices import RichChoice, ChoiceGroup, register_choices from apps.core.choices import ChoiceGroup, RichChoice, register_choices
# ============================================================================= # =============================================================================
# USER ROLES # USER ROLES

View File

@@ -1,8 +1,8 @@
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone from django.utils import timezone
from .models import User from .models import User
class UserExportService: class UserExportService:
"""Service for exporting all user data.""" """Service for exporting all user data."""
@@ -10,18 +10,18 @@ class UserExportService:
def export_user_data(user: User) -> dict: def export_user_data(user: User) -> dict:
""" """
Export all data associated with a user or an object containing counts/metadata and actual data. Export all data associated with a user or an object containing counts/metadata and actual data.
Args: Args:
user: The user to export data for user: The user to export data for
Returns: Returns:
dict: The complete user data export dict: The complete user data export
""" """
# Import models locally to avoid circular imports # Import models locally to avoid circular imports
from apps.lists.models import UserList
from apps.parks.models import ParkReview from apps.parks.models import ParkReview
from apps.rides.models import RideReview from apps.rides.models import RideReview
from apps.lists.models import UserList
# User account and profile # User account and profile
user_data = { user_data = {
"username": user.username, "username": user.username,
@@ -32,7 +32,7 @@ class UserExportService:
"is_active": user.is_active, "is_active": user.is_active,
"role": user.role, "role": user.role,
} }
profile_data = {} profile_data = {}
if hasattr(user, "profile"): if hasattr(user, "profile"):
profile = user.profile profile = user.profile
@@ -60,11 +60,11 @@ class UserExportService:
park_reviews = list(ParkReview.objects.filter(user=user).values( park_reviews = list(ParkReview.objects.filter(user=user).values(
"park__name", "rating", "review", "created_at", "updated_at", "is_published" "park__name", "rating", "review", "created_at", "updated_at", "is_published"
)) ))
ride_reviews = list(RideReview.objects.filter(user=user).values( ride_reviews = list(RideReview.objects.filter(user=user).values(
"ride__name", "rating", "review", "created_at", "updated_at", "is_published" "ride__name", "rating", "review", "created_at", "updated_at", "is_published"
)) ))
# Lists # Lists
user_lists = [] user_lists = []
for user_list in UserList.objects.filter(user=user): for user_list in UserList.objects.filter(user=user):
@@ -75,7 +75,7 @@ class UserExportService:
"created_at": user_list.created_at, "created_at": user_list.created_at,
"items": items "items": items
}) })
export_data = { export_data = {
"account": user_data, "account": user_data,
"profile": profile_data, "profile": profile_data,
@@ -90,5 +90,5 @@ class UserExportService:
"version": "1.0" "version": "1.0"
} }
} }
return export_data return export_data

View File

@@ -0,0 +1,106 @@
"""
Login History Model
Tracks user login events for security auditing and compliance with
the login_history_retention setting on the User model.
"""
import pghistory
from django.conf import settings
from django.db import models
@pghistory.track()
class LoginHistory(models.Model):
"""
Records each successful login attempt for a user.
Used for security auditing, login notifications, and compliance with
the user's login_history_retention preference.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="login_history",
help_text="User who logged in",
)
ip_address = models.GenericIPAddressField(
null=True,
blank=True,
help_text="IP address from which the login occurred",
)
user_agent = models.CharField(
max_length=500,
blank=True,
help_text="Browser/client user agent string",
)
login_method = models.CharField(
max_length=20,
choices=[
("PASSWORD", "Password"),
("GOOGLE", "Google OAuth"),
("DISCORD", "Discord OAuth"),
("MAGIC_LINK", "Magic Link"),
("SESSION", "Session Refresh"),
],
default="PASSWORD",
help_text="Method used for authentication",
)
login_timestamp = models.DateTimeField(
auto_now_add=True,
db_index=True,
help_text="When the login occurred",
)
success = models.BooleanField(
default=True,
help_text="Whether the login was successful",
)
# Optional geolocation data (can be populated asynchronously)
country = models.CharField(
max_length=100,
blank=True,
help_text="Country derived from IP (optional)",
)
city = models.CharField(
max_length=100,
blank=True,
help_text="City derived from IP (optional)",
)
class Meta:
verbose_name = "Login History"
verbose_name_plural = "Login History"
ordering = ["-login_timestamp"]
indexes = [
models.Index(fields=["user", "-login_timestamp"]),
models.Index(fields=["ip_address"]),
]
def __str__(self):
return f"{self.user.username} login at {self.login_timestamp}"
@classmethod
def cleanup_old_entries(cls, days=90):
"""
Remove login history entries older than the specified number of days.
Respects each user's login_history_retention preference.
"""
from datetime import timedelta
from django.utils import timezone
# Default cleanup for entries older than the specified days
cutoff = timezone.now() - timedelta(days=days)
deleted_count, _ = cls.objects.filter(
login_timestamp__lt=cutoff
).delete()
return deleted_count

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from apps.parks.models import ParkReview, Park, ParkPhoto from django.core.management.base import BaseCommand
from apps.parks.models import Park, ParkPhoto, ParkReview
from apps.rides.models import Ride, RidePhoto from apps.rides.models import Ride, RidePhoto
User = get_user_model() User = get_user_model()
@@ -52,8 +53,8 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides")) self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
# Clean up test files # Clean up test files
import os
import glob import glob
import os
# Clean up test uploads # Clean up test uploads
media_patterns = [ media_patterns = [

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission, User from django.contrib.auth.models import Group, Permission, User
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -8,6 +8,7 @@ Usage:
""" """
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from apps.accounts.models import User from apps.accounts.models import User
from apps.accounts.services import UserDeletionService from apps.accounts.services import UserDeletionService
@@ -48,10 +49,7 @@ class Command(BaseCommand):
# Find the user # Find the user
try: try:
if username: user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id)
user = User.objects.get(username=username)
else:
user = User.objects.get(user_id=user_id)
except User.DoesNotExist: except User.DoesNotExist:
identifier = username or user_id identifier = username or user_id
raise CommandError(f'User "{identifier}" does not exist') raise CommandError(f'User "{identifier}" does not exist')

View File

@@ -1,7 +1,8 @@
from django.core.management.base import BaseCommand import os
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
import os from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,6 +1,7 @@
import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import os
def generate_avatar(letter): def generate_avatar(letter):

View File

@@ -1,4 +1,5 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from apps.accounts.models import UserProfile from apps.accounts.models import UserProfile

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection

View File

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand
from apps.accounts.models import User from apps.accounts.models import User
from apps.accounts.signals import create_default_groups from apps.accounts.signals import create_default_groups

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,9 +1,10 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
from dotenv import load_dotenv
import os import os
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from dotenv import load_dotenv
class Command(BaseCommand): class Command(BaseCommand):
help = "Sets up social authentication apps" help = "Sets up social authentication apps"

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
User = get_user_model() User = get_user_model()

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,6 +1,6 @@
from allauth.socialaccount.models import SocialApp
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.test import Client from django.test import Client
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand): class Command(BaseCommand):

View File

@@ -27,14 +27,14 @@ def safe_add_avatar_field(apps, schema_editor):
# Check if the column already exists # Check if the column already exists
with schema_editor.connection.cursor() as cursor: with schema_editor.connection.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT column_name SELECT column_name
FROM information_schema.columns FROM information_schema.columns
WHERE table_name='accounts_userprofile' WHERE table_name='accounts_userprofile'
AND column_name='avatar_id' AND column_name='avatar_id'
""") """)
column_exists = cursor.fetchone() is not None column_exists = cursor.fetchone() is not None
if not column_exists: if not column_exists:
# Column doesn't exist, add it # Column doesn't exist, add it
UserProfile = apps.get_model('accounts', 'UserProfile') UserProfile = apps.get_model('accounts', 'UserProfile')
@@ -55,14 +55,14 @@ def reverse_safe_add_avatar_field(apps, schema_editor):
# Check if the column exists and remove it # Check if the column exists and remove it
with schema_editor.connection.cursor() as cursor: with schema_editor.connection.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT column_name SELECT column_name
FROM information_schema.columns FROM information_schema.columns
WHERE table_name='accounts_userprofile' WHERE table_name='accounts_userprofile'
AND column_name='avatar_id' AND column_name='avatar_id'
""") """)
column_exists = cursor.fetchone() is not None column_exists = cursor.fetchone() is not None
if column_exists: if column_exists:
UserProfile = apps.get_model('accounts', 'UserProfile') UserProfile = apps.get_model('accounts', 'UserProfile')
field = models.ForeignKey( field = models.ForeignKey(

View File

@@ -23,9 +23,9 @@ class Migration(migrations.Migration):
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS ( IF NOT EXISTS (
SELECT column_name SELECT column_name
FROM information_schema.columns FROM information_schema.columns
WHERE table_name='accounts_userprofileevent' WHERE table_name='accounts_userprofileevent'
AND column_name='avatar_id' AND column_name='avatar_id'
) THEN ) THEN
ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid; ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;

View File

@@ -1,8 +1,9 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35 # Generated by Django 5.2.5 on 2025-09-15 17:35
import apps.core.choices.fields
from django.db import migrations from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -1,12 +1,13 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10 # Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import django.db.models.deletion import django.db.models.deletion
import pgtrigger.compiler import pgtrigger.compiler
import pgtrigger.migrations import pgtrigger.migrations
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import apps.core.choices.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@@ -0,0 +1,184 @@
# Generated by Django 5.2.9 on 2025-12-27 20:58
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0014_remove_toplist_user_remove_toplistitem_top_list_and_more"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
migrations.CreateModel(
name="LoginHistory",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"ip_address",
models.GenericIPAddressField(
blank=True, help_text="IP address from which the login occurred", null=True
),
),
(
"user_agent",
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
),
(
"login_method",
models.CharField(
choices=[
("PASSWORD", "Password"),
("GOOGLE", "Google OAuth"),
("DISCORD", "Discord OAuth"),
("MAGIC_LINK", "Magic Link"),
("SESSION", "Session Refresh"),
],
default="PASSWORD",
help_text="Method used for authentication",
max_length=20,
),
),
(
"login_timestamp",
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the login occurred"),
),
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
(
"country",
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
),
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
(
"user",
models.ForeignKey(
help_text="User who logged in",
on_delete=django.db.models.deletion.CASCADE,
related_name="login_history",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Login History",
"verbose_name_plural": "Login History",
"ordering": ["-login_timestamp"],
},
),
migrations.CreateModel(
name="LoginHistoryEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"ip_address",
models.GenericIPAddressField(
blank=True, help_text="IP address from which the login occurred", null=True
),
),
(
"user_agent",
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
),
(
"login_method",
models.CharField(
choices=[
("PASSWORD", "Password"),
("GOOGLE", "Google OAuth"),
("DISCORD", "Discord OAuth"),
("MAGIC_LINK", "Magic Link"),
("SESSION", "Session Refresh"),
],
default="PASSWORD",
help_text="Method used for authentication",
max_length=20,
),
),
("login_timestamp", models.DateTimeField(auto_now_add=True, help_text="When the login occurred")),
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
(
"country",
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
),
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.loginhistory",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
help_text="User who logged in",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="loginhistory",
index=models.Index(fields=["user", "-login_timestamp"], name="accounts_lo_user_id_156da7_idx"),
),
migrations.AddIndex(
model_name="loginhistory",
index=models.Index(fields=["ip_address"], name="accounts_lo_ip_addr_142937_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="loginhistory",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
hash="9ccc4d52099a09097d02128eb427d58ae955a377",
operation="INSERT",
pgid="pgtrigger_insert_insert_dc41d",
table="accounts_loginhistory",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="loginhistory",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
hash="d5d998a5af1a55f181ebe8500a70022e8e4db724",
operation="UPDATE",
pgid="pgtrigger_update_update_110f5",
table="accounts_loginhistory",
when="AFTER",
),
),
),
]

View File

@@ -3,7 +3,7 @@ Mixins for authentication views.
""" """
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from apps.core.utils.turnstile import validate_turnstile_token, get_client_ip from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token
class TurnstileMixin: class TurnstileMixin:
@@ -15,30 +15,30 @@ class TurnstileMixin:
def validate_turnstile(self, request): def validate_turnstile(self, request):
""" """
Validate the Turnstile response token. Validate the Turnstile response token.
The token can be provided as: The token can be provided as:
- 'cf-turnstile-response' in POST data (form submission) - 'cf-turnstile-response' in POST data (form submission)
- 'turnstile_token' in JSON body (API request) - 'turnstile_token' in JSON body (API request)
""" """
# Try to get token from various sources # Try to get token from various sources
token = None token = None
# Check POST data (form submissions) # Check POST data (form submissions)
if hasattr(request, 'POST'): if hasattr(request, 'POST'):
token = request.POST.get("cf-turnstile-response") token = request.POST.get("cf-turnstile-response")
# Check JSON body (API requests) # Check JSON body (API requests)
if not token and hasattr(request, 'data'): if not token and hasattr(request, 'data'):
data = getattr(request, 'data', {}) data = getattr(request, 'data', {})
if hasattr(data, 'get'): if hasattr(data, 'get'):
token = data.get('turnstile_token') or data.get('cf-turnstile-response') token = data.get('turnstile_token') or data.get('cf-turnstile-response')
# Get client IP # Get client IP
ip = get_client_ip(request) ip = get_client_ip(request)
# Validate the token # Validate the token
result = validate_turnstile_token(token, ip) result = validate_turnstile_token(token, ip)
if not result.get('success'): if not result.get('success'):
error_msg = result.get('error', 'Captcha verification failed. Please try again.') error_msg = result.get('error', 'Captcha verification failed. Please try again.')
raise ValidationError(error_msg) raise ValidationError(error_msg)

View File

@@ -1,16 +1,18 @@
from django.dispatch import receiver import secrets
from django.db.models.signals import post_save from datetime import timedelta
import pghistory
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import secrets
from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField from apps.core.choices import RichChoiceField
import pghistory from apps.core.history import TrackedModel
# from django_cloudflareimages_toolkit.models import CloudflareImage # from django_cloudflareimages_toolkit.models import CloudflareImage
@@ -358,6 +360,9 @@ class EmailVerification(models.Model):
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, help_text="When this verification was created" auto_now_add=True, help_text="When this verification was created"
) )
updated_at = models.DateTimeField(
auto_now=True, help_text="When this verification was last updated"
)
last_sent = models.DateTimeField( last_sent = models.DateTimeField(
auto_now_add=True, help_text="When the verification email was last sent" auto_now_add=True, help_text="When the verification email was last sent"
) )

View File

@@ -3,11 +3,12 @@ Selectors for user and account-related data retrieval.
Following Django styleguide pattern for separating data access from business logic. Following Django styleguide pattern for separating data access from business logic.
""" """
from typing import Dict, Any
from django.db.models import QuerySet, Q, F, Count
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import Count, F, Q, QuerySet
from django.utils import timezone
User = get_user_model() User = get_user_model()
@@ -196,7 +197,7 @@ def users_with_social_accounts() -> QuerySet:
) )
def user_statistics_summary() -> Dict[str, Any]: def user_statistics_summary() -> dict[str, Any]:
""" """
Get overall user statistics for dashboard/analytics. Get overall user statistics for dashboard/analytics.

View File

@@ -1,14 +1,16 @@
from rest_framework import serializers from datetime import timedelta
from typing import cast
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from django_forwardemail.services import EmailService
from django.template.loader import render_to_string from django.template.loader import render_to_string
from typing import cast from django.utils import timezone
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from rest_framework import serializers
from .models import PasswordReset, User
UserModel = get_user_model() UserModel = get_user_model()

View File

@@ -12,7 +12,7 @@ Recent additions:
import logging import logging
import re import re
from typing import Any, Dict, Optional from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
@@ -58,7 +58,7 @@ class AccountService:
old_password: str, old_password: str,
new_password: str, new_password: str,
request: HttpRequest, request: HttpRequest,
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Change user password with validation and notification. Change user password with validation and notification.
@@ -146,7 +146,7 @@ class AccountService:
user: User, user: User,
new_email: str, new_email: str,
request: HttpRequest, request: HttpRequest,
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Initiate email change with verification. Initiate email change with verification.
@@ -234,7 +234,7 @@ class AccountService:
logger.error(f"Failed to send email verification: {e}") logger.error(f"Failed to send email verification: {e}")
@staticmethod @staticmethod
def verify_email_change(*, token: str) -> Dict[str, Any]: def verify_email_change(*, token: str) -> dict[str, Any]:
""" """
Verify email change token and update user email. Verify email change token and update user email.
@@ -375,35 +375,35 @@ class UserDeletionService:
# Transfer all submissions to deleted user # Transfer all submissions to deleted user
# Reviews # Reviews
if hasattr(user, "park_reviews"): if hasattr(user, "park_reviews"):
getattr(user, "park_reviews").update(user=deleted_user) user.park_reviews.update(user=deleted_user)
if hasattr(user, "ride_reviews"): if hasattr(user, "ride_reviews"):
getattr(user, "ride_reviews").update(user=deleted_user) user.ride_reviews.update(user=deleted_user)
# Photos # Photos
if hasattr(user, "uploaded_park_photos"): if hasattr(user, "uploaded_park_photos"):
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user) user.uploaded_park_photos.update(uploaded_by=deleted_user)
if hasattr(user, "uploaded_ride_photos"): if hasattr(user, "uploaded_ride_photos"):
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user) user.uploaded_ride_photos.update(uploaded_by=deleted_user)
# Top Lists # Top Lists
if hasattr(user, "top_lists"): if hasattr(user, "top_lists"):
getattr(user, "top_lists").update(user=deleted_user) user.top_lists.update(user=deleted_user)
# Moderation submissions # Moderation submissions
if hasattr(user, "edit_submissions"): if hasattr(user, "edit_submissions"):
getattr(user, "edit_submissions").update(user=deleted_user) user.edit_submissions.update(user=deleted_user)
if hasattr(user, "photo_submissions"): if hasattr(user, "photo_submissions"):
getattr(user, "photo_submissions").update(user=deleted_user) user.photo_submissions.update(user=deleted_user)
# Moderation actions - these can be set to NULL since they're not user content # Moderation actions - these can be set to NULL since they're not user content
if hasattr(user, "moderated_park_reviews"): if hasattr(user, "moderated_park_reviews"):
getattr(user, "moderated_park_reviews").update(moderated_by=None) user.moderated_park_reviews.update(moderated_by=None)
if hasattr(user, "moderated_ride_reviews"): if hasattr(user, "moderated_ride_reviews"):
getattr(user, "moderated_ride_reviews").update(moderated_by=None) user.moderated_ride_reviews.update(moderated_by=None)
if hasattr(user, "handled_submissions"): if hasattr(user, "handled_submissions"):
getattr(user, "handled_submissions").update(handled_by=None) user.handled_submissions.update(handled_by=None)
if hasattr(user, "handled_photos"): if hasattr(user, "handled_photos"):
getattr(user, "handled_photos").update(handled_by=None) user.handled_photos.update(handled_by=None)
# Store user info for the summary # Store user info for the summary
user_info = { user_info = {
@@ -426,7 +426,7 @@ class UserDeletionService:
} }
@classmethod @classmethod
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]: def can_delete_user(cls, user: User) -> tuple[bool, str | None]:
""" """
Check if a user can be safely deleted. Check if a user can be safely deleted.

View File

@@ -5,18 +5,19 @@ This service handles the creation, delivery, and management of notifications
for various events including submission approvals/rejections. for various events including submission approvals/rejections.
""" """
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.conf import settings
from django.db import models
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
import logging import logging
from datetime import datetime, timedelta
from typing import Any
from apps.accounts.models import User, UserNotification, NotificationPreference from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.template.loader import render_to_string
from django.utils import timezone
from django_forwardemail.services import EmailService from django_forwardemail.services import EmailService
from apps.accounts.models import NotificationPreference, User, UserNotification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,10 +30,10 @@ class NotificationService:
notification_type: str, notification_type: str,
title: str, title: str,
message: str, message: str,
related_object: Optional[Any] = None, related_object: Any | None = None,
priority: str = UserNotification.Priority.NORMAL, priority: str = UserNotification.Priority.NORMAL,
extra_data: Optional[Dict[str, Any]] = None, extra_data: dict[str, Any] | None = None,
expires_at: Optional[datetime] = None, expires_at: datetime | None = None,
) -> UserNotification: ) -> UserNotification:
""" """
Create a new notification for a user. Create a new notification for a user.
@@ -273,9 +274,9 @@ class NotificationService:
def get_user_notifications( def get_user_notifications(
user: User, user: User,
unread_only: bool = False, unread_only: bool = False,
notification_types: Optional[List[str]] = None, notification_types: list[str] | None = None,
limit: Optional[int] = None, limit: int | None = None,
) -> List[UserNotification]: ) -> list[UserNotification]:
""" """
Get notifications for a user. Get notifications for a user.
@@ -308,7 +309,7 @@ class NotificationService:
@staticmethod @staticmethod
def mark_notifications_read( def mark_notifications_read(
user: User, notification_ids: Optional[List[int]] = None user: User, notification_ids: list[int] | None = None
) -> int: ) -> int:
""" """
Mark notifications as read for a user. Mark notifications as read for a user.

View File

@@ -6,13 +6,14 @@ social authentication providers while ensuring users never lock themselves
out of their accounts. out of their accounts.
""" """
from typing import Dict, List, Tuple, TYPE_CHECKING import logging
from django.contrib.auth import get_user_model from typing import TYPE_CHECKING
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers import registry from allauth.socialaccount.providers import registry
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest from django.http import HttpRequest
import logging
if TYPE_CHECKING: if TYPE_CHECKING:
from apps.accounts.models import User from apps.accounts.models import User
@@ -26,7 +27,7 @@ class SocialProviderService:
"""Service for managing social provider connections.""" """Service for managing social provider connections."""
@staticmethod @staticmethod
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]: def can_disconnect_provider(user: User, provider: str) -> tuple[bool, str]:
""" """
Check if a user can safely disconnect a social provider. Check if a user can safely disconnect a social provider.
@@ -69,7 +70,7 @@ class SocialProviderService:
return False, "Unable to verify disconnection safety. Please try again." return False, "Unable to verify disconnection safety. Please try again."
@staticmethod @staticmethod
def get_connected_providers(user: "User") -> List[Dict]: def get_connected_providers(user: "User") -> list[dict]:
""" """
Get all social providers connected to a user's account. Get all social providers connected to a user's account.
@@ -106,7 +107,7 @@ class SocialProviderService:
return [] return []
@staticmethod @staticmethod
def get_available_providers(request: HttpRequest) -> List[Dict]: def get_available_providers(request: HttpRequest) -> list[dict]:
""" """
Get all available social providers for the current site. Get all available social providers for the current site.
@@ -152,7 +153,7 @@ class SocialProviderService:
return [] return []
@staticmethod @staticmethod
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]: def disconnect_provider(user: "User", provider: str) -> tuple[bool, str]:
""" """
Disconnect a social provider from a user's account. Disconnect a social provider from a user's account.
@@ -191,7 +192,7 @@ class SocialProviderService:
return False, f"Failed to disconnect {provider} account. Please try again." return False, f"Failed to disconnect {provider} account. Please try again."
@staticmethod @staticmethod
def get_auth_status(user: "User") -> Dict: def get_auth_status(user: "User") -> dict:
""" """
Get comprehensive authentication status for a user. Get comprehensive authentication status for a user.
@@ -231,7 +232,7 @@ class SocialProviderService:
} }
@staticmethod @staticmethod
def validate_provider_exists(provider: str) -> Tuple[bool, str]: def validate_provider_exists(provider: str) -> tuple[bool, str]:
""" """
Validate that a social provider is configured and available. Validate that a social provider is configured and available.

View File

@@ -5,19 +5,18 @@ This service handles user account deletion while preserving submissions
and maintaining data integrity across the platform. and maintaining data integrity across the platform.
""" """
from django.utils import timezone
from django.db import transaction
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from typing import Dict, Any, Tuple, Optional
import logging import logging
import secrets import secrets
import string import string
from datetime import datetime from datetime import datetime
from typing import Any
from apps.accounts.models import User from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.db import transaction
from django.template.loader import render_to_string
from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,7 +40,7 @@ class UserDeletionService:
_deletion_requests = {} _deletion_requests = {}
@staticmethod @staticmethod
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]: def can_delete_user(user: User) -> tuple[bool, str | None]:
""" """
Check if a user can be safely deleted. Check if a user can be safely deleted.
@@ -104,7 +103,7 @@ class UserDeletionService:
return deletion_request return deletion_request
@staticmethod @staticmethod
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]: def verify_and_delete_user(verification_code: str) -> dict[str, Any]:
""" """
Verify deletion code and delete user account. Verify deletion code and delete user account.
@@ -169,7 +168,7 @@ class UserDeletionService:
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]: def delete_user_preserve_submissions(user: User) -> dict[str, Any]:
""" """
Delete a user account while preserving all their submissions. Delete a user account while preserving all their submissions.
@@ -217,7 +216,7 @@ class UserDeletionService:
} }
@staticmethod @staticmethod
def _count_user_submissions(user: User) -> Dict[str, int]: def _count_user_submissions(user: User) -> dict[str, int]:
"""Count all submissions for a user.""" """Count all submissions for a user."""
counts = {} counts = {}

View File

@@ -1,10 +1,13 @@
from django.db.models.signals import post_save, pre_save import requests
from django.dispatch import receiver
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db import transaction from django.contrib.auth.signals import user_logged_in
from django.core.files import File from django.core.files import File
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
import requests from django.db import transaction
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from .login_history import LoginHistory
from .models import User, UserProfile from .models import User, UserProfile
@@ -185,3 +188,41 @@ def create_default_groups():
print(f"Permission not found: {codename}") print(f"Permission not found: {codename}")
except Exception as e: except Exception as e:
print(f"Error creating default groups: {str(e)}") print(f"Error creating default groups: {str(e)}")
@receiver(user_logged_in)
def log_successful_login(sender, user, request, **kwargs):
"""
Log successful login events to LoginHistory.
This signal handler captures the IP address, user agent, and login method
for auditing and security purposes.
"""
try:
# Get IP address
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
ip_address = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR')
# Get user agent
user_agent = request.META.get('HTTP_USER_AGENT', '')[:500]
# Determine login method from session or request
login_method = 'PASSWORD'
if hasattr(request, 'session'):
sociallogin = getattr(request, '_sociallogin', None)
if sociallogin:
provider = sociallogin.account.provider.upper()
if provider in ['GOOGLE', 'DISCORD']:
login_method = provider
# Create login history entry
LoginHistory.objects.create(
user=user,
ip_address=ip_address,
user_agent=user_agent,
login_method=login_method,
success=True,
)
except Exception as e:
# Don't let login history failure prevent login
print(f"Error logging login history for user {user.username}: {str(e)}")

View File

@@ -1,7 +1,9 @@
from django.test import TestCase from unittest.mock import MagicMock, patch
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from unittest.mock import patch, MagicMock from django.test import TestCase
from .models import User, UserProfile from .models import User, UserProfile
from .signals import create_default_groups from .signals import create_default_groups

View File

@@ -6,7 +6,6 @@ password reset, and top list admin classes including query optimization
and custom actions. and custom actions.
""" """
import pytest
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
@@ -20,7 +19,6 @@ from apps.accounts.admin import (
from apps.accounts.models import ( from apps.accounts.models import (
EmailVerification, EmailVerification,
PasswordReset, PasswordReset,
User, User,
UserProfile, UserProfile,
) )

View File

@@ -7,9 +7,8 @@ These tests verify that:
3. Business rules are enforced at the model level 3. Business rules are enforced at the model level
""" """
from django.test import TestCase
from django.db import IntegrityError from django.db import IntegrityError
from django.core.exceptions import ValidationError from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from apps.accounts.models import User from apps.accounts.models import User

View File

@@ -2,10 +2,11 @@
Tests for user deletion while preserving submissions. Tests for user deletion while preserving submissions.
""" """
from django.test import TestCase
from django.db import transaction from django.db import transaction
from apps.accounts.services import UserDeletionService from django.test import TestCase
from apps.accounts.models import User, UserProfile from apps.accounts.models import User, UserProfile
from apps.accounts.services import UserDeletionService
class UserDeletionServiceTest(TestCase): class UserDeletionServiceTest(TestCase):
@@ -140,13 +141,12 @@ class UserDeletionServiceTest(TestCase):
original_user_count = User.objects.count() original_user_count = User.objects.count()
# Mock a failure during the deletion process # Mock a failure during the deletion process
with self.assertRaises(Exception): with self.assertRaises(Exception), transaction.atomic():
with transaction.atomic(): # Start the deletion process
# Start the deletion process UserDeletionService.get_or_create_deleted_user()
UserDeletionService.get_or_create_deleted_user()
# Simulate an error # Simulate an error
raise Exception("Simulated error during deletion") raise Exception("Simulated error during deletion")
# Verify user count hasn't changed # Verify user count hasn't changed
self.assertEqual(User.objects.count(), original_user_count) self.assertEqual(User.objects.count(), original_user_count)

View File

@@ -1,6 +1,7 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from allauth.account.views import LogoutView from allauth.account.views import LogoutView
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views from . import views
app_name = "accounts" app_name = "accounts"

View File

@@ -1,41 +1,42 @@
from django.views.generic import DetailView, TemplateView
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.db.models import QuerySet
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
from django.urls import reverse
from django.contrib.auth import login
from django.core.files.uploadedfile import UploadedFile
from apps.accounts.models import (
User,
PasswordReset,
EmailVerification,
UserProfile,
)
from apps.lists.models import UserList
from django_forwardemail.services import EmailService
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from allauth.account.views import LoginView, SignupView
from .mixins import TurnstileMixin
from typing import Dict, Any, Optional, Union, cast
from django_htmx.http import HttpResponseClientRefresh
from contextlib import suppress
import logging import logging
import re import re
from contextlib import suppress
from datetime import timedelta
from typing import Any, cast
from apps.core.logging import log_exception, log_security_event from allauth.account.views import LoginView, SignupView
from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.views.generic import DetailView, TemplateView
from django_forwardemail.services import EmailService
from django_htmx.http import HttpResponseClientRefresh
from apps.accounts.models import (
EmailVerification,
PasswordReset,
User,
UserProfile,
)
from apps.core.logging import log_security_event
from apps.lists.models import UserList
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from .mixins import TurnstileMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -184,7 +185,7 @@ class ProfileView(DetailView):
def get_queryset(self) -> QuerySet[User]: def get_queryset(self) -> QuerySet[User]:
return User.objects.select_related("profile") return User.objects.select_related("profile")
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
user = cast(User, self.get_object()) user = cast(User, self.get_object())
@@ -220,7 +221,7 @@ class ProfileView(DetailView):
class SettingsView(LoginRequiredMixin, TemplateView): class SettingsView(LoginRequiredMixin, TemplateView):
template_name = "accounts/settings.html" template_name = "accounts/settings.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["user"] = self.request.user context["user"] = self.request.user
return context return context
@@ -283,7 +284,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
def _handle_password_change( def _handle_password_change(
self, request: HttpRequest self, request: HttpRequest
) -> Optional[HttpResponseRedirect]: ) -> HttpResponseRedirect | None:
user = cast(User, request.user) user = cast(User, request.user)
old_password = request.POST.get("old_password", "") old_password = request.POST.get("old_password", "")
new_password = request.POST.get("new_password", "") new_password = request.POST.get("new_password", "")
@@ -385,7 +386,7 @@ def create_password_reset_token(user: User) -> str:
def send_password_reset_email( def send_password_reset_email(
user: User, site: Union[Site, RequestSite], token: str user: User, site: Site | RequestSite, token: str
) -> None: ) -> None:
reset_url = reverse("password_reset_confirm", kwargs={"token": token}) reset_url = reverse("password_reset_confirm", kwargs={"token": token})
context = { context = {
@@ -435,7 +436,7 @@ def handle_password_reset(
user: User, user: User,
new_password: str, new_password: str,
reset: PasswordReset, reset: PasswordReset,
site: Union[Site, RequestSite], site: Site | RequestSite,
) -> None: ) -> None:
user.set_password(new_password) user.set_password(new_password)
user.save() user.save()
@@ -457,7 +458,7 @@ def handle_password_reset(
def send_password_reset_confirmation( def send_password_reset_confirmation(
user: User, site: Union[Site, RequestSite] user: User, site: Site | RequestSite
) -> None: ) -> None:
context = { context = {
"user": user, "user": user,

View File

@@ -14,32 +14,25 @@ Usage:
import random import random
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import List
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
from django.utils.text import slugify from django.utils.text import slugify
# Import all models # Import all models
from apps.accounts.models import ( from apps.accounts.models import NotificationPreference, UserDeletionRequest, UserNotification, UserProfile
User, UserProfile, UserNotification,
NotificationPreference, UserDeletionRequest
)
from apps.parks.models import (
Park, ParkLocation, ParkArea, ParkPhoto, ParkReview
)
from apps.parks.models.companies import Company as ParkCompany, CompanyHeadquarters
from apps.rides.models import (
Ride, RideModel, RollerCoasterStats, RidePhoto, RideReview, RideLocation
)
from apps.rides.models.company import Company as RideCompany
from apps.core.history import HistoricalSlug from apps.core.history import HistoricalSlug
from apps.parks.models import Park, ParkArea, ParkLocation, ParkPhoto, ParkReview
from apps.parks.models.companies import Company as ParkCompany
from apps.parks.models.companies import CompanyHeadquarters
from apps.rides.models import Ride, RideLocation, RideModel, RidePhoto, RideReview, RollerCoasterStats
from apps.rides.models.company import Company as RideCompany
# Try to import optional models that may not exist # Try to import optional models that may not exist
try: try:
from apps.rides.models import RideModelVariant, RideModelPhoto, RideModelTechnicalSpec from apps.rides.models import RideModelPhoto, RideModelTechnicalSpec, RideModelVariant
except ImportError: except ImportError:
RideModelVariant = None RideModelVariant = None
RideModelPhoto = None RideModelPhoto = None
@@ -51,7 +44,7 @@ except ImportError:
RideRanking = None RideRanking = None
try: try:
from apps.moderation.models import ModerationQueue, ModerationAction from apps.moderation.models import ModerationAction, ModerationQueue
except ImportError: except ImportError:
ModerationQueue = None ModerationQueue = None
ModerationAction = None ModerationAction = None
@@ -125,16 +118,16 @@ class Command(BaseCommand):
ride_models = self.create_ride_models(options['ride_models'], companies) ride_models = self.create_ride_models(options['ride_models'], companies)
parks = self.create_parks(options['parks'], companies) parks = self.create_parks(options['parks'], companies)
rides = self.create_rides(options['rides'], parks, companies, ride_models) rides = self.create_rides(options['rides'], parks, companies, ride_models)
# Create content and interactions # Create content and interactions
self.create_reviews(options['reviews'], users, parks, rides) self.create_reviews(options['reviews'], users, parks, rides)
self.create_notifications(users) self.create_notifications(users)
self.create_moderation_data(users, parks, rides) self.create_moderation_data(users, parks, rides)
# Create media and photos # Create media and photos
self.create_photos(parks, rides, ride_models) self.create_photos(parks, rides, ride_models)
# Create rankings and statistics # Create rankings and statistics
self.create_rankings(rides) self.create_rankings(rides)
@@ -146,26 +139,26 @@ class Command(BaseCommand):
def clear_data(self): def clear_data(self):
"""Clear existing data in reverse dependency order""" """Clear existing data in reverse dependency order"""
self.stdout.write('🗑️ Clearing existing data...') self.stdout.write('🗑️ Clearing existing data...')
models_to_clear = [ models_to_clear = [
# Content and interactions (clear first) # Content and interactions (clear first)
UserNotification, NotificationPreference, UserNotification, NotificationPreference,
ParkReview, RideReview, ModerationAction, ModerationQueue, ParkReview, RideReview, ModerationAction, ModerationQueue,
# Media # Media
ParkPhoto, RidePhoto, CloudflareImage, ParkPhoto, RidePhoto, CloudflareImage,
# Core entities # Core entities
RollerCoasterStats, Ride, ParkArea, Park, ParkLocation, RollerCoasterStats, Ride, ParkArea, Park, ParkLocation,
RideModel, CompanyHeadquarters, ParkCompany, RideCompany, RideModel, CompanyHeadquarters, ParkCompany, RideCompany,
# Users (clear last due to foreign key dependencies) # Users (clear last due to foreign key dependencies)
UserDeletionRequest, UserProfile, User, UserDeletionRequest, UserProfile, User,
# History # History
HistoricalSlug, HistoricalSlug,
] ]
# Add optional models if they exist # Add optional models if they exist
if RideRanking: if RideRanking:
models_to_clear.insert(4, RideRanking) models_to_clear.insert(4, RideRanking)
@@ -179,7 +172,7 @@ class Command(BaseCommand):
models_to_clear.insert(-6, RideModelVariant) models_to_clear.insert(-6, RideModelVariant)
if ModerationQueue: if ModerationQueue:
models_to_clear.insert(4, ModerationQueue) models_to_clear.insert(4, ModerationQueue)
for model in models_to_clear: for model in models_to_clear:
try: try:
count = model.objects.count() count = model.objects.count()
@@ -193,12 +186,12 @@ class Command(BaseCommand):
# Continue with other models # Continue with other models
continue continue
def create_users(self, count: int) -> List[User]: def create_users(self, count: int) -> list[User]:
"""Create diverse users with comprehensive profiles""" """Create diverse users with comprehensive profiles"""
self.stdout.write(f'👥 Creating {count} users...') self.stdout.write(f'👥 Creating {count} users...')
users = [] users = []
# Create admin user if it doesn't exist # Create admin user if it doesn't exist
admin, created = User.objects.get_or_create( admin, created = User.objects.get_or_create(
username='admin', username='admin',
@@ -216,7 +209,7 @@ class Command(BaseCommand):
admin.set_password('admin123') admin.set_password('admin123')
admin.save() admin.save()
users.append(admin) users.append(admin)
# Create moderator if it doesn't exist # Create moderator if it doesn't exist
moderator, created = User.objects.get_or_create( moderator, created = User.objects.get_or_create(
username='moderator', username='moderator',
@@ -233,7 +226,7 @@ class Command(BaseCommand):
moderator.set_password('mod123') moderator.set_password('mod123')
moderator.save() moderator.save()
users.append(moderator) users.append(moderator)
# Sample user data # Sample user data
first_names = [ first_names = [
'Alex', 'Jordan', 'Taylor', 'Casey', 'Morgan', 'Riley', 'Avery', 'Quinn', 'Alex', 'Jordan', 'Taylor', 'Casey', 'Morgan', 'Riley', 'Avery', 'Quinn',
@@ -241,23 +234,23 @@ class Command(BaseCommand):
'Jamie', 'Kendall', 'Logan', 'Parker', 'Peyton', 'Reese', 'Sage', 'Jamie', 'Kendall', 'Logan', 'Parker', 'Peyton', 'Reese', 'Sage',
'Skyler', 'Sydney', 'Tanner' 'Skyler', 'Sydney', 'Tanner'
] ]
last_names = [ last_names = [
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller',
'Davis', 'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez', 'Davis', 'Rodriguez', 'Martinez', 'Hernandez', 'Lopez', 'Gonzalez',
'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
'Lee', 'Perez', 'Thompson', 'White', 'Harris' 'Lee', 'Perez', 'Thompson', 'White', 'Harris'
] ]
domains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com'] domains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com']
# Create regular users # Create regular users
for i in range(count - 2): # -2 for admin and moderator for _i in range(count - 2): # -2 for admin and moderator
first_name = random.choice(first_names) first_name = random.choice(first_names)
last_name = random.choice(last_names) last_name = random.choice(last_names)
username = f"{first_name.lower()}{last_name.lower()}{random.randint(1, 999)}" username = f"{first_name.lower()}{last_name.lower()}{random.randint(1, 999)}"
email = f"{username}@{random.choice(domains)}" email = f"{username}@{random.choice(domains)}"
user = User.objects.create_user( user = User.objects.create_user(
username=username, username=username,
email=email, email=email,
@@ -275,7 +268,7 @@ class Command(BaseCommand):
two_factor_enabled=random.choice([True, False]), two_factor_enabled=random.choice([True, False]),
login_notifications=random.choice([True, False]), login_notifications=random.choice([True, False]),
) )
# Create detailed notification preferences # Create detailed notification preferences
user.notification_preferences = { user.notification_preferences = {
'email': { 'email': {
@@ -295,7 +288,7 @@ class Command(BaseCommand):
} }
} }
user.save() user.save()
# Create user profile with ride credits # Create user profile with ride credits
profile = UserProfile.objects.get(user=user) profile = UserProfile.objects.get(user=user)
profile.bio = f"Thrill seeker from {random.choice(['California', 'Florida', 'Ohio', 'Pennsylvania', 'Texas'])}. Love roller coasters!" profile.bio = f"Thrill seeker from {random.choice(['California', 'Florida', 'Ohio', 'Pennsylvania', 'Texas'])}. Love roller coasters!"
@@ -304,7 +297,7 @@ class Command(BaseCommand):
profile.dark_ride_credits = random.randint(0, 100) profile.dark_ride_credits = random.randint(0, 100)
profile.flat_ride_credits = random.randint(0, 200) profile.flat_ride_credits = random.randint(0, 200)
profile.water_ride_credits = random.randint(0, 50) profile.water_ride_credits = random.randint(0, 50)
# Add social media links for some users # Add social media links for some users
if random.random() < 0.3: if random.random() < 0.3:
profile.twitter = f"https://twitter.com/{username}" profile.twitter = f"https://twitter.com/{username}"
@@ -312,19 +305,19 @@ class Command(BaseCommand):
profile.instagram = f"https://instagram.com/{username}" profile.instagram = f"https://instagram.com/{username}"
if random.random() < 0.1: if random.random() < 0.1:
profile.youtube = f"https://youtube.com/@{username}" profile.youtube = f"https://youtube.com/@{username}"
profile.save() profile.save()
users.append(user) users.append(user)
self.stdout.write(f' ✅ Created {len(users)} users') self.stdout.write(f' ✅ Created {len(users)} users')
return users return users
def create_companies(self, count: int) -> List: def create_companies(self, count: int) -> list:
"""Create companies with different roles""" """Create companies with different roles"""
self.stdout.write(f'🏢 Creating {count} companies...') self.stdout.write(f'🏢 Creating {count} companies...')
companies = [] companies = []
# Major theme park operators # Major theme park operators
operators_data = [ operators_data = [
('Walt Disney Company', ['OPERATOR', 'PROPERTY_OWNER'], 1923, 'Burbank, CA, USA'), ('Walt Disney Company', ['OPERATOR', 'PROPERTY_OWNER'], 1923, 'Burbank, CA, USA'),
@@ -335,7 +328,7 @@ class Command(BaseCommand):
('Busch Gardens', ['OPERATOR'], 1959, 'Tampa, FL, USA'), ('Busch Gardens', ['OPERATOR'], 1959, 'Tampa, FL, USA'),
('Knott\'s Berry Farm', ['OPERATOR'], 1920, 'Buena Park, CA, USA'), ('Knott\'s Berry Farm', ['OPERATOR'], 1920, 'Buena Park, CA, USA'),
] ]
# Major ride manufacturers # Major ride manufacturers
manufacturers_data = [ manufacturers_data = [
('Bolliger & Mabillard', ['MANUFACTURER'], 1988, 'Monthey, Switzerland'), ('Bolliger & Mabillard', ['MANUFACTURER'], 1988, 'Monthey, Switzerland'),
@@ -347,16 +340,16 @@ class Command(BaseCommand):
('Premier Rides', ['MANUFACTURER'], 1994, 'Baltimore, MD, USA'), ('Premier Rides', ['MANUFACTURER'], 1994, 'Baltimore, MD, USA'),
('S&S Worldwide', ['MANUFACTURER'], 1994, 'Logan, UT, USA'), ('S&S Worldwide', ['MANUFACTURER'], 1994, 'Logan, UT, USA'),
] ]
# Ride designers # Ride designers
designers_data = [ designers_data = [
('Werner Stengel', ['DESIGNER'], 1965, 'Munich, Germany'), ('Werner Stengel', ['DESIGNER'], 1965, 'Munich, Germany'),
('Alan Schilke', ['DESIGNER'], 1990, 'Hayden, ID, USA'), ('Alan Schilke', ['DESIGNER'], 1990, 'Hayden, ID, USA'),
('John Wardley', ['DESIGNER'], 1970, 'London, UK'), ('John Wardley', ['DESIGNER'], 1970, 'London, UK'),
] ]
all_company_data = operators_data + manufacturers_data + designers_data all_company_data = operators_data + manufacturers_data + designers_data
for name, roles, founded_year, location in all_company_data: for name, roles, founded_year, location in all_company_data:
# Determine which Company model to use based on roles # Determine which Company model to use based on roles
if 'OPERATOR' in roles or 'PROPERTY_OWNER' in roles: if 'OPERATOR' in roles or 'PROPERTY_OWNER' in roles:
@@ -387,7 +380,7 @@ class Command(BaseCommand):
'coasters_count': random.randint(5, 100) if 'MANUFACTURER' in roles else 0, 'coasters_count': random.randint(5, 100) if 'MANUFACTURER' in roles else 0,
} }
) )
# Create headquarters if company was created and is a ParkCompany # Create headquarters if company was created and is a ParkCompany
if created and isinstance(company, ParkCompany): if created and isinstance(company, ParkCompany):
city, state_country = location.rsplit(', ', 1) city, state_country = location.rsplit(', ', 1)
@@ -397,7 +390,7 @@ class Command(BaseCommand):
else: else:
state = '' state = ''
country = state_country country = state_country
CompanyHeadquarters.objects.get_or_create( CompanyHeadquarters.objects.get_or_create(
company=company, company=company,
defaults={ defaults={
@@ -408,16 +401,16 @@ class Command(BaseCommand):
'postal_code': f"{random.randint(10000, 99999)}" if country == 'USA' else '', 'postal_code': f"{random.randint(10000, 99999)}" if country == 'USA' else '',
} }
) )
companies.append(company) companies.append(company)
# Create additional random companies to reach the target count # Create additional random companies to reach the target count
company_types = ['Theme Parks', 'Amusements', 'Entertainment', 'Rides', 'Design', 'Engineering'] company_types = ['Theme Parks', 'Amusements', 'Entertainment', 'Rides', 'Design', 'Engineering']
for i in range(len(all_company_data), count): for _i in range(len(all_company_data), count):
company_type = random.choice(company_types) company_type = random.choice(company_types)
name = f"{random.choice(['Global', 'International', 'Premier', 'Elite', 'Advanced', 'Creative'])} {company_type} {'Group' if random.random() < 0.5 else 'Corporation'}" name = f"{random.choice(['Global', 'International', 'Premier', 'Elite', 'Advanced', 'Creative'])} {company_type} {'Group' if random.random() < 0.5 else 'Corporation'}"
roles = [] roles = []
if 'Theme Parks' in name or 'Amusements' in name: if 'Theme Parks' in name or 'Amusements' in name:
roles = ['OPERATOR'] roles = ['OPERATOR']
@@ -429,7 +422,7 @@ class Command(BaseCommand):
roles = ['DESIGNER'] roles = ['DESIGNER']
else: else:
roles = [random.choice(['OPERATOR', 'MANUFACTURER', 'DESIGNER'])] roles = [random.choice(['OPERATOR', 'MANUFACTURER', 'DESIGNER'])]
# Use appropriate company model based on roles # Use appropriate company model based on roles
if 'OPERATOR' in roles or 'PROPERTY_OWNER' in roles: if 'OPERATOR' in roles or 'PROPERTY_OWNER' in roles:
company = ParkCompany.objects.create( company = ParkCompany.objects.create(
@@ -453,12 +446,12 @@ class Command(BaseCommand):
rides_count=random.randint(5, 100) if 'MANUFACTURER' in roles else 0, rides_count=random.randint(5, 100) if 'MANUFACTURER' in roles else 0,
coasters_count=random.randint(2, 50) if 'MANUFACTURER' in roles else 0, coasters_count=random.randint(2, 50) if 'MANUFACTURER' in roles else 0,
) )
# Create headquarters # Create headquarters
cities = ['Los Angeles', 'New York', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose'] cities = ['Los Angeles', 'New York', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose']
states = ['CA', 'NY', 'IL', 'TX', 'AZ', 'PA', 'TX', 'CA', 'TX', 'CA'] states = ['CA', 'NY', 'IL', 'TX', 'AZ', 'PA', 'TX', 'CA', 'TX', 'CA']
city_state = random.choice(list(zip(cities, states))) city_state = random.choice(list(zip(cities, states, strict=False)))
CompanyHeadquarters.objects.create( CompanyHeadquarters.objects.create(
company=company, company=company,
city=city_state[0], city=city_state[0],
@@ -467,23 +460,23 @@ class Command(BaseCommand):
street_address=f"{random.randint(100, 9999)} {random.choice(['Business', 'Corporate', 'Industry', 'Commerce'])} {random.choice(['Pkwy', 'Blvd', 'Dr', 'Way'])}", street_address=f"{random.randint(100, 9999)} {random.choice(['Business', 'Corporate', 'Industry', 'Commerce'])} {random.choice(['Pkwy', 'Blvd', 'Dr', 'Way'])}",
postal_code=f"{random.randint(10000, 99999)}", postal_code=f"{random.randint(10000, 99999)}",
) )
companies.append(company) companies.append(company)
self.stdout.write(f' ✅ Created {len(companies)} companies') self.stdout.write(f' ✅ Created {len(companies)} companies')
return companies return companies
def create_ride_models(self, count: int, companies: List) -> List[RideModel]: def create_ride_models(self, count: int, companies: list) -> list[RideModel]:
"""Create ride models from manufacturers""" """Create ride models from manufacturers"""
self.stdout.write(f'🎢 Creating {count} ride models...') self.stdout.write(f'🎢 Creating {count} ride models...')
manufacturers = [c for c in companies if 'MANUFACTURER' in c.roles] manufacturers = [c for c in companies if 'MANUFACTURER' in c.roles]
if not manufacturers: if not manufacturers:
self.stdout.write(' ⚠️ No manufacturers found, skipping ride models') self.stdout.write(' ⚠️ No manufacturers found, skipping ride models')
return [] return []
ride_models = [] ride_models = []
# Famous ride models # Famous ride models
famous_models = [ famous_models = [
('Dive Coaster', 'RC', 'Bolliger & Mabillard', 'Vertical drop roller coaster with holding brake'), ('Dive Coaster', 'RC', 'Bolliger & Mabillard', 'Vertical drop roller coaster with holding brake'),
@@ -507,12 +500,12 @@ class Command(BaseCommand):
('Drop Tower', 'FR', 'Intamin', 'Vertical drop ride'), ('Drop Tower', 'FR', 'Intamin', 'Vertical drop ride'),
('Gyro Drop', 'FR', 'Intamin', 'Tilting drop tower'), ('Gyro Drop', 'FR', 'Intamin', 'Tilting drop tower'),
] ]
for model_name, category, manufacturer_name, description in famous_models: for model_name, category, manufacturer_name, description in famous_models:
manufacturer = next((c for c in manufacturers if manufacturer_name in c.name), None) manufacturer = next((c for c in manufacturers if manufacturer_name in c.name), None)
if not manufacturer: if not manufacturer:
manufacturer = random.choice(manufacturers) manufacturer = random.choice(manufacturers)
ride_model, created = RideModel.objects.get_or_create( ride_model, created = RideModel.objects.get_or_create(
name=model_name, name=model_name,
manufacturer=manufacturer, manufacturer=manufacturer,
@@ -536,7 +529,7 @@ class Command(BaseCommand):
'total_installations': random.randint(1, 50), 'total_installations': random.randint(1, 50),
} }
) )
# Create technical specs if model exists # Create technical specs if model exists
if category == 'RC' and RideModelTechnicalSpec: if category == 'RC' and RideModelTechnicalSpec:
specs = [ specs = [
@@ -545,7 +538,7 @@ class Command(BaseCommand):
('CAPACITY', 'Riders per Train', f"{random.randint(20, 32)}", 'people'), ('CAPACITY', 'Riders per Train', f"{random.randint(20, 32)}", 'people'),
('SAFETY', 'Block Zones', f"{random.randint(4, 8)}", 'zones'), ('SAFETY', 'Block Zones', f"{random.randint(4, 8)}", 'zones'),
] ]
for spec_category, spec_name, spec_value, spec_unit in specs: for spec_category, spec_name, spec_value, spec_unit in specs:
RideModelTechnicalSpec.objects.create( RideModelTechnicalSpec.objects.create(
ride_model=ride_model, ride_model=ride_model,
@@ -554,31 +547,31 @@ class Command(BaseCommand):
spec_value=spec_value, spec_value=spec_value,
spec_unit=spec_unit, spec_unit=spec_unit,
) )
# Create variants for some models if model exists # Create variants for some models if model exists
if random.random() < 0.3 and RideModelVariant: if random.random() < 0.3 and RideModelVariant:
variant_names = ['Compact', 'Extended', 'Family', 'Extreme', 'Custom'] variant_names = ['Compact', 'Extended', 'Family', 'Extreme', 'Custom']
variant_name = random.choice(variant_names) variant_name = random.choice(variant_names)
RideModelVariant.objects.create( RideModelVariant.objects.create(
ride_model=ride_model, ride_model=ride_model,
name=f"{variant_name} Version", name=f"{variant_name} Version",
description=f"Modified version of {model_name} for {variant_name.lower()} installations", description=f"Modified version of {model_name} for {variant_name.lower()} installations",
distinguishing_features=f"Optimized for {variant_name.lower()} market segment", distinguishing_features=f"Optimized for {variant_name.lower()} market segment",
) )
ride_models.append(ride_model) ride_models.append(ride_model)
# Create additional random models # Create additional random models
model_types = ['Coaster', 'Ride', 'System', 'Experience', 'Adventure'] model_types = ['Coaster', 'Ride', 'System', 'Experience', 'Adventure']
prefixes = ['Mega', 'Super', 'Ultra', 'Hyper', 'Giga', 'Extreme', 'Family', 'Junior'] prefixes = ['Mega', 'Super', 'Ultra', 'Hyper', 'Giga', 'Extreme', 'Family', 'Junior']
for i in range(len(famous_models), count): for _i in range(len(famous_models), count):
manufacturer = random.choice(manufacturers) manufacturer = random.choice(manufacturers)
category = random.choice(['RC', 'DR', 'FR', 'WR', 'TR']) category = random.choice(['RC', 'DR', 'FR', 'WR', 'TR'])
model_name = f"{random.choice(prefixes)} {random.choice(model_types)}" model_name = f"{random.choice(prefixes)} {random.choice(model_types)}"
ride_model = RideModel.objects.create( ride_model = RideModel.objects.create(
name=model_name, name=model_name,
manufacturer=manufacturer, manufacturer=manufacturer,
@@ -606,31 +599,31 @@ class Command(BaseCommand):
]), ]),
total_installations=random.randint(0, 25), total_installations=random.randint(0, 25),
) )
ride_models.append(ride_model) ride_models.append(ride_model)
self.stdout.write(f' ✅ Created {len(ride_models)} ride models') self.stdout.write(f' ✅ Created {len(ride_models)} ride models')
return ride_models return ride_models
def create_parks(self, count: int, companies: List) -> List[Park]: def create_parks(self, count: int, companies: list) -> list[Park]:
"""Create parks with locations and areas""" """Create parks with locations and areas"""
self.stdout.write(f'🏰 Creating {count} parks...') self.stdout.write(f'🏰 Creating {count} parks...')
if count == 0: if count == 0:
self.stdout.write(' Skipping park creation (count = 0)') self.stdout.write(' Skipping park creation (count = 0)')
return [] return []
operators = [c for c in companies if 'OPERATOR' in c.roles] operators = [c for c in companies if 'OPERATOR' in c.roles]
property_owners = [c for c in companies if 'PROPERTY_OWNER' in c.roles] property_owners = [c for c in companies if 'PROPERTY_OWNER' in c.roles]
if not operators: if not operators:
raise CommandError('No operators found. Create companies first.') raise CommandError('No operators found. Create companies first.')
parks = [] parks = []
# Famous theme parks with timezone information # Famous theme parks with timezone information
famous_parks = [ famous_parks = [
('Magic Kingdom', 'Walt Disney World\'s flagship theme park', 'THEME_PARK', 'OPERATING', ('Magic Kingdom', 'Walt Disney World\'s flagship theme park', 'THEME_PARK', 'OPERATING',
date(1971, 10, 1), 107, 'Orlando', 'FL', 'USA', 28.4177, -81.5812, 'America/New_York'), date(1971, 10, 1), 107, 'Orlando', 'FL', 'USA', 28.4177, -81.5812, 'America/New_York'),
('Disneyland', 'The original Disney theme park', 'THEME_PARK', 'OPERATING', ('Disneyland', 'The original Disney theme park', 'THEME_PARK', 'OPERATING',
date(1955, 7, 17), 85, 'Anaheim', 'CA', 'USA', 33.8121, -117.9190, 'America/Los_Angeles'), date(1955, 7, 17), 85, 'Anaheim', 'CA', 'USA', 33.8121, -117.9190, 'America/Los_Angeles'),
@@ -647,7 +640,7 @@ class Command(BaseCommand):
('SeaWorld Orlando', 'Marine life theme park', 'THEME_PARK', 'OPERATING', ('SeaWorld Orlando', 'Marine life theme park', 'THEME_PARK', 'OPERATING',
date(1973, 12, 15), 200, 'Orlando', 'FL', 'USA', 28.4110, -81.4610, 'America/New_York'), date(1973, 12, 15), 200, 'Orlando', 'FL', 'USA', 28.4110, -81.4610, 'America/New_York'),
] ]
for park_name, description, park_type, status, opening_date, size_acres, city, state, country, lat, lng, timezone_str in famous_parks: for park_name, description, park_type, status, opening_date, size_acres, city, state, country, lat, lng, timezone_str in famous_parks:
# Find appropriate operator # Find appropriate operator
operator = None operator = None
@@ -665,15 +658,15 @@ class Command(BaseCommand):
operator = next((c for c in operators if 'Busch' in c.name), None) operator = next((c for c in operators if 'Busch' in c.name), None)
elif 'SeaWorld' in park_name: elif 'SeaWorld' in park_name:
operator = next((c for c in operators if 'SeaWorld' in c.name), None) operator = next((c for c in operators if 'SeaWorld' in c.name), None)
if not operator: if not operator:
operator = random.choice(operators) operator = random.choice(operators)
# Find property owner (could be same as operator) # Find property owner (could be same as operator)
property_owner = None property_owner = None
if property_owners and random.random() < 0.7: if property_owners and random.random() < 0.7:
property_owner = random.choice(property_owners) property_owner = random.choice(property_owners)
# Use get_or_create to avoid duplicates # Use get_or_create to avoid duplicates
park, created = Park.objects.get_or_create( park, created = Park.objects.get_or_create(
name=park_name, name=park_name,
@@ -693,14 +686,14 @@ class Command(BaseCommand):
) )
if not created: if not created:
self.stdout.write(f' Using existing park: {park_name}') self.stdout.write(f' Using existing park: {park_name}')
# Create park location only if it doesn't exist # Create park location only if it doesn't exist
location_exists = False location_exists = False
try: try:
location_exists = hasattr(park, 'location') and park.location is not None location_exists = hasattr(park, 'location') and park.location is not None
except Exception: except Exception:
location_exists = False location_exists = False
if created or not location_exists: if created or not location_exists:
ParkLocation.objects.get_or_create( ParkLocation.objects.get_or_create(
park=park, park=park,
@@ -713,7 +706,7 @@ class Command(BaseCommand):
'postal_code': f"{random.randint(10000, 99999)}" if country == 'USA' else '', 'postal_code': f"{random.randint(10000, 99999)}" if country == 'USA' else '',
} }
) )
# Create park areas only if park was created # Create park areas only if park was created
if created: if created:
area_names = ['Main Street', 'Fantasyland', 'Tomorrowland', 'Adventureland', 'Frontierland'] area_names = ['Main Street', 'Fantasyland', 'Tomorrowland', 'Adventureland', 'Frontierland']
@@ -725,9 +718,9 @@ class Command(BaseCommand):
'description': f"Themed area within {park_name}", 'description': f"Themed area within {park_name}",
} }
) )
parks.append(park) parks.append(park)
# Create additional random parks # Create additional random parks
park_types = ['THEME_PARK', 'AMUSEMENT_PARK', 'WATER_PARK', 'FAMILY_ENTERTAINMENT_CENTER'] park_types = ['THEME_PARK', 'AMUSEMENT_PARK', 'WATER_PARK', 'FAMILY_ENTERTAINMENT_CENTER']
cities_data = [ cities_data = [
@@ -740,28 +733,28 @@ class Command(BaseCommand):
('San Antonio', 'TX', 'USA', 29.4241, -98.4936), ('San Antonio', 'TX', 'USA', 29.4241, -98.4936),
('San Diego', 'CA', 'USA', 32.7157, -117.1611), ('San Diego', 'CA', 'USA', 32.7157, -117.1611),
] ]
for i in range(len(famous_parks), count): for i in range(len(famous_parks), count):
park_type = random.choice(park_types) park_type = random.choice(park_types)
# Make park names more unique by adding a number # Make park names more unique by adding a number
park_name = f"{random.choice(['Adventure', 'Magic', 'Wonder', 'Fantasy', 'Thrill', 'Family'])} {random.choice(['World', 'Land', 'Park', 'Kingdom', 'Gardens'])} {i + 1}" park_name = f"{random.choice(['Adventure', 'Magic', 'Wonder', 'Fantasy', 'Thrill', 'Family'])} {random.choice(['World', 'Land', 'Park', 'Kingdom', 'Gardens'])} {i + 1}"
operator = random.choice(operators) operator = random.choice(operators)
property_owner = random.choice(property_owners) if property_owners and random.random() < 0.5 else None property_owner = random.choice(property_owners) if property_owners and random.random() < 0.5 else None
city, state, country, lat, lng = random.choice(cities_data) city, state, country, lat, lng = random.choice(cities_data)
# Determine timezone based on state # Determine timezone based on state
timezone_map = { timezone_map = {
'CA': 'America/Los_Angeles', 'CA': 'America/Los_Angeles',
'NY': 'America/New_York', 'NY': 'America/New_York',
'IL': 'America/Chicago', 'IL': 'America/Chicago',
'TX': 'America/Chicago', 'TX': 'America/Chicago',
'AZ': 'America/Phoenix', 'AZ': 'America/Phoenix',
'PA': 'America/New_York', 'PA': 'America/New_York',
} }
park_timezone = timezone_map.get(state, 'America/New_York') park_timezone = timezone_map.get(state, 'America/New_York')
park = Park.objects.create( park = Park.objects.create(
name=park_name, name=park_name,
description=f"Exciting {park_type.lower().replace('_', ' ')} featuring thrilling rides and family entertainment", description=f"Exciting {park_type.lower().replace('_', ' ')} featuring thrilling rides and family entertainment",
@@ -776,11 +769,11 @@ class Command(BaseCommand):
coaster_count=random.randint(2, 15), coaster_count=random.randint(2, 15),
timezone=park_timezone, timezone=park_timezone,
) )
# Create park location with slight coordinate variation # Create park location with slight coordinate variation
lat_offset = random.uniform(-0.1, 0.1) lat_offset = random.uniform(-0.1, 0.1)
lng_offset = random.uniform(-0.1, 0.1) lng_offset = random.uniform(-0.1, 0.1)
ParkLocation.objects.create( ParkLocation.objects.create(
park=park, park=park,
point=Point(lng + lng_offset, lat + lat_offset), point=Point(lng + lng_offset, lat + lat_offset),
@@ -790,7 +783,7 @@ class Command(BaseCommand):
country=country, country=country,
postal_code=f"{random.randint(10000, 99999)}", postal_code=f"{random.randint(10000, 99999)}",
) )
# Create park areas # Create park areas
area_names = ['Main Plaza', 'Adventure Zone', 'Family Area', 'Thrill Section', 'Water World', 'Kids Corner'] area_names = ['Main Plaza', 'Adventure Zone', 'Family Area', 'Thrill Section', 'Water World', 'Kids Corner']
for area_name in random.sample(area_names, random.randint(2, 4)): for area_name in random.sample(area_names, random.randint(2, 4)):
@@ -799,25 +792,25 @@ class Command(BaseCommand):
name=area_name, name=area_name,
description=f"Themed area within {park_name}", description=f"Themed area within {park_name}",
) )
parks.append(park) parks.append(park)
self.stdout.write(f' ✅ Created {len(parks)} parks') self.stdout.write(f' ✅ Created {len(parks)} parks')
return parks return parks
def create_rides(self, count: int, parks: List[Park], companies: List, ride_models: List[RideModel]) -> List[Ride]: def create_rides(self, count: int, parks: list[Park], companies: list, ride_models: list[RideModel]) -> list[Ride]:
"""Create rides with comprehensive details""" """Create rides with comprehensive details"""
self.stdout.write(f'🎠 Creating {count} rides...') self.stdout.write(f'🎠 Creating {count} rides...')
if not parks: if not parks:
self.stdout.write(' ⚠️ No parks found, skipping rides') self.stdout.write(' ⚠️ No parks found, skipping rides')
return [] return []
manufacturers = [c for c in companies if 'MANUFACTURER' in c.roles] manufacturers = [c for c in companies if 'MANUFACTURER' in c.roles]
designers = [c for c in companies if 'DESIGNER' in c.roles] designers = [c for c in companies if 'DESIGNER' in c.roles]
rides = [] rides = []
# Famous roller coasters # Famous roller coasters
famous_coasters = [ famous_coasters = [
('Steel Vengeance', 'RC', 'Hybrid steel-wood roller coaster', 'Rocky Mountain Construction'), ('Steel Vengeance', 'RC', 'Hybrid steel-wood roller coaster', 'Rocky Mountain Construction'),
@@ -831,7 +824,7 @@ class Command(BaseCommand):
('Twisted Timbers', 'RC', 'RMC conversion of wooden coaster', 'Rocky Mountain Construction'), ('Twisted Timbers', 'RC', 'RMC conversion of wooden coaster', 'Rocky Mountain Construction'),
('Goliath', 'RC', 'Hyper coaster with massive drops', 'Bolliger & Mabillard'), ('Goliath', 'RC', 'Hyper coaster with massive drops', 'Bolliger & Mabillard'),
] ]
# Create famous coasters # Create famous coasters
for coaster_name, category, description, manufacturer_name in famous_coasters: for coaster_name, category, description, manufacturer_name in famous_coasters:
park = random.choice(parks) park = random.choice(parks)
@@ -840,14 +833,14 @@ class Command(BaseCommand):
manufacturer = next((c for c in manufacturers if manufacturer_name in c.name), None) manufacturer = next((c for c in manufacturers if manufacturer_name in c.name), None)
if not manufacturer and manufacturers: if not manufacturer and manufacturers:
manufacturer = random.choice(manufacturers) manufacturer = random.choice(manufacturers)
designer = random.choice(designers) if designers and random.random() < 0.3 else None designer = random.choice(designers) if designers and random.random() < 0.3 else None
ride_model = random.choice(ride_models) if ride_models and random.random() < 0.5 else None ride_model = random.choice(ride_models) if ride_models and random.random() < 0.5 else None
# Get park areas for this park # Get park areas for this park
park_areas = list(park.areas.all()) park_areas = list(park.areas.all())
park_area = random.choice(park_areas) if park_areas else None park_area = random.choice(park_areas) if park_areas else None
ride = Ride.objects.create( ride = Ride.objects.create(
name=coaster_name, name=coaster_name,
description=description, description=description,
@@ -864,7 +857,7 @@ class Command(BaseCommand):
ride_duration_seconds=random.randint(90, 240), ride_duration_seconds=random.randint(90, 240),
average_rating=Decimal(str(random.uniform(7.0, 9.5))), average_rating=Decimal(str(random.uniform(7.0, 9.5))),
) )
# Create roller coaster stats # Create roller coaster stats
if category == 'RC': if category == 'RC':
RollerCoasterStats.objects.create( RollerCoasterStats.objects.create(
@@ -884,9 +877,9 @@ class Command(BaseCommand):
cars_per_train=random.randint(6, 8), cars_per_train=random.randint(6, 8),
seats_per_car=random.randint(2, 4), seats_per_car=random.randint(2, 4),
) )
rides.append(ride) rides.append(ride)
# Create additional random rides # Create additional random rides
ride_names = [ ride_names = [
'Thunder Mountain', 'Space Coaster', 'Wild Eagle', 'Dragon Fire', 'Phoenix Rising', 'Thunder Mountain', 'Space Coaster', 'Wild Eagle', 'Dragon Fire', 'Phoenix Rising',
@@ -894,21 +887,21 @@ class Command(BaseCommand):
'Viper', 'Cobra', 'Rattlesnake', 'Sidewinder', 'Diamondback', 'Copperhead', 'Viper', 'Cobra', 'Rattlesnake', 'Sidewinder', 'Diamondback', 'Copperhead',
'Banshee', 'Valkyrie', 'Griffon', 'Falcon', 'Eagle\'s Flight', 'Soaring Heights' 'Banshee', 'Valkyrie', 'Griffon', 'Falcon', 'Eagle\'s Flight', 'Soaring Heights'
] ]
categories = ['RC', 'DR', 'FR', 'WR', 'TR', 'OT'] categories = ['RC', 'DR', 'FR', 'WR', 'TR', 'OT']
for i in range(len(famous_coasters), count): for _i in range(len(famous_coasters), count):
park = random.choice(parks) park = random.choice(parks)
park_areas = list(park.areas.all()) park_areas = list(park.areas.all())
park_area = random.choice(park_areas) if park_areas else None park_area = random.choice(park_areas) if park_areas else None
ride_name = random.choice(ride_names) ride_name = random.choice(ride_names)
category = random.choice(categories) category = random.choice(categories)
manufacturer = random.choice(manufacturers) if manufacturers and random.random() < 0.7 else None manufacturer = random.choice(manufacturers) if manufacturers and random.random() < 0.7 else None
designer = random.choice(designers) if designers and random.random() < 0.2 else None designer = random.choice(designers) if designers and random.random() < 0.2 else None
ride_model = random.choice(ride_models) if ride_models and random.random() < 0.4 else None ride_model = random.choice(ride_models) if ride_models and random.random() < 0.4 else None
ride = Ride.objects.create( ride = Ride.objects.create(
name=ride_name, name=ride_name,
description=f"Exciting {category} ride with thrilling elements and smooth operation", description=f"Exciting {category} ride with thrilling elements and smooth operation",
@@ -925,7 +918,7 @@ class Command(BaseCommand):
ride_duration_seconds=random.randint(60, 300), ride_duration_seconds=random.randint(60, 300),
average_rating=Decimal(str(random.uniform(6.0, 9.0))), average_rating=Decimal(str(random.uniform(6.0, 9.0))),
) )
# Create roller coaster stats for RC category # Create roller coaster stats for RC category
if category == 'RC': if category == 'RC':
RollerCoasterStats.objects.create( RollerCoasterStats.objects.create(
@@ -945,20 +938,20 @@ class Command(BaseCommand):
cars_per_train=random.randint(4, 8), cars_per_train=random.randint(4, 8),
seats_per_car=random.randint(2, 4), seats_per_car=random.randint(2, 4),
) )
rides.append(ride) rides.append(ride)
self.stdout.write(f' ✅ Created {len(rides)} rides') self.stdout.write(f' ✅ Created {len(rides)} rides')
return rides return rides
def create_reviews(self, count: int, users: List[User], parks: List[Park], rides: List[Ride]) -> None: def create_reviews(self, count: int, users: list[User], parks: list[Park], rides: list[Ride]) -> None:
"""Create park and ride reviews""" """Create park and ride reviews"""
self.stdout.write(f'📝 Creating {count} reviews...') self.stdout.write(f'📝 Creating {count} reviews...')
if not users or (not parks and not rides): if not users or (not parks and not rides):
self.stdout.write(' ⚠️ No users or content found, skipping reviews') self.stdout.write(' ⚠️ No users or content found, skipping reviews')
return return
review_texts = [ review_texts = [
"Amazing experience! The rides were thrilling and the staff was very friendly.", "Amazing experience! The rides were thrilling and the staff was very friendly.",
"Great park with excellent theming. The roller coasters are world-class.", "Great park with excellent theming. The roller coasters are world-class.",
@@ -971,21 +964,21 @@ class Command(BaseCommand):
"Family-friendly atmosphere with rides for all ages.", "Family-friendly atmosphere with rides for all ages.",
"Outstanding park operations and friendly staff throughout.", "Outstanding park operations and friendly staff throughout.",
] ]
# Create park reviews # Create park reviews
park_review_count = count // 2 park_review_count = count // 2
created_park_reviews = 0 created_park_reviews = 0
attempts = 0 attempts = 0
max_attempts = park_review_count * 3 # Allow multiple attempts to avoid infinite loops max_attempts = park_review_count * 3 # Allow multiple attempts to avoid infinite loops
while created_park_reviews < park_review_count and attempts < max_attempts: while created_park_reviews < park_review_count and attempts < max_attempts:
if not parks: if not parks:
break break
user = random.choice(users) user = random.choice(users)
park = random.choice(parks) park = random.choice(parks)
attempts += 1 attempts += 1
# Use get_or_create to avoid duplicates # Use get_or_create to avoid duplicates
review, created = ParkReview.objects.get_or_create( review, created = ParkReview.objects.get_or_create(
user=user, user=user,
@@ -1002,24 +995,24 @@ class Command(BaseCommand):
), ),
} }
) )
if created: if created:
created_park_reviews += 1 created_park_reviews += 1
# Create ride reviews # Create ride reviews
ride_review_count = count - created_park_reviews ride_review_count = count - created_park_reviews
created_ride_reviews = 0 created_ride_reviews = 0
attempts = 0 attempts = 0
max_attempts = ride_review_count * 3 # Allow multiple attempts to avoid infinite loops max_attempts = ride_review_count * 3 # Allow multiple attempts to avoid infinite loops
while created_ride_reviews < ride_review_count and attempts < max_attempts: while created_ride_reviews < ride_review_count and attempts < max_attempts:
if not rides: if not rides:
break break
user = random.choice(users) user = random.choice(users)
ride = random.choice(rides) ride = random.choice(rides)
attempts += 1 attempts += 1
# Use get_or_create to avoid duplicates # Use get_or_create to avoid duplicates
review, created = RideReview.objects.get_or_create( review, created = RideReview.objects.get_or_create(
user=user, user=user,
@@ -1036,36 +1029,36 @@ class Command(BaseCommand):
), ),
} }
) )
if created: if created:
created_ride_reviews += 1 created_ride_reviews += 1
self.stdout.write(f' ✅ Created {count} reviews') self.stdout.write(f' ✅ Created {count} reviews')
def create_notifications(self, users: List[User]) -> None: def create_notifications(self, users: list[User]) -> None:
"""Create sample notifications for users""" """Create sample notifications for users"""
self.stdout.write('🔔 Creating notifications...') self.stdout.write('🔔 Creating notifications...')
if not users: if not users:
self.stdout.write(' ⚠️ No users found, skipping notifications') self.stdout.write(' ⚠️ No users found, skipping notifications')
return return
notification_count = 0 notification_count = 0
notification_types = [ notification_types = [
("submission_approved", "Your park submission has been approved!", "Great news! Your submission for Adventure Park has been approved and is now live."), ("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."), ("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."), ("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."), ("achievement_unlocked", "Achievement unlocked!", "Congratulations! You've unlocked the 'Coaster Enthusiast' achievement."),
] ]
# Create notifications for random users # Create notifications for random users
for user in random.sample(users, min(len(users), 15)): for user in random.sample(users, min(len(users), 15)):
for _ in range(random.randint(1, 3)): for _ in range(random.randint(1, 3)):
notification_type, title, message = random.choice(notification_types) notification_type, title, message = random.choice(notification_types)
UserNotification.objects.create( UserNotification.objects.create(
user=user, user=user,
notification_type=notification_type, notification_type=notification_type,
@@ -1077,50 +1070,50 @@ class Command(BaseCommand):
push_sent=random.choice([True, False]), push_sent=random.choice([True, False]),
) )
notification_count += 1 notification_count += 1
self.stdout.write(f' ✅ Created {notification_count} notifications') self.stdout.write(f' ✅ Created {notification_count} notifications')
def create_moderation_data(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None: def create_moderation_data(self, users: list[User], parks: list[Park], rides: list[Ride]) -> None:
"""Create moderation queue and actions""" """Create moderation queue and actions"""
self.stdout.write('🛡️ Creating moderation data...') self.stdout.write('🛡️ Creating moderation data...')
if not ModerationQueue or not ModerationAction: if not ModerationQueue or not ModerationAction:
self.stdout.write(' ⚠️ Moderation models not available, skipping') self.stdout.write(' ⚠️ Moderation models not available, skipping')
return return
if not users or (not parks and not rides): if not users or (not parks and not rides):
self.stdout.write(' ⚠️ No users or content found, skipping moderation data') self.stdout.write(' ⚠️ No users or content found, skipping moderation data')
return return
# This would create sample moderation queue items and actions # This would create sample moderation queue items and actions
# Implementation depends on the actual moderation models structure # Implementation depends on the actual moderation models structure
self.stdout.write(' ✅ Moderation data creation skipped (models not fully defined)') self.stdout.write(' ✅ Moderation data creation skipped (models not fully defined)')
def create_photos(self, parks: List[Park], rides: List[Ride], ride_models: List[RideModel]) -> None: def create_photos(self, parks: list[Park], rides: list[Ride], ride_models: list[RideModel]) -> None:
"""Create sample photo records""" """Create sample photo records"""
self.stdout.write('📸 Creating photo records...') self.stdout.write('📸 Creating photo records...')
if not CloudflareImage: if not CloudflareImage:
self.stdout.write(' ⚠️ CloudflareImage model not available, skipping photo creation') self.stdout.write(' ⚠️ CloudflareImage model not available, skipping photo creation')
return return
# Since we don't have actual Cloudflare images, we'll skip photo creation # Since we don't have actual Cloudflare images, we'll skip photo creation
# In a real scenario, you would need actual CloudflareImage instances # In a real scenario, you would need actual CloudflareImage instances
self.stdout.write(' ⚠️ Photo creation skipped (requires actual CloudflareImage instances)') self.stdout.write(' ⚠️ Photo creation skipped (requires actual CloudflareImage instances)')
self.stdout.write(' To create photos, you need to upload actual images to Cloudflare first') self.stdout.write(' To create photos, you need to upload actual images to Cloudflare first')
def create_rankings(self, rides: List[Ride]) -> None: def create_rankings(self, rides: list[Ride]) -> None:
"""Create ride rankings if model exists""" """Create ride rankings if model exists"""
self.stdout.write('🏆 Creating ride rankings...') self.stdout.write('🏆 Creating ride rankings...')
if not RideRanking: if not RideRanking:
self.stdout.write(' ⚠️ RideRanking model not available, skipping') self.stdout.write(' ⚠️ RideRanking model not available, skipping')
return return
if not rides: if not rides:
self.stdout.write(' ⚠️ No rides found, skipping rankings') self.stdout.write(' ⚠️ No rides found, skipping rankings')
return return
# This would create sample ride rankings # This would create sample ride rankings
# Implementation depends on the actual RideRanking model structure # Implementation depends on the actual RideRanking model structure
self.stdout.write(' ✅ Ride rankings creation skipped (model structure not fully defined)') self.stdout.write(' ✅ Ride rankings creation skipped (model structure not fully defined)')
@@ -1129,7 +1122,7 @@ class Command(BaseCommand):
"""Print a summary of created data""" """Print a summary of created data"""
self.stdout.write('\n📊 Data Seeding Summary:') self.stdout.write('\n📊 Data Seeding Summary:')
self.stdout.write('=' * 50) self.stdout.write('=' * 50)
# Count all created objects # Count all created objects
counts = { counts = {
'Users': User.objects.count(), 'Users': User.objects.count(),
@@ -1145,9 +1138,9 @@ class Command(BaseCommand):
'Park Photos': ParkPhoto.objects.count(), 'Park Photos': ParkPhoto.objects.count(),
'Ride Photos': RidePhoto.objects.count(), 'Ride Photos': RidePhoto.objects.count(),
} }
for model_name, count in counts.items(): for model_name, count in counts.items():
self.stdout.write(f' {model_name}: {count}') self.stdout.write(f' {model_name}: {count}')
self.stdout.write('=' * 50) self.stdout.write('=' * 50)
self.stdout.write('🎉 Seeding completed! Your ThrillWiki database is ready for testing.') self.stdout.write('🎉 Seeding completed! Your ThrillWiki database is ready for testing.')

View File

@@ -1,4 +1,4 @@
from django.urls import path, include from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("v1/", include("apps.api.v1.urls")), path("v1/", include("apps.api.v1.urls")),

View File

@@ -1,5 +1,6 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from apps.accounts.models import UserProfile from apps.accounts.models import UserProfile
from apps.accounts.serializers import UserSerializer # existing shared user serializer from apps.accounts.serializers import UserSerializer # existing shared user serializer
@@ -24,7 +25,7 @@ class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.models import CloudflareImage
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id) image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
instance.avatar = image instance.avatar = image
return super().update(instance, validated_data) return super().update(instance, validated_data)

View File

@@ -2,8 +2,14 @@
URL configuration for user account management API endpoints. URL configuration for user account management API endpoints.
""" """
from django.urls import path from django.urls import include, path
from . import views from rest_framework.routers import DefaultRouter
from . import views, views_credits, views_magic_link
# Register ViewSets
router = DefaultRouter()
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
urlpatterns = [ urlpatterns = [
# Admin endpoints for user management # Admin endpoints for user management
@@ -108,19 +114,18 @@ urlpatterns = [
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"), path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"), path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"), path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
# Login history endpoint
path("login-history/", views.get_login_history, name="get_login_history"),
# Magic Link (Login by Code) endpoints
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
# Public Profile # Public Profile
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"), path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
]
# Register ViewSets # ViewSet routes
from rest_framework.routers import DefaultRouter
from . import views_credits
from django.urls import include
router = DefaultRouter()
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
urlpatterns += [
path("", include(router.urls)), path("", include(router.urls)),
] ]

View File

@@ -6,43 +6,44 @@ user deletion while preserving submissions, profile management, settings,
preferences, privacy, notifications, and security. preferences, privacy, notifications, and security.
""" """
from apps.api.v1.serializers.accounts import (
CompleteUserSerializer,
PublicUserSerializer,
UserPreferencesSerializer,
NotificationSettingsSerializer,
PrivacySettingsSerializer,
SecuritySettingsSerializer,
UserStatisticsSerializer,
UserListSerializer,
AccountUpdateSerializer,
ProfileUpdateSerializer,
ThemePreferenceSerializer,
UserNotificationSerializer,
NotificationPreferenceSerializer,
MarkNotificationsReadSerializer,
AvatarUploadSerializer,
)
from apps.accounts.services import UserDeletionService
from apps.accounts.export_service import UserExportService
from apps.accounts.models import (
User,
UserProfile,
UserNotification,
NotificationPreference,
)
from apps.lists.models import UserList
import logging import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
from django.utils import timezone from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.models import CloudflareImage
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from apps.accounts.export_service import UserExportService
from apps.accounts.models import (
NotificationPreference,
User,
UserNotification,
UserProfile,
)
from apps.accounts.services import UserDeletionService
from apps.api.v1.serializers.accounts import (
AccountUpdateSerializer,
AvatarUploadSerializer,
CompleteUserSerializer,
MarkNotificationsReadSerializer,
NotificationPreferenceSerializer,
NotificationSettingsSerializer,
PrivacySettingsSerializer,
ProfileUpdateSerializer,
PublicUserSerializer,
SecuritySettingsSerializer,
ThemePreferenceSerializer,
UserListSerializer,
UserNotificationSerializer,
UserPreferencesSerializer,
UserStatisticsSerializer,
)
from apps.lists.models import UserList
# Set up logging # Set up logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -307,7 +308,7 @@ def save_avatar_image(request):
try: try:
cloudflare_image = CloudflareImage.objects.get( cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id) cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare # Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded' cloudflare_image.status = 'uploaded'
cloudflare_image.uploaded_at = timezone.now() cloudflare_image.uploaded_at = timezone.now()
@@ -319,7 +320,7 @@ def save_avatar_image(request):
cloudflare_image.height = image_data.get('height') cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '') cloudflare_image.format = image_data.get('format', '')
cloudflare_image.save() cloudflare_image.save()
except CloudflareImage.DoesNotExist: except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response # Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create( cloudflare_image = CloudflareImage.objects.create(
@@ -367,7 +368,7 @@ def save_avatar_image(request):
except Exception as e: except Exception as e:
logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}") logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails # Continue with database deletion even if Cloudflare deletion fails
old_avatar.delete() old_avatar.delete()
# Debug logging to see what's happening with the CloudflareImage # Debug logging to see what's happening with the CloudflareImage
@@ -442,7 +443,7 @@ def delete_avatar(request):
avatar_to_delete = profile.avatar avatar_to_delete = profile.avatar
profile.avatar = None profile.avatar = None
profile.save() profile.save()
# Delete from Cloudflare first, then from database # Delete from Cloudflare first, then from database
try: try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService from django_cloudflareimages_toolkit.services import CloudflareImagesService
@@ -452,7 +453,7 @@ def delete_avatar(request):
except Exception as e: except Exception as e:
logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}") logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails # Continue with database deletion even if Cloudflare deletion fails
avatar_to_delete.delete() avatar_to_delete.delete()
# Get the default avatar URL # Get the default avatar URL
@@ -1273,10 +1274,10 @@ def update_security_settings(request):
# Handle security settings updates # Handle security settings updates
if "two_factor_enabled" in request.data: if "two_factor_enabled" in request.data:
setattr(user, "two_factor_enabled", request.data["two_factor_enabled"]) user.two_factor_enabled = request.data["two_factor_enabled"]
if "login_notifications" in request.data: if "login_notifications" in request.data:
setattr(user, "login_notifications", request.data["login_notifications"]) user.login_notifications = request.data["login_notifications"]
user.save() user.save()
@@ -1612,7 +1613,7 @@ def export_user_data(request):
except Exception as e: except Exception as e:
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True) logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
return Response( return Response(
{"error": "Failed to generate data export"}, {"error": "Failed to generate data export"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR status=status.HTTP_500_INTERNAL_SERVER_ERROR
) )
@@ -1636,54 +1637,73 @@ def get_public_user_profile(request, username):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
# === MISSING FUNCTION IMPLEMENTATIONS ===
@extend_schema( @extend_schema(
operation_id="request_account_deletion", operation_id="get_login_history",
summary="Request account deletion", summary="Get user login history",
description="Request deletion of the authenticated user's account.", description=(
"Returns the authenticated user's recent login history including "
"IP addresses, devices, and timestamps for security auditing."
),
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Maximum number of entries to return (default: 20, max: 100)",
),
],
responses={ responses={
200: {"description": "Deletion request created"}, 200: {
400: {"description": "Cannot delete account"}, "description": "Login history entries",
}, "example": {
tags=["Self-Service Account Management"], "results": [
) {
@api_view(["POST"]) "id": 1,
@permission_classes([IsAuthenticated]) "ip_address": "192.168.1.1",
def request_account_deletion(request): "user_agent": "Mozilla/5.0...",
"""Request account deletion.""" "login_method": "PASSWORD",
try: "login_method_display": "Password",
user = request.user "login_timestamp": "2024-12-27T10:30:00Z",
"country": "United States",
# Check if user can be deleted "city": "New York",
can_delete, reason = UserDeletionService.can_delete_user(user) }
if not can_delete: ],
return Response( "count": 1,
{"success": False, "error": reason},
status=status.HTTP_400_BAD_REQUEST,
)
# Create deletion request
deletion_request = UserDeletionService.create_deletion_request(user)
return Response(
{
"success": True,
"message": "Verification code sent to your email",
"expires_at": deletion_request.expires_at,
"email": user.email,
}, },
status=status.HTTP_200_OK, },
) 401: {"description": "Authentication required"},
},
tags=["User Security"],
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_login_history(request):
"""Get user login history for security auditing."""
from apps.accounts.login_history import LoginHistory
user = request.user
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get login history for user
entries = LoginHistory.objects.filter(user=user).order_by("-login_timestamp")[:limit]
# Serialize
results = []
for entry in entries:
results.append({
"id": entry.id,
"ip_address": entry.ip_address,
"user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents
"login_method": entry.login_method,
"login_method_display": dict(LoginHistory._meta.get_field('login_method').choices).get(entry.login_method, entry.login_method),
"login_timestamp": entry.login_timestamp.isoformat(),
"country": entry.country,
"city": entry.city,
"success": entry.success,
})
return Response({
"results": results,
"count": len(results),
})
except ValueError as e:
return Response(
{"success": False, "error": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
return Response(
{"success": False, "error": f"Error creating deletion request: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

View File

@@ -1,9 +1,14 @@
from rest_framework import viewsets, permissions, filters from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from apps.rides.models.credits import RideCredit
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
from apps.rides.models.credits import RideCredit
class RideCreditViewSet(viewsets.ModelViewSet): class RideCreditViewSet(viewsets.ModelViewSet):
""" """
@@ -14,8 +19,8 @@ class RideCreditViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly] permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug'] filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating'] ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order']
ordering = ['-last_ridden_at'] ordering = ['display_order', '-last_ridden_at']
def get_queryset(self): def get_queryset(self):
""" """
@@ -23,18 +28,77 @@ class RideCreditViewSet(viewsets.ModelViewSet):
Optionally filter by user via query param ?user=username Optionally filter by user via query param ?user=username
""" """
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user') queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
# Filter by user if provided # Filter by user if provided
username = self.request.query_params.get('user') username = self.request.query_params.get('user')
if username: if username:
queryset = queryset.filter(user__username=username) queryset = queryset.filter(user__username=username)
return queryset return queryset
def perform_create(self, serializer): def perform_create(self, serializer):
"""Associate the current user with the ride credit.""" """Associate the current user with the ride credit."""
serializer.save(user=self.request.user) serializer.save(user=self.request.user)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
@extend_schema(
summary="Reorder ride credits",
description="Bulk update the display order of ride credits. Send a list of {id, order} objects.",
request={
'application/json': {
'type': 'object',
'properties': {
'order': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'id': {'type': 'integer'},
'order': {'type': 'integer'}
},
'required': ['id', 'order']
}
}
}
}
}
)
def reorder(self, request):
"""
Bulk update display_order for multiple credits.
Expects: {"order": [{"id": 1, "order": 0}, {"id": 2, "order": 1}, ...]}
"""
order_data = request.data.get('order', [])
if not order_data:
return Response(
{'error': 'No order data provided'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate that all credits belong to the current user
credit_ids = [item['id'] for item in order_data]
user_credits = RideCredit.objects.filter(
id__in=credit_ids,
user=request.user
).values_list('id', flat=True)
if set(credit_ids) != set(user_credits):
return Response(
{'error': 'You can only reorder your own credits'},
status=status.HTTP_403_FORBIDDEN
)
# Bulk update in a transaction
with transaction.atomic():
for item in order_data:
RideCredit.objects.filter(
id=item['id'],
user=request.user
).update(display_order=item['order'])
return Response({'status': 'reordered', 'count': len(order_data)})
@extend_schema( @extend_schema(
summary="List ride credits", summary="List ride credits",
description="List ride credits. filter by user username.", description="List ride credits. filter by user username.",
@@ -49,3 +113,4 @@ class RideCreditViewSet(viewsets.ModelViewSet):
) )
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)

View File

@@ -0,0 +1,180 @@
"""
Magic Link (Login by Code) API views.
Provides API endpoints for passwordless login via email code.
Uses django-allauth's built-in login-by-code functionality.
"""
from django.conf import settings
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
try:
from allauth.account.internal.flows.login_by_code import perform_login_by_code, request_login_code
from allauth.account.models import EmailAddress
from allauth.account.utils import user_email # noqa: F401 - imported to verify availability
HAS_LOGIN_BY_CODE = True
except ImportError:
HAS_LOGIN_BY_CODE = False
@extend_schema(
summary="Request magic link login code",
description="Send a one-time login code to the user's email address.",
request={
'application/json': {
'type': 'object',
'properties': {
'email': {'type': 'string', 'format': 'email'}
},
'required': ['email']
}
},
responses={
200: {'description': 'Login code sent successfully'},
400: {'description': 'Invalid email or feature disabled'},
},
examples=[
OpenApiExample(
'Request login code',
value={'email': 'user@example.com'},
request_only=True
)
]
)
@api_view(['POST'])
@permission_classes([AllowAny])
def request_magic_link(request):
"""
Request a login code to be sent to the user's email.
This is the first step of the magic link flow:
1. User enters their email
2. If the email exists, a code is sent
3. User enters the code to complete login
"""
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
return Response(
{'error': 'Magic link login is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
if not HAS_LOGIN_BY_CODE:
return Response(
{'error': 'Login by code is not available in this version of allauth'},
status=status.HTTP_400_BAD_REQUEST
)
email = request.data.get('email', '').lower().strip()
if not email:
return Response(
{'error': 'Email is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if email exists (don't reveal if it doesn't for security)
try:
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
user = email_address.user
# Request the login code
request_login_code(request._request, user)
return Response({
'success': True,
'message': 'If an account exists with this email, a login code has been sent.',
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
})
except EmailAddress.DoesNotExist:
# Don't reveal that the email doesn't exist
return Response({
'success': True,
'message': 'If an account exists with this email, a login code has been sent.',
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
})
@extend_schema(
summary="Verify magic link code",
description="Verify the login code and complete the login process.",
request={
'application/json': {
'type': 'object',
'properties': {
'email': {'type': 'string', 'format': 'email'},
'code': {'type': 'string'}
},
'required': ['email', 'code']
}
},
responses={
200: {'description': 'Login successful'},
400: {'description': 'Invalid or expired code'},
}
)
@api_view(['POST'])
@permission_classes([AllowAny])
def verify_magic_link(request):
"""
Verify the login code and complete the login.
This is the second step of the magic link flow.
"""
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
return Response(
{'error': 'Magic link login is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
if not HAS_LOGIN_BY_CODE:
return Response(
{'error': 'Login by code is not available'},
status=status.HTTP_400_BAD_REQUEST
)
email = request.data.get('email', '').lower().strip()
code = request.data.get('code', '').strip()
if not email or not code:
return Response(
{'error': 'Email and code are required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
user = email_address.user
# Attempt to verify the code and log in
success = perform_login_by_code(request._request, user, code)
if success:
return Response({
'success': True,
'message': 'Login successful',
'user': {
'id': user.id,
'username': user.username,
'email': user.email
}
})
else:
return Response(
{'error': 'Invalid or expired code. Please request a new one.'},
status=status.HTTP_400_BAD_REQUEST
)
except EmailAddress.DoesNotExist:
return Response(
{'error': 'Invalid email or code'},
status=status.HTTP_400_BAD_REQUEST
)
except Exception:
return Response(
{'error': 'Invalid or expired code. Please request a new one.'},
status=status.HTTP_400_BAD_REQUEST
)

View File

@@ -0,0 +1,385 @@
"""
MFA (Multi-Factor Authentication) API Views
Provides REST API endpoints for MFA operations using django-allauth's mfa module.
Supports TOTP (Time-based One-Time Password) authentication.
"""
import base64
from io import BytesIO
from django.conf import settings
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
try:
import qrcode
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
@extend_schema(
operation_id="get_mfa_status",
summary="Get MFA status for current user",
description="Returns whether MFA is enabled and what methods are configured.",
responses={
200: {
"description": "MFA status",
"example": {
"mfa_enabled": True,
"totp_enabled": True,
"recovery_codes_count": 10,
},
},
},
tags=["MFA"],
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_mfa_status(request):
"""Get MFA status for current user."""
from allauth.mfa.models import Authenticator
user = request.user
authenticators = Authenticator.objects.filter(user=user)
totp_enabled = authenticators.filter(type=Authenticator.Type.TOTP).exists()
recovery_enabled = authenticators.filter(type=Authenticator.Type.RECOVERY_CODES).exists()
# Count recovery codes if any
recovery_count = 0
if recovery_enabled:
try:
recovery_auth = authenticators.get(type=Authenticator.Type.RECOVERY_CODES)
recovery_count = len(recovery_auth.data.get("codes", []))
except Authenticator.DoesNotExist:
pass
return Response({
"mfa_enabled": totp_enabled,
"totp_enabled": totp_enabled,
"recovery_codes_enabled": recovery_enabled,
"recovery_codes_count": recovery_count,
})
@extend_schema(
operation_id="setup_totp",
summary="Initialize TOTP setup",
description="Generates a new TOTP secret and returns the QR code for scanning.",
responses={
200: {
"description": "TOTP setup data",
"example": {
"secret": "ABCDEFGHIJKLMNOP",
"provisioning_uri": "otpauth://totp/ThrillWiki:user@example.com?secret=...",
"qr_code_base64": "data:image/png;base64,...",
},
},
},
tags=["MFA"],
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def setup_totp(request):
"""Generate TOTP secret and QR code for setup."""
from allauth.mfa.totp.internal import auth as totp_auth
user = request.user
# Generate TOTP secret
secret = totp_auth.get_totp_secret(None) # Generate new secret
# Build provisioning URI
issuer = getattr(settings, "MFA_TOTP_ISSUER", "ThrillWiki")
account_name = user.email or user.username
uri = f"otpauth://totp/{issuer}:{account_name}?secret={secret}&issuer={issuer}"
# Generate QR code if qrcode library is available
qr_code_base64 = None
if HAS_QRCODE:
qr = qrcode.make(uri)
buffer = BytesIO()
qr.save(buffer, format="PNG")
qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"
# Store secret in session for later verification
request.session["pending_totp_secret"] = secret
return Response({
"secret": secret,
"provisioning_uri": uri,
"qr_code_base64": qr_code_base64,
})
@extend_schema(
operation_id="activate_totp",
summary="Activate TOTP with verification code",
description="Verifies the TOTP code and activates 2FA for the user.",
request={
"application/json": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "6-digit TOTP code from authenticator app",
"example": "123456",
}
},
"required": ["code"],
}
},
responses={
200: {
"description": "TOTP activated successfully",
"example": {
"success": True,
"message": "Two-factor authentication enabled",
"recovery_codes": ["ABCD1234", "EFGH5678"],
},
},
400: {"description": "Invalid code or missing setup data"},
},
tags=["MFA"],
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def activate_totp(request):
"""Verify TOTP code and activate MFA."""
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal import auth as recovery_auth
from allauth.mfa.totp.internal import auth as totp_auth
user = request.user
code = request.data.get("code", "").strip()
if not code:
return Response(
{"success": False, "error": "Verification code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get pending secret from session
secret = request.session.get("pending_totp_secret")
if not secret:
return Response(
{"success": False, "error": "No pending TOTP setup. Please start setup again."},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify the code
if not totp_auth.validate_totp_code(secret, code):
return Response(
{"success": False, "error": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if already has TOTP
if Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
return Response(
{"success": False, "error": "TOTP is already enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
# Create TOTP authenticator
Authenticator.objects.create(
user=user,
type=Authenticator.Type.TOTP,
data={"secret": secret},
)
# Generate recovery codes
codes = recovery_auth.generate_recovery_codes()
Authenticator.objects.create(
user=user,
type=Authenticator.Type.RECOVERY_CODES,
data={"codes": codes},
)
# Clear session
del request.session["pending_totp_secret"]
return Response({
"success": True,
"message": "Two-factor authentication enabled",
"recovery_codes": codes,
})
@extend_schema(
operation_id="deactivate_totp",
summary="Disable TOTP authentication",
description="Removes TOTP from the user's account after password verification.",
request={
"application/json": {
"type": "object",
"properties": {
"password": {
"type": "string",
"description": "Current password for confirmation",
}
},
"required": ["password"],
}
},
responses={
200: {
"description": "TOTP disabled",
"example": {"success": True, "message": "Two-factor authentication disabled"},
},
400: {"description": "Invalid password or MFA not enabled"},
},
tags=["MFA"],
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def deactivate_totp(request):
"""Disable TOTP authentication."""
from allauth.mfa.models import Authenticator
user = request.user
password = request.data.get("password", "")
# Verify password
if not user.check_password(password):
return Response(
{"success": False, "error": "Invalid password"},
status=status.HTTP_400_BAD_REQUEST,
)
# Remove TOTP and recovery codes
deleted_count, _ = Authenticator.objects.filter(
user=user,
type__in=[Authenticator.Type.TOTP, Authenticator.Type.RECOVERY_CODES]
).delete()
if deleted_count == 0:
return Response(
{"success": False, "error": "Two-factor authentication is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response({
"success": True,
"message": "Two-factor authentication disabled",
})
@extend_schema(
operation_id="verify_totp",
summary="Verify TOTP code during login",
description="Verifies the TOTP code as part of the login process.",
request={
"application/json": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "6-digit TOTP code"}
},
"required": ["code"],
}
},
responses={
200: {"description": "Code verified", "example": {"success": True}},
400: {"description": "Invalid code"},
},
tags=["MFA"],
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def verify_totp(request):
"""Verify TOTP code."""
from allauth.mfa.models import Authenticator
from allauth.mfa.totp.internal import auth as totp_auth
user = request.user
code = request.data.get("code", "").strip()
if not code:
return Response(
{"success": False, "error": "Verification code is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
authenticator = Authenticator.objects.get(user=user, type=Authenticator.Type.TOTP)
secret = authenticator.data.get("secret")
if totp_auth.validate_totp_code(secret, code):
return Response({"success": True})
else:
return Response(
{"success": False, "error": "Invalid verification code"},
status=status.HTTP_400_BAD_REQUEST,
)
except Authenticator.DoesNotExist:
return Response(
{"success": False, "error": "TOTP is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
@extend_schema(
operation_id="regenerate_recovery_codes",
summary="Regenerate recovery codes",
description="Generates new recovery codes (invalidates old ones).",
request={
"application/json": {
"type": "object",
"properties": {
"password": {"type": "string", "description": "Current password"}
},
"required": ["password"],
}
},
responses={
200: {
"description": "New recovery codes",
"example": {"success": True, "recovery_codes": ["ABCD1234", "EFGH5678"]},
},
400: {"description": "Invalid password or MFA not enabled"},
},
tags=["MFA"],
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def regenerate_recovery_codes(request):
"""Regenerate recovery codes."""
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal import auth as recovery_auth
user = request.user
password = request.data.get("password", "")
# Verify password
if not user.check_password(password):
return Response(
{"success": False, "error": "Invalid password"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if TOTP is enabled
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
return Response(
{"success": False, "error": "Two-factor authentication is not enabled"},
status=status.HTTP_400_BAD_REQUEST,
)
# Generate new codes
codes = recovery_auth.generate_recovery_codes()
# Update or create recovery codes authenticator
authenticator, created = Authenticator.objects.update_or_create(
user=user,
type=Authenticator.Type.RECOVERY_CODES,
defaults={"data": {"codes": codes}},
)
return Response({
"success": True,
"recovery_codes": codes,
})

View File

@@ -5,21 +5,21 @@ This module contains all serializers related to authentication, user accounts,
profiles, top lists, and user statistics. profiles, top lists, and user statistics.
""" """
from typing import Any, Dict
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
)
from django.contrib.auth.password_validation import validate_password
from django.utils.crypto import get_random_string
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from apps.accounts.models import PasswordReset from typing import Any
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.utils import timezone
from django.utils.crypto import get_random_string
from drf_spectacular.utils import (
OpenApiExample,
extend_schema_field,
extend_schema_serializer,
)
from rest_framework import serializers
from apps.accounts.models import PasswordReset
UserModel = get_user_model() UserModel = get_user_model()
@@ -192,11 +192,13 @@ class SignupInputSerializer(serializers.ModelSerializer):
def _send_verification_email(self, user): def _send_verification_email(self, user):
"""Send email verification to the user.""" """Send email verification to the user."""
from apps.accounts.models import EmailVerification import logging
from django.contrib.sites.shortcuts import get_current_site
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService from django_forwardemail.services import EmailService
from django.contrib.sites.shortcuts import get_current_site
import logging from apps.accounts.models import EmailVerification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -436,7 +438,7 @@ class UserProfileOutputSerializer(serializers.Serializer):
return obj.get_avatar_url() return obj.get_avatar_url()
@extend_schema_field(serializers.DictField()) @extend_schema_field(serializers.DictField())
def get_user(self, obj) -> Dict[str, Any]: def get_user(self, obj) -> dict[str, Any]:
return { return {
"username": obj.user.username, "username": obj.user.username,
"date_joined": obj.user.date_joined, "date_joined": obj.user.date_joined,

View File

@@ -6,15 +6,15 @@ Main authentication serializers are imported directly from the parent serializer
""" """
from .social import ( from .social import (
ConnectedProviderSerializer,
AvailableProviderSerializer, AvailableProviderSerializer,
SocialAuthStatusSerializer, ConnectedProviderSerializer,
ConnectedProvidersListOutputSerializer,
ConnectProviderInputSerializer, ConnectProviderInputSerializer,
ConnectProviderOutputSerializer, ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer, DisconnectProviderOutputSerializer,
SocialProviderListOutputSerializer, SocialAuthStatusSerializer,
ConnectedProvidersListOutputSerializer,
SocialProviderErrorSerializer, SocialProviderErrorSerializer,
SocialProviderListOutputSerializer,
) )
__all__ = [ __all__ = [

View File

@@ -5,8 +5,8 @@ Serializers for handling social provider connection/disconnection requests
and responses in the ThrillWiki API. and responses in the ThrillWiki API.
""" """
from rest_framework import serializers
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from rest_framework import serializers
User = get_user_model() User = get_user_model()

View File

@@ -5,29 +5,30 @@ This module contains URL patterns for core authentication functionality only.
User profiles and top lists are handled by the dedicated accounts app. User profiles and top lists are handled by the dedicated accounts app.
""" """
from django.urls import path, include from django.urls import include, path
from rest_framework_simplejwt.views import TokenRefreshView
from . import mfa as mfa_views
from .views import ( from .views import (
# Main auth views
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView, AuthStatusAPIView,
# Email verification views
EmailVerificationAPIView,
ResendVerificationAPIView,
# Social provider management views # Social provider management views
AvailableProvidersAPIView, AvailableProvidersAPIView,
ConnectedProvidersAPIView, ConnectedProvidersAPIView,
ConnectProviderAPIView, ConnectProviderAPIView,
CurrentUserAPIView,
DisconnectProviderAPIView, DisconnectProviderAPIView,
# Email verification views
EmailVerificationAPIView,
# Main auth views
LoginAPIView,
LogoutAPIView,
PasswordChangeAPIView,
PasswordResetAPIView,
ResendVerificationAPIView,
SignupAPIView,
SocialAuthStatusAPIView, SocialAuthStatusAPIView,
SocialProvidersAPIView,
) )
from rest_framework_simplejwt.views import TokenRefreshView
urlpatterns = [ urlpatterns = [
# Core authentication endpoints # Core authentication endpoints
@@ -98,6 +99,14 @@ urlpatterns = [
ResendVerificationAPIView.as_view(), ResendVerificationAPIView.as_view(),
name="auth-resend-verification", name="auth-resend-verification",
), ),
# MFA (Multi-Factor Authentication) endpoints
path("mfa/status/", mfa_views.get_mfa_status, name="auth-mfa-status"),
path("mfa/totp/setup/", mfa_views.setup_totp, name="auth-mfa-totp-setup"),
path("mfa/totp/activate/", mfa_views.activate_totp, name="auth-mfa-totp-activate"),
path("mfa/totp/deactivate/", mfa_views.deactivate_totp, name="auth-mfa-totp-deactivate"),
path("mfa/totp/verify/", mfa_views.verify_totp, name="auth-mfa-totp-verify"),
path("mfa/recovery-codes/regenerate/", mfa_views.regenerate_recovery_codes, name="auth-mfa-recovery-regenerate"),
] ]
# Note: User profiles and top lists functionality is now handled by the accounts app # Note: User profiles and top lists functionality is now handled by the accounts app

View File

@@ -6,44 +6,46 @@ login, signup, logout, password management, social authentication,
user profiles, and top lists. user profiles, and top lists.
""" """
from .serializers_package.social import ( from typing import cast # added 'cast'
ConnectedProviderSerializer,
AvailableProviderSerializer, from django.contrib.auth import authenticate, get_user_model, login, logout
SocialAuthStatusSerializer,
ConnectProviderInputSerializer,
ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer,
SocialProviderErrorSerializer,
)
from apps.accounts.services.social_provider_service import SocialProviderService
from django.contrib.auth import authenticate, login, logout, get_user_model
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from typing import Optional, cast # added 'cast'
from django.http import HttpRequest # new import from django.http import HttpRequest # new import
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status from rest_framework import status
from rest_framework.views import APIView from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema, extend_schema_view
from apps.accounts.services.social_provider_service import SocialProviderService
# Import directly from the auth serializers.py file (not the serializers package) # Import directly from the auth serializers.py file (not the serializers package)
from .serializers import ( from .serializers import (
AuthStatusOutputSerializer,
# Authentication serializers # Authentication serializers
LoginInputSerializer, LoginInputSerializer,
LoginOutputSerializer, LoginOutputSerializer,
SignupInputSerializer,
SignupOutputSerializer,
LogoutOutputSerializer, LogoutOutputSerializer,
UserOutputSerializer,
PasswordResetInputSerializer,
PasswordResetOutputSerializer,
PasswordChangeInputSerializer, PasswordChangeInputSerializer,
PasswordChangeOutputSerializer, PasswordChangeOutputSerializer,
PasswordResetInputSerializer,
PasswordResetOutputSerializer,
SignupInputSerializer,
SignupOutputSerializer,
SocialProviderOutputSerializer, SocialProviderOutputSerializer,
AuthStatusOutputSerializer, UserOutputSerializer,
)
from .serializers_package.social import (
AvailableProviderSerializer,
ConnectedProviderSerializer,
ConnectProviderInputSerializer,
ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer,
SocialAuthStatusSerializer,
SocialProviderErrorSerializer,
) )
# Handle optional dependencies with fallback classes # Handle optional dependencies with fallback classes
@@ -62,10 +64,7 @@ try:
# Ensure the imported object is a class/type that can be used as a base class. # Ensure the imported object is a class/type that can be used as a base class.
# If it's not a type for any reason, fall back to the safe mixin. # If it's not a type for any reason, fall back to the safe mixin.
if isinstance(_ImportedTurnstileMixin, type): TurnstileMixin = _ImportedTurnstileMixin if isinstance(_ImportedTurnstileMixin, type) else FallbackTurnstileMixin
TurnstileMixin = _ImportedTurnstileMixin
else:
TurnstileMixin = FallbackTurnstileMixin
except Exception: except Exception:
# Catch any import errors or unexpected exceptions and use the fallback mixin. # Catch any import errors or unexpected exceptions and use the fallback mixin.
TurnstileMixin = FallbackTurnstileMixin TurnstileMixin = FallbackTurnstileMixin
@@ -88,7 +87,7 @@ def _get_underlying_request(request: Request) -> HttpRequest:
# Helper: encapsulate user lookup + authenticate to reduce complexity in view # Helper: encapsulate user lookup + authenticate to reduce complexity in view
def _authenticate_user_by_lookup( def _authenticate_user_by_lookup(
email_or_username: str, password: str, request: Request email_or_username: str, password: str, request: Request
) -> Optional[UserModel]: ) -> UserModel | None:
""" """
Try a single optimized query to find a user by email OR username then authenticate. Try a single optimized query to find a user by email OR username then authenticate.
Returns authenticated user or None. Returns authenticated user or None.
@@ -199,7 +198,7 @@ class LoginAPIView(APIView):
else: else:
return Response( return Response(
{ {
"error": "Email verification required", "error": "Email verification required",
"message": "Please verify your email address before logging in. Check your email for a verification link.", "message": "Please verify your email address before logging in. Check your email for a verification link.",
"email_verification_required": True "email_verification_required": True
}, },
@@ -246,7 +245,7 @@ class SignupAPIView(APIView):
serializer = SignupInputSerializer(data=request.data, context={"request": request}) serializer = SignupInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid(): if serializer.is_valid():
user = serializer.save() user = serializer.save()
# Don't log in the user immediately - they need to verify their email first # Don't log in the user immediately - they need to verify their email first
response_serializer = SignupOutputSerializer( response_serializer = SignupOutputSerializer(
{ {
@@ -754,23 +753,23 @@ class EmailVerificationAPIView(APIView):
def get(self, request: Request, token: str) -> Response: def get(self, request: Request, token: str) -> Response:
from apps.accounts.models import EmailVerification from apps.accounts.models import EmailVerification
try: try:
verification = EmailVerification.objects.select_related('user').get(token=token) verification = EmailVerification.objects.select_related('user').get(token=token)
user = verification.user user = verification.user
# Activate the user # Activate the user
user.is_active = True user.is_active = True
user.save() user.save()
# Delete the verification record # Delete the verification record
verification.delete() verification.delete()
return Response({ return Response({
"message": "Email verified successfully. You can now log in.", "message": "Email verified successfully. You can now log in.",
"success": True "success": True
}) })
except EmailVerification.DoesNotExist: except EmailVerification.DoesNotExist:
return Response( return Response(
{"error": "Invalid or expired verification token"}, {"error": "Invalid or expired verification token"},
@@ -798,45 +797,46 @@ class ResendVerificationAPIView(APIView):
authentication_classes = [] authentication_classes = []
def post(self, request: Request) -> Response: def post(self, request: Request) -> Response:
from apps.accounts.models import EmailVerification from django.contrib.sites.shortcuts import get_current_site
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService from django_forwardemail.services import EmailService
from django.contrib.sites.shortcuts import get_current_site
from apps.accounts.models import EmailVerification
email = request.data.get('email') email = request.data.get('email')
if not email: if not email:
return Response( return Response(
{"error": "Email address is required"}, {"error": "Email address is required"},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
try: try:
user = UserModel.objects.get(email__iexact=email.strip().lower()) user = UserModel.objects.get(email__iexact=email.strip().lower())
# Don't resend if user is already active # Don't resend if user is already active
if user.is_active: if user.is_active:
return Response( return Response(
{"error": "Email is already verified"}, {"error": "Email is already verified"},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Create or update verification record # Create or update verification record
verification, created = EmailVerification.objects.get_or_create( verification, created = EmailVerification.objects.get_or_create(
user=user, user=user,
defaults={'token': get_random_string(64)} defaults={'token': get_random_string(64)}
) )
if not created: if not created:
# Update existing token and timestamp # Update existing token and timestamp
verification.token = get_random_string(64) verification.token = get_random_string(64)
verification.save() verification.save()
# Send verification email # Send verification email
site = get_current_site(_get_underlying_request(request)) site = get_current_site(_get_underlying_request(request))
verification_url = request.build_absolute_uri( verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/" f"/api/v1/auth/verify-email/{verification.token}/"
) )
try: try:
EmailService.send_email( EmailService.send_email(
to=user.email, to=user.email,
@@ -854,22 +854,22 @@ The ThrillWiki Team
""".strip(), """.strip(),
site=site, site=site,
) )
return Response({ return Response({
"message": "Verification email sent successfully", "message": "Verification email sent successfully",
"success": True "success": True
}) })
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f"Failed to send verification email to {user.email}: {e}") logger.error(f"Failed to send verification email to {user.email}: {e}")
return Response( return Response(
{"error": "Failed to send verification email"}, {"error": "Failed to send verification email"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR status=status.HTTP_500_INTERNAL_SERVER_ERROR
) )
except UserModel.DoesNotExist: except UserModel.DoesNotExist:
# Don't reveal whether email exists # Don't reveal whether email exists
return Response({ return Response({

View File

@@ -4,6 +4,7 @@ Centralized from apps.core.urls
""" """
from django.urls import path from django.urls import path
from . import views from . import views
# Entity search endpoints - migrated from apps.core.urls # Entity search endpoints - migrated from apps.core.urls

View File

@@ -8,18 +8,20 @@ Caching Strategy:
- EntityNotFoundView: No caching - POST requests with context-specific data - EntityNotFoundView: No caching - POST requests with context-specific data
""" """
from rest_framework.views import APIView
from rest_framework.response import Response import contextlib
from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from typing import Optional, List from rest_framework.response import Response
from drf_spectacular.utils import extend_schema from rest_framework.views import APIView
from apps.core.services.entity_fuzzy_matching import (
entity_fuzzy_matcher,
EntityType,
)
from apps.core.decorators.cache_decorators import cache_api_response from apps.core.decorators.cache_decorators import cache_api_response
from apps.core.services.entity_fuzzy_matching import (
EntityType,
entity_fuzzy_matcher,
)
class EntityFuzzySearchView(APIView): class EntityFuzzySearchView(APIView):
@@ -199,10 +201,8 @@ class EntityNotFoundView(APIView):
# Determine entity types to search based on context # Determine entity types to search based on context
entity_types = [] entity_types = []
if entity_type_hint: if entity_type_hint:
try: with contextlib.suppress(ValueError):
entity_types = [EntityType(entity_type_hint)] entity_types = [EntityType(entity_type_hint)]
except ValueError:
pass
# If we have park context, prioritize ride searches # If we have park context, prioritize ride searches
if context.get("park_slug") and not entity_types: if context.get("park_slug") and not entity_types:
@@ -344,7 +344,7 @@ class QuickEntitySuggestionView(APIView):
# Utility function for other views to use # Utility function for other views to use
def get_entity_suggestions( def get_entity_suggestions(
query: str, entity_types: Optional[List[str]] = None, user=None query: str, entity_types: list[str] | None = None, user=None
): ):
""" """
Utility function for other Django views to get entity suggestions. Utility function for other Django views to get entity suggestions.

View File

@@ -4,6 +4,7 @@ Centralized from apps.email_service.urls
""" """
from django.urls import path from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [

View File

@@ -3,13 +3,13 @@ Centralized email service API views.
Migrated from apps.email_service.views Migrated from apps.email_service.views
""" """
from rest_framework.views import APIView from django.contrib.sites.shortcuts import get_current_site
from rest_framework.response import Response from django_forwardemail.services import EmailService
from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from django.contrib.sites.shortcuts import get_current_site from rest_framework.response import Response
from drf_spectacular.utils import extend_schema from rest_framework.views import APIView
from django_forwardemail.services import EmailService
@extend_schema( @extend_schema(

View File

@@ -4,7 +4,7 @@ History API URLs
URL patterns for history-related API endpoints. URL patterns for history-related API endpoints.
""" """
from django.urls import path, include from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ( from .views import (

View File

@@ -5,18 +5,21 @@ This module provides ViewSets for accessing historical data and change tracking
across all models in the ThrillWiki system using django-pghistory. across all models in the ThrillWiki system using django-pghistory.
""" """
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from collections.abc import Sequence
from datetime import datetime
from typing import cast
import pghistory.models
from django.db.models import Count, QuerySet
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import serializers as drf_serializers
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.request import Request
from typing import Optional, cast, Sequence
from django.shortcuts import get_object_or_404
from django.db.models import Count, QuerySet
import pghistory.models
from datetime import datetime
# Import models # Import models
from apps.parks.models import Park from apps.parks.models import Park
@@ -24,7 +27,6 @@ from apps.rides.models import Ride
# Import serializers # Import serializers
from .. import serializers as history_serializers from .. import serializers as history_serializers
from rest_framework import serializers as drf_serializers
# Minimal fallback serializer used when a specific serializer symbol is missing. # Minimal fallback serializer used when a specific serializer symbol is missing.
@@ -79,7 +81,7 @@ ALL_TRACKED_MODELS: Sequence[str] = [
# --- Helper utilities to reduce duplicated logic / cognitive complexity --- # --- Helper utilities to reduce duplicated logic / cognitive complexity ---
def _parse_date(date_str: Optional[str]) -> Optional[datetime]: def _parse_date(date_str: str | None) -> datetime | None:
if not date_str: if not date_str:
return None return None
try: try:

View File

@@ -1,4 +1,5 @@
from django.urls import path from django.urls import path
from .views import GenerateUploadURLView from .views import GenerateUploadURLView
urlpatterns = [ urlpatterns = [

View File

@@ -1,12 +1,14 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from apps.core.utils.cloudflare import get_direct_upload_url
from django.core.exceptions import ImproperlyConfigured
import requests
import logging import logging
import requests
from django.core.exceptions import ImproperlyConfigured
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.core.utils.cloudflare import get_direct_upload_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GenerateUploadURLView(APIView): class GenerateUploadURLView(APIView):
@@ -29,7 +31,7 @@ class GenerateUploadURLView(APIView):
{"detail": "Failed to generate upload URL."}, {"detail": "Failed to generate upload URL."},
status=status.HTTP_502_BAD_GATEWAY status=status.HTTP_502_BAD_GATEWAY
) )
except Exception as e: except Exception:
logger.exception("Unexpected error generating upload URL") logger.exception("Unexpected error generating upload URL")
return Response( return Response(
{"detail": "An unexpected error occurred."}, {"detail": "An unexpected error occurred."},

View File

@@ -4,6 +4,7 @@ Migrated from apps.core.urls.map_urls to centralized API structure.
""" """
from django.urls import path from django.urls import path
from . import views from . import views
# Map API endpoints - migrated from apps.core.urls.map_urls # Map API endpoints - migrated from apps.core.urls.map_urls

View File

@@ -12,30 +12,31 @@ Caching Strategy:
import logging import logging
from django.core.cache import cache
from django.http import HttpRequest
from django.db.models import Q
from django.contrib.gis.geos import Polygon from django.contrib.gis.geos import Polygon
from rest_framework.views import APIView from django.core.cache import cache
from rest_framework.response import Response from django.db.models import Q
from rest_framework import status from django.http import HttpRequest
from rest_framework.permissions import AllowAny, IsAdminUser from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import ( from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
extend_schema, extend_schema,
extend_schema_view, extend_schema_view,
OpenApiParameter,
OpenApiExample,
) )
from drf_spectacular.types import OpenApiTypes from rest_framework import status
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.core.decorators.cache_decorators import cache_api_response
from apps.core.services.enhanced_cache_service import EnhancedCacheService
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
from apps.core.services.enhanced_cache_service import EnhancedCacheService
from apps.core.decorators.cache_decorators import cache_api_response
from ..serializers.maps import ( from ..serializers.maps import (
MapLocationDetailSerializer,
MapLocationsResponseSerializer, MapLocationsResponseSerializer,
MapSearchResponseSerializer, MapSearchResponseSerializer,
MapLocationDetailSerializer,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -7,7 +7,8 @@ TypeScript interfaces, providing immediate feedback during development.
import json import json
import logging import logging
from typing import Dict, Any from typing import Any
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
@@ -19,52 +20,49 @@ logger = logging.getLogger(__name__)
class ContractValidationMiddleware(MiddlewareMixin): class ContractValidationMiddleware(MiddlewareMixin):
""" """
Development-only middleware that validates API responses against expected contracts. Development-only middleware that validates API responses against expected contracts.
This middleware: This middleware:
1. Checks all API responses for contract compliance 1. Checks all API responses for contract compliance
2. Logs warnings when responses don't match expected TypeScript interfaces 2. Logs warnings when responses don't match expected TypeScript interfaces
3. Specifically validates filter metadata structure 3. Specifically validates filter metadata structure
4. Alerts when categorical filters are strings instead of objects 4. Alerts when categorical filters are strings instead of objects
Only active when DEBUG=True to avoid performance impact in production. Only active when DEBUG=True to avoid performance impact in production.
""" """
def __init__(self, get_response): def __init__(self, get_response):
super().__init__(get_response) super().__init__(get_response)
self.get_response = get_response self.get_response = get_response
self.enabled = getattr(settings, 'DEBUG', False) self.enabled = getattr(settings, 'DEBUG', False)
if self.enabled: if self.enabled:
logger.info("Contract validation middleware enabled (DEBUG mode)") logger.info("Contract validation middleware enabled (DEBUG mode)")
def process_response(self, request, response): def process_response(self, request, response):
"""Process API responses to check for contract violations.""" """Process API responses to check for contract violations."""
if not self.enabled: if not self.enabled:
return response return response
# Only validate API endpoints # Only validate API endpoints
if not request.path.startswith('/api/'): if not request.path.startswith('/api/'):
return response return response
# Only validate JSON responses # Only validate JSON responses
if not isinstance(response, (JsonResponse, Response)): if not isinstance(response, (JsonResponse, Response)):
return response return response
# Only validate successful responses (2xx status codes) # Only validate successful responses (2xx status codes)
if not (200 <= response.status_code < 300): if not (200 <= response.status_code < 300):
return response return response
try: try:
# Get response data # Get response data
if isinstance(response, Response): data = response.data if isinstance(response, Response) else json.loads(response.content.decode('utf-8'))
data = response.data
else:
data = json.loads(response.content.decode('utf-8'))
# Validate the response # Validate the response
self._validate_response_contract(request.path, data) self._validate_response_contract(request.path, data)
except Exception as e: except Exception as e:
# Log validation errors but don't break the response # Log validation errors but don't break the response
logger.warning( logger.warning(
@@ -76,55 +74,55 @@ class ContractValidationMiddleware(MiddlewareMixin):
'validation_error': str(e) 'validation_error': str(e)
} }
) )
return response return response
def _validate_response_contract(self, path: str, data: Any) -> None: def _validate_response_contract(self, path: str, data: Any) -> None:
"""Validate response data against expected contracts.""" """Validate response data against expected contracts."""
# Check for filter metadata endpoints # Check for filter metadata endpoints
if 'filter-options' in path or 'filter_options' in path: if 'filter-options' in path or 'filter_options' in path:
self._validate_filter_metadata(path, data) self._validate_filter_metadata(path, data)
# Check for hybrid filtering endpoints # Check for hybrid filtering endpoints
if 'hybrid' in path: if 'hybrid' in path:
self._validate_hybrid_response(path, data) self._validate_hybrid_response(path, data)
# Check for pagination responses # Check for pagination responses
if isinstance(data, dict) and 'results' in data: if isinstance(data, dict) and 'results' in data:
self._validate_pagination_response(path, data) self._validate_pagination_response(path, data)
# Check for common contract violations # Check for common contract violations
self._validate_common_patterns(path, data) self._validate_common_patterns(path, data)
def _validate_filter_metadata(self, path: str, data: Any) -> None: def _validate_filter_metadata(self, path: str, data: Any) -> None:
"""Validate filter metadata structure.""" """Validate filter metadata structure."""
if not isinstance(data, dict): if not isinstance(data, dict):
self._log_contract_violation( self._log_contract_violation(
path, path,
"FILTER_METADATA_NOT_DICT", "FILTER_METADATA_NOT_DICT",
f"Filter metadata should be a dictionary, got {type(data).__name__}" f"Filter metadata should be a dictionary, got {type(data).__name__}"
) )
return return
# Check for categorical filters # Check for categorical filters
if 'categorical' in data: if 'categorical' in data:
categorical = data['categorical'] categorical = data['categorical']
if isinstance(categorical, dict): if isinstance(categorical, dict):
for filter_name, filter_options in categorical.items(): for filter_name, filter_options in categorical.items():
self._validate_categorical_filter(path, filter_name, filter_options) self._validate_categorical_filter(path, filter_name, filter_options)
# Check for ranges # Check for ranges
if 'ranges' in data: if 'ranges' in data:
ranges = data['ranges'] ranges = data['ranges']
if isinstance(ranges, dict): if isinstance(ranges, dict):
for range_name, range_data in ranges.items(): for range_name, range_data in ranges.items():
self._validate_range_filter(path, range_name, range_data) self._validate_range_filter(path, range_name, range_data)
def _validate_categorical_filter(self, path: str, filter_name: str, filter_options: Any) -> None: def _validate_categorical_filter(self, path: str, filter_name: str, filter_options: Any) -> None:
"""Validate categorical filter options format.""" """Validate categorical filter options format."""
if not isinstance(filter_options, list): if not isinstance(filter_options, list):
self._log_contract_violation( self._log_contract_violation(
path, path,
@@ -132,7 +130,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}" f"Categorical filter '{filter_name}' should be an array, got {type(filter_options).__name__}"
) )
return return
for i, option in enumerate(filter_options): for i, option in enumerate(filter_options):
if isinstance(option, str): if isinstance(option, str):
# CRITICAL: This is the main contract violation we're trying to catch # CRITICAL: This is the main contract violation we're trying to catch
@@ -163,10 +161,10 @@ class ContractValidationMiddleware(MiddlewareMixin):
"INVALID_COUNT_TYPE", "INVALID_COUNT_TYPE",
f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}" f"Categorical filter '{filter_name}' option {i} 'count' should be a number, got {type(option['count']).__name__}"
) )
def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None: def _validate_range_filter(self, path: str, range_name: str, range_data: Any) -> None:
"""Validate range filter format.""" """Validate range filter format."""
if not isinstance(range_data, dict): if not isinstance(range_data, dict):
self._log_contract_violation( self._log_contract_violation(
path, path,
@@ -174,7 +172,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}" f"Range filter '{range_name}' should be an object, got {type(range_data).__name__}"
) )
return return
# Check required properties # Check required properties
required_props = ['min', 'max'] required_props = ['min', 'max']
for prop in required_props: for prop in required_props:
@@ -184,7 +182,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
"MISSING_RANGE_PROPERTY", "MISSING_RANGE_PROPERTY",
f"Range filter '{range_name}' missing required property '{prop}'" f"Range filter '{range_name}' missing required property '{prop}'"
) )
# Check step property # Check step property
if 'step' in range_data and not isinstance(range_data['step'], (int, float)): if 'step' in range_data and not isinstance(range_data['step'], (int, float)):
self._log_contract_violation( self._log_contract_violation(
@@ -192,13 +190,13 @@ class ContractValidationMiddleware(MiddlewareMixin):
"INVALID_STEP_TYPE", "INVALID_STEP_TYPE",
f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}" f"Range filter '{range_name}' 'step' should be a number, got {type(range_data['step']).__name__}"
) )
def _validate_hybrid_response(self, path: str, data: Any) -> None: def _validate_hybrid_response(self, path: str, data: Any) -> None:
"""Validate hybrid filtering response structure.""" """Validate hybrid filtering response structure."""
if not isinstance(data, dict): if not isinstance(data, dict):
return return
# Check for strategy field # Check for strategy field
if 'strategy' in data: if 'strategy' in data:
strategy = data['strategy'] strategy = data['strategy']
@@ -208,14 +206,14 @@ class ContractValidationMiddleware(MiddlewareMixin):
"INVALID_STRATEGY_VALUE", "INVALID_STRATEGY_VALUE",
f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'" f"Hybrid response strategy should be 'client_side' or 'server_side', got '{strategy}'"
) )
# Check filter_metadata structure # Check filter_metadata structure
if 'filter_metadata' in data: if 'filter_metadata' in data:
self._validate_filter_metadata(path, data['filter_metadata']) self._validate_filter_metadata(path, data['filter_metadata'])
def _validate_pagination_response(self, path: str, data: Dict[str, Any]) -> None: def _validate_pagination_response(self, path: str, data: dict[str, Any]) -> None:
"""Validate pagination response structure.""" """Validate pagination response structure."""
# Check for required pagination fields # Check for required pagination fields
required_fields = ['count', 'results'] required_fields = ['count', 'results']
for field in required_fields: for field in required_fields:
@@ -225,7 +223,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
"MISSING_PAGINATION_FIELD", "MISSING_PAGINATION_FIELD",
f"Pagination response missing required field '{field}'" f"Pagination response missing required field '{field}'"
) )
# Check results is array # Check results is array
if 'results' in data and not isinstance(data['results'], list): if 'results' in data and not isinstance(data['results'], list):
self._log_contract_violation( self._log_contract_violation(
@@ -233,17 +231,17 @@ class ContractValidationMiddleware(MiddlewareMixin):
"RESULTS_NOT_ARRAY", "RESULTS_NOT_ARRAY",
f"Pagination 'results' should be an array, got {type(data['results']).__name__}" f"Pagination 'results' should be an array, got {type(data['results']).__name__}"
) )
def _validate_common_patterns(self, path: str, data: Any) -> None: def _validate_common_patterns(self, path: str, data: Any) -> None:
"""Validate common API response patterns.""" """Validate common API response patterns."""
if isinstance(data, dict): if isinstance(data, dict):
# Check for null vs undefined issues # Check for null vs undefined issues
for key, value in data.items(): for key, value in data.items():
if value is None and key.endswith('_id'): if value is None and key.endswith('_id'):
# ID fields should probably be null, not undefined # ID fields should probably be null, not undefined
continue continue
# Check for numeric fields that might be strings # Check for numeric fields that might be strings
if key.endswith('_count') and isinstance(value, str): if key.endswith('_count') and isinstance(value, str):
try: try:
@@ -255,16 +253,16 @@ class ContractValidationMiddleware(MiddlewareMixin):
) )
except ValueError: except ValueError:
pass pass
def _log_contract_violation( def _log_contract_violation(
self, self,
path: str, path: str,
violation_type: str, violation_type: str,
message: str, message: str,
severity: str = "WARNING" severity: str = "WARNING"
) -> None: ) -> None:
"""Log a contract violation with structured data.""" """Log a contract violation with structured data."""
log_data = { log_data = {
'contract_violation': True, 'contract_violation': True,
'violation_type': violation_type, 'violation_type': violation_type,
@@ -273,15 +271,15 @@ class ContractValidationMiddleware(MiddlewareMixin):
'message': message, 'message': message,
'suggestion': self._get_violation_suggestion(violation_type) 'suggestion': self._get_violation_suggestion(violation_type)
} }
if severity == "ERROR": if severity == "ERROR":
logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) logger.error(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
else: else:
logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data) logger.warning(f"CONTRACT VIOLATION [{violation_type}]: {message}", extra=log_data)
def _get_violation_suggestion(self, violation_type: str) -> str: def _get_violation_suggestion(self, violation_type: str) -> str:
"""Get suggestion for fixing a contract violation.""" """Get suggestion for fixing a contract violation."""
suggestions = { suggestions = {
"CATEGORICAL_OPTION_IS_STRING": ( "CATEGORICAL_OPTION_IS_STRING": (
"Convert string arrays to object arrays with {value, label, count} structure. " "Convert string arrays to object arrays with {value, label, count} structure. "
@@ -308,31 +306,31 @@ class ContractValidationMiddleware(MiddlewareMixin):
"Check serializer implementation." "Check serializer implementation."
) )
} }
return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.") return suggestions.get(violation_type, "Check the API response format against frontend TypeScript interfaces.")
class ContractValidationSettings: class ContractValidationSettings:
"""Settings for contract validation middleware.""" """Settings for contract validation middleware."""
# Enable/disable specific validation checks # Enable/disable specific validation checks
VALIDATE_FILTER_METADATA = True VALIDATE_FILTER_METADATA = True
VALIDATE_PAGINATION = True VALIDATE_PAGINATION = True
VALIDATE_HYBRID_RESPONSES = True VALIDATE_HYBRID_RESPONSES = True
VALIDATE_COMMON_PATTERNS = True VALIDATE_COMMON_PATTERNS = True
# Severity levels for different violations # Severity levels for different violations
CATEGORICAL_STRING_SEVERITY = "ERROR" # This is the critical issue CATEGORICAL_STRING_SEVERITY = "ERROR" # This is the critical issue
MISSING_PROPERTY_SEVERITY = "WARNING" MISSING_PROPERTY_SEVERITY = "WARNING"
TYPE_MISMATCH_SEVERITY = "WARNING" TYPE_MISMATCH_SEVERITY = "WARNING"
# Paths to exclude from validation # Paths to exclude from validation
EXCLUDED_PATHS = [ EXCLUDED_PATHS = [
'/api/docs/', '/api/docs/',
'/api/schema/', '/api/schema/',
'/api/v1/auth/', # Auth endpoints might have different structures '/api/v1/auth/', # Auth endpoints might have different structures
] ]
@classmethod @classmethod
def should_validate_path(cls, path: str) -> bool: def should_validate_path(cls, path: str) -> bool:
"""Check if a path should be validated.""" """Check if a path should be validated."""

View File

@@ -2,14 +2,16 @@
Park history API views. Park history API views.
""" """
from rest_framework import viewsets, mixins
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
from apps.parks.models import Park from apps.parks.models import Park
from apps.rides.models import Ride from apps.rides.models import Ride
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
class ParkHistoryViewSet(viewsets.GenericViewSet): class ParkHistoryViewSet(viewsets.GenericViewSet):
""" """
@@ -18,7 +20,7 @@ class ParkHistoryViewSet(viewsets.GenericViewSet):
permission_classes = [AllowAny] permission_classes = [AllowAny]
lookup_field = "slug" lookup_field = "slug"
lookup_url_kwarg = "park_slug" lookup_url_kwarg = "park_slug"
@extend_schema( @extend_schema(
summary="Get park history", summary="Get park history",
description="Retrieve history events for a park.", description="Retrieve history events for a park.",
@@ -27,24 +29,24 @@ class ParkHistoryViewSet(viewsets.GenericViewSet):
) )
def list(self, request, park_slug=None): def list(self, request, park_slug=None):
park = get_object_or_404(Park, slug=park_slug) park = get_object_or_404(Park, slug=park_slug)
events = [] events = []
if hasattr(park, "events"): if hasattr(park, "events"):
events = park.events.all().order_by("-pgh_created_at") events = park.events.all().order_by("-pgh_created_at")
summary = { summary = {
"total_events": len(events), "total_events": len(events),
"first_recorded": events.last().pgh_created_at if len(events) else None, "first_recorded": events.last().pgh_created_at if len(events) else None,
"last_modified": events.first().pgh_created_at if len(events) else None, "last_modified": events.first().pgh_created_at if len(events) else None,
} }
data = { data = {
"park": park, "park": park,
"current_state": park, "current_state": park,
"summary": summary, "summary": summary,
"events": events "events": events
} }
serializer = ParkHistoryOutputSerializer(data) serializer = ParkHistoryOutputSerializer(data)
return Response(serializer.data) return Response(serializer.data)

View File

@@ -6,27 +6,26 @@ Provides CRUD operations for park reviews nested under parks/{slug}/reviews/
""" """
import logging import logging
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Avg from django.db.models import Avg
from django.utils import timezone from django.utils import timezone
from drf_spectacular.utils import extend_schema_view, extend_schema from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.types import OpenApiTypes
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError, NotFound from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from apps.parks.models import Park, ParkReview
from apps.api.v1.serializers.park_reviews import ( from apps.api.v1.serializers.park_reviews import (
ParkReviewOutputSerializer,
ParkReviewCreateInputSerializer, ParkReviewCreateInputSerializer,
ParkReviewUpdateInputSerializer,
ParkReviewListOutputSerializer, ParkReviewListOutputSerializer,
ParkReviewOutputSerializer,
ParkReviewStatsOutputSerializer, ParkReviewStatsOutputSerializer,
ParkReviewModerationInputSerializer, ParkReviewUpdateInputSerializer,
) )
from apps.parks.models import Park, ParkReview
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -66,10 +65,7 @@ class ParkReviewViewSet(ModelViewSet):
def get_permissions(self): def get_permissions(self):
"""Set permissions based on action.""" """Set permissions based on action."""
if self.action in ['list', 'retrieve', 'stats']: permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def get_queryset(self): def get_queryset(self):
@@ -143,7 +139,7 @@ class ParkReviewViewSet(ModelViewSet):
reviews = ParkReview.objects.filter(park=park, is_published=True) reviews = ParkReview.objects.filter(park=park, is_published=True)
total_reviews = reviews.count() total_reviews = reviews.count()
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg'] avg_rating = reviews.aggregate(avg=Avg('rating'))['avg']
rating_distribution = {} rating_distribution = {}
for i in range(1, 11): for i in range(1, 11):
rating_distribution[str(i)] = reviews.filter(rating=i).count() rating_distribution[str(i)] = reviews.filter(rating=i).count()

View File

@@ -6,19 +6,16 @@ This module implements endpoints for accessing rides within specific parks:
- GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context - GET /parks/{park_slug}/rides/{ride_slug}/ - Get specific ride details within park context
""" """
from typing import Any from django.db.models import Q
from django.db import models
from django.db.models import Q, Count, Avg
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from drf_spectacular.types import OpenApiTypes
from rest_framework import status, permissions from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.views import APIView from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination from rest_framework.views import APIView
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Import models # Import models
try: try:
@@ -32,8 +29,8 @@ except Exception:
# Import serializers # Import serializers
try: try:
from apps.api.v1.serializers.rides import RideListOutputSerializer, RideDetailOutputSerializer
from apps.api.v1.serializers.parks import ParkDetailOutputSerializer from apps.api.v1.serializers.parks import ParkDetailOutputSerializer
from apps.api.v1.serializers.rides import RideDetailOutputSerializer, RideListOutputSerializer
SERIALIZERS_AVAILABLE = True SERIALIZERS_AVAILABLE = True
except Exception: except Exception:
SERIALIZERS_AVAILABLE = False SERIALIZERS_AVAILABLE = False
@@ -47,7 +44,7 @@ class StandardResultsSetPagination(PageNumberPagination):
class ParkRidesListAPIView(APIView): class ParkRidesListAPIView(APIView):
"""List rides at a specific park with pagination and filtering.""" """List rides at a specific park with pagination and filtering."""
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
@extend_schema( @extend_schema(
@@ -59,7 +56,7 @@ class ParkRidesListAPIView(APIView):
type=OpenApiTypes.INT, description="Page number"), type=OpenApiTypes.INT, description="Page number"),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Number of results per page (max 100)"), type=OpenApiTypes.INT, description="Number of results per page (max 100)"),
# Filtering # Filtering
OpenApiParameter(name="category", location=OpenApiParameter.QUERY, OpenApiParameter(name="category", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by ride category"), type=OpenApiTypes.STR, description="Filter by ride category"),
@@ -67,7 +64,7 @@ class ParkRidesListAPIView(APIView):
type=OpenApiTypes.STR, description="Filter by operational status"), type=OpenApiTypes.STR, description="Filter by operational status"),
OpenApiParameter(name="search", location=OpenApiParameter.QUERY, OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Search rides by name"), type=OpenApiTypes.STR, description="Search rides by name"),
# Ordering # Ordering
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY, OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Order results by field"), type=OpenApiTypes.STR, description="Order results by field"),
@@ -158,7 +155,7 @@ class ParkRidesListAPIView(APIView):
class ParkRideDetailAPIView(APIView): class ParkRideDetailAPIView(APIView):
"""Get specific ride details within park context.""" """Get specific ride details within park context."""
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
@extend_schema( @extend_schema(
@@ -222,7 +219,7 @@ class ParkRideDetailAPIView(APIView):
class ParkComprehensiveDetailAPIView(APIView): class ParkComprehensiveDetailAPIView(APIView):
"""Get comprehensive park details including summary of rides.""" """Get comprehensive park details including summary of rides."""
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
@extend_schema( @extend_schema(
@@ -271,7 +268,7 @@ class ParkComprehensiveDetailAPIView(APIView):
rides_serializer = RideListOutputSerializer( rides_serializer = RideListOutputSerializer(
rides_sample, many=True, context={"request": request, "park": park} rides_sample, many=True, context={"request": request, "park": park}
) )
# Enhance response with rides data # Enhance response with rides data
park_data["rides_summary"] = { park_data["rides_summary"] = {
"total_count": park.ride_count or 0, "total_count": park.ride_count or 0,

View File

@@ -11,23 +11,24 @@ This module implements comprehensive park endpoints with full filtering support:
Supports all 24 filtering parameters from frontend API documentation. Supports all 24 filtering parameters from frontend API documentation.
""" """
import contextlib
from typing import Any 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 django.db import models
from rest_framework.views import APIView from django.db.models import Avg, Count, Q
from django.db.models.query import QuerySet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination from rest_framework.views import APIView
from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Import models # Import models
try: try:
from apps.parks.models import Park, Company from apps.parks.models import Company, Park
MODELS_AVAILABLE = True MODELS_AVAILABLE = True
except Exception: except Exception:
Park = None # type: ignore Park = None # type: ignore
@@ -45,11 +46,11 @@ except Exception:
# Import serializers # Import serializers
try: try:
from apps.api.v1.serializers.parks import ( from apps.api.v1.serializers.parks import (
ParkListOutputSerializer,
ParkDetailOutputSerializer,
ParkCreateInputSerializer, ParkCreateInputSerializer,
ParkUpdateInputSerializer, ParkDetailOutputSerializer,
ParkImageSettingsInputSerializer, ParkImageSettingsInputSerializer,
ParkListOutputSerializer,
ParkUpdateInputSerializer,
) )
SERIALIZERS_AVAILABLE = True SERIALIZERS_AVAILABLE = True
except Exception: except Exception:
@@ -247,12 +248,12 @@ class ParkListCreateAPIView(APIView):
'city': 'location__city__iexact', 'city': 'location__city__iexact',
'continent': 'location__continent__iexact' 'continent': 'location__continent__iexact'
} }
for param_name, filter_field in location_filters.items(): for param_name, filter_field in location_filters.items():
value = params.get(param_name) value = params.get(param_name)
if value: if value:
qs = qs.filter(**{filter_field: value}) qs = qs.filter(**{filter_field: value})
return qs return qs
def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet: def _apply_park_attribute_filters(self, qs: QuerySet, params: dict) -> QuerySet:
@@ -264,7 +265,7 @@ class ParkListCreateAPIView(APIView):
status_filter = params.get("status") status_filter = params.get("status")
if status_filter: if status_filter:
qs = qs.filter(status=status_filter) qs = qs.filter(status=status_filter)
return qs return qs
def _apply_company_filters(self, qs: QuerySet, params: dict) -> QuerySet: def _apply_company_filters(self, qs: QuerySet, params: dict) -> QuerySet:
@@ -275,73 +276,59 @@ class ParkListCreateAPIView(APIView):
'property_owner_id': 'property_owner_id', 'property_owner_id': 'property_owner_id',
'property_owner_slug': 'property_owner__slug' 'property_owner_slug': 'property_owner__slug'
} }
for param_name, filter_field in company_filters.items(): for param_name, filter_field in company_filters.items():
value = params.get(param_name) value = params.get(param_name)
if value: if value:
qs = qs.filter(**{filter_field: value}) qs = qs.filter(**{filter_field: value})
return qs return qs
def _apply_rating_filters(self, qs: QuerySet, params: dict) -> QuerySet: def _apply_rating_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply rating-based filtering to the queryset.""" """Apply rating-based filtering to the queryset."""
min_rating = params.get("min_rating") min_rating = params.get("min_rating")
if min_rating: if min_rating:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(average_rating__gte=float(min_rating)) qs = qs.filter(average_rating__gte=float(min_rating))
except (ValueError, TypeError):
pass
max_rating = params.get("max_rating") max_rating = params.get("max_rating")
if max_rating: if max_rating:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(average_rating__lte=float(max_rating)) qs = qs.filter(average_rating__lte=float(max_rating))
except (ValueError, TypeError):
pass
return qs return qs
def _apply_ride_count_filters(self, qs: QuerySet, params: dict) -> QuerySet: def _apply_ride_count_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply ride count filtering to the queryset.""" """Apply ride count filtering to the queryset."""
min_ride_count = params.get("min_ride_count") min_ride_count = params.get("min_ride_count")
if min_ride_count: if min_ride_count:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(ride_count__gte=int(min_ride_count)) qs = qs.filter(ride_count__gte=int(min_ride_count))
except (ValueError, TypeError):
pass
max_ride_count = params.get("max_ride_count") max_ride_count = params.get("max_ride_count")
if max_ride_count: if max_ride_count:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(ride_count__lte=int(max_ride_count)) qs = qs.filter(ride_count__lte=int(max_ride_count))
except (ValueError, TypeError):
pass
return qs return qs
def _apply_opening_year_filters(self, qs: QuerySet, params: dict) -> QuerySet: def _apply_opening_year_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply opening year filtering to the queryset.""" """Apply opening year filtering to the queryset."""
opening_year = params.get("opening_year") opening_year = params.get("opening_year")
if opening_year: if opening_year:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(opening_date__year=int(opening_year)) qs = qs.filter(opening_date__year=int(opening_year))
except (ValueError, TypeError):
pass
min_opening_year = params.get("min_opening_year") min_opening_year = params.get("min_opening_year")
if min_opening_year: if min_opening_year:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(opening_date__year__gte=int(min_opening_year)) qs = qs.filter(opening_date__year__gte=int(min_opening_year))
except (ValueError, TypeError):
pass
max_opening_year = params.get("max_opening_year") max_opening_year = params.get("max_opening_year")
if max_opening_year: if max_opening_year:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(opening_date__year__lte=int(max_opening_year)) qs = qs.filter(opening_date__year__lte=int(max_opening_year))
except (ValueError, TypeError):
pass
return qs return qs
def _apply_roller_coaster_filters(self, qs: QuerySet, params: dict) -> QuerySet: def _apply_roller_coaster_filters(self, qs: QuerySet, params: dict) -> QuerySet:
@@ -355,18 +342,14 @@ class ParkListCreateAPIView(APIView):
min_roller_coaster_count = params.get("min_roller_coaster_count") min_roller_coaster_count = params.get("min_roller_coaster_count")
if min_roller_coaster_count: if min_roller_coaster_count:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count)) qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count))
except (ValueError, TypeError):
pass
max_roller_coaster_count = params.get("max_roller_coaster_count") max_roller_coaster_count = params.get("max_roller_coaster_count")
if max_roller_coaster_count: if max_roller_coaster_count:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count)) qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
except (ValueError, TypeError):
pass
return qs return qs
@extend_schema( @extend_schema(
@@ -440,13 +423,13 @@ class ParkDetailAPIView(APIView):
def _get_park_or_404(self, identifier: str) -> Any: def _get_park_or_404(self, identifier: str) -> Any:
if not MODELS_AVAILABLE: if not MODELS_AVAILABLE:
raise NotFound( raise NotFound(
(
"Park detail is not available because domain models " "Park detail is not available because domain models "
"are not imported. Implement apps.parks.models.Park " "are not imported. Implement apps.parks.models.Park "
"to enable detail endpoints." "to enable detail endpoints."
)
) )
# Try to parse as integer ID first # Try to parse as integer ID first
try: try:
pk = int(identifier) pk = int(identifier)
@@ -475,36 +458,36 @@ class ParkDetailAPIView(APIView):
summary="Get park full details", summary="Get park full details",
description=""" description="""
Retrieve comprehensive park details including: Retrieve comprehensive park details including:
**Core Information:** **Core Information:**
- Basic park details (name, slug, description, status) - Basic park details (name, slug, description, status)
- Opening/closing dates and operating season - Opening/closing dates and operating season
- Size in acres and website URL - Size in acres and website URL
- Statistics (average rating, ride count, coaster count) - Statistics (average rating, ride count, coaster count)
**Location Data:** **Location Data:**
- Full address with coordinates - Full address with coordinates
- City, state, country information - City, state, country information
- Formatted address string - Formatted address string
**Company Information:** **Company Information:**
- Operating company details - Operating company details
- Property owner information (if different) - Property owner information (if different)
**Media:** **Media:**
- All approved photos with Cloudflare variants - All approved photos with Cloudflare variants
- Primary photo designation - Primary photo designation
- Banner and card image settings - Banner and card image settings
**Related Content:** **Related Content:**
- Park areas/themed sections - Park areas/themed sections
- Associated rides (summary) - Associated rides (summary)
**Lookup Methods:** **Lookup Methods:**
- By ID: `/api/v1/parks/123/` - By ID: `/api/v1/parks/123/`
- By current slug: `/api/v1/parks/cedar-point/` - By current slug: `/api/v1/parks/cedar-point/`
- By historical slug: `/api/v1/parks/old-cedar-point-name/` - By historical slug: `/api/v1/parks/old-cedar-point-name/`
**No Query Parameters Required** - This endpoint returns full details by default. **No Query Parameters Required** - This endpoint returns full details by default.
""", """,
responses={ responses={
@@ -598,11 +581,11 @@ class FilterOptionsAPIView(APIView):
"""Return comprehensive filter options with Rich Choice Objects metadata.""" """Return comprehensive filter options with Rich Choice Objects metadata."""
# Import Rich Choice registry # Import Rich Choice registry
from apps.core.choices.registry import get_choices from apps.core.choices.registry import get_choices
# Always get static choice definitions from Rich Choice Objects (primary source) # Always get static choice definitions from Rich Choice Objects (primary source)
park_types = get_choices('types', 'parks') park_types = get_choices('types', 'parks')
statuses = get_choices('statuses', 'parks') statuses = get_choices('statuses', 'parks')
# Convert Rich Choice Objects to frontend format with metadata # Convert Rich Choice Objects to frontend format with metadata
park_types_data = [ park_types_data = [
{ {
@@ -616,7 +599,7 @@ class FilterOptionsAPIView(APIView):
} }
for choice in park_types for choice in park_types
] ]
statuses_data = [ statuses_data = [
{ {
"value": choice.value, "value": choice.value,
@@ -629,12 +612,12 @@ class FilterOptionsAPIView(APIView):
} }
for choice in statuses for choice in statuses
] ]
# Get dynamic data from database if models are available # Get dynamic data from database if models are available
if MODELS_AVAILABLE: if MODELS_AVAILABLE:
# Add any dynamic data queries here # Add any dynamic data queries here
pass pass
return Response({ return Response({
"park_types": park_types_data, "park_types": park_types_data,
"statuses": statuses_data, "statuses": statuses_data,
@@ -707,7 +690,7 @@ class FilterOptionsAPIView(APIView):
# Get rich choice objects from registry # Get rich choice objects from registry
park_types = get_choices('types', 'parks') park_types = get_choices('types', 'parks')
statuses = get_choices('statuses', 'parks') statuses = get_choices('statuses', 'parks')
# Convert Rich Choice Objects to frontend format with metadata # Convert Rich Choice Objects to frontend format with metadata
park_types_data = [ park_types_data = [
{ {
@@ -721,7 +704,7 @@ class FilterOptionsAPIView(APIView):
} }
for choice in park_types for choice in park_types
] ]
statuses_data = [ statuses_data = [
{ {
"value": choice.value, "value": choice.value,
@@ -1118,7 +1101,7 @@ class OperatorListAPIView(APIView):
} }
for op in operators for op in operators
] ]
return Response({ return Response({
"results": data, "results": data,
"count": len(data) "count": len(data)

View File

@@ -13,27 +13,27 @@ if TYPE_CHECKING:
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.utils import timezone from django.utils import timezone
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError, NotFound from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet 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 ( from apps.api.v1.rides.serializers import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer, RidePhotoApprovalInputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoOutputSerializer,
RidePhotoStatsOutputSerializer, RidePhotoStatsOutputSerializer,
RidePhotoUpdateInputSerializer,
) )
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.rides.models.media import RidePhoto
from apps.rides.services.media_service import RideMediaService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -116,10 +116,7 @@ class RidePhotoViewSet(ModelViewSet):
def get_permissions(self): def get_permissions(self):
"""Set permissions based on action.""" """Set permissions based on action."""
if self.action in ['list', 'retrieve', 'stats']: permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def get_queryset(self): def get_queryset(self):
@@ -131,7 +128,7 @@ class RidePhotoViewSet(ModelViewSet):
# Filter by park and ride from URL kwargs # Filter by park and ride from URL kwargs
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if park_slug and ride_slug: if park_slug and ride_slug:
try: try:
park, _ = Park.get_by_slug(park_slug) park, _ = Park.get_by_slug(park_slug)
@@ -158,7 +155,7 @@ class RidePhotoViewSet(ModelViewSet):
"""Create a new ride photo using RideMediaService.""" """Create a new ride photo using RideMediaService."""
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug: if not park_slug or not ride_slug:
raise ValidationError("Park and ride slugs are required") raise ValidationError("Park and ride slugs are required")
@@ -185,7 +182,7 @@ class RidePhotoViewSet(ModelViewSet):
# Set the instance for the serializer response # Set the instance for the serializer response
serializer.instance = photo serializer.instance = photo
logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}") logger.info(f"Created ride photo {photo.id} for ride {ride.name} by user {self.request.user.username}")
except Exception as e: except Exception as e:
@@ -249,7 +246,7 @@ class RidePhotoViewSet(ModelViewSet):
RideMediaService.delete_photo( RideMediaService.delete_photo(
instance, deleted_by=self.request.user instance, deleted_by=self.request.user
) )
logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}") logger.info(f"Deleted ride photo {instance.id} by user {self.request.user.username}")
except Exception as e: except Exception as e:
logger.error(f"Error deleting ride photo: {e}") logger.error(f"Error deleting ride photo: {e}")
@@ -331,7 +328,7 @@ class RidePhotoViewSet(ModelViewSet):
validated_data = getattr(serializer, "validated_data", {}) validated_data = getattr(serializer, "validated_data", {})
photo_ids = validated_data.get("photo_ids") photo_ids = validated_data.get("photo_ids")
approve = validated_data.get("approve") approve = validated_data.get("approve")
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
@@ -381,7 +378,7 @@ class RidePhotoViewSet(ModelViewSet):
"""Get photo statistics for the ride.""" """Get photo statistics for the ride."""
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug: if not park_slug or not ride_slug:
return Response( return Response(
{"error": "Park and ride slugs are required"}, {"error": "Park and ride slugs are required"},
@@ -431,7 +428,7 @@ class RidePhotoViewSet(ModelViewSet):
"""Save a Cloudflare image as a ride photo after direct upload to Cloudflare.""" """Save a Cloudflare image as a ride photo after direct upload to Cloudflare."""
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug: if not park_slug or not ride_slug:
return Response( return Response(
{"error": "Park and ride slugs are required"}, {"error": "Park and ride slugs are required"},

View File

@@ -12,28 +12,28 @@ if TYPE_CHECKING:
pass pass
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Avg, Count, Q from django.db.models import Avg
from django.utils import timezone from django.utils import timezone
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError, NotFound from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet 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 ( from apps.api.v1.serializers.ride_reviews import (
RideReviewOutputSerializer,
RideReviewCreateInputSerializer, RideReviewCreateInputSerializer,
RideReviewUpdateInputSerializer,
RideReviewListOutputSerializer, RideReviewListOutputSerializer,
RideReviewStatsOutputSerializer,
RideReviewModerationInputSerializer, RideReviewModerationInputSerializer,
RideReviewOutputSerializer,
RideReviewStatsOutputSerializer,
RideReviewUpdateInputSerializer,
) )
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.rides.models.reviews import RideReview
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -115,10 +115,7 @@ class RideReviewViewSet(ModelViewSet):
def get_permissions(self): def get_permissions(self):
"""Set permissions based on action.""" """Set permissions based on action."""
if self.action in ['list', 'retrieve', 'stats']: permission_classes = [AllowAny] if self.action in ['list', 'retrieve', 'stats'] else [IsAuthenticated]
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def get_queryset(self): def get_queryset(self):
@@ -130,7 +127,7 @@ class RideReviewViewSet(ModelViewSet):
# Filter by park and ride from URL kwargs # Filter by park and ride from URL kwargs
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if park_slug and ride_slug: if park_slug and ride_slug:
try: try:
park, _ = Park.get_by_slug(park_slug) park, _ = Park.get_by_slug(park_slug)
@@ -141,7 +138,7 @@ class RideReviewViewSet(ModelViewSet):
return queryset.none() return queryset.none()
# Filter published reviews for non-staff users # Filter published reviews for non-staff users
if not (hasattr(self.request, 'user') and if not (hasattr(self.request, 'user') and
getattr(self.request.user, 'is_staff', False)): getattr(self.request.user, 'is_staff', False)):
queryset = queryset.filter(is_published=True) queryset = queryset.filter(is_published=True)
@@ -162,7 +159,7 @@ class RideReviewViewSet(ModelViewSet):
"""Create a new ride review.""" """Create a new ride review."""
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug: if not park_slug or not ride_slug:
raise ValidationError("Park and ride slugs are required") raise ValidationError("Park and ride slugs are required")
@@ -185,7 +182,7 @@ class RideReviewViewSet(ModelViewSet):
user=self.request.user, user=self.request.user,
is_published=True # Auto-publish for now, can add moderation later 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}") logger.info(f"Created ride review {review.id} for ride {ride.name} by user {self.request.user.username}")
except Exception as e: except Exception as e:
@@ -241,7 +238,7 @@ class RideReviewViewSet(ModelViewSet):
"""Get review statistics for the ride.""" """Get review statistics for the ride."""
park_slug = self.kwargs.get("park_slug") park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug") ride_slug = self.kwargs.get("ride_slug")
if not park_slug or not ride_slug: if not park_slug or not ride_slug:
return Response( return Response(
{"error": "Park and ride slugs are required"}, {"error": "Park and ride slugs are required"},
@@ -265,19 +262,19 @@ class RideReviewViewSet(ModelViewSet):
try: try:
# Get review statistics # Get review statistics
reviews = RideReview.objects.filter(ride=ride, is_published=True) reviews = RideReview.objects.filter(ride=ride, is_published=True)
total_reviews = reviews.count() total_reviews = reviews.count()
published_reviews = total_reviews # Since we're filtering published published_reviews = total_reviews # Since we're filtering published
pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count() pending_reviews = RideReview.objects.filter(ride=ride, is_published=False).count()
# Calculate average rating # Calculate average rating
avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating'] avg_rating = reviews.aggregate(avg_rating=Avg('rating'))['avg_rating']
# Get rating distribution # Get rating distribution
rating_distribution = {} rating_distribution = {}
for i in range(1, 11): for i in range(1, 11):
rating_distribution[str(i)] = reviews.filter(rating=i).count() rating_distribution[str(i)] = reviews.filter(rating=i).count()
# Get recent reviews count (last 30 days) # Get recent reviews count (last 30 days)
from datetime import timedelta from datetime import timedelta
thirty_days_ago = timezone.now() - timedelta(days=30) thirty_days_ago = timezone.now() - timedelta(days=30)

View File

@@ -5,12 +5,13 @@ This module contains serializers for park-specific media functionality.
Enhanced from rogue implementation to maintain full feature parity. Enhanced from rogue implementation to maintain full feature parity.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
OpenApiExample,
extend_schema_field, extend_schema_field,
extend_schema_serializer, extend_schema_serializer,
OpenApiExample,
) )
from rest_framework import serializers
from apps.parks.models import Park, ParkPhoto from apps.parks.models import Park, ParkPhoto
@@ -235,7 +236,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
Enhanced serializer for hybrid filtering strategy. Enhanced serializer for hybrid filtering strategy.
Includes all filterable fields for client-side filtering. Includes all filterable fields for client-side filtering.
""" """
# Location fields from related ParkLocation # Location fields from related ParkLocation
city = serializers.SerializerMethodField() city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField() state = serializers.SerializerMethodField()
@@ -243,19 +244,19 @@ class HybridParkSerializer(serializers.ModelSerializer):
continent = serializers.SerializerMethodField() continent = serializers.SerializerMethodField()
latitude = serializers.SerializerMethodField() latitude = serializers.SerializerMethodField()
longitude = serializers.SerializerMethodField() longitude = serializers.SerializerMethodField()
# Company fields # Company fields
operator_name = serializers.CharField(source="operator.name", read_only=True) operator_name = serializers.CharField(source="operator.name", read_only=True)
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True) property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
# Image URLs for display # Image URLs for display
banner_image_url = serializers.SerializerMethodField() banner_image_url = serializers.SerializerMethodField()
card_image_url = serializers.SerializerMethodField() card_image_url = serializers.SerializerMethodField()
# Computed fields for filtering # Computed fields for filtering
opening_year = serializers.IntegerField(read_only=True) opening_year = serializers.IntegerField(read_only=True)
search_text = serializers.CharField(read_only=True) search_text = serializers.CharField(read_only=True)
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_city(self, obj): def get_city(self, obj):
"""Get city from related location.""" """Get city from related location."""
@@ -263,7 +264,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
return obj.location.city if hasattr(obj, 'location') and obj.location else None return obj.location.city if hasattr(obj, 'location') and obj.location else None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_state(self, obj): def get_state(self, obj):
"""Get state from related location.""" """Get state from related location."""
@@ -271,7 +272,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
return obj.location.state if hasattr(obj, 'location') and obj.location else None return obj.location.state if hasattr(obj, 'location') and obj.location else None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_country(self, obj): def get_country(self, obj):
"""Get country from related location.""" """Get country from related location."""
@@ -279,7 +280,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
return obj.location.country if hasattr(obj, 'location') and obj.location else None return obj.location.country if hasattr(obj, 'location') and obj.location else None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_continent(self, obj): def get_continent(self, obj):
"""Get continent from related location.""" """Get continent from related location."""
@@ -287,7 +288,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
return obj.location.continent if hasattr(obj, 'location') and obj.location else None return obj.location.continent if hasattr(obj, 'location') and obj.location else None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.FloatField(allow_null=True)) @extend_schema_field(serializers.FloatField(allow_null=True))
def get_latitude(self, obj): def get_latitude(self, obj):
"""Get latitude from related location.""" """Get latitude from related location."""
@@ -297,7 +298,7 @@ class HybridParkSerializer(serializers.ModelSerializer):
return None return None
except (AttributeError, IndexError, TypeError): except (AttributeError, IndexError, TypeError):
return None return None
@extend_schema_field(serializers.FloatField(allow_null=True)) @extend_schema_field(serializers.FloatField(allow_null=True))
def get_longitude(self, obj): def get_longitude(self, obj):
"""Get longitude from related location.""" """Get longitude from related location."""
@@ -307,14 +308,14 @@ class HybridParkSerializer(serializers.ModelSerializer):
return None return None
except (AttributeError, IndexError, TypeError): except (AttributeError, IndexError, TypeError):
return None return None
@extend_schema_field(serializers.URLField(allow_null=True)) @extend_schema_field(serializers.URLField(allow_null=True))
def get_banner_image_url(self, obj): def get_banner_image_url(self, obj):
"""Get banner image URL.""" """Get banner image URL."""
if obj.banner_image and obj.banner_image.image: if obj.banner_image and obj.banner_image.image:
return obj.banner_image.image.url return obj.banner_image.image.url
return None return None
@extend_schema_field(serializers.URLField(allow_null=True)) @extend_schema_field(serializers.URLField(allow_null=True))
def get_card_image_url(self, obj): def get_card_image_url(self, obj):
"""Get card image URL.""" """Get card image URL."""
@@ -332,42 +333,42 @@ class HybridParkSerializer(serializers.ModelSerializer):
"description", "description",
"status", "status",
"park_type", "park_type",
# Dates and computed fields # Dates and computed fields
"opening_date", "opening_date",
"closing_date", "closing_date",
"opening_year", "opening_year",
"operating_season", "operating_season",
# Location fields # Location fields
"city", "city",
"state", "state",
"country", "country",
"continent", "continent",
"latitude", "latitude",
"longitude", "longitude",
# Company relationships # Company relationships
"operator_name", "operator_name",
"property_owner_name", "property_owner_name",
# Statistics # Statistics
"size_acres", "size_acres",
"average_rating", "average_rating",
"ride_count", "ride_count",
"coaster_count", "coaster_count",
# Images # Images
"banner_image_url", "banner_image_url",
"card_image_url", "card_image_url",
# URLs # URLs
"website", "website",
"url", "url",
# Computed fields for filtering # Computed fields for filtering
"search_text", "search_text",
# Metadata # Metadata
"created_at", "created_at",
"updated_at", "updated_at",

View File

@@ -6,28 +6,10 @@ intentionally expansive to match the rides API functionality and provide
complete feature parity for parks management. complete feature parity for parks management.
""" """
from django.urls import path, include from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .park_views import (
ParkListCreateAPIView,
ParkDetailAPIView,
FilterOptionsAPIView,
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
OperatorListAPIView,
)
from .park_rides_views import (
ParkRidesListAPIView,
ParkRideDetailAPIView,
ParkComprehensiveDetailAPIView,
)
from apps.parks.views import location_search, reverse_geocode from apps.parks.views import location_search, reverse_geocode
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
from .ride_photos_views import RidePhotoViewSet
from .ride_photos_views import RidePhotoViewSet
from .ride_reviews_views import RideReviewViewSet
from apps.parks.views_roadtrip import ( from apps.parks.views_roadtrip import (
CreateTripView, CreateTripView,
FindParksAlongRouteView, FindParksAlongRouteView,
@@ -35,6 +17,24 @@ from apps.parks.views_roadtrip import (
ParkDistanceCalculatorView, ParkDistanceCalculatorView,
) )
from .park_rides_views import (
ParkComprehensiveDetailAPIView,
ParkRideDetailAPIView,
ParkRidesListAPIView,
)
from .park_views import (
CompanySearchAPIView,
FilterOptionsAPIView,
OperatorListAPIView,
ParkDetailAPIView,
ParkImageSettingsAPIView,
ParkListCreateAPIView,
ParkSearchSuggestionsAPIView,
)
from .ride_photos_views import RidePhotoViewSet
from .ride_reviews_views import RideReviewViewSet
from .views import HybridParkAPIView, ParkFilterMetadataAPIView, ParkPhotoViewSet
# Create router for nested photo endpoints # Create router for nested photo endpoints
router = DefaultRouter() router = DefaultRouter()
router.register(r"", ParkPhotoViewSet, basename="park-photo") router.register(r"", ParkPhotoViewSet, basename="park-photo")
@@ -42,13 +42,12 @@ router.register(r"", ParkPhotoViewSet, basename="park-photo")
# Create routers for nested ride endpoints # Create routers for nested ride endpoints
ride_photos_router = DefaultRouter() ride_photos_router = DefaultRouter()
ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo") ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
from .ride_reviews_views import RideReviewViewSet
ride_reviews_router = DefaultRouter() ride_reviews_router = DefaultRouter()
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review") ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
from .park_reviews_views import ParkReviewViewSet
from .history_views import ParkHistoryViewSet, RideHistoryViewSet from .history_views import ParkHistoryViewSet, RideHistoryViewSet
from .park_reviews_views import ParkReviewViewSet
# Create routers for nested park endpoints # Create routers for nested park endpoints
reviews_router = DefaultRouter() reviews_router = DefaultRouter()
@@ -60,11 +59,11 @@ app_name = "api_v1_parks"
urlpatterns = [ urlpatterns = [
# Core list/create endpoints # Core list/create endpoints
path("", ParkListCreateAPIView.as_view(), name="park-list-create"), path("", ParkListCreateAPIView.as_view(), name="park-list-create"),
# Hybrid filtering endpoints # Hybrid filtering endpoints
path("hybrid/", HybridParkAPIView.as_view(), name="park-hybrid-list"), path("hybrid/", HybridParkAPIView.as_view(), name="park-hybrid-list"),
path("hybrid/filter-metadata/", ParkFilterMetadataAPIView.as_view(), name="park-hybrid-filter-metadata"), path("hybrid/filter-metadata/", ParkFilterMetadataAPIView.as_view(), name="park-hybrid-filter-metadata"),
# Filter options # Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"), path("filter-options/", FilterOptionsAPIView.as_view(), name="park-filter-options"),
# Autocomplete / suggestion endpoints # Autocomplete / suggestion endpoints
@@ -80,14 +79,14 @@ urlpatterns = [
), ),
# Detail and action endpoints - supports both ID and slug # Detail and action endpoints - supports both ID and slug
path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"), path("<str:pk>/", ParkDetailAPIView.as_view(), name="park-detail"),
# Park rides endpoints # Park rides endpoints
path("<str:park_slug>/rides/", ParkRidesListAPIView.as_view(), name="park-rides-list"), 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"), path("<str:park_slug>/rides/<str:ride_slug>/", ParkRideDetailAPIView.as_view(), name="park-ride-detail"),
# Comprehensive park detail endpoint with rides summary # Comprehensive park detail endpoint with rides summary
path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"), path("<str:park_slug>/detail/", ParkComprehensiveDetailAPIView.as_view(), name="park-comprehensive-detail"),
# Park image settings endpoint # Park image settings endpoint
path( path(
"<int:pk>/image-settings/", "<int:pk>/image-settings/",
@@ -96,21 +95,21 @@ urlpatterns = [
), ),
# Park photo endpoints - domain-specific photo management # Park photo endpoints - domain-specific photo management
path("<str:park_pk>/photos/", include(router.urls)), path("<str:park_pk>/photos/", include(router.urls)),
# Nested ride photo endpoints - photos for specific rides within parks # Nested ride photo endpoints - photos for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)), path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
# Nested ride review endpoints - reviews for specific rides within parks # Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)), path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Nested ride review endpoints - reviews for specific rides within parks # Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)), path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Ride History # Ride History
path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"), path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"),
# Park Reviews # Park Reviews
path("<str:park_slug>/reviews/", include(reviews_router.urls)), path("<str:park_slug>/reviews/", include(reviews_router.urls)),
# Park History # Park History
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"), path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"),

View File

@@ -26,14 +26,13 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from apps.core.decorators.cache_decorators import cache_api_response
from apps.core.exceptions import ( from apps.core.exceptions import (
NotFoundError, NotFoundError,
PermissionDeniedError,
ServiceError, ServiceError,
ValidationException, ValidationException,
) )
from apps.core.utils.error_handling import ErrorHandler from apps.core.utils.error_handling import ErrorHandler
from apps.core.decorators.cache_decorators import cache_api_response
from apps.parks.models import Park, ParkPhoto from apps.parks.models import Park, ParkPhoto
from apps.parks.services import ParkMediaService from apps.parks.services import ParkMediaService
from apps.parks.services.hybrid_loader import smart_park_loader from apps.parks.services.hybrid_loader import smart_park_loader
@@ -130,10 +129,7 @@ class ParkPhotoViewSet(ModelViewSet):
def get_permissions(self): def get_permissions(self):
"""Set permissions based on action.""" """Set permissions based on action."""
if self.action in ["list", "retrieve", "stats"]: permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def get_queryset(self): # type: ignore[override] def get_queryset(self): # type: ignore[override]
@@ -171,11 +167,8 @@ class ParkPhotoViewSet(ModelViewSet):
raise ValidationError("Park ID/Slug is required") raise ValidationError("Park ID/Slug is required")
try: try:
if str(park_id).isdigit(): park = Park.objects.get(pk=park_id) if str(park_id).isdigit() else Park.objects.get(slug=park_id)
park = Park.objects.get(pk=park_id)
else:
park = Park.objects.get(slug=park_id)
# Use real park ID # Use real park ID
park_id = park.id park_id = park.id
except Park.DoesNotExist: except Park.DoesNotExist:
@@ -398,10 +391,7 @@ class ParkPhotoViewSet(ModelViewSet):
park = None park = None
if park_pk: if park_pk:
try: try:
if str(park_pk).isdigit(): park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
park = Park.objects.get(pk=park_pk)
else:
park = Park.objects.get(slug=park_pk)
except Park.DoesNotExist: except Park.DoesNotExist:
return ErrorHandler.handle_api_error( return ErrorHandler.handle_api_error(
NotFoundError(f"Park with id/slug {park_pk} not found"), NotFoundError(f"Park with id/slug {park_pk} not found"),
@@ -490,10 +480,7 @@ class ParkPhotoViewSet(ModelViewSet):
) )
try: try:
if str(park_pk).isdigit(): park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
park = Park.objects.get(pk=park_pk)
else:
park = Park.objects.get(slug=park_pk)
except Park.DoesNotExist: except Park.DoesNotExist:
return Response( return Response(
{"error": "Park not found"}, {"error": "Park not found"},
@@ -509,9 +496,9 @@ class ParkPhotoViewSet(ModelViewSet):
try: try:
# Import CloudflareImage model and service # Import CloudflareImage model and service
from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService from django_cloudflareimages_toolkit.services import CloudflareImagesService
from django.utils import timezone
# Always fetch the latest image data from Cloudflare API # Always fetch the latest image data from Cloudflare API
# Get image details from Cloudflare API # Get image details from Cloudflare API

View File

@@ -0,0 +1,12 @@
"""URL routes for Company CRUD API."""
from django.urls import path
from .company_views import CompanyDetailAPIView, CompanyListCreateAPIView
app_name = "api_v1_companies"
urlpatterns = [
path("", CompanyListCreateAPIView.as_view(), name="company-list-create"),
path("<int:pk>/", CompanyDetailAPIView.as_view(), name="company-detail"),
]

View File

@@ -0,0 +1,167 @@
"""
Company API views for ThrillWiki API v1.
This module implements CRUD endpoints for company management:
- List / Create: GET /companies/ POST /companies/
- Retrieve / Update / Delete: GET /companies/{id}/ PATCH/PUT/DELETE
"""
from django.db.models import Q
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.api.v1.serializers.companies import (
CompanyCreateInputSerializer,
CompanyDetailOutputSerializer,
CompanyUpdateInputSerializer,
)
try:
from apps.rides.models.company import Company
MODELS_AVAILABLE = True
except ImportError:
Company = None
MODELS_AVAILABLE = False
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size"
max_page_size = 100
class CompanyListCreateAPIView(APIView):
"""List and create companies."""
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List all companies",
description="List companies with optional search and role filtering.",
parameters=[
OpenApiParameter(name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter(name="role", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR),
OpenApiParameter(name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT),
],
responses={200: CompanyDetailOutputSerializer(many=True)},
tags=["Companies"],
)
def get(self, request: Request) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Company models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
qs = Company.objects.all().order_by("name")
# Search filter
search = request.query_params.get("search", "")
if search:
qs = qs.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Role filter
role = request.query_params.get("role", "")
if role:
qs = qs.filter(roles__contains=[role])
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
serializer = CompanyDetailOutputSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
@extend_schema(
summary="Create a new company",
description="Create a new company with the given details.",
request=CompanyCreateInputSerializer,
responses={201: CompanyDetailOutputSerializer()},
tags=["Companies"],
)
def post(self, request: Request) -> Response:
if not MODELS_AVAILABLE:
return Response(
{"detail": "Company models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
serializer_in = CompanyCreateInputSerializer(data=request.data)
serializer_in.is_valid(raise_exception=True)
validated = serializer_in.validated_data
company = Company.objects.create(
name=validated["name"],
roles=validated["roles"],
description=validated.get("description", ""),
website=validated.get("website", ""),
founded_date=validated.get("founded_date"),
)
serializer = CompanyDetailOutputSerializer(company)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class CompanyDetailAPIView(APIView):
"""Retrieve, update, and delete a company."""
permission_classes = [permissions.AllowAny]
def _get_company_or_404(self, pk: int) -> "Company":
if not MODELS_AVAILABLE:
raise NotFound("Company models not available")
try:
return Company.objects.get(pk=pk)
except Company.DoesNotExist:
raise NotFound("Company not found")
@extend_schema(
summary="Retrieve a company",
description="Get detailed information about a specific company.",
responses={200: CompanyDetailOutputSerializer()},
tags=["Companies"],
)
def get(self, request: Request, pk: int) -> Response:
company = self._get_company_or_404(pk)
serializer = CompanyDetailOutputSerializer(company)
return Response(serializer.data)
@extend_schema(
summary="Update a company",
description="Update a company (partial update supported).",
request=CompanyUpdateInputSerializer,
responses={200: CompanyDetailOutputSerializer()},
tags=["Companies"],
)
def patch(self, request: Request, pk: int) -> Response:
company = self._get_company_or_404(pk)
serializer_in = CompanyUpdateInputSerializer(data=request.data, partial=True)
serializer_in.is_valid(raise_exception=True)
for field, value in serializer_in.validated_data.items():
setattr(company, field, value)
company.save()
serializer = CompanyDetailOutputSerializer(company)
return Response(serializer.data)
def put(self, request: Request, pk: int) -> Response:
return self.patch(request, pk)
@extend_schema(
summary="Delete a company",
description="Delete a company.",
responses={204: None},
tags=["Companies"],
)
def delete(self, request: Request, pk: int) -> Response:
company = self._get_company_or_404(pk)
company.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -11,17 +11,17 @@ This file exposes comprehensive endpoints for ride model management:
from django.urls import path from django.urls import path
from .views import ( from .views import (
RideModelListCreateAPIView,
RideModelDetailAPIView, RideModelDetailAPIView,
RideModelSearchAPIView,
RideModelFilterOptionsAPIView, RideModelFilterOptionsAPIView,
RideModelStatsAPIView, RideModelListCreateAPIView,
RideModelVariantListCreateAPIView,
RideModelVariantDetailAPIView,
RideModelTechnicalSpecListCreateAPIView,
RideModelTechnicalSpecDetailAPIView,
RideModelPhotoListCreateAPIView,
RideModelPhotoDetailAPIView, RideModelPhotoDetailAPIView,
RideModelPhotoListCreateAPIView,
RideModelSearchAPIView,
RideModelStatsAPIView,
RideModelTechnicalSpecDetailAPIView,
RideModelTechnicalSpecListCreateAPIView,
RideModelVariantDetailAPIView,
RideModelVariantListCreateAPIView,
) )
app_name = "api_v1_ride_models" app_name = "api_v1_ride_models"

View File

@@ -12,40 +12,40 @@ This module implements comprehensive endpoints for ride model management:
- Photos: CRUD operations for ride model photos - Photos: CRUD operations for ride model photos
""" """
from typing import Any
from datetime import timedelta from datetime import timedelta
from typing import Any
from rest_framework import status, permissions from django.db.models import Count, Q
from rest_framework.views import APIView from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination from rest_framework.views import APIView
from rest_framework.exceptions import NotFound, ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.db.models import Q, Count
from django.utils import timezone
# Import serializers # Import serializers
from apps.api.v1.serializers.ride_models import ( from apps.api.v1.serializers.ride_models import (
RideModelListOutputSerializer,
RideModelDetailOutputSerializer,
RideModelCreateInputSerializer, RideModelCreateInputSerializer,
RideModelUpdateInputSerializer, RideModelDetailOutputSerializer,
RideModelFilterInputSerializer, RideModelFilterInputSerializer,
RideModelVariantOutputSerializer, RideModelListOutputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantUpdateInputSerializer,
RideModelStatsOutputSerializer, RideModelStatsOutputSerializer,
RideModelUpdateInputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantOutputSerializer,
RideModelVariantUpdateInputSerializer,
) )
# Attempt to import models; fall back gracefully if not present # Attempt to import models; fall back gracefully if not present
try: try:
from apps.rides.models import ( from apps.rides.models import (
RideModel, RideModel,
RideModelVariant,
RideModelPhoto, RideModelPhoto,
RideModelTechnicalSpec, RideModelTechnicalSpec,
RideModelVariant,
) )
from apps.rides.models.company import Company from apps.rides.models.company import Company
@@ -54,12 +54,12 @@ except ImportError:
try: try:
# Try alternative import path # Try alternative import path
from apps.rides.models.rides import ( from apps.rides.models.rides import (
Company,
RideModel, RideModel,
RideModelVariant,
RideModelPhoto, RideModelPhoto,
RideModelTechnicalSpec, RideModelTechnicalSpec,
RideModelVariant,
) )
from apps.rides.models.rides import Company
MODELS_AVAILABLE = True MODELS_AVAILABLE = True
except ImportError: except ImportError:
@@ -486,14 +486,14 @@ class RideModelFilterOptionsAPIView(APIView):
"""Return filter options for ride models with Rich Choice Objects metadata.""" """Return filter options for ride models with Rich Choice Objects metadata."""
# Import Rich Choice registry # Import Rich Choice registry
from apps.core.choices.registry import get_choices from apps.core.choices.registry import get_choices
if not MODELS_AVAILABLE: if not MODELS_AVAILABLE:
# Use Rich Choice Objects for fallback options # Use Rich Choice Objects for fallback options
try: try:
# Get rich choice objects from registry # Get rich choice objects from registry
categories = get_choices('categories', 'rides') categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides') target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata # Convert Rich Choice Objects to frontend format with metadata
categories_data = [ categories_data = [
{ {
@@ -507,7 +507,7 @@ class RideModelFilterOptionsAPIView(APIView):
} }
for choice in categories for choice in categories
] ]
target_markets_data = [ target_markets_data = [
{ {
"value": choice.value, "value": choice.value,
@@ -520,7 +520,7 @@ class RideModelFilterOptionsAPIView(APIView):
} }
for choice in target_markets for choice in target_markets
] ]
except Exception: except Exception:
# Ultimate fallback with basic structure # Ultimate fallback with basic structure
categories_data = [ categories_data = [
@@ -538,7 +538,7 @@ class RideModelFilterOptionsAPIView(APIView):
{"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": "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}, {"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({ return Response({
"categories": categories_data, "categories": categories_data,
"target_markets": target_markets_data, "target_markets": target_markets_data,
@@ -557,11 +557,11 @@ class RideModelFilterOptionsAPIView(APIView):
# Get static choice definitions from Rich Choice Objects (primary source) # Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries # Get dynamic data from database queries
# Get rich choice objects from registry # Get rich choice objects from registry
categories = get_choices('categories', 'rides') categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides') target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata # Convert Rich Choice Objects to frontend format with metadata
categories_data = [ categories_data = [
{ {
@@ -575,7 +575,7 @@ class RideModelFilterOptionsAPIView(APIView):
} }
for choice in categories for choice in categories
] ]
target_markets_data = [ target_markets_data = [
{ {
"value": choice.value, "value": choice.value,

View File

@@ -5,23 +5,25 @@ This module contains ride photo ViewSet following the parks pattern for domain c
Enhanced from centralized media API to provide domain-specific ride photo management. Enhanced from centralized media API to provide domain-specific ride photo management.
""" """
from .serializers import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer,
RidePhotoStatsOutputSerializer,
)
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .serializers import (
RidePhotoApprovalInputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoOutputSerializer,
RidePhotoStatsOutputSerializer,
RidePhotoUpdateInputSerializer,
)
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
import logging import logging
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@@ -29,9 +31,8 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from apps.rides.models import RidePhoto, Ride from apps.rides.models import Ride, RidePhoto
from apps.rides.services.media_service import RideMediaService from apps.rides.services.media_service import RideMediaService
from django.contrib.auth import get_user_model
UserModel = get_user_model() UserModel = get_user_model()
@@ -460,9 +461,9 @@ class RidePhotoViewSet(ModelViewSet):
try: try:
# Import CloudflareImage model and service # Import CloudflareImage model and service
from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService from django_cloudflareimages_toolkit.services import CloudflareImagesService
from django.utils import timezone
# Always fetch the latest image data from Cloudflare API # Always fetch the latest image data from Cloudflare API
try: try:

View File

@@ -4,12 +4,13 @@ Ride media serializers for ThrillWiki API v1.
This module contains serializers for ride-specific media functionality. This module contains serializers for ride-specific media functionality.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
OpenApiExample,
extend_schema_field, extend_schema_field,
extend_schema_serializer, extend_schema_serializer,
OpenApiExample,
) )
from rest_framework import serializers
from apps.rides.models import Ride, RidePhoto from apps.rides.models import Ride, RidePhoto
@@ -267,33 +268,33 @@ class HybridRideSerializer(serializers.ModelSerializer):
Enhanced serializer for hybrid filtering strategy. Enhanced serializer for hybrid filtering strategy.
Includes all filterable fields for client-side filtering. Includes all filterable fields for client-side filtering.
""" """
# Park fields # Park fields
park_name = serializers.CharField(source="park.name", read_only=True) park_name = serializers.CharField(source="park.name", read_only=True)
park_slug = serializers.CharField(source="park.slug", read_only=True) park_slug = serializers.CharField(source="park.slug", read_only=True)
# Park location fields # Park location fields
park_city = serializers.SerializerMethodField() park_city = serializers.SerializerMethodField()
park_state = serializers.SerializerMethodField() park_state = serializers.SerializerMethodField()
park_country = serializers.SerializerMethodField() park_country = serializers.SerializerMethodField()
# Park area fields # Park area fields
park_area_name = serializers.CharField(source="park_area.name", read_only=True, allow_null=True) park_area_name = serializers.CharField(source="park_area.name", read_only=True, allow_null=True)
park_area_slug = serializers.CharField(source="park_area.slug", read_only=True, allow_null=True) park_area_slug = serializers.CharField(source="park_area.slug", read_only=True, allow_null=True)
# Company fields # Company fields
manufacturer_name = serializers.CharField(source="manufacturer.name", read_only=True, allow_null=True) manufacturer_name = serializers.CharField(source="manufacturer.name", read_only=True, allow_null=True)
manufacturer_slug = serializers.CharField(source="manufacturer.slug", read_only=True, allow_null=True) manufacturer_slug = serializers.CharField(source="manufacturer.slug", read_only=True, allow_null=True)
designer_name = serializers.CharField(source="designer.name", read_only=True, allow_null=True) designer_name = serializers.CharField(source="designer.name", read_only=True, allow_null=True)
designer_slug = serializers.CharField(source="designer.slug", read_only=True, allow_null=True) designer_slug = serializers.CharField(source="designer.slug", read_only=True, allow_null=True)
# Ride model fields # Ride model fields
ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True) ride_model_name = serializers.CharField(source="ride_model.name", read_only=True, allow_null=True)
ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True) ride_model_slug = serializers.CharField(source="ride_model.slug", read_only=True, allow_null=True)
ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True) ride_model_category = serializers.CharField(source="ride_model.category", read_only=True, allow_null=True)
ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True) ride_model_manufacturer_name = serializers.CharField(source="ride_model.manufacturer.name", read_only=True, allow_null=True)
ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True) ride_model_manufacturer_slug = serializers.CharField(source="ride_model.manufacturer.slug", read_only=True, allow_null=True)
# Roller coaster stats fields # Roller coaster stats fields
coaster_height_ft = serializers.SerializerMethodField() coaster_height_ft = serializers.SerializerMethodField()
coaster_length_ft = serializers.SerializerMethodField() coaster_length_ft = serializers.SerializerMethodField()
@@ -309,15 +310,15 @@ class HybridRideSerializer(serializers.ModelSerializer):
coaster_trains_count = serializers.SerializerMethodField() coaster_trains_count = serializers.SerializerMethodField()
coaster_cars_per_train = serializers.SerializerMethodField() coaster_cars_per_train = serializers.SerializerMethodField()
coaster_seats_per_car = serializers.SerializerMethodField() coaster_seats_per_car = serializers.SerializerMethodField()
# Image URLs for display # Image URLs for display
banner_image_url = serializers.SerializerMethodField() banner_image_url = serializers.SerializerMethodField()
card_image_url = serializers.SerializerMethodField() card_image_url = serializers.SerializerMethodField()
# Computed fields for filtering # Computed fields for filtering
opening_year = serializers.IntegerField(read_only=True) opening_year = serializers.IntegerField(read_only=True)
search_text = serializers.CharField(read_only=True) search_text = serializers.CharField(read_only=True)
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_park_city(self, obj): def get_park_city(self, obj):
"""Get city from park location.""" """Get city from park location."""
@@ -327,7 +328,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_park_state(self, obj): def get_park_state(self, obj):
"""Get state from park location.""" """Get state from park location."""
@@ -337,7 +338,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_park_country(self, obj): def get_park_country(self, obj):
"""Get country from park location.""" """Get country from park location."""
@@ -347,7 +348,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.FloatField(allow_null=True)) @extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_height_ft(self, obj): def get_coaster_height_ft(self, obj):
"""Get roller coaster height.""" """Get roller coaster height."""
@@ -357,7 +358,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except (AttributeError, TypeError): except (AttributeError, TypeError):
return None return None
@extend_schema_field(serializers.FloatField(allow_null=True)) @extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_length_ft(self, obj): def get_coaster_length_ft(self, obj):
"""Get roller coaster length.""" """Get roller coaster length."""
@@ -367,7 +368,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except (AttributeError, TypeError): except (AttributeError, TypeError):
return None return None
@extend_schema_field(serializers.FloatField(allow_null=True)) @extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_speed_mph(self, obj): def get_coaster_speed_mph(self, obj):
"""Get roller coaster speed.""" """Get roller coaster speed."""
@@ -377,7 +378,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except (AttributeError, TypeError): except (AttributeError, TypeError):
return None return None
@extend_schema_field(serializers.IntegerField(allow_null=True)) @extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_inversions(self, obj): def get_coaster_inversions(self, obj):
"""Get roller coaster inversions.""" """Get roller coaster inversions."""
@@ -387,7 +388,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.IntegerField(allow_null=True)) @extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_ride_time_seconds(self, obj): def get_coaster_ride_time_seconds(self, obj):
"""Get roller coaster ride time.""" """Get roller coaster ride time."""
@@ -397,7 +398,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_track_type(self, obj): def get_coaster_track_type(self, obj):
"""Get roller coaster track type.""" """Get roller coaster track type."""
@@ -407,7 +408,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_track_material(self, obj): def get_coaster_track_material(self, obj):
"""Get roller coaster track material.""" """Get roller coaster track material."""
@@ -417,7 +418,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_roller_coaster_type(self, obj): def get_coaster_roller_coaster_type(self, obj):
"""Get roller coaster type.""" """Get roller coaster type."""
@@ -427,7 +428,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.FloatField(allow_null=True)) @extend_schema_field(serializers.FloatField(allow_null=True))
def get_coaster_max_drop_height_ft(self, obj): def get_coaster_max_drop_height_ft(self, obj):
"""Get roller coaster max drop height.""" """Get roller coaster max drop height."""
@@ -437,7 +438,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except (AttributeError, TypeError): except (AttributeError, TypeError):
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_propulsion_system(self, obj): def get_coaster_propulsion_system(self, obj):
"""Get roller coaster propulsion system.""" """Get roller coaster propulsion system."""
@@ -447,7 +448,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.CharField(allow_null=True)) @extend_schema_field(serializers.CharField(allow_null=True))
def get_coaster_train_style(self, obj): def get_coaster_train_style(self, obj):
"""Get roller coaster train style.""" """Get roller coaster train style."""
@@ -457,7 +458,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.IntegerField(allow_null=True)) @extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_trains_count(self, obj): def get_coaster_trains_count(self, obj):
"""Get roller coaster trains count.""" """Get roller coaster trains count."""
@@ -467,7 +468,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.IntegerField(allow_null=True)) @extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_cars_per_train(self, obj): def get_coaster_cars_per_train(self, obj):
"""Get roller coaster cars per train.""" """Get roller coaster cars per train."""
@@ -477,7 +478,7 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.IntegerField(allow_null=True)) @extend_schema_field(serializers.IntegerField(allow_null=True))
def get_coaster_seats_per_car(self, obj): def get_coaster_seats_per_car(self, obj):
"""Get roller coaster seats per car.""" """Get roller coaster seats per car."""
@@ -487,14 +488,14 @@ class HybridRideSerializer(serializers.ModelSerializer):
return None return None
except AttributeError: except AttributeError:
return None return None
@extend_schema_field(serializers.URLField(allow_null=True)) @extend_schema_field(serializers.URLField(allow_null=True))
def get_banner_image_url(self, obj): def get_banner_image_url(self, obj):
"""Get banner image URL.""" """Get banner image URL."""
if obj.banner_image and obj.banner_image.image: if obj.banner_image and obj.banner_image.image:
return obj.banner_image.image.url return obj.banner_image.image.url
return None return None
@extend_schema_field(serializers.URLField(allow_null=True)) @extend_schema_field(serializers.URLField(allow_null=True))
def get_card_image_url(self, obj): def get_card_image_url(self, obj):
"""Get card image URL.""" """Get card image URL."""
@@ -513,44 +514,44 @@ class HybridRideSerializer(serializers.ModelSerializer):
"category", "category",
"status", "status",
"post_closing_status", "post_closing_status",
# Dates and computed fields # Dates and computed fields
"opening_date", "opening_date",
"closing_date", "closing_date",
"status_since", "status_since",
"opening_year", "opening_year",
# Park fields # Park fields
"park_name", "park_name",
"park_slug", "park_slug",
"park_city", "park_city",
"park_state", "park_state",
"park_country", "park_country",
# Park area fields # Park area fields
"park_area_name", "park_area_name",
"park_area_slug", "park_area_slug",
# Company fields # Company fields
"manufacturer_name", "manufacturer_name",
"manufacturer_slug", "manufacturer_slug",
"designer_name", "designer_name",
"designer_slug", "designer_slug",
# Ride model fields # Ride model fields
"ride_model_name", "ride_model_name",
"ride_model_slug", "ride_model_slug",
"ride_model_category", "ride_model_category",
"ride_model_manufacturer_name", "ride_model_manufacturer_name",
"ride_model_manufacturer_slug", "ride_model_manufacturer_slug",
# Ride specifications # Ride specifications
"min_height_in", "min_height_in",
"max_height_in", "max_height_in",
"capacity_per_hour", "capacity_per_hour",
"ride_duration_seconds", "ride_duration_seconds",
"average_rating", "average_rating",
# Roller coaster stats # Roller coaster stats
"coaster_height_ft", "coaster_height_ft",
"coaster_length_ft", "coaster_length_ft",
@@ -566,18 +567,18 @@ class HybridRideSerializer(serializers.ModelSerializer):
"coaster_trains_count", "coaster_trains_count",
"coaster_cars_per_train", "coaster_cars_per_train",
"coaster_seats_per_car", "coaster_seats_per_car",
# Images # Images
"banner_image_url", "banner_image_url",
"card_image_url", "card_image_url",
# URLs # URLs
"url", "url",
"park_url", "park_url",
# Computed fields for filtering # Computed fields for filtering
"search_text", "search_text",
# Metadata # Metadata
"created_at", "created_at",
"updated_at", "updated_at",

View File

@@ -8,23 +8,23 @@ actions (bulk, publish, export, import, recommendations) should be added
to the views module when business logic is available. to the views module when business logic is available.
""" """
from django.urls import path, include from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .photo_views import RidePhotoViewSet
from .views import ( from .views import (
RideListCreateAPIView,
RideDetailAPIView,
FilterOptionsAPIView,
CompanySearchAPIView, CompanySearchAPIView,
DesignerListAPIView,
FilterOptionsAPIView,
HybridRideAPIView,
ManufacturerListAPIView,
RideDetailAPIView,
RideFilterMetadataAPIView,
RideImageSettingsAPIView,
RideListCreateAPIView,
RideModelSearchAPIView, RideModelSearchAPIView,
RideSearchSuggestionsAPIView, RideSearchSuggestionsAPIView,
RideImageSettingsAPIView,
HybridRideAPIView,
RideFilterMetadataAPIView,
ManufacturerListAPIView,
DesignerListAPIView,
) )
from .photo_views import RidePhotoViewSet
# Create router for nested photo endpoints # Create router for nested photo endpoints
router = DefaultRouter() router = DefaultRouter()
@@ -35,11 +35,11 @@ app_name = "api_v1_rides"
urlpatterns = [ urlpatterns = [
# Core list/create endpoints # Core list/create endpoints
path("", RideListCreateAPIView.as_view(), name="ride-list-create"), path("", RideListCreateAPIView.as_view(), name="ride-list-create"),
# Hybrid filtering endpoints # Hybrid filtering endpoints
path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"), path("hybrid/", HybridRideAPIView.as_view(), name="ride-hybrid-filtering"),
path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"), path("hybrid/filter-metadata/", RideFilterMetadataAPIView.as_view(), name="ride-hybrid-filter-metadata"),
# Filter options # Filter options
path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"), path("filter-options/", FilterOptionsAPIView.as_view(), name="ride-filter-options"),
# Autocomplete / suggestion endpoints # Autocomplete / suggestion endpoints

View File

@@ -23,12 +23,13 @@ Caching Strategy:
- RideSearchSuggestionsAPIView.get: 5 minutes (300s) - suggestions should be fresh - RideSearchSuggestionsAPIView.get: 5 minutes (300s) - suggestions should be fresh
""" """
import contextlib
import logging import logging
from typing import Any from typing import Any
from django.db import models from django.db import models
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import permissions, status from rest_framework import permissions, status
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
@@ -53,9 +54,9 @@ smart_ride_loader = SmartRideLoader()
# Attempt to import model-level helpers; fall back gracefully if not present. # Attempt to import model-level helpers; fall back gracefully if not present.
try: try:
from apps.parks.models import Company, Park
from apps.rides.models import Ride, RideModel from apps.rides.models import Ride, RideModel
from apps.rides.models.rides import RollerCoasterStats from apps.rides.models.rides import RollerCoasterStats
from apps.parks.models import Park, Company
MODELS_AVAILABLE = True MODELS_AVAILABLE = True
except Exception: except Exception:
@@ -370,10 +371,8 @@ class RideListCreateAPIView(APIView):
park_id = params.get("park_id") park_id = params.get("park_id")
if park_id: if park_id:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(park_id=int(park_id)) qs = qs.filter(park_id=int(park_id))
except (ValueError, TypeError):
pass
return qs return qs
@@ -393,10 +392,8 @@ class RideListCreateAPIView(APIView):
"""Apply manufacturer and designer filtering.""" """Apply manufacturer and designer filtering."""
manufacturer_id = params.get("manufacturer_id") manufacturer_id = params.get("manufacturer_id")
if manufacturer_id: if manufacturer_id:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(manufacturer_id=int(manufacturer_id)) qs = qs.filter(manufacturer_id=int(manufacturer_id))
except (ValueError, TypeError):
pass
manufacturer_slug = params.get("manufacturer_slug") manufacturer_slug = params.get("manufacturer_slug")
if manufacturer_slug: if manufacturer_slug:
@@ -404,10 +401,8 @@ class RideListCreateAPIView(APIView):
designer_id = params.get("designer_id") designer_id = params.get("designer_id")
if designer_id: if designer_id:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(designer_id=int(designer_id)) qs = qs.filter(designer_id=int(designer_id))
except (ValueError, TypeError):
pass
designer_slug = params.get("designer_slug") designer_slug = params.get("designer_slug")
if designer_slug: if designer_slug:
@@ -419,10 +414,8 @@ class RideListCreateAPIView(APIView):
"""Apply ride model filtering.""" """Apply ride model filtering."""
ride_model_id = params.get("ride_model_id") ride_model_id = params.get("ride_model_id")
if ride_model_id: if ride_model_id:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(ride_model_id=int(ride_model_id)) qs = qs.filter(ride_model_id=int(ride_model_id))
except (ValueError, TypeError):
pass
ride_model_slug = params.get("ride_model_slug") ride_model_slug = params.get("ride_model_slug")
manufacturer_slug_for_model = params.get("manufacturer_slug") manufacturer_slug_for_model = params.get("manufacturer_slug")
@@ -438,17 +431,13 @@ class RideListCreateAPIView(APIView):
"""Apply rating-based filtering.""" """Apply rating-based filtering."""
min_rating = params.get("min_rating") min_rating = params.get("min_rating")
if min_rating: if min_rating:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(average_rating__gte=float(min_rating)) qs = qs.filter(average_rating__gte=float(min_rating))
except (ValueError, TypeError):
pass
max_rating = params.get("max_rating") max_rating = params.get("max_rating")
if max_rating: if max_rating:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(average_rating__lte=float(max_rating)) qs = qs.filter(average_rating__lte=float(max_rating))
except (ValueError, TypeError):
pass
return qs return qs
@@ -456,17 +445,13 @@ class RideListCreateAPIView(APIView):
"""Apply height requirement filtering.""" """Apply height requirement filtering."""
min_height_req = params.get("min_height_requirement") min_height_req = params.get("min_height_requirement")
if min_height_req: if min_height_req:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(min_height_in__gte=int(min_height_req)) qs = qs.filter(min_height_in__gte=int(min_height_req))
except (ValueError, TypeError):
pass
max_height_req = params.get("max_height_requirement") max_height_req = params.get("max_height_requirement")
if max_height_req: if max_height_req:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(max_height_in__lte=int(max_height_req)) qs = qs.filter(max_height_in__lte=int(max_height_req))
except (ValueError, TypeError):
pass
return qs return qs
@@ -474,17 +459,13 @@ class RideListCreateAPIView(APIView):
"""Apply capacity filtering.""" """Apply capacity filtering."""
min_capacity = params.get("min_capacity") min_capacity = params.get("min_capacity")
if min_capacity: if min_capacity:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(capacity_per_hour__gte=int(min_capacity)) qs = qs.filter(capacity_per_hour__gte=int(min_capacity))
except (ValueError, TypeError):
pass
max_capacity = params.get("max_capacity") max_capacity = params.get("max_capacity")
if max_capacity: if max_capacity:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(capacity_per_hour__lte=int(max_capacity)) qs = qs.filter(capacity_per_hour__lte=int(max_capacity))
except (ValueError, TypeError):
pass
return qs return qs
@@ -492,24 +473,18 @@ class RideListCreateAPIView(APIView):
"""Apply opening year filtering.""" """Apply opening year filtering."""
opening_year = params.get("opening_year") opening_year = params.get("opening_year")
if opening_year: if opening_year:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(opening_date__year=int(opening_year)) qs = qs.filter(opening_date__year=int(opening_year))
except (ValueError, TypeError):
pass
min_opening_year = params.get("min_opening_year") min_opening_year = params.get("min_opening_year")
if min_opening_year: if min_opening_year:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(opening_date__year__gte=int(min_opening_year)) qs = qs.filter(opening_date__year__gte=int(min_opening_year))
except (ValueError, TypeError):
pass
max_opening_year = params.get("max_opening_year") max_opening_year = params.get("max_opening_year")
if max_opening_year: if max_opening_year:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(opening_date__year__lte=int(max_opening_year)) qs = qs.filter(opening_date__year__lte=int(max_opening_year))
except (ValueError, TypeError):
pass
return qs return qs
@@ -530,47 +505,35 @@ class RideListCreateAPIView(APIView):
# Height filters # Height filters
min_height_ft = params.get("min_height_ft") min_height_ft = params.get("min_height_ft")
if min_height_ft: if min_height_ft:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft)) qs = qs.filter(coaster_stats__height_ft__gte=float(min_height_ft))
except (ValueError, TypeError):
pass
max_height_ft = params.get("max_height_ft") max_height_ft = params.get("max_height_ft")
if max_height_ft: if max_height_ft:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft)) qs = qs.filter(coaster_stats__height_ft__lte=float(max_height_ft))
except (ValueError, TypeError):
pass
# Speed filters # Speed filters
min_speed_mph = params.get("min_speed_mph") min_speed_mph = params.get("min_speed_mph")
if min_speed_mph: if min_speed_mph:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph)) qs = qs.filter(coaster_stats__speed_mph__gte=float(min_speed_mph))
except (ValueError, TypeError):
pass
max_speed_mph = params.get("max_speed_mph") max_speed_mph = params.get("max_speed_mph")
if max_speed_mph: if max_speed_mph:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph)) qs = qs.filter(coaster_stats__speed_mph__lte=float(max_speed_mph))
except (ValueError, TypeError):
pass
# Inversion filters # Inversion filters
min_inversions = params.get("min_inversions") min_inversions = params.get("min_inversions")
if min_inversions: if min_inversions:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions)) qs = qs.filter(coaster_stats__inversions__gte=int(min_inversions))
except (ValueError, TypeError):
pass
max_inversions = params.get("max_inversions") max_inversions = params.get("max_inversions")
if max_inversions: if max_inversions:
try: with contextlib.suppress(ValueError, TypeError):
qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions)) qs = qs.filter(coaster_stats__inversions__lte=int(max_inversions))
except (ValueError, TypeError):
pass
has_inversions = params.get("has_inversions") has_inversions = params.get("has_inversions")
if has_inversions is not None: if has_inversions is not None:
@@ -2176,10 +2139,8 @@ class HybridRideAPIView(APIView):
value = query_params.get(param) value = query_params.get(param)
if value: if value:
if param == "park_id": if param == "park_id":
try: with contextlib.suppress(ValueError):
filters[param] = int(value) filters[param] = int(value)
except ValueError:
pass
else: else:
filters[param] = value filters[param] = value
@@ -2461,14 +2422,14 @@ class RideFilterMetadataAPIView(APIView):
class BaseCompanyListAPIView(APIView): class BaseCompanyListAPIView(APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
role = None role = None
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
if not MODELS_AVAILABLE: if not MODELS_AVAILABLE:
return Response( return Response(
{"detail": "Models not available"}, {"detail": "Models not available"},
status=status.HTTP_501_NOT_IMPLEMENTED status=status.HTTP_501_NOT_IMPLEMENTED
) )
companies = ( companies = (
Company.objects.filter(roles__contains=[self.role]) Company.objects.filter(roles__contains=[self.role])
.annotate(ride_count=Count("manufactured_rides" if self.role == "MANUFACTURER" else "designed_rides")) .annotate(ride_count=Count("manufactured_rides" if self.role == "MANUFACTURER" else "designed_rides"))
@@ -2486,7 +2447,7 @@ class BaseCompanyListAPIView(APIView):
} }
for c in companies for c in companies
] ]
return Response({ return Response({
"results": data, "results": data,
"count": len(data) "count": len(data)

View File

@@ -5,88 +5,88 @@ This module provides a unified interface to all serializers across different dom
while maintaining the modular structure for better organization and maintainability. while maintaining the modular structure for better organization and maintainability.
""" """
import importlib
from typing import Any
# --- Companies and ride models domain ---
from .companies import (
CompanyCreateInputSerializer,
CompanyDetailOutputSerializer,
CompanyUpdateInputSerializer,
RideModelCreateInputSerializer,
RideModelDetailOutputSerializer,
RideModelUpdateInputSerializer,
) # noqa: F401
# --- Parks domain ---
from .parks import (
ParkAreaCreateInputSerializer,
ParkAreaDetailOutputSerializer,
ParkAreaUpdateInputSerializer,
ParkCreateInputSerializer,
ParkDetailOutputSerializer,
ParkFilterInputSerializer,
ParkListOutputSerializer,
ParkLocationCreateInputSerializer,
ParkLocationOutputSerializer,
ParkLocationUpdateInputSerializer,
ParkSuggestionOutputSerializer,
ParkSuggestionSerializer,
ParkUpdateInputSerializer,
) # noqa: F401
# --- Rides domain ---
from .rides import (
RideCreateInputSerializer,
RideDetailOutputSerializer,
RideFilterInputSerializer,
RideListOutputSerializer,
RideLocationCreateInputSerializer,
RideLocationOutputSerializer,
RideLocationUpdateInputSerializer,
RideModelOutputSerializer,
RideParkOutputSerializer,
RideReviewCreateInputSerializer,
RideReviewOutputSerializer,
RideReviewUpdateInputSerializer,
RideUpdateInputSerializer,
RollerCoasterStatsCreateInputSerializer,
RollerCoasterStatsOutputSerializer,
RollerCoasterStatsUpdateInputSerializer,
) # noqa: F401
from .services import ( from .services import (
HealthCheckOutputSerializer,
PerformanceMetricsOutputSerializer,
SimpleHealthOutputSerializer,
EmailSendInputSerializer,
EmailTemplateOutputSerializer,
MapDataOutputSerializer,
CoordinateInputSerializer, CoordinateInputSerializer,
HistoryEventSerializer,
HistoryEntryOutputSerializer,
HistoryCreateInputSerializer,
ModerationSubmissionSerializer,
ModerationSubmissionOutputSerializer,
RoadtripParkSerializer,
RoadtripCreateInputSerializer,
RoadtripOutputSerializer,
GeocodeInputSerializer,
GeocodeOutputSerializer,
DistanceCalculationInputSerializer, DistanceCalculationInputSerializer,
DistanceCalculationOutputSerializer, DistanceCalculationOutputSerializer,
EmailSendInputSerializer,
EmailTemplateOutputSerializer,
GeocodeInputSerializer,
GeocodeOutputSerializer,
HealthCheckOutputSerializer,
HistoryCreateInputSerializer,
HistoryEntryOutputSerializer,
HistoryEventSerializer,
MapDataOutputSerializer,
ModerationSubmissionOutputSerializer,
ModerationSubmissionSerializer,
PerformanceMetricsOutputSerializer,
RoadtripCreateInputSerializer,
RoadtripOutputSerializer,
RoadtripParkSerializer,
SimpleHealthOutputSerializer,
) # noqa: F401 ) # noqa: F401
from typing import Any, Dict, List
import importlib
# --- Shared utilities and base classes --- # --- Shared utilities and base classes ---
from .shared import ( from .shared import (
FilterOptionSerializer, FilterOptionSerializer,
FilterRangeSerializer, FilterRangeSerializer,
StandardizedFilterMetadataSerializer, StandardizedFilterMetadataSerializer,
validate_filter_metadata_contract,
ensure_filter_option_format, ensure_filter_option_format,
) # noqa: F401 validate_filter_metadata_contract,
# --- Parks domain ---
from .parks import (
ParkListOutputSerializer,
ParkDetailOutputSerializer,
ParkCreateInputSerializer,
ParkUpdateInputSerializer,
ParkFilterInputSerializer,
ParkAreaDetailOutputSerializer,
ParkAreaCreateInputSerializer,
ParkAreaUpdateInputSerializer,
ParkLocationOutputSerializer,
ParkLocationCreateInputSerializer,
ParkLocationUpdateInputSerializer,
ParkSuggestionSerializer,
ParkSuggestionOutputSerializer,
) # noqa: F401
# --- Companies and ride models domain ---
from .companies import (
CompanyDetailOutputSerializer,
CompanyCreateInputSerializer,
CompanyUpdateInputSerializer,
RideModelDetailOutputSerializer,
RideModelCreateInputSerializer,
RideModelUpdateInputSerializer,
) # noqa: F401
# --- Rides domain ---
from .rides import (
RideParkOutputSerializer,
RideModelOutputSerializer,
RideListOutputSerializer,
RideDetailOutputSerializer,
RideCreateInputSerializer,
RideUpdateInputSerializer,
RideFilterInputSerializer,
RollerCoasterStatsOutputSerializer,
RollerCoasterStatsCreateInputSerializer,
RollerCoasterStatsUpdateInputSerializer,
RideLocationOutputSerializer,
RideLocationCreateInputSerializer,
RideLocationUpdateInputSerializer,
RideReviewOutputSerializer,
RideReviewCreateInputSerializer,
RideReviewUpdateInputSerializer,
) # noqa: F401 ) # noqa: F401
# --- Accounts domain: try multiple likely locations, fall back to placeholders --- # --- Accounts domain: try multiple likely locations, fall back to placeholders ---
_ACCOUNTS_SYMBOLS: List[str] = [ _ACCOUNTS_SYMBOLS: list[str] = [
"UserProfileOutputSerializer", "UserProfileOutputSerializer",
"UserProfileCreateInputSerializer", "UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer", "UserProfileUpdateInputSerializer",
@@ -106,7 +106,7 @@ _ACCOUNTS_SYMBOLS: List[str] = [
] ]
def _import_accounts_symbols() -> Dict[str, Any]: def _import_accounts_symbols() -> dict[str, Any]:
""" """
Try a list of candidate module paths and return a dict mapping expected symbol Try a list of candidate module paths and return a dict mapping expected symbol
names to the objects found. If no candidate provides a symbol, the symbol maps to None. names to the objects found. If no candidate provides a symbol, the symbol maps to None.
@@ -119,7 +119,7 @@ def _import_accounts_symbols() -> Dict[str, Any]:
] ]
# Prepare default placeholders # Prepare default placeholders
result: Dict[str, Any] = {name: None for name in _ACCOUNTS_SYMBOLS} result: dict[str, Any] = dict.fromkeys(_ACCOUNTS_SYMBOLS)
for modname in candidates: for modname in candidates:
try: try:

View File

@@ -5,21 +5,22 @@ This module contains all serializers related to user account management,
profile settings, preferences, privacy, notifications, and security. profile settings, preferences, privacy, notifications, and security.
""" """
from rest_framework import serializers
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
OpenApiExample, OpenApiExample,
extend_schema_serializer,
) )
from rest_framework import serializers
from apps.accounts.models import ( from apps.accounts.models import (
User,
UserProfile,
UserNotification,
NotificationPreference, NotificationPreference,
User,
UserNotification,
UserProfile,
) )
from apps.core.choices.serializers import RichChoiceFieldSerializer
from apps.lists.models import UserList from apps.lists.models import UserList
from apps.rides.models.credits import RideCredit from apps.rides.models.credits import RideCredit
from apps.core.choices.serializers import RichChoiceFieldSerializer
UserModel = get_user_model() UserModel = get_user_model()
@@ -187,7 +188,7 @@ class PublicUserSerializer(serializers.ModelSerializer):
Only exposes public information. Only exposes public information.
""" """
profile = UserProfileSerializer(read_only=True) profile = UserProfileSerializer(read_only=True)
class Meta: class Meta:
model = User model = User
fields = [ fields = [
@@ -906,9 +907,10 @@ class AvatarUploadSerializer(serializers.Serializer):
# Try to validate with PIL # Try to validate with PIL
try: try:
from PIL import Image
import io import io
from PIL import Image
value.seek(0) value.seek(0)
image_data = value.read() image_data = value.read()
value.seek(0) # Reset for later use value.seek(0) # Reset for later use

View File

@@ -5,14 +5,14 @@ This module contains all serializers related to user authentication,
registration, password management, and social authentication. registration, password management, and social authentication.
""" """
from rest_framework import serializers from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth import get_user_model, authenticate
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
OpenApiExample, OpenApiExample,
extend_schema_serializer,
) )
from rest_framework import serializers
UserModel = get_user_model() UserModel = get_user_model()

View File

@@ -5,16 +5,16 @@ This module contains all serializers related to companies that operate parks
or manufacture rides, as well as ride model serializers. or manufacture rides, as well as ride model serializers.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample, OpenApiExample,
extend_schema_field,
extend_schema_serializer,
) )
from rest_framework import serializers
from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer from apps.core.choices.serializers import RichChoiceFieldSerializer
from .shared import ModelChoices
# === COMPANY SERIALIZERS === # === COMPANY SERIALIZERS ===

View File

@@ -5,8 +5,8 @@ This module contains serializers for history tracking and timeline functionality
using django-pghistory. using django-pghistory.
""" """
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
class ParkHistoryEventSerializer(serializers.Serializer): class ParkHistoryEventSerializer(serializers.Serializer):

View File

@@ -5,13 +5,12 @@ This module contains all serializers related to map functionality,
including location data, search results, and clustering. including location data, search results, and clustering.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample, OpenApiExample,
extend_schema_field,
extend_schema_serializer,
) )
from rest_framework import serializers
# === MAP LOCATION SERIALIZERS === # === MAP LOCATION SERIALIZERS ===

View File

@@ -5,13 +5,12 @@ This module contains serializers for photo uploads, media management,
and related media functionality. and related media functionality.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample, OpenApiExample,
extend_schema_field,
extend_schema_serializer,
) )
from rest_framework import serializers
# === MEDIA SERIALIZERS === # === MEDIA SERIALIZERS ===

View File

@@ -5,13 +5,12 @@ This module contains serializers for statistics, health checks, and other
miscellaneous functionality. miscellaneous functionality.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_field, extend_schema_field,
) )
from .shared import ModelChoices from rest_framework import serializers
from apps.core.choices.serializers import RichChoiceFieldSerializer
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === STATISTICS SERIALIZERS === # === STATISTICS SERIALIZERS ===

View File

@@ -4,10 +4,12 @@ Serializers for park review API endpoints.
This module contains serializers for park review CRUD operations. This module contains serializers for park review CRUD operations.
""" """
from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer
from rest_framework import serializers from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from apps.parks.models.reviews import ParkReview
from apps.api.v1.serializers.reviews import ReviewUserSerializer from apps.api.v1.serializers.reviews import ReviewUserSerializer
from apps.parks.models.reviews import ParkReview
@extend_schema_serializer( @extend_schema_serializer(
examples=[ examples=[

View File

@@ -5,18 +5,18 @@ This module contains all serializers related to parks, park areas, park location
and park search functionality. and park search functionality.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample, OpenApiExample,
extend_schema_field,
extend_schema_serializer,
) )
from rest_framework import serializers
from apps.core.choices.serializers import RichChoiceFieldSerializer
from apps.core.services.media_url_service import MediaURLService
from config.django import base as settings from config.django import base as settings
from .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices from .shared import CompanyOutputSerializer, LocationOutputSerializer, ModelChoices
from apps.core.services.media_url_service import MediaURLService
from apps.core.choices.serializers import RichChoiceFieldSerializer
# === PARK SERIALIZERS === # === PARK SERIALIZERS ===

View File

@@ -5,6 +5,7 @@ This module contains serializers for park-specific media functionality.
""" """
from rest_framework import serializers from rest_framework import serializers
from apps.parks.models import ParkPhoto from apps.parks.models import ParkPhoto

View File

@@ -3,9 +3,10 @@ Serializers for review-related API endpoints.
""" """
from rest_framework import serializers from rest_framework import serializers
from apps.accounts.models import User
from apps.parks.models.reviews import ParkReview from apps.parks.models.reviews import ParkReview
from apps.rides.models.reviews import RideReview from apps.rides.models.reviews import RideReview
from apps.accounts.models import User
class ReviewUserSerializer(serializers.ModelSerializer): class ReviewUserSerializer(serializers.ModelSerializer):

View File

@@ -1,17 +1,18 @@
from rest_framework import serializers from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from apps.rides.models.credits import RideCredit
from apps.rides.models import Ride
from apps.api.v1.serializers.rides import RideListOutputSerializer from apps.api.v1.serializers.rides import RideListOutputSerializer
from apps.rides.models import Ride
from apps.rides.models.credits import RideCredit
class RideCreditSerializer(serializers.ModelSerializer): class RideCreditSerializer(serializers.ModelSerializer):
"""Serializer for user ride credits.""" """Serializer for user ride credits."""
ride_id = serializers.PrimaryKeyRelatedField( ride_id = serializers.PrimaryKeyRelatedField(
queryset=Ride.objects.all(), source='ride', write_only=True queryset=Ride.objects.all(), source='ride', write_only=True
) )
ride = RideListOutputSerializer(read_only=True) ride = RideListOutputSerializer(read_only=True)
class Meta: class Meta:
model = RideCredit model = RideCredit
fields = [ fields = [
@@ -23,6 +24,7 @@ class RideCreditSerializer(serializers.ModelSerializer):
'first_ridden_at', 'first_ridden_at',
'last_ridden_at', 'last_ridden_at',
'notes', 'notes',
'display_order',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]
@@ -37,7 +39,7 @@ class RideCreditSerializer(serializers.ModelSerializer):
last = attrs.get('last_ridden_at') last = attrs.get('last_ridden_at')
if first and last and last < first: if first and last and last < first:
raise serializers.ValidationError("Last ridden date cannot be before first ridden date.") raise serializers.ValidationError("Last ridden date cannot be before first ridden date.")
return attrs return attrs
def create(self, validated_data): def create(self, validated_data):

View File

@@ -5,16 +5,17 @@ This module contains all serializers related to ride models, variants,
technical specifications, and related functionality. technical specifications, and related functionality.
""" """
from rest_framework import serializers
from drf_spectacular.utils import ( from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample, OpenApiExample,
extend_schema_field,
extend_schema_serializer,
) )
from rest_framework import serializers
from apps.core.choices.serializers import RichChoiceFieldSerializer
from config.django import base as settings from config.django import base as settings
from .shared import ModelChoices from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# Use dynamic imports to avoid circular import issues # Use dynamic imports to avoid circular import issues
@@ -23,9 +24,9 @@ def get_ride_model_classes():
"""Get ride model classes dynamically to avoid import issues.""" """Get ride model classes dynamically to avoid import issues."""
from apps.rides.models import ( from apps.rides.models import (
RideModel, RideModel,
RideModelVariant,
RideModelPhoto, RideModelPhoto,
RideModelTechnicalSpec, RideModelTechnicalSpec,
RideModelVariant,
) )
return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec return RideModel, RideModelVariant, RideModelPhoto, RideModelTechnicalSpec

View File

@@ -4,11 +4,11 @@ Serializers for ride review API endpoints.
This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance. This module contains serializers for ride review CRUD operations with Rich Choice Objects compliance.
""" """
from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer
from rest_framework import serializers 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.accounts.models import User
from apps.core.choices.serializers import RichChoiceSerializer from apps.rides.models.reviews import RideReview
class ReviewUserSerializer(serializers.ModelSerializer): class ReviewUserSerializer(serializers.ModelSerializer):
@@ -74,7 +74,7 @@ class RideReviewOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride reviews.""" """Output serializer for ride reviews."""
user = ReviewUserSerializer(read_only=True) user = ReviewUserSerializer(read_only=True)
# Ride information # Ride information
ride = serializers.SerializerMethodField() ride = serializers.SerializerMethodField()
park = serializers.SerializerMethodField() park = serializers.SerializerMethodField()

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