mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 18:07:01 -05:00
feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.
This commit is contained in:
@@ -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.*
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
106
backend/apps/accounts/login_history.py
Normal file
106
backend/apps/accounts/login_history.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.accounts.models import UserProfile
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.urls import path, include
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/", include("apps.api.v1.urls")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
180
backend/apps/api/v1/accounts/views_magic_link.py
Normal file
180
backend/apps/api/v1/accounts/views_magic_link.py
Normal 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
|
||||
)
|
||||
385
backend/apps/api/v1/auth/mfa.py
Normal file
385
backend/apps/api/v1/auth/mfa.py
Normal 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,
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,7 @@ Centralized from apps.email_service.urls
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import GenerateUploadURLView
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
12
backend/apps/api/v1/rides/company_urls.py
Normal file
12
backend/apps/api/v1/rides/company_urls.py
Normal 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"),
|
||||
]
|
||||
167
backend/apps/api/v1/rides/company_views.py
Normal file
167
backend/apps/api/v1/rides/company_views.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ This module contains serializers for park-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user