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."""
|
||||
|
||||
@@ -18,9 +18,9 @@ class UserExportService:
|
||||
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 = {
|
||||
|
||||
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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,8 +141,7 @@ class UserDeletionServiceTest(TestCase):
|
||||
original_user_count = User.objects.count()
|
||||
|
||||
# Mock a failure during the deletion process
|
||||
with self.assertRaises(Exception):
|
||||
with transaction.atomic():
|
||||
with self.assertRaises(Exception), transaction.atomic():
|
||||
# Start the deletion process
|
||||
UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -193,7 +186,7 @@ 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...')
|
||||
|
||||
@@ -252,7 +245,7 @@ class Command(BaseCommand):
|
||||
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)}"
|
||||
@@ -319,7 +312,7 @@ class Command(BaseCommand):
|
||||
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...')
|
||||
|
||||
@@ -414,7 +407,7 @@ class Command(BaseCommand):
|
||||
# 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'}"
|
||||
|
||||
@@ -457,7 +450,7 @@ class Command(BaseCommand):
|
||||
# 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,
|
||||
@@ -473,7 +466,7 @@ class Command(BaseCommand):
|
||||
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...')
|
||||
|
||||
@@ -573,7 +566,7 @@ class Command(BaseCommand):
|
||||
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'])
|
||||
|
||||
@@ -612,7 +605,7 @@ class Command(BaseCommand):
|
||||
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...')
|
||||
|
||||
@@ -805,7 +798,7 @@ class Command(BaseCommand):
|
||||
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...')
|
||||
|
||||
@@ -897,7 +890,7 @@ class Command(BaseCommand):
|
||||
|
||||
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
|
||||
@@ -951,7 +944,7 @@ class Command(BaseCommand):
|
||||
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...')
|
||||
|
||||
@@ -1044,7 +1037,7 @@ class Command(BaseCommand):
|
||||
|
||||
|
||||
|
||||
def create_notifications(self, users: List[User]) -> None:
|
||||
def create_notifications(self, users: list[User]) -> None:
|
||||
"""Create sample notifications for users"""
|
||||
self.stdout.write('🔔 Creating notifications...')
|
||||
|
||||
@@ -1080,7 +1073,7 @@ class Command(BaseCommand):
|
||||
|
||||
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...')
|
||||
|
||||
@@ -1096,7 +1089,7 @@ class Command(BaseCommand):
|
||||
# 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...')
|
||||
|
||||
@@ -1109,7 +1102,7 @@ class Command(BaseCommand):
|
||||
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...')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -109,18 +115,17 @@ urlpatterns = [
|
||||
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__)
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
200: {
|
||||
"description": "Login history entries",
|
||||
"example": {
|
||||
"results": [
|
||||
{
|
||||
"success": True,
|
||||
"message": "Verification code sent to your email",
|
||||
"expires_at": deletion_request.expires_at,
|
||||
"email": user.email,
|
||||
"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):
|
||||
"""
|
||||
@@ -35,6 +40,65 @@ class RideCreditViewSet(viewsets.ModelViewSet):
|
||||
"""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.
|
||||
@@ -798,10 +797,11 @@ 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:
|
||||
|
||||
@@ -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
|
||||
@@ -57,10 +58,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
|
||||
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)
|
||||
@@ -213,7 +211,7 @@ class ContractValidationMiddleware(MiddlewareMixin):
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -287,17 +288,13 @@ class ParkListCreateAPIView(APIView):
|
||||
"""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
|
||||
|
||||
@@ -305,17 +302,13 @@ class ParkListCreateAPIView(APIView):
|
||||
"""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
|
||||
|
||||
@@ -323,24 +316,18 @@ class ParkListCreateAPIView(APIView):
|
||||
"""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
|
||||
|
||||
@@ -355,17 +342,13 @@ 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
|
||||
|
||||
@@ -440,11 +423,11 @@ 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,10 +167,7 @@ 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
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."""
|
||||
@@ -23,6 +24,7 @@ class RideCreditSerializer(serializers.ModelSerializer):
|
||||
'first_ridden_at',
|
||||
'last_ridden_at',
|
||||
'notes',
|
||||
'display_order',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,16 +5,17 @@ This module contains all serializers related to rides, roller coaster statistics
|
||||
ride locations, and ride reviews.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema_serializer,
|
||||
extend_schema_field,
|
||||
OpenApiExample,
|
||||
extend_schema_field,
|
||||
extend_schema_serializer,
|
||||
)
|
||||
from config.django import base as settings
|
||||
from .shared import ModelChoices
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
from config.django import base as settings
|
||||
|
||||
from .shared import ModelChoices
|
||||
|
||||
# === RIDE SERIALIZERS ===
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ This module contains serializers for ride-specific media functionality.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.rides.models import RidePhoto
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user