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."""
@@ -18,9 +18,9 @@ class UserExportService:
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 = {

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

@@ -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:

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,8 +141,7 @@ 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()

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
@@ -193,7 +186,7 @@ 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...')
@@ -252,7 +245,7 @@ class Command(BaseCommand):
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)}"
@@ -319,7 +312,7 @@ class Command(BaseCommand):
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...')
@@ -414,7 +407,7 @@ class Command(BaseCommand):
# 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'}"
@@ -457,7 +450,7 @@ class Command(BaseCommand):
# 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,
@@ -473,7 +466,7 @@ class Command(BaseCommand):
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...')
@@ -573,7 +566,7 @@ class Command(BaseCommand):
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'])
@@ -612,7 +605,7 @@ class Command(BaseCommand):
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...')
@@ -805,7 +798,7 @@ class Command(BaseCommand):
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...')
@@ -897,7 +890,7 @@ class Command(BaseCommand):
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
@@ -951,7 +944,7 @@ class Command(BaseCommand):
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...')
@@ -1044,7 +1037,7 @@ class Command(BaseCommand):
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...')
@@ -1080,7 +1073,7 @@ class Command(BaseCommand):
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...')
@@ -1096,7 +1089,7 @@ class Command(BaseCommand):
# 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...')
@@ -1109,7 +1102,7 @@ class Command(BaseCommand):
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...')

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

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
@@ -109,18 +115,17 @@ urlpatterns = [
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__)
@@ -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()
@@ -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"])
@permission_classes([IsAuthenticated])
def request_account_deletion(request):
"""Request account deletion."""
try:
user = request.user
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
return Response(
{"success": False, "error": reason},
status=status.HTTP_400_BAD_REQUEST,
)
# Create deletion request
deletion_request = UserDeletionService.create_deletion_request(user)
return Response(
{ {
"success": True, "id": 1,
"message": "Verification code sent to your email", "ip_address": "192.168.1.1",
"expires_at": deletion_request.expires_at, "user_agent": "Mozilla/5.0...",
"email": user.email, "login_method": "PASSWORD",
"login_method_display": "Password",
"login_timestamp": "2024-12-27T10:30:00Z",
"country": "United States",
"city": "New York",
}
],
"count": 1,
}, },
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):
""" """
@@ -35,6 +40,65 @@ class RideCreditViewSet(viewsets.ModelViewSet):
"""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.
@@ -798,10 +797,11 @@ 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:

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
@@ -57,10 +58,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
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)
@@ -213,7 +211,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
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

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):
""" """

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):

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

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:
@@ -287,17 +288,13 @@ class ParkListCreateAPIView(APIView):
"""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
@@ -305,17 +302,13 @@ class ParkListCreateAPIView(APIView):
"""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
@@ -323,24 +316,18 @@ class ParkListCreateAPIView(APIView):
"""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
@@ -355,17 +342,13 @@ 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
@@ -440,11 +423,11 @@ 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

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):

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):

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

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()

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,10 +167,7 @@ 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
@@ -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:

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

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()

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

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()
@@ -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,8 +1,9 @@
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."""
@@ -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',
] ]

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):

View File

@@ -5,16 +5,17 @@ This module contains all serializers related to rides, roller coaster statistics
ride locations, and ride reviews. ride locations, and ride reviews.
""" """
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 config.django import base as settings from rest_framework import serializers
from .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
from apps.core.choices.serializers import RichChoiceFieldSerializer
from config.django import base as settings
from .shared import ModelChoices
# === RIDE SERIALIZERS === # === RIDE SERIALIZERS ===

View File

@@ -5,6 +5,7 @@ This module contains serializers for ride-specific media functionality.
""" """
from rest_framework import serializers from rest_framework import serializers
from apps.rides.models import RidePhoto from apps.rides.models import RidePhoto

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