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
> **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 |
---
# Gap Analysis Matrix - Deep Logic Audit
**Generated:** 2025-12-27 | **Audit Level:** Maximum Thoroughness (Line-by-Line)
## 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)
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
### Ride Statistics Models
### High Priority (UX Components)
7. **Multi-Step Submission Wizard** - UX for complex forms
8. **ParkCard / RideCard Components** - Reusable entity cards
9. **HoverCard Previews** - Rich preview on hover
10. **ImageGallery Lightbox (Zoom/Download)** - Photo viewing
11. **Grid/List View Toggle** on listings
12. **Search History** feature
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `height_ft` as Decimal(6,2) | `rides/models/rides.py:1000` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
| `length_ft` as Decimal(7,2) | `rides/models/rides.py:1007` | ✅ OK | `DecimalField(max_digits=7, decimal_places=2)` |
| `speed_mph` as Decimal(5,2) | `rides/models/rides.py:1014` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
| `max_drop_height_ft` | `rides/models/rides.py:1046` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
| `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)
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
### Water/Dark/Flat Ride Stats
### Low Priority (Nice to Have)
21. **Login History View** in settings
22. **Data Export Button** in UI
23. **Ride Drag Reorder** for credits
24. **Water/Dark Ride Specific Specs** - Content completeness
25. **Date Precision Selector** - Data entry accuracy
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `WaterRideStats.splash_height_ft` | `rides/models/stats.py:59` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
| `WaterRideStats.wetness_level` | `rides/models/stats.py:52` | ✅ OK | CharField with choices |
| `DarkRideStats.scene_count` | `rides/models/stats.py:112` | ✅ OK | PositiveIntegerField |
| `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.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
@@ -33,10 +33,7 @@ class CustomAccountAdapter(DefaultAccountAdapter):
"current_site": current_site,
"key": emailconfirmation.key,
}
if signup:
email_template = "account/email/email_confirmation_signup"
else:
email_template = "account/email/email_confirmation"
email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation"
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.auth.admin import UserAdmin
from django.contrib.auth.models import Group
from django.db.models import Count, Sum
from django.utils import timezone
from django.utils.html import format_html
@@ -25,7 +24,6 @@ from apps.core.admin import (
ExportActionMixin,
QueryOptimizationMixin,
ReadOnlyAdminMixin,
TimestampFieldsMixin,
)
from .models import (

View File

@@ -7,8 +7,7 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects.
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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.core.management.base import 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 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
User = get_user_model()
@@ -52,8 +53,8 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
# Clean up test files
import os
import glob
import os
# Clean up test uploads
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 django.contrib.sites.models import Site
from django.core.management.base import 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.core.management.base import BaseCommand
class Command(BaseCommand):

View File

@@ -8,6 +8,7 @@ Usage:
"""
from django.core.management.base import BaseCommand, CommandError
from apps.accounts.models import User
from apps.accounts.services import UserDeletionService
@@ -48,10 +49,7 @@ class Command(BaseCommand):
# Find the user
try:
if username:
user = User.objects.get(username=username)
else:
user = User.objects.get(user_id=user_id)
user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id)
except User.DoesNotExist:
identifier = username or user_id
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 django.contrib.sites.models import Site
import os
from django.core.management.base import BaseCommand
class Command(BaseCommand):

View File

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

View File

@@ -1,4 +1,5 @@
from django.core.management.base import BaseCommand
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 django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
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.core.management.base import BaseCommand
from apps.accounts.models import User
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.core.management.base import 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
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):
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.sites.models import Site
from django.core.management.base import BaseCommand
User = get_user_model()

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import 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.test import Client
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
import apps.core.choices.fields
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
import apps.core.choices.fields
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 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:
@@ -15,30 +15,30 @@ class TurnstileMixin:
def validate_turnstile(self, request):
"""
Validate the Turnstile response token.
The token can be provided as:
- 'cf-turnstile-response' in POST data (form submission)
- 'turnstile_token' in JSON body (API request)
"""
# Try to get token from various sources
token = None
# Check POST data (form submissions)
if hasattr(request, 'POST'):
token = request.POST.get("cf-turnstile-response")
# Check JSON body (API requests)
if not token and hasattr(request, 'data'):
data = getattr(request, 'data', {})
if hasattr(data, 'get'):
token = data.get('turnstile_token') or data.get('cf-turnstile-response')
# Get client IP
ip = get_client_ip(request)
# Validate the token
result = validate_turnstile_token(token, ip)
if not result.get('success'):
error_msg = result.get('error', 'Captcha verification failed. Please try again.')
raise ValidationError(error_msg)

View File

@@ -1,16 +1,18 @@
from django.dispatch import receiver
from django.db.models.signals import post_save
import secrets
from datetime import timedelta
import pghistory
from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericForeignKey
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.utils.translation import gettext_lazy as _
import secrets
from datetime import timedelta
from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
from apps.core.history import TrackedModel
# from django_cloudflareimages_toolkit.models import CloudflareImage
@@ -358,6 +360,9 @@ class EmailVerification(models.Model):
created_at = models.DateTimeField(
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(
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.
"""
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 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()
@@ -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.

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.password_validation import validate_password
from django.utils.crypto import get_random_string
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from django_forwardemail.services import EmailService
from django.template.loader import render_to_string
from typing import cast
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()

View File

@@ -12,7 +12,7 @@ Recent additions:
import logging
import re
from typing import Any, Dict, Optional
from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
@@ -58,7 +58,7 @@ class AccountService:
old_password: str,
new_password: str,
request: HttpRequest,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Change user password with validation and notification.
@@ -146,7 +146,7 @@ class AccountService:
user: User,
new_email: str,
request: HttpRequest,
) -> Dict[str, Any]:
) -> dict[str, Any]:
"""
Initiate email change with verification.
@@ -234,7 +234,7 @@ class AccountService:
logger.error(f"Failed to send email verification: {e}")
@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.
@@ -375,35 +375,35 @@ class UserDeletionService:
# Transfer all submissions to deleted user
# 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"):
getattr(user, "ride_reviews").update(user=deleted_user)
user.ride_reviews.update(user=deleted_user)
# 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"):
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
user.uploaded_ride_photos.update(uploaded_by=deleted_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
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"):
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
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"):
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
user.moderated_ride_reviews.update(moderated_by=None)
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"):
getattr(user, "handled_photos").update(handled_by=None)
user.handled_photos.update(handled_by=None)
# Store user info for the summary
user_info = {
@@ -426,7 +426,7 @@ class UserDeletionService:
}
@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.

View File

@@ -5,18 +5,19 @@ This service handles the creation, delivery, and management of notifications
for various events including submission approvals/rejections.
"""
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.conf import settings
from django.db import models
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
import logging
from 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 apps.accounts.models import NotificationPreference, User, UserNotification
logger = logging.getLogger(__name__)
@@ -29,10 +30,10 @@ class NotificationService:
notification_type: str,
title: str,
message: str,
related_object: Optional[Any] = None,
related_object: Any | None = None,
priority: str = UserNotification.Priority.NORMAL,
extra_data: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
extra_data: dict[str, Any] | None = None,
expires_at: datetime | None = None,
) -> UserNotification:
"""
Create a new notification for a user.
@@ -273,9 +274,9 @@ class NotificationService:
def get_user_notifications(
user: User,
unread_only: bool = False,
notification_types: Optional[List[str]] = None,
limit: Optional[int] = None,
) -> List[UserNotification]:
notification_types: list[str] | None = None,
limit: int | None = None,
) -> list[UserNotification]:
"""
Get notifications for a user.
@@ -308,7 +309,7 @@ class NotificationService:
@staticmethod
def mark_notifications_read(
user: User, notification_ids: Optional[List[int]] = None
user: User, notification_ids: list[int] | None = None
) -> int:
"""
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.
"""
from typing import Dict, List, Tuple, TYPE_CHECKING
from django.contrib.auth import get_user_model
import logging
from typing import TYPE_CHECKING
from allauth.socialaccount.models import SocialApp
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.http import HttpRequest
import logging
if TYPE_CHECKING:
from apps.accounts.models import User
@@ -26,7 +27,7 @@ class SocialProviderService:
"""Service for managing social provider connections."""
@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.
@@ -69,7 +70,7 @@ class SocialProviderService:
return False, "Unable to verify disconnection safety. Please try again."
@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.
@@ -106,7 +107,7 @@ class SocialProviderService:
return []
@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.
@@ -152,7 +153,7 @@ class SocialProviderService:
return []
@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.
@@ -191,7 +192,7 @@ class SocialProviderService:
return False, f"Failed to disconnect {provider} account. Please try again."
@staticmethod
def get_auth_status(user: "User") -> Dict:
def get_auth_status(user: "User") -> dict:
"""
Get comprehensive authentication status for a user.
@@ -231,7 +232,7 @@ class SocialProviderService:
}
@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.

View File

@@ -5,19 +5,18 @@ This service handles user account deletion while preserving submissions
and maintaining data integrity across the platform.
"""
from django.utils import timezone
from django.db import transaction
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from typing import Dict, Any, Tuple, Optional
import logging
import secrets
import string
from datetime import datetime
from 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__)
@@ -41,7 +40,7 @@ class UserDeletionService:
_deletion_requests = {}
@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.
@@ -104,7 +103,7 @@ class UserDeletionService:
return deletion_request
@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.
@@ -169,7 +168,7 @@ class UserDeletionService:
@staticmethod
@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.
@@ -217,7 +216,7 @@ class UserDeletionService:
}
@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."""
counts = {}

View File

@@ -1,10 +1,13 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
import requests
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.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
@@ -185,3 +188,41 @@ def create_default_groups():
print(f"Permission not found: {codename}")
except Exception as 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.contenttypes.models import ContentType
from unittest.mock import patch, MagicMock
from django.test import TestCase
from .models import User, UserProfile
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.
"""
import pytest
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
@@ -20,7 +19,6 @@ from apps.accounts.admin import (
from apps.accounts.models import (
EmailVerification,
PasswordReset,
User,
UserProfile,
)

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from allauth.account.views import LogoutView
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
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 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__)
@@ -184,7 +185,7 @@ class ProfileView(DetailView):
def get_queryset(self) -> QuerySet[User]:
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)
user = cast(User, self.get_object())
@@ -220,7 +221,7 @@ class ProfileView(DetailView):
class SettingsView(LoginRequiredMixin, TemplateView):
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["user"] = self.request.user
return context
@@ -283,7 +284,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
def _handle_password_change(
self, request: HttpRequest
) -> Optional[HttpResponseRedirect]:
) -> HttpResponseRedirect | None:
user = cast(User, request.user)
old_password = request.POST.get("old_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(
user: User, site: Union[Site, RequestSite], token: str
user: User, site: Site | RequestSite, token: str
) -> None:
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
context = {
@@ -435,7 +436,7 @@ def handle_password_reset(
user: User,
new_password: str,
reset: PasswordReset,
site: Union[Site, RequestSite],
site: Site | RequestSite,
) -> None:
user.set_password(new_password)
user.save()
@@ -457,7 +458,7 @@ def handle_password_reset(
def send_password_reset_confirmation(
user: User, site: Union[Site, RequestSite]
user: User, site: Site | RequestSite
) -> None:
context = {
"user": user,

View File

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

View File

@@ -1,4 +1,4 @@
from django.urls import path, include
from django.urls import include, path
urlpatterns = [
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 rest_framework import serializers
from apps.accounts.models import UserProfile
from apps.accounts.serializers import UserSerializer # existing shared user serializer
@@ -24,7 +25,7 @@ class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
from django_cloudflareimages_toolkit.models import CloudflareImage
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
instance.avatar = image
return super().update(instance, validated_data)

View File

@@ -2,8 +2,14 @@
URL configuration for user account management API endpoints.
"""
from django.urls import path
from . import views
from django.urls import include, path
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 = [
# Admin endpoints for user management
@@ -108,19 +114,18 @@ urlpatterns = [
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
path("profile/avatar/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
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
]
# Register ViewSets
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 += [
# ViewSet routes
path("", include(router.urls)),
]

View File

@@ -6,43 +6,44 @@ user deletion while preserving submissions, profile management, settings,
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
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 rest_framework.permissions import AllowAny
from django.utils import timezone
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
logger = logging.getLogger(__name__)
@@ -307,7 +308,7 @@ def save_avatar_image(request):
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.uploaded_at = timezone.now()
@@ -319,7 +320,7 @@ def save_avatar_image(request):
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create(
@@ -367,7 +368,7 @@ def save_avatar_image(request):
except Exception as e:
logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
old_avatar.delete()
# Debug logging to see what's happening with the CloudflareImage
@@ -442,7 +443,7 @@ def delete_avatar(request):
avatar_to_delete = profile.avatar
profile.avatar = None
profile.save()
# Delete from Cloudflare first, then from database
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
@@ -452,7 +453,7 @@ def delete_avatar(request):
except Exception as e:
logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
avatar_to_delete.delete()
# Get the default avatar URL
@@ -1273,10 +1274,10 @@ def update_security_settings(request):
# Handle security settings updates
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:
setattr(user, "login_notifications", request.data["login_notifications"])
user.login_notifications = request.data["login_notifications"]
user.save()
@@ -1612,7 +1613,7 @@ def export_user_data(request):
except Exception as e:
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
return Response(
{"error": "Failed to generate data export"},
{"error": "Failed to generate data export"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@@ -1636,54 +1637,73 @@ def get_public_user_profile(request, username):
return Response(serializer.data, status=status.HTTP_200_OK)
# === MISSING FUNCTION IMPLEMENTATIONS ===
@extend_schema(
operation_id="request_account_deletion",
summary="Request account deletion",
description="Request deletion of the authenticated user's account.",
operation_id="get_login_history",
summary="Get user login history",
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={
200: {"description": "Deletion request created"},
400: {"description": "Cannot delete account"},
},
tags=["Self-Service Account Management"],
)
@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,
"message": "Verification code sent to your email",
"expires_at": deletion_request.expires_at,
"email": user.email,
200: {
"description": "Login history entries",
"example": {
"results": [
{
"id": 1,
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"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 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.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):
"""
@@ -14,8 +19,8 @@ class RideCreditViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating']
ordering = ['-last_ridden_at']
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order']
ordering = ['display_order', '-last_ridden_at']
def get_queryset(self):
"""
@@ -23,18 +28,77 @@ class RideCreditViewSet(viewsets.ModelViewSet):
Optionally filter by user via query param ?user=username
"""
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
# Filter by user if provided
username = self.request.query_params.get('user')
if username:
queryset = queryset.filter(user__username=username)
return queryset
def perform_create(self, serializer):
"""Associate the current user with the ride credit."""
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(
summary="List ride credits",
description="List ride credits. filter by user username.",
@@ -49,3 +113,4 @@ class RideCreditViewSet(viewsets.ModelViewSet):
)
def list(self, 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.
"""
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 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()
@@ -192,11 +192,13 @@ class SignupInputSerializer(serializers.ModelSerializer):
def _send_verification_email(self, 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_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__)
@@ -436,7 +438,7 @@ class UserProfileOutputSerializer(serializers.Serializer):
return obj.get_avatar_url()
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> Dict[str, Any]:
def get_user(self, obj) -> dict[str, Any]:
return {
"username": obj.user.username,
"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 (
ConnectedProviderSerializer,
AvailableProviderSerializer,
SocialAuthStatusSerializer,
ConnectedProviderSerializer,
ConnectedProvidersListOutputSerializer,
ConnectProviderInputSerializer,
ConnectProviderOutputSerializer,
DisconnectProviderOutputSerializer,
SocialProviderListOutputSerializer,
ConnectedProvidersListOutputSerializer,
SocialAuthStatusSerializer,
SocialProviderErrorSerializer,
SocialProviderListOutputSerializer,
)
__all__ = [

View File

@@ -5,8 +5,8 @@ Serializers for handling social provider connection/disconnection requests
and responses in the ThrillWiki API.
"""
from rest_framework import serializers
from django.contrib.auth import get_user_model
from rest_framework import serializers
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.
"""
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 (
# Main auth views
LoginAPIView,
SignupAPIView,
LogoutAPIView,
CurrentUserAPIView,
PasswordResetAPIView,
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView,
# Email verification views
EmailVerificationAPIView,
ResendVerificationAPIView,
# Social provider management views
AvailableProvidersAPIView,
ConnectedProvidersAPIView,
ConnectProviderAPIView,
CurrentUserAPIView,
DisconnectProviderAPIView,
# Email verification views
EmailVerificationAPIView,
# Main auth views
LoginAPIView,
LogoutAPIView,
PasswordChangeAPIView,
PasswordResetAPIView,
ResendVerificationAPIView,
SignupAPIView,
SocialAuthStatusAPIView,
SocialProvidersAPIView,
)
from rest_framework_simplejwt.views import TokenRefreshView
urlpatterns = [
# Core authentication endpoints
@@ -98,6 +99,14 @@ urlpatterns = [
ResendVerificationAPIView.as_view(),
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

View File

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

View File

@@ -4,6 +4,7 @@ Centralized from apps.core.urls
"""
from django.urls import path
from . import views
# 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
"""
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.permissions import AllowAny
from typing import Optional, List
from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
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.services.entity_fuzzy_matching import (
EntityType,
entity_fuzzy_matcher,
)
class EntityFuzzySearchView(APIView):
@@ -199,10 +201,8 @@ class EntityNotFoundView(APIView):
# Determine entity types to search based on context
entity_types = []
if entity_type_hint:
try:
with contextlib.suppress(ValueError):
entity_types = [EntityType(entity_type_hint)]
except ValueError:
pass
# If we have park context, prioritize ride searches
if context.get("park_slug") and not entity_types:
@@ -344,7 +344,7 @@ class QuickEntitySuggestionView(APIView):
# Utility function for other views to use
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.

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ History API URLs
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 .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.
"""
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.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.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
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
from apps.parks.models import Park
@@ -24,7 +27,6 @@ from apps.rides.models import Ride
# Import 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.
@@ -79,7 +81,7 @@ ALL_TRACKED_MODELS: Sequence[str] = [
# --- 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:
return None
try:

View File

@@ -1,4 +1,5 @@
from django.urls import path
from .views import GenerateUploadURLView
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 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__)
class GenerateUploadURLView(APIView):
@@ -29,7 +31,7 @@ class GenerateUploadURLView(APIView):
{"detail": "Failed to generate upload URL."},
status=status.HTTP_502_BAD_GATEWAY
)
except Exception as e:
except Exception:
logger.exception("Unexpected error generating upload URL")
return Response(
{"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 . import views
# Map API endpoints - migrated from apps.core.urls.map_urls

View File

@@ -12,30 +12,31 @@ Caching Strategy:
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 rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAdminUser
from django.core.cache import cache
from django.db.models import Q
from django.http import HttpRequest
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
extend_schema,
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.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 (
MapLocationDetailSerializer,
MapLocationsResponseSerializer,
MapSearchResponseSerializer,
MapLocationDetailSerializer,
)
logger = logging.getLogger(__name__)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,14 +26,13 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.core.decorators.cache_decorators import cache_api_response
from apps.core.exceptions import (
NotFoundError,
PermissionDeniedError,
ServiceError,
ValidationException,
)
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.services import ParkMediaService
from apps.parks.services.hybrid_loader import smart_park_loader
@@ -130,10 +129,7 @@ class ParkPhotoViewSet(ModelViewSet):
def get_permissions(self):
"""Set permissions based on action."""
if self.action in ["list", "retrieve", "stats"]:
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
permission_classes = [AllowAny] if self.action in ["list", "retrieve", "stats"] else [IsAuthenticated]
return [permission() for permission in permission_classes]
def get_queryset(self): # type: ignore[override]
@@ -171,11 +167,8 @@ class ParkPhotoViewSet(ModelViewSet):
raise ValidationError("Park ID/Slug is required")
try:
if str(park_id).isdigit():
park = Park.objects.get(pk=park_id)
else:
park = Park.objects.get(slug=park_id)
park = Park.objects.get(pk=park_id) if str(park_id).isdigit() else Park.objects.get(slug=park_id)
# Use real park ID
park_id = park.id
except Park.DoesNotExist:
@@ -398,10 +391,7 @@ class ParkPhotoViewSet(ModelViewSet):
park = None
if park_pk:
try:
if str(park_pk).isdigit():
park = Park.objects.get(pk=park_pk)
else:
park = Park.objects.get(slug=park_pk)
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
except Park.DoesNotExist:
return ErrorHandler.handle_api_error(
NotFoundError(f"Park with id/slug {park_pk} not found"),
@@ -490,10 +480,7 @@ class ParkPhotoViewSet(ModelViewSet):
)
try:
if str(park_pk).isdigit():
park = Park.objects.get(pk=park_pk)
else:
park = Park.objects.get(slug=park_pk)
park = Park.objects.get(pk=park_pk) if str(park_pk).isdigit() else Park.objects.get(slug=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
@@ -509,9 +496,9 @@ class ParkPhotoViewSet(ModelViewSet):
try:
# Import CloudflareImage model and service
from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService
from django.utils import timezone
# Always fetch the latest image data 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 .views import (
RideModelListCreateAPIView,
RideModelDetailAPIView,
RideModelSearchAPIView,
RideModelFilterOptionsAPIView,
RideModelStatsAPIView,
RideModelVariantListCreateAPIView,
RideModelVariantDetailAPIView,
RideModelTechnicalSpecListCreateAPIView,
RideModelTechnicalSpecDetailAPIView,
RideModelPhotoListCreateAPIView,
RideModelListCreateAPIView,
RideModelPhotoDetailAPIView,
RideModelPhotoListCreateAPIView,
RideModelSearchAPIView,
RideModelStatsAPIView,
RideModelTechnicalSpecDetailAPIView,
RideModelTechnicalSpecListCreateAPIView,
RideModelVariantDetailAPIView,
RideModelVariantListCreateAPIView,
)
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
"""
from typing import Any
from datetime import timedelta
from typing import Any
from rest_framework import status, permissions
from rest_framework.views import APIView
from django.db.models import Count, Q
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.response import Response
from rest_framework.pagination import PageNumberPagination
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
from rest_framework.views import APIView
# Import serializers
from apps.api.v1.serializers.ride_models import (
RideModelListOutputSerializer,
RideModelDetailOutputSerializer,
RideModelCreateInputSerializer,
RideModelUpdateInputSerializer,
RideModelDetailOutputSerializer,
RideModelFilterInputSerializer,
RideModelVariantOutputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantUpdateInputSerializer,
RideModelListOutputSerializer,
RideModelStatsOutputSerializer,
RideModelUpdateInputSerializer,
RideModelVariantCreateInputSerializer,
RideModelVariantOutputSerializer,
RideModelVariantUpdateInputSerializer,
)
# Attempt to import models; fall back gracefully if not present
try:
from apps.rides.models import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
RideModelVariant,
)
from apps.rides.models.company import Company
@@ -54,12 +54,12 @@ except ImportError:
try:
# Try alternative import path
from apps.rides.models.rides import (
Company,
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
RideModelVariant,
)
from apps.rides.models.rides import Company
MODELS_AVAILABLE = True
except ImportError:
@@ -486,14 +486,14 @@ class RideModelFilterOptionsAPIView(APIView):
"""Return filter options for ride models with Rich Choice Objects metadata."""
# Import Rich Choice registry
from apps.core.choices.registry import get_choices
if not MODELS_AVAILABLE:
# Use Rich Choice Objects for fallback options
try:
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
@@ -507,7 +507,7 @@ class RideModelFilterOptionsAPIView(APIView):
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,
@@ -520,7 +520,7 @@ class RideModelFilterOptionsAPIView(APIView):
}
for choice in target_markets
]
except Exception:
# Ultimate fallback with basic structure
categories_data = [
@@ -538,7 +538,7 @@ class RideModelFilterOptionsAPIView(APIView):
{"value": "KIDDIE", "label": "Kiddie", "description": "Designed for young children", "color": "pink", "icon": "kiddie", "css_class": "bg-pink-100 text-pink-800", "sort_order": 4},
{"value": "ALL_AGES", "label": "All Ages", "description": "Enjoyable for all age groups", "color": "blue", "icon": "all-ages", "css_class": "bg-blue-100 text-blue-800", "sort_order": 5},
]
return Response({
"categories": categories_data,
"target_markets": target_markets_data,
@@ -557,11 +557,11 @@ class RideModelFilterOptionsAPIView(APIView):
# Get static choice definitions from Rich Choice Objects (primary source)
# Get dynamic data from database queries
# Get rich choice objects from registry
categories = get_choices('categories', 'rides')
target_markets = get_choices('target_markets', 'rides')
# Convert Rich Choice Objects to frontend format with metadata
categories_data = [
{
@@ -575,7 +575,7 @@ class RideModelFilterOptionsAPIView(APIView):
}
for choice in categories
]
target_markets_data = [
{
"value": choice.value,

View File

@@ -5,23 +5,25 @@ This module contains ride photo ViewSet following the parks pattern for domain c
Enhanced from centralized media API to provide domain-specific ride photo management.
"""
from .serializers import (
RidePhotoOutputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoUpdateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoApprovalInputSerializer,
RidePhotoStatsOutputSerializer,
)
from typing import TYPE_CHECKING
from .serializers import (
RidePhotoApprovalInputSerializer,
RidePhotoCreateInputSerializer,
RidePhotoListOutputSerializer,
RidePhotoOutputSerializer,
RidePhotoStatsOutputSerializer,
RidePhotoUpdateInputSerializer,
)
if TYPE_CHECKING:
pass
import logging
from django.contrib.auth import get_user_model
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.utils import extend_schema, extend_schema_view
from rest_framework import status
from rest_framework.decorators import action
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.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 django.contrib.auth import get_user_model
UserModel = get_user_model()
@@ -460,9 +461,9 @@ class RidePhotoViewSet(ModelViewSet):
try:
# Import CloudflareImage model and service
from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService
from django.utils import timezone
# Always fetch the latest image data from Cloudflare API
try:

View File

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

View File

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

View File

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

View File

@@ -5,88 +5,88 @@ This module provides a unified interface to all serializers across different dom
while maintaining the modular structure for better organization and maintainability.
"""
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 (
HealthCheckOutputSerializer,
PerformanceMetricsOutputSerializer,
SimpleHealthOutputSerializer,
EmailSendInputSerializer,
EmailTemplateOutputSerializer,
MapDataOutputSerializer,
CoordinateInputSerializer,
HistoryEventSerializer,
HistoryEntryOutputSerializer,
HistoryCreateInputSerializer,
ModerationSubmissionSerializer,
ModerationSubmissionOutputSerializer,
RoadtripParkSerializer,
RoadtripCreateInputSerializer,
RoadtripOutputSerializer,
GeocodeInputSerializer,
GeocodeOutputSerializer,
DistanceCalculationInputSerializer,
DistanceCalculationOutputSerializer,
EmailSendInputSerializer,
EmailTemplateOutputSerializer,
GeocodeInputSerializer,
GeocodeOutputSerializer,
HealthCheckOutputSerializer,
HistoryCreateInputSerializer,
HistoryEntryOutputSerializer,
HistoryEventSerializer,
MapDataOutputSerializer,
ModerationSubmissionOutputSerializer,
ModerationSubmissionSerializer,
PerformanceMetricsOutputSerializer,
RoadtripCreateInputSerializer,
RoadtripOutputSerializer,
RoadtripParkSerializer,
SimpleHealthOutputSerializer,
) # noqa: F401
from typing import Any, Dict, List
import importlib
# --- Shared utilities and base classes ---
from .shared import (
FilterOptionSerializer,
FilterRangeSerializer,
StandardizedFilterMetadataSerializer,
validate_filter_metadata_contract,
ensure_filter_option_format,
) # noqa: F401
# --- 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,
validate_filter_metadata_contract,
) # noqa: F401
# --- Accounts domain: try multiple likely locations, fall back to placeholders ---
_ACCOUNTS_SYMBOLS: List[str] = [
_ACCOUNTS_SYMBOLS: list[str] = [
"UserProfileOutputSerializer",
"UserProfileCreateInputSerializer",
"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
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
result: Dict[str, Any] = {name: None for name in _ACCOUNTS_SYMBOLS}
result: dict[str, Any] = dict.fromkeys(_ACCOUNTS_SYMBOLS)
for modname in candidates:
try:

View File

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

View File

@@ -5,8 +5,8 @@ This module contains serializers for history tracking and timeline functionality
using django-pghistory.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
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.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
OpenApiExample,
extend_schema_field,
extend_schema_serializer,
)
from rest_framework import serializers
# === MAP LOCATION SERIALIZERS ===

View File

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

View File

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

View File

@@ -4,10 +4,12 @@ Serializers for park review API endpoints.
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 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.parks.models.reviews import ParkReview
@extend_schema_serializer(
examples=[

View File

@@ -5,18 +5,18 @@ This module contains all serializers related to parks, park areas, park location
and park search functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
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 .shared import LocationOutputSerializer, CompanyOutputSerializer, ModelChoices
from apps.core.services.media_url_service import MediaURLService
from apps.core.choices.serializers import RichChoiceFieldSerializer
from .shared import CompanyOutputSerializer, LocationOutputSerializer, ModelChoices
# === PARK SERIALIZERS ===

View File

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

View File

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

View File

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

View File

@@ -5,16 +5,17 @@ This module contains all serializers related to ride models, variants,
technical specifications, and related functionality.
"""
from rest_framework import serializers
from drf_spectacular.utils import (
extend_schema_serializer,
extend_schema_field,
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 .shared import ModelChoices
from apps.core.choices.serializers import RichChoiceFieldSerializer
# 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."""
from apps.rides.models import (
RideModel,
RideModelVariant,
RideModelPhoto,
RideModelTechnicalSpec,
RideModelVariant,
)
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.
"""
from drf_spectacular.utils import OpenApiExample, extend_schema_field, extend_schema_serializer
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from apps.rides.models.reviews import RideReview
from apps.accounts.models import User
from apps.core.choices.serializers import RichChoiceSerializer
from apps.rides.models.reviews import RideReview
class ReviewUserSerializer(serializers.ModelSerializer):
@@ -74,7 +74,7 @@ class RideReviewOutputSerializer(serializers.ModelSerializer):
"""Output serializer for ride reviews."""
user = ReviewUserSerializer(read_only=True)
# Ride information
ride = serializers.SerializerMethodField()
park = serializers.SerializerMethodField()

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