Compare commits

..

2 Commits

Author SHA1 Message Date
pacnpal
8dd5d88906 Last with the old frontend 2025-08-28 11:38:22 -04:00
pacnpal
c4702559fb Last with the old frontend 2025-08-28 11:37:24 -04:00
1196 changed files with 51551 additions and 208202 deletions

View File

@@ -1,73 +0,0 @@
# Migration Source: thrillwiki-87
The React project at `/Volumes/macminissd/Projects/thrillwiki-87` is the **authoritative source** for ThrillWiki features and functionality.
## Core Principle
**thrillwiki-87 is LAW.** When migrating features, the React implementation defines:
- What features must exist
- How they should behave
- What data structures are required
- What UI patterns to follow
## Source Project Structure
```
thrillwiki-87/
├── src/
│ ├── components/ # React components (44 directories)
│ ├── pages/ # Route pages (39 files)
│ ├── hooks/ # React hooks (80+ files)
│ ├── types/ # TypeScript definitions (63 files)
│ ├── contexts/ # React contexts
│ ├── lib/ # Utilities
│ └── integrations/ # External service integrations
├── docs/ # Feature documentation (78 files)
│ ├── SITE_OVERVIEW.md
│ ├── DESIGN_SYSTEM.md
│ ├── COMPONENTS.md
│ ├── PAGES.md
│ └── USER_FLOWS.md
└── supabase/ # Backend schemas and functions
```
## Technology Translation
| React (Source) | Nuxt 4 (Target) |
|----------------|-----------------|
| React component | Vue SFC (.vue) |
| useState | ref() / reactive() |
| useEffect | watch() / onMounted() |
| useContext | provide() / inject() or Pinia |
| React Router | Nuxt file-based routing |
| React Query | useAsyncData / useFetch |
| shadcn-ui | Nuxt UI |
| Supabase client | Django REST API via useApi() |
| Edge Functions | Django views |
## Backend Translation
| Supabase (Source) | Django (Target) |
|-------------------|-----------------|
| Table | Django Model |
| RLS policies | DRF permissions |
| Edge Functions | Django views/viewsets |
| Realtime | SSE / WebSockets |
| Auth | django-allauth + JWT |
| Storage | Cloudflare R2 |
## Migration Workflow
1. **Find source** in thrillwiki-87
2. **Read the docs** in thrillwiki-87/docs/
3. **Check existing** Nuxt implementation
4. **Port missing features** to achieve parity
5. **Verify behavior** matches source
## Key Source Files to Reference
When porting a feature, always check:
- `thrillwiki-87/docs/` for specifications
- `thrillwiki-87/src/types/` for data structures
- `thrillwiki-87/src/hooks/` for business logic
- `thrillwiki-87/src/components/` for UI patterns

View File

@@ -1,83 +0,0 @@
# Source Mapping: React → Nuxt
Quick reference for mapping thrillwiki-87 paths to thrillwiki_django_no_react paths.
## Directory Mappings
| React (thrillwiki-87) | Nuxt (thrillwiki_django_no_react) |
|----------------------|-----------------------------------|
| `src/components/` | `frontend/app/components/` |
| `src/pages/` | `frontend/app/pages/` |
| `src/hooks/` | `frontend/app/composables/` |
| `src/types/` | `frontend/app/types/` |
| `src/lib/` | `frontend/app/utils/` |
| `src/contexts/` | `frontend/app/stores/` (Pinia) |
| `docs/` | `source_docs/` |
| `supabase/migrations/` | `backend/apps/*/models.py` |
## Component Mappings (shadcn-ui → Nuxt UI)
| shadcn-ui | Nuxt UI |
|-----------|---------|
| `<Button>` | `<UButton>` |
| `<Card>` | `<UCard>` |
| `<Dialog>` | `<UModal>` |
| `<Input>` | `<UInput>` |
| `<Select>` | `<USelect>` / `<USelectMenu>` |
| `<Tabs>` | `<UTabs>` |
| `<Table>` | `<UTable>` |
| `<Badge>` | `<UBadge>` |
| `<Avatar>` | `<UAvatar>` |
| `<Tooltip>` | `<UTooltip>` |
| `<Sheet>` | `<USlideover>` |
| `<AlertDialog>` | `<UModal>` + confirm pattern |
| `<Skeleton>` | `<USkeleton>` |
| `<Textarea>` | `<UTextarea>` |
| `<Checkbox>` | `<UCheckbox>` |
| `<RadioGroup>` | `<URadioGroup>` |
| `<Switch>` | `<UToggle>` |
| `<DropdownMenu>` | `<UDropdown>` |
| `<Command>` | `<UCommandPalette>` |
| `<Popover>` | `<UPopover>` |
## Page Mappings
| React Page | Nuxt Page |
|------------|-----------|
| `Index.tsx` | `pages/index.vue` |
| `Parks.tsx` | `pages/parks/index.vue` |
| `ParkDetail.tsx` | `pages/parks/[park_slug]/index.vue` |
| `Rides.tsx` | `pages/rides/index.vue` |
| `RideDetail.tsx` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
| `Manufacturers.tsx` | `pages/manufacturers/index.vue` |
| `ManufacturerDetail.tsx` | `pages/manufacturers/[slug].vue` |
| `Designers.tsx` | `pages/designers/index.vue` |
| `DesignerDetail.tsx` | `pages/designers/[slug].vue` |
| `Operators.tsx` | `pages/operators/index.vue` |
| `OperatorDetail.tsx` | `pages/operators/[slug].vue` |
| `ParkOwners.tsx` | `pages/owners/index.vue` |
| `PropertyOwnerDetail.tsx` | `pages/owners/[slug].vue` |
| `Auth.tsx` | `pages/auth/login.vue`, `pages/auth/signup.vue` |
| `Profile.tsx` | `pages/profile/index.vue` |
| `Search.tsx` | `pages/search.vue` |
| `AdminDashboard.tsx` | `pages/admin/index.vue` |
## Hook → Composable Mappings
| React Hook | Vue Composable |
|------------|----------------|
| `useAuth.tsx` | `useAuth.ts` |
| `useSearch.tsx` | `useSearchHistory.ts` |
| `useModerationQueue.ts` | `useModeration.ts` |
| `useProfile.tsx` | (inline in pages) |
| `useLocations.ts` | `useParksApi.ts` |
| `useUnitPreferences.ts` | `useUnits.ts` |
## API Endpoint Translation
| Supabase RPC/Query | Django API |
|--------------------|------------|
| `supabase.from('parks')` | `GET /api/v1/parks/` |
| `supabase.rpc('search_*')` | `GET /api/v1/search/` |
| `supabase.auth.*` | `/api/v1/auth/*` |
| Edge Functions | Django views in `backend/apps/*/views.py` |

View File

@@ -1,329 +0,0 @@
# ThrillWiki API Conventions
Standards for designing and implementing APIs between the Django backend and Nuxt frontend.
## API Base Structure
### URL Patterns
```
/api/v1/ # API root (versioned)
/api/v1/parks/ # List/Create parks
/api/v1/parks/{slug}/ # Retrieve/Update/Delete park
/api/v1/parks/{slug}/rides/ # Nested resource
/api/v1/auth/ # Authentication endpoints
/api/v1/users/me/ # Current user
```
### HTTP Methods
| Method | Usage |
|--------|-------|
| GET | Retrieve resource(s) |
| POST | Create resource |
| PUT | Replace resource entirely |
| PATCH | Update resource partially |
| DELETE | Remove resource |
## Response Formats
### Success Response (Single Resource)
```json
{
"id": "uuid",
"name": "Cedar Point",
"slug": "cedar-point",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-16T14:20:00Z",
...
}
```
### Success Response (List)
```json
{
"count": 150,
"next": "/api/v1/parks/?page=2",
"previous": null,
"results": [
{ ... },
{ ... }
]
}
```
### Error Response
```json
{
"error": {
"code": "validation_error",
"message": "Invalid input data",
"details": {
"name": ["This field is required."],
"status": ["Invalid choice."]
}
}
}
```
### HTTP Status Codes
| Code | Meaning | When to Use |
|------|---------|-------------|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Validation errors |
| 401 | Unauthorized | Missing/invalid auth |
| 403 | Forbidden | Insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource |
| 500 | Server Error | Unexpected errors |
## Pagination
### Query Parameters
```
?page=2 # Page number (default: 1)
?page_size=20 # Items per page (default: 20, max: 100)
```
### Response
```json
{
"count": 150,
"next": "/api/v1/parks/?page=3",
"previous": "/api/v1/parks/?page=1",
"results": [ ... ]
}
```
## Filtering & Sorting
### Filtering
```
GET /api/v1/parks/?status=operating
GET /api/v1/parks/?country=USA&status=operating
GET /api/v1/rides/?park=cedar-point&type=coaster
```
### Search
```
GET /api/v1/parks/?search=cedar
```
### Sorting
```
GET /api/v1/parks/?ordering=name # Ascending
GET /api/v1/parks/?ordering=-created_at # Descending
GET /api/v1/parks/?ordering=-rating,name # Multiple fields
```
## Authentication
### Token-Based Auth
```
Authorization: Bearer <jwt_token>
```
### Endpoints
```
POST /api/v1/auth/login/ # Get tokens
POST /api/v1/auth/register/ # Create account
POST /api/v1/auth/refresh/ # Refresh access token
POST /api/v1/auth/logout/ # Invalidate tokens
GET /api/v1/auth/me/ # Current user info
```
### Social Auth
```
POST /api/v1/auth/google/ # Google OAuth
POST /api/v1/auth/discord/ # Discord OAuth
```
## Content Submission API
### Submit New Content
```
POST /api/v1/submissions/
{
"content_type": "park",
"data": {
"name": "New Park Name",
"city": "Orlando",
...
}
}
```
### Response
```json
{
"id": "submission-uuid",
"status": "pending",
"content_type": "park",
"data": { ... },
"created_at": "2024-01-15T10:30:00Z"
}
```
### Submit Edit to Existing Content
```
POST /api/v1/submissions/
{
"content_type": "park",
"object_id": "existing-park-uuid",
"data": {
"description": "Updated description..."
}
}
```
## Moderation API
### Get Moderation Queue
```
GET /api/v1/moderation/
GET /api/v1/moderation/?status=pending&type=park
```
### Review Submission
```
POST /api/v1/moderation/{id}/approve/
POST /api/v1/moderation/{id}/reject/
{
"notes": "Reason for rejection..."
}
```
## Nested Resources
### Pattern
```
/api/v1/parks/{slug}/rides/ # List rides in park
/api/v1/parks/{slug}/reviews/ # List park reviews
/api/v1/parks/{slug}/photos/ # List park photos
```
### When to Nest vs Flat
**Nest when:**
- Resource only makes sense in context of parent (park photos)
- Need to filter by parent frequently
**Use flat with filter when:**
- Resource can exist independently
- Need to query across parents
```
# Flat with filter
GET /api/v1/rides/?park=cedar-point
# Or nested
GET /api/v1/parks/cedar-point/rides/
```
## Geolocation API
### Parks Nearby
```
GET /api/v1/parks/nearby/?lat=41.4821&lng=-82.6822&radius=50
```
### Response includes distance
```json
{
"results": [
{
"id": "uuid",
"name": "Cedar Point",
"distance": 12.5, // in user's preferred units
...
}
]
}
```
## File Uploads
### Photo Upload
```
POST /api/v1/photos/
Content-Type: multipart/form-data
{
"content_type": "park",
"object_id": "park-uuid",
"image": <file>,
"caption": "Optional caption"
}
```
### Response
```json
{
"id": "photo-uuid",
"url": "https://cdn.thrillwiki.com/photos/...",
"thumbnail_url": "https://cdn.thrillwiki.com/photos/.../thumb",
"status": "pending", // Pending moderation
"created_at": "2024-01-15T10:30:00Z"
}
```
## Rate Limiting
### Headers
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1642089600
```
### When Limited
```
HTTP/1.1 429 Too Many Requests
{
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Try again in 60 seconds."
}
}
```
## Frontend Integration
### API Client Setup (Nuxt)
```typescript
// composables/useApi.ts
export function useApi() {
const config = useRuntimeConfig()
const authStore = useAuthStore()
const api = $fetch.create({
baseURL: config.public.apiBase,
headers: authStore.token ? {
Authorization: `Bearer ${authStore.token}`
} : {},
onResponseError: ({ response }) => {
if (response.status === 401) {
authStore.logout()
navigateTo('/auth/login')
}
}
})
return api
}
```
### Usage in Components
```typescript
const api = useApi()
// Fetch parks
const { data: parks } = await useAsyncData('parks', () =>
api('/parks/', { params: { status: 'operating' } })
)
// Create submission
await api('/submissions/', {
method: 'POST',
body: { content_type: 'park', data: formData }
})
```

View File

@@ -1,306 +0,0 @@
# ThrillWiki Component Patterns
Guidelines for building UI components consistent with ThrillWiki's design system.
## Component Hierarchy
```
components/
├── layout/ # Page structure
│ ├── Header.vue
│ ├── Footer.vue
│ ├── Sidebar.vue
│ └── PageContainer.vue
├── ui/ # Base components (shadcn/ui style)
│ ├── Button.vue
│ ├── Card.vue
│ ├── Input.vue
│ ├── Badge.vue
│ ├── Avatar.vue
│ ├── Modal.vue
│ └── ...
├── entity/ # Domain-specific cards
│ ├── ParkCard.vue
│ ├── RideCard.vue
│ ├── ReviewCard.vue
│ ├── CreditCard.vue
│ └── CompanyCard.vue
├── forms/ # Form components
│ ├── ParkForm.vue
│ ├── ReviewForm.vue
│ └── ...
└── specialty/ # Complex/unique components
├── SearchAutocomplete.vue
├── Map.vue
├── ImageGallery.vue
├── UnitDisplay.vue
└── RatingDisplay.vue
```
## Base Components (ui/)
### Card
```vue
<template>
<div :class="[
'rounded-lg border bg-card text-card-foreground',
interactive && 'hover:shadow-md transition-shadow cursor-pointer',
className
]">
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
interactive?: boolean
className?: string
}>()
</script>
```
### Button
```vue
<template>
<button :class="[buttonVariants({ variant, size }), className]">
<slot />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'
size?: 'default' | 'sm' | 'lg' | 'icon'
className?: string
}>()
</script>
```
### Badge
```vue
<template>
<span :class="[badgeVariants({ variant }), className]">
<slot />
</span>
</template>
<script setup lang="ts">
defineProps<{
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'
className?: string
}>()
</script>
```
## Entity Cards
### ParkCard
Displays park preview with image, name, location, stats, and status.
**Required Props:**
- `park: Park` - Park object
**Displays:**
- Park image (with fallback)
- Park name
- Location (city, country)
- Ride count
- Average rating
- Status badge (Operating/Closed/Under Construction)
**Interactions:**
- Click navigates to park detail page
- Hover shows elevation shadow
```vue
<template>
<NuxtLink :to="`/parks/${park.slug}`">
<Card interactive>
<div class="aspect-video relative overflow-hidden rounded-t-lg">
<NuxtImg
:src="park.image || '/placeholder-park.jpg'"
:alt="park.name"
class="object-cover w-full h-full"
/>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg line-clamp-1">{{ park.name }}</h3>
<p class="text-sm text-muted-foreground flex items-center gap-1">
<MapPin class="w-4 h-4" />
{{ park.city }}, {{ park.country }}
</p>
<div class="flex items-center justify-between mt-2">
<span class="text-sm">🎢 {{ park.rideCount }} rides</span>
<RatingDisplay :rating="park.averageRating" size="sm" />
</div>
<Badge :variant="statusVariant" class="mt-2">
{{ park.status }}
</Badge>
</div>
</Card>
</NuxtLink>
</template>
```
### RideCard
Similar structure to ParkCard, but shows:
- Ride image
- Ride name
- Park name (linked)
- Key specs (speed, height)
- Type badge + Status badge
### ReviewCard
Displays user review with:
- User avatar + username
- Rating (5-star display)
- Review date (relative)
- Review text
- Helpful votes (👍 count)
- Actions (Reply, Report)
### CreditCard
For user's ride credit list:
- Ride thumbnail
- Ride name + park name
- Ride count with +/- controls
- Last ridden date
- Edit button
## Specialty Components
### SearchAutocomplete
Global search with instant results.
**Features:**
- Debounced input (300ms)
- Results grouped by type (Parks, Rides, Companies)
- Keyboard navigation
- Click or Enter to navigate
- Empty state handling
**Implementation Notes:**
- Use `useFetch` with `watch` for reactive searching
- Show loading skeleton while fetching
- Limit results to 10 per category
- Highlight matching text
### UnitDisplay
Converts and displays values in user's preferred units.
```vue
<template>
<span>{{ formattedValue }}</span>
</template>
<script setup lang="ts">
const props = defineProps<{
value: number
type: 'speed' | 'height' | 'length' | 'weight'
showBoth?: boolean // Show both metric and imperial
}>()
const { preferredUnits } = useUnits()
const formattedValue = computed(() => {
// Convert and format based on type and preference
})
</script>
```
### RatingDisplay
Star rating visualization.
```vue
<template>
<div class="flex items-center gap-1">
<div class="flex">
<Star
v-for="i in 5"
:key="i"
:class="[
'w-4 h-4',
i <= Math.round(rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'
]"
/>
</div>
<span class="text-sm font-medium">{{ rating.toFixed(1) }}</span>
<span v-if="count" class="text-sm text-muted-foreground">({{ count }})</span>
</div>
</template>
```
### Map (Leaflet)
Interactive map for parks nearby feature.
**Features:**
- Marker clusters for dense areas
- Custom markers for different park types
- Popup on marker click with park preview
- Zoom controls
- Full-screen toggle
- User location marker (if permitted)
## Form Components
### Standard Form Structure
```vue
<template>
<form @submit.prevent="handleSubmit">
<div class="space-y-4">
<FormField label="Name" :error="errors.name">
<Input v-model="form.name" />
</FormField>
<FormField label="Description">
<Textarea v-model="form.description" />
</FormField>
<div class="flex gap-2 justify-end">
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
<Button type="submit" :loading="isSubmitting">Save</Button>
</div>
</div>
</form>
</template>
```
### Validation
- Use Zod for schema validation
- Display errors inline below fields
- Disable submit button while invalid
- Show loading state during submission
## Loading States
### Skeleton Loading
All cards should have skeleton states:
```vue
<template>
<Card v-if="loading">
<Skeleton class="aspect-video rounded-t-lg" />
<div class="p-4 space-y-2">
<Skeleton class="h-5 w-3/4" />
<Skeleton class="h-4 w-1/2" />
<Skeleton class="h-4 w-1/4" />
</div>
</Card>
<ActualCard v-else :data="data" />
</template>
```
### Empty States
Provide clear empty states with:
- Relevant icon
- Clear message
- Suggested action (button or link)
```vue
<EmptyState
icon="Search"
title="No results found"
description="Try adjusting your search or filters"
>
<Button @click="clearFilters">Clear Filters</Button>
</EmptyState>
```

View File

@@ -1,191 +0,0 @@
# ThrillWiki Design System Rules
Visual identity, colors, typography, and styling guidelines for ThrillWiki UI.
## Brand Identity
- **Name**: ThrillWiki (optional "(Beta)" suffix during preview)
- **Tagline**: The Ultimate Theme Park Database
- **Personality**: Enthusiastic, trustworthy, community-driven
- **Visual Style**: Modern, clean, subtle gradients, smooth animations, card-based layouts
## Color System
### Semantic Color Tokens (use these, not raw hex values)
| Token | Purpose | Usage |
|-------|---------|-------|
| `--background` | Page background | Main content area |
| `--foreground` | Primary text | Body text, headings |
| `--primary` | Interactive elements | Buttons, links |
| `--secondary` | Secondary UI | Secondary buttons |
| `--muted` | Subdued content | Hints, disabled states |
| `--accent` | Highlights | Focus rings, special callouts |
| `--destructive` | Danger actions | Delete buttons, errors |
| `--success` | Positive feedback | Success messages, confirmations |
| `--warning` | Caution | Alerts, warnings |
### Status Colors (Entity Badges)
- **Operating**: Green (`--success`)
- **Closed**: Red (`--destructive`)
- **Under Construction**: Amber (`--warning`)
### Dark Mode
- Automatically supported via CSS variables
- Reduce contrast (use off-white, not pure white)
- Replace shadows with subtle glows
- Slightly dim images
## Typography
### Font
- **Primary Font**: Inter (Sans-Serif)
- **Weights**: 400 (Regular), 500 (Medium), 600 (Semibold), 700 (Bold)
- **Fallback**: system-ui, sans-serif
### Type Scale
| Size | Name | Usage |
|------|------|-------|
| 48px | Display | Hero headlines |
| 36px | H1 | Page titles |
| 30px | H2 | Section headers |
| 24px | H3 | Card titles |
| 20px | H4 | Subheadings |
| 16px | Body | Default text |
| 14px | Small | Secondary text |
| 12px | Caption | Labels, hints |
### Text Classes (Tailwind)
```
Page Title: text-4xl font-bold
Section Header: text-2xl font-semibold
Card Title: text-lg font-medium
Body: text-base
Caption: text-sm text-muted-foreground
```
## Spacing System
### Base Unit
- 4px base unit
- All spacing is multiples of 4
### Spacing Scale
| Token | Size | Usage |
|-------|------|-------|
| `space-1` (p-1) | 4px | Tight gaps |
| `space-2` (p-2) | 8px | Icon gaps |
| `space-3` (p-3) | 12px | Small padding |
| `space-4` (p-4) | 16px | Default padding |
| `space-6` (p-6) | 24px | Section gaps |
| `space-8` (p-8) | 32px | Large gaps |
| `space-12` (p-12) | 48px | Section margins |
## Border Radius
| Token | Size | Usage |
|-------|------|-------|
| `rounded-sm` | 4px | Small elements |
| `rounded` | 8px | Buttons, inputs |
| `rounded-lg` | 12px | Cards |
| `rounded-xl` | 16px | Large cards, modals |
| `rounded-full` | 9999px | Pills, avatars |
## Layout
### Max Content Width
- Main content: `max-w-7xl` (1280px)
- Centered with `mx-auto`
- Responsive padding: `px-4 md:px-6 lg:px-8`
### Grid System
- Desktop (1024px+): 4 columns
- Tablet (768px+): 2 columns
- Mobile (<768px): 1 column
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
```
## Component Patterns
### Cards
- Background: `bg-card`
- Border: `border border-border`
- Radius: `rounded-lg`
- Padding: `p-4` or `p-6`
- Interactive cards: Add `hover:shadow-md transition-shadow cursor-pointer`
### Buttons
| Variant | Classes |
|---------|---------|
| Primary | `bg-primary text-primary-foreground hover:bg-primary/90` |
| Secondary | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
| Outline | `border border-input bg-background hover:bg-accent` |
| Ghost | `hover:bg-accent hover:text-accent-foreground` |
| Destructive | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
### Inputs
- Border: `border border-input`
- Focus: `focus:ring-2 focus:ring-primary focus:border-transparent`
- Error: `border-destructive focus:ring-destructive`
### Badges
```html
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Operating
</span>
```
## Icons
- **Library**: Lucide icons (via `lucide-vue-next`)
- **Default Size**: 24px (w-6 h-6)
- **Stroke Width**: 1.5px
- **Color**: Inherit from text color
Common icons:
- Search, Menu, User, Heart, Star, MapPin, Calendar, Camera, Edit, Trash, Check, X, ChevronRight, ExternalLink
## Responsive Breakpoints
| Breakpoint | Width | Target |
|------------|-------|--------|
| `sm` | 640px | Large phones |
| `md` | 768px | Tablets |
| `lg` | 1024px | Small laptops |
| `xl` | 1280px | Desktops |
| `2xl` | 1536px | Large screens |
## Accessibility Requirements
- Color contrast: 4.5:1 minimum for normal text
- Focus states: Visible focus ring on all interactive elements
- Motion: Respect `prefers-reduced-motion`
- Screen readers: Proper ARIA labels on interactive elements
- Keyboard: All functionality accessible via keyboard
## ThrillWiki-Specific Patterns
### Unit Display
Always provide unit conversion toggle (metric/imperial):
```vue
<UnitDisplay :value="121" type="speed" />
<!-- Renders: "121 km/h" or "75 mph" based on user preference -->
```
### Rating Display
```vue
<RatingDisplay :rating="4.2" :count="156" />
<!-- Renders: 4.2 (156 reviews) -->
```
### Entity Cards
All entity cards (Park, Ride, Company) should show:
- Image (with loading skeleton)
- Name (primary text)
- Key details (secondary text)
- Status badge
- Quick stats (rating, count, etc.)

View File

@@ -1,254 +0,0 @@
# ThrillWiki Django Backend Standards
Rules for developing the ThrillWiki backend with Django and Django REST Framework.
## Project Structure
```
backend/
├── config/ # Project settings
│ ├── settings/
│ │ ├── base.py # Shared settings
│ │ ├── development.py # Dev-specific
│ │ └── production.py # Prod-specific
│ ├── urls.py # Root URL config
│ └── wsgi.py
├── apps/
│ ├── parks/ # Park-related models and APIs
│ ├── rides/ # Ride-related models and APIs
│ ├── companies/ # Manufacturers, operators, etc.
│ ├── users/ # User profiles, authentication
│ ├── reviews/ # User reviews and ratings
│ ├── credits/ # Ride credits tracking
│ ├── submissions/ # Content submission system
│ ├── moderation/ # Moderation queue
│ └── core/ # Shared utilities
└── tests/ # Test files
```
## Django App Structure
Each app should follow this structure:
```
app_name/
├── __init__.py
├── admin.py # Django admin configuration
├── apps.py # App configuration
├── models.py # Database models
├── serializers.py # DRF serializers
├── views.py # DRF viewsets/views
├── urls.py # URL routing
├── permissions.py # Custom permissions (if needed)
├── filters.py # DRF filters (if needed)
├── signals.py # Django signals (if needed)
└── tests/
├── __init__.py
├── test_models.py
├── test_views.py
└── test_serializers.py
```
## Model Conventions
### Base Model
All models should inherit from a base model with common fields:
```python
from django.db import models
import uuid
class BaseModel(models.Model):
"""Base model with common fields"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
```
### Model Example
```python
class Park(BaseModel):
"""A theme park or amusement park"""
class Status(models.TextChoices):
OPERATING = 'operating', 'Operating'
CLOSED = 'closed', 'Closed'
CONSTRUCTION = 'construction', 'Under Construction'
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.OPERATING
)
location = models.PointField() # GeoDjango
city = models.CharField(max_length=100)
country = models.CharField(max_length=100)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
```
### Versioning for Moderated Content
For content that needs version history:
```python
class ParkVersion(BaseModel):
"""Version history for park edits"""
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name='versions')
data = models.JSONField() # Snapshot of park data
changed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
change_summary = models.CharField(max_length=255)
is_current = models.BooleanField(default=False)
```
## API Conventions
### URL Structure
```python
# apps/parks/urls.py
from rest_framework.routers import DefaultRouter
from .views import ParkViewSet
router = DefaultRouter()
router.register('parks', ParkViewSet, basename='park')
urlpatterns = router.urls
# Results in:
# GET /api/parks/ - List parks
# POST /api/parks/ - Create park (submission)
# GET /api/parks/{slug}/ - Get park detail
# PUT /api/parks/{slug}/ - Update park (submission)
# DELETE /api/parks/{slug}/ - Delete park (admin only)
```
### ViewSet Structure
```python
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticatedOrReadOnly
class ParkViewSet(viewsets.ModelViewSet):
"""API endpoint for parks"""
queryset = Park.objects.all()
serializer_class = ParkSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'slug'
filterset_class = ParkFilter
search_fields = ['name', 'city', 'country']
ordering_fields = ['name', 'created_at']
def get_queryset(self):
"""Optimize queries with select_related and prefetch_related"""
return Park.objects.select_related(
'operator', 'owner'
).prefetch_related(
'rides', 'photos'
)
@action(detail=True, methods=['get'])
def rides(self, request, slug=None):
"""Get rides for a specific park"""
park = self.get_object()
rides = park.rides.all()
serializer = RideSerializer(rides, many=True)
return Response(serializer.data)
```
### Serializer Patterns
```python
from rest_framework import serializers
class ParkSerializer(serializers.ModelSerializer):
"""Serializer for Park model"""
ride_count = serializers.IntegerField(read_only=True)
average_rating = serializers.FloatField(read_only=True)
class Meta:
model = Park
fields = [
'id', 'name', 'slug', 'description', 'status',
'city', 'country', 'ride_count', 'average_rating',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'slug', 'created_at', 'updated_at']
class ParkDetailSerializer(ParkSerializer):
"""Extended serializer for park detail view"""
rides = RideSerializer(many=True, read_only=True)
photos = PhotoSerializer(many=True, read_only=True)
class Meta(ParkSerializer.Meta):
fields = ParkSerializer.Meta.fields + ['rides', 'photos']
```
## Query Optimization
- ALWAYS use `select_related()` for ForeignKey relationships
- ALWAYS use `prefetch_related()` for ManyToMany and reverse FK relationships
- Annotate computed fields at the database level when possible
- Use pagination for all list endpoints
```python
# Good
parks = Park.objects.select_related('operator').prefetch_related('rides')
# Bad - causes N+1 queries
for park in parks:
print(park.operator.name) # Each iteration hits the database
```
## Permissions
### Custom Permission Classes
```python
from rest_framework.permissions import BasePermission
class IsModeratorOrReadOnly(BasePermission):
"""Allow read access to all, write access to moderators"""
def has_permission(self, request, view):
if request.method in ['GET', 'HEAD', 'OPTIONS']:
return True
return request.user.is_authenticated and request.user.is_moderator
```
## Submission & Moderation Flow
All user-submitted content goes through moderation:
1. User submits content → Creates `Submission` record with status `pending`
2. Moderator reviews → Approves or rejects
3. On approval → Content is published, version record created
```python
class Submission(BaseModel):
class Status(models.TextChoices):
PENDING = 'pending', 'Pending Review'
APPROVED = 'approved', 'Approved'
REJECTED = 'rejected', 'Rejected'
CHANGES_REQUESTED = 'changes_requested', 'Changes Requested'
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.UUIDField(null=True, blank=True)
data = models.JSONField()
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
submitted_by = models.ForeignKey(User, on_delete=models.CASCADE)
reviewed_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
review_notes = models.TextField(blank=True)
```
## Testing Requirements
- Write tests for all models, views, and serializers
- Use pytest and pytest-django
- Use factories (factory_boy) for test data
- Test permissions thoroughly
- Test edge cases and error conditions

View File

@@ -1,204 +0,0 @@
# ThrillWiki Nuxt 4 Frontend Standards
Rules for developing the ThrillWiki frontend with Nuxt 4.
## Project Structure
```
frontend/
├── app.vue # Root component
├── nuxt.config.ts # Nuxt configuration
├── pages/ # File-based routing
│ ├── index.vue # Homepage (/)
│ ├── parks/
│ │ ├── index.vue # /parks
│ │ ├── nearby.vue # /parks/nearby
│ │ └── [slug].vue # /parks/:slug
│ └── ...
├── components/
│ ├── layout/ # Header, Footer, Sidebar
│ ├── ui/ # Base components (Button, Card, Input)
│ ├── entity/ # ParkCard, RideCard, ReviewCard
│ └── forms/ # Form components
├── composables/ # Shared logic (useAuth, useApi, useUnits)
├── stores/ # Pinia stores
├── types/ # TypeScript interfaces
└── assets/
└── css/ # Global styles, Tailwind config
```
## Component Conventions
### Naming
- Use PascalCase for component files: `ParkCard.vue`, `SearchAutocomplete.vue`
- Use kebab-case in templates: `<park-card>`, `<search-autocomplete>`
- Prefix base components with `Base`: `BaseButton.vue`, `BaseInput.vue`
### Component Structure
```vue
<script setup lang="ts">
// 1. Type imports
import type { Park } from '~/types'
// 2. Component imports (auto-imported usually)
// 3. Props and emits
const props = defineProps<{
park: Park
variant?: 'default' | 'compact'
}>()
const emit = defineEmits<{
(e: 'select', park: Park): void
}>()
// 4. Composables
const { formatDistance } = useUnits()
// 5. Refs and reactive state
const isExpanded = ref(false)
// 6. Computed properties
const displayLocation = computed(() =>
`${props.park.city}, ${props.park.country}`
)
// 7. Functions
function handleClick() {
emit('select', props.park)
}
// 8. Lifecycle hooks (if needed)
</script>
<template>
<!-- Template here -->
</template>
<style scoped>
/* Scoped styles, prefer Tailwind classes in template */
</style>
```
### TypeScript
- Enable strict mode
- Define interfaces for all data structures in `types/`
- Use `defineProps<T>()` with TypeScript generics
- No `any` types without explicit justification
## Routing
### File-Based Routes
Follow Nuxt 4 file-based routing conventions:
- `pages/index.vue``/`
- `pages/parks/index.vue``/parks`
- `pages/parks/[slug].vue``/parks/:slug`
- `pages/parks/[park]/rides/[ride].vue``/parks/:park/rides/:ride`
### Navigation
```typescript
// Use navigateTo for programmatic navigation
await navigateTo('/parks/cedar-point')
// Use NuxtLink for declarative navigation
<NuxtLink to="/parks">All Parks</NuxtLink>
```
## Data Fetching
### Use Composables
```typescript
// composables/useParks.ts
export function useParks() {
const { data, pending, error, refresh } = useFetch('/api/parks/')
return {
parks: data,
loading: pending,
error,
refresh
}
}
```
### In Components
```typescript
// Use useAsyncData for page-level data
const { data: park } = await useAsyncData(
`park-${route.params.slug}`,
() => $fetch(`/api/parks/${route.params.slug}/`)
)
```
## State Management (Pinia)
### Store Structure
```typescript
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
async function login(credentials: LoginCredentials) {
// Implementation
}
function logout() {
user.value = null
}
return { user, isAuthenticated, login, logout }
})
```
### Using Stores
```typescript
const authStore = useAuthStore()
const { user, isAuthenticated } = storeToRefs(authStore)
```
## API Integration
### Base API Composable
```typescript
// composables/useApi.ts
export function useApi() {
const config = useRuntimeConfig()
const authStore = useAuthStore()
return $fetch.create({
baseURL: config.public.apiBase,
headers: {
...(authStore.token && { Authorization: `Bearer ${authStore.token}` })
}
})
}
```
## Error Handling
### Page Errors
```typescript
// In page components
const { data, error } = await useAsyncData(...)
if (error.value) {
throw createError({
statusCode: error.value.statusCode || 500,
message: error.value.message
})
}
```
### Form Errors
- Display validation errors inline with form fields
- Use toast notifications for API errors
- Provide clear user feedback
## Accessibility Requirements
- All interactive elements must be keyboard accessible
- Provide proper ARIA labels
- Ensure color contrast meets WCAG AA standards
- Support `prefers-reduced-motion`
- Use semantic HTML elements

View File

@@ -1,85 +0,0 @@
---
description: Ensure compliance with source_docs specifications - Continuous guard
---
# Source Docs Compliance Workflow
You are now in **Compliance Guard Mode**. The documents in `source_docs/` are LAW. Every code change must comply with these specifications.
## The Constitution
The following documents are the single source of truth:
- `source_docs/SITE_OVERVIEW.md` - High-level product vision
- `source_docs/DESIGN_SYSTEM.md` - Visual identity, colors, typography, gradients
- `source_docs/COMPONENTS.md` - Component specifications and patterns
- `source_docs/PAGES.md` - Page layouts and content
- `source_docs/USER_FLOWS.md` - User journeys and interaction specifications
## Before Making ANY Code Change
1. **Identify Relevant Specs**: Determine which source_docs apply to the change
2. **Read the Spec**: View the relevant sections to understand requirements
3. **Check for Deviations**: Compare current/proposed code against the spec
4. **Cite Your Sources**: Reference specific line numbers when claiming compliance
## Compliance Checklist
### For Components (check against COMPONENTS.md, DESIGN_SYSTEM.md):
- [ ] Uses correct component structure (header, content, footer)
- [ ] Uses primary color palette (blue for primary, NOT emerald/teal unless specified)
- [ ] Uses centralized constants instead of hardcoded options
- [ ] Follows established patterns (sticky footer, tab navigation, etc.)
- [ ] Proper button variants per spec
- [ ] Dark mode support
### For User Flows (check against USER_FLOWS.md):
- [ ] All required fields present (e.g., submission notes for edits)
- [ ] Proper validation implemented
- [ ] Error states handled per spec
- [ ] Success feedback matches spec
### For Forms:
- [ ] Options imported from `~/utils/constants.ts`
- [ ] Backend-synced choices (check `backend/apps/*/choices.py`)
- [ ] Required fields marked and validated
- [ ] Proper help text and hints
## When Deviation is Found
1. **STOP** - Do not proceed with the current approach
2. **LOG** - Document the deviation with:
- Requirement (from source_docs)
- Current implementation
- Proposed fix
3. **FIX** - Implement the compliant solution
4. **VERIFY** - Ensure the fix matches the spec
## Status Tags
Use these in any audit or review:
- `[OK]` - Compliant with spec
- `[DEVIATION]` - Implemented differently than spec
- `[MISSING]` - Required feature not implemented
- `[RISK]` - Potential issue that needs investigation
## Key Constants Files
Always use these instead of hardcoding:
- `frontend/app/utils/constants.ts` - Status configs, park types, etc.
- `backend/apps/parks/choices.py` - Park status and type definitions
- `backend/apps/rides/choices.py` - Ride status and category definitions
- `backend/apps/moderation/choices.py` - Submission status definitions
## Example Audit Output
```markdown
## Compliance Audit: ComponentName.vue
### Checking Against: [List relevant docs]
| Requirement | Source | Status |
|-------------|--------|--------|
| Uses primary gradient | DESIGN_SYSTEM.md L91-98 | [OK] |
| Submission note field | USER_FLOWS.md L473-476 | [MISSING] → FIX |
| Sticky footer pattern | COMPONENTS.md L583-602 | [DEVIATION] → FIX |
```

View File

@@ -1,168 +0,0 @@
---
description: Migrate a React component from thrillwiki-87 to Vue/Nuxt
---
# Migrate Component Workflow
Convert a React component from thrillwiki-87 (the authoritative source) to Vue 3 Composition API for the Nuxt 4 project.
## Step 1: Locate Source Component
Find the React component in thrillwiki-87:
```bash
# React components are in:
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
```
Common component directories:
- `auth/` - Authentication components
- `parks/` - Park-related components
- `rides/` - Ride-related components
- `forms/` - Form components
- `moderation/` - Moderation queue components
- `ui/` - Base UI primitives (shadcn-ui)
- `common/` - Shared utilities
- `layout/` - Layout components
## Step 2: Analyze React Component
Extract these patterns from the source:
```tsx
// Props interface
interface ComponentProps {
prop1: string
prop2?: number
}
// State hooks
const [value, setValue] = useState<Type>(initial)
// Effects
useEffect(() => { /* logic */ }, [deps])
// Event handlers
const handleClick = () => { /* logic */ }
// Render return
return <JSX />
```
## Step 3: Translate to Vue 3
### Props
```tsx
// React
interface Props { name: string; count?: number }
```
```vue
<!-- Vue -->
<script setup lang="ts">
defineProps<{
name: string
count?: number
}>()
</script>
```
### State
```tsx
// React
const [value, setValue] = useState<string>('')
```
```vue
<!-- Vue -->
<script setup lang="ts">
const value = ref<string>('')
</script>
```
### Effects
```tsx
// React
useEffect(() => {
fetchData()
}, [id])
```
```vue
<!-- Vue -->
<script setup lang="ts">
watch(() => id, () => {
fetchData()
}, { immediate: true })
</script>
```
### Events
```tsx
// React
onClick={() => onSelect(item)}
```
```vue
<!-- Vue -->
@click="emit('select', item)"
```
## Step 4: Map UI Components
Translate shadcn-ui to Nuxt UI:
| shadcn-ui | Nuxt UI |
|-----------|---------|
| `<Button>` | `<UButton>` |
| `<Card>` | `<UCard>` |
| `<Dialog>` | `<UModal>` |
| `<Input>` | `<UInput>` |
| `<Select>` | `<USelect>` |
| `<Tabs>` | `<UTabs>` |
| `<Badge>` | `<UBadge>` |
## Step 5: Update API Calls
Replace Supabase with Django API:
```tsx
// React (Supabase)
const { data } = await supabase.from('parks').select('*')
```
```vue
<!-- Vue (Django) -->
<script setup lang="ts">
const { data } = await useApi<Park[]>('/parks/')
</script>
```
## Step 6: Place in Nuxt Structure
Target paths:
```
frontend/app/components/
├── auth/ # Auth components
├── cards/ # Card components
├── common/ # Shared utilities
├── modals/ # Modal dialogs
├── rides/ # Ride components
└── ui/ # Base UI components
```
## Step 7: Verify Parity
- [ ] All props from source are present
- [ ] All state variables ported
- [ ] All event handlers work
- [ ] Styling matches (Tailwind classes)
- [ ] Loading states present
- [ ] Error states handled
- [ ] Dark mode works
## Example Migration
**Source**: `thrillwiki-87/src/components/parks/ParkCard.tsx`
**Target**: `frontend/app/components/cards/ParkCard.vue`
Check existing target. If it exists, compare and add missing features. If not, create new.

View File

@@ -1,223 +0,0 @@
---
description: Convert a React hook from thrillwiki-87 to a Vue composable
---
# Migrate Hook Workflow
Convert a React hook from thrillwiki-87 to a Vue 3 composable for the Nuxt 4 project.
## Step 1: Locate Source Hook
Find the React hook in thrillwiki-87:
```bash
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
```
Key hooks (80+ total):
- `useAuth.tsx` - Authentication state
- `useModerationQueue.ts` - Moderation logic (21KB)
- `useEntityVersions.ts` - Version history (14KB)
- `useSearch.tsx` - Search functionality
- `useUnitPreferences.ts` - Unit conversion
- `useProfile.tsx` - User profile
- `useLocations.ts` - Location data
- `useRideCreditFilters.ts` - Credit filtering
## Step 2: Analyze Hook Pattern
Extract the hook structure:
```tsx
export function useFeature(params: Params) {
// State
const [data, setData] = useState<Type>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
// Effects
useEffect(() => {
fetchData()
}, [dependency])
// Actions
const doSomething = async () => {
setLoading(true)
try {
const result = await api.call()
setData(result)
} catch (e) {
setError(e)
} finally {
setLoading(false)
}
}
return { data, loading, error, doSomething }
}
```
## Step 3: Convert to Composable
### Basic Structure
```tsx
// React
export function useFeature() {
const [value, setValue] = useState('')
return { value, setValue }
}
```
```typescript
// Vue
export function useFeature() {
const value = ref('')
function setValue(newValue: string) {
value.value = newValue
}
return { value, setValue }
}
```
### State Conversions
```tsx
// React
const [count, setCount] = useState(0)
const [user, setUser] = useState<User | null>(null)
const [items, setItems] = useState<Item[]>([])
```
```typescript
// Vue
const count = ref(0)
const user = ref<User | null>(null)
const items = ref<Item[]>([])
```
### Effect Conversions
```tsx
// React - Run on mount
useEffect(() => {
initialize()
}, [])
```
```typescript
// Vue
onMounted(() => {
initialize()
})
```
```tsx
// React - Watch dependency
useEffect(() => {
fetchData(id)
}, [id])
```
```typescript
// Vue
watch(() => id, (newId) => {
fetchData(newId)
}, { immediate: true })
```
### Supabase → Django API
```tsx
// React (Supabase)
const { data } = await supabase
.from('parks')
.select('*')
.eq('slug', slug)
.single()
```
```typescript
// Vue (Django)
const api = useApi()
const { data } = await api<Park>(`/parks/${slug}/`)
```
## Step 4: Handle Complex Patterns
### useCallback → Plain Function
```tsx
// React
const memoizedFn = useCallback(() => {
doSomething(dep)
}, [dep])
```
```typescript
// Vue - Usually no memo needed
function doSomething() {
// Vue's reactivity handles this
}
```
### useMemo → computed
```tsx
// React
const derived = useMemo(() => expensiveCalc(data), [data])
```
```typescript
// Vue
const derived = computed(() => expensiveCalc(data.value))
```
### Custom Hook Composition
```tsx
// React
function useFeature() {
const auth = useAuth()
const { data } = useQuery(...)
// ...
}
```
```typescript
// Vue
export function useFeature() {
const { user } = useAuth()
const api = useApi()
// ...
}
```
## Step 5: Target Location
Place composables in:
```
frontend/app/composables/
├── useApi.ts # Base API client
├── useAuth.ts # Authentication
├── useParksApi.ts # Parks API
├── useRidesApi.ts # Rides API
├── useModeration.ts # Moderation queue
└── use[Feature].ts # New composables
```
## Step 6: Verify Parity
- [ ] All returned values present
- [ ] All actions/methods work
- [ ] State updates correctly
- [ ] API calls translated
- [ ] Error handling maintained
- [ ] Loading states work
- [ ] TypeScript types correct
## Priority Hooks to Migrate
| Hook | Size | Complexity |
|------|------|------------|
| useModerationQueue.ts | 21KB | High |
| useEntityVersions.ts | 14KB | High |
| useAuth.tsx | 11KB | Medium |
| useAutoComplete.ts | 10KB | Medium |
| useRateLimitAlerts.ts | 10KB | Medium |
| useRideCreditFilters.ts | 9KB | Medium |
| useAdminSettings.ts | 9KB | Medium |

View File

@@ -1,183 +0,0 @@
---
description: Migrate a React page from thrillwiki-87 to Nuxt 4
---
# Migrate Page Workflow
Port a React page from thrillwiki-87 (the authoritative source) to a Nuxt 4 page.
## Step 1: Locate Source Page
Find the React page in thrillwiki-87:
```bash
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
```
Key pages:
- `Index.tsx` - Homepage
- `Parks.tsx` - Parks listing
- `ParkDetail.tsx` - Individual park (36KB - complex)
- `Rides.tsx` - Rides listing
- `RideDetail.tsx` - Individual ride (54KB - most complex)
- `Profile.tsx` - User profile (51KB - complex)
- `Search.tsx` - Global search
- `AdminDashboard.tsx` - Admin panel
## Step 2: Analyze Page Structure
Extract from React page:
```tsx
// Route params
const { id } = useParams()
// Data fetching
const { data, isLoading, error } = useQuery(...)
// SEO
// (usually react-helmet or similar)
// Page layout structure
return (
<Layout>
<Tabs>...</Tabs>
<Content>...</Content>
</Layout>
)
```
## Step 3: Create Nuxt Page
### Route Params
```tsx
// React
const { parkSlug } = useParams()
```
```vue
<!-- Nuxt -->
<script setup lang="ts">
const route = useRoute()
const parkSlug = route.params.park_slug as string
</script>
```
### Data Fetching
```tsx
// React (React Query)
const { data, isLoading } = useQuery(['park', id], () => fetchPark(id))
```
```vue
<!-- Nuxt -->
<script setup lang="ts">
const { data, pending } = await useAsyncData(
`park-${parkSlug}`,
() => useParksApi().getPark(parkSlug)
)
</script>
```
### SEO Meta
```tsx
// React (Helmet)
<Helmet>
<title>{park.name} | ThrillWiki</title>
</Helmet>
```
```vue
<!-- Nuxt -->
<script setup lang="ts">
useSeoMeta({
title: () => `${data.value?.name} | ThrillWiki`,
description: () => data.value?.description
})
</script>
```
### Page Meta
```vue
<script setup lang="ts">
definePageMeta({
middleware: ['auth'], // if authentication required
layout: 'default'
})
</script>
```
## Step 4: Port Page Sections
Common patterns:
### Tabs Structure
```tsx
// React
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsList>
<TabsContent value="overview">...</TabsContent>
</Tabs>
```
```vue
<!-- Nuxt -->
<UTabs :items="tabs" v-model="activeTab">
<template #default="{ item }">
<component :is="item.component" v-bind="item.props" />
</template>
</UTabs>
```
### Loading States
```tsx
// React
{isLoading ? <Skeleton /> : <Content />}
```
```vue
<!-- Nuxt -->
<template>
<div v-if="pending">
<USkeleton class="h-48" />
</div>
<div v-else-if="data">
<!-- Content -->
</div>
</template>
```
## Step 5: Target Location
Nuxt pages use file-based routing:
| React Route | Nuxt File Path |
|-------------|----------------|
| `/parks` | `pages/parks/index.vue` |
| `/parks/:slug` | `pages/parks/[park_slug]/index.vue` |
| `/parks/:slug/rides/:ride` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
| `/manufacturers/:id` | `pages/manufacturers/[slug].vue` |
## Step 6: Verify Feature Parity
Compare source page with target:
- [ ] All tabs/sections present
- [ ] All data displayed
- [ ] All actions work (edit, delete, etc.)
- [ ] Responsive layout matches
- [ ] Loading states present
- [ ] Error handling works
- [ ] SEO meta correct
## Reference: Page Complexity
| Page | Source Size | Priority |
|------|-------------|----------|
| RideDetail.tsx | 54KB | High |
| Profile.tsx | 51KB | High |
| AdminSettings.tsx | 44KB | Medium |
| ParkDetail.tsx | 36KB | High |
| Auth.tsx | 29KB | Medium |
| Parks.tsx | 22KB | High |
| Rides.tsx | 20KB | High |

View File

@@ -1,201 +0,0 @@
---
description: Port TypeScript types from thrillwiki-87 to the Nuxt project
---
# Migrate Type Workflow
Port TypeScript type definitions from thrillwiki-87 to the Nuxt 4 project, ensuring sync with Django serializers.
## Step 1: Locate Source Types
Find types in thrillwiki-87:
```bash
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
```
Key type files (63 total):
- `database.ts` - Core entity types (12KB)
- `statuses.ts` - Status enums (13KB)
- `moderation.ts` - Moderation types (10KB)
- `rideAttributes.ts` - Ride specs (8KB)
- `versioning.ts` - Version history (7KB)
- `submissions.ts` - Submission types
- `company.ts` - Company entities
## Step 2: Analyze Source Types
Extract type structure:
```typescript
// thrillwiki-87/src/types/database.ts
export interface Park {
id: string
name: string
slug: string
park_type: ParkType
status: ParkStatus
opening_date?: string
closing_date?: string
location?: ParkLocation
// ...
}
export interface ParkLocation {
latitude: number
longitude: number
country: string
city?: string
// ...
}
```
## Step 3: Check Django Serializers
Before porting, verify Django backend has matching fields:
```bash
# Check serializers in:
backend/apps/parks/serializers.py
backend/apps/rides/serializers.py
backend/apps/companies/serializers.py
```
The Django serializer defines what the API returns:
```python
class ParkSerializer(serializers.ModelSerializer):
class Meta:
model = Park
fields = ['id', 'name', 'slug', 'park_type', 'status', ...]
```
## Step 4: Handle Naming Conventions
Django uses snake_case, TypeScript typically uses camelCase. Choose one strategy:
### Option A: Match Django exactly (Recommended)
```typescript
// Keep snake_case from API
export interface Park {
id: string
name: string
park_type: string
created_at: string
}
```
### Option B: Transform in API layer
```typescript
// camelCase in types
export interface Park {
id: string
name: string
parkType: string
createdAt: string
}
// Transform in useApi or serializer
```
**Current project uses Option A (snake_case).**
## Step 5: Port the Types
Create/update type files:
```typescript
// frontend/app/types/park.ts
export interface Park {
id: string
name: string
slug: string
park_type: string
status: string
short_description?: string
description?: string
opening_date?: string
closing_date?: string
operating_season?: string
website?: string
latitude?: number
longitude?: number
// Match Django serializer fields exactly
}
export interface ParkDetail extends Park {
rides?: Ride[]
reviews?: Review[]
photos?: Photo[]
// Extended fields from detail serializer
}
```
## Step 6: Port Enums/Options
Source may have enums that should be constants:
```typescript
// thrillwiki-87/src/types/statuses.ts
export const PARK_STATUS = {
OPERATING: 'operating',
CLOSED: 'closed',
// ...
} as const
```
```typescript
// frontend/app/utils/constants.ts
export const PARK_STATUS_OPTIONS = [
{ value: 'operating', label: 'Operating', color: 'green' },
{ value: 'closed', label: 'Closed', color: 'red' },
// Sync with backend/apps/parks/choices.py
]
```
## Step 7: Target Locations
```
frontend/app/types/
├── index.ts # Re-exports and common types
├── park.ts # Park types
├── ride.ts # Ride types
├── company.ts # Company types (manufacturer, designer, etc.)
├── user.ts # User/profile types
├── moderation.ts # Moderation types
└── api.ts # API response wrappers
```
## Step 8: Verify Type Sync
| Layer | File | Must Match |
|-------|------|------------|
| Django Model | `backend/apps/*/models.py` | Database schema |
| Serializer | `backend/apps/*/serializers.py` | API response |
| Frontend Type | `frontend/app/types/*.ts` | Serializer fields |
Run checks:
```bash
# Check Django fields
python manage.py shell -c "from apps.parks.models import Park; print([f.name for f in Park._meta.fields])"
# Check serializer fields
python manage.py shell -c "from apps.parks.serializers import ParkSerializer; print(ParkSerializer().fields.keys())"
```
## Step 9: Checklist
- [ ] All source type fields ported
- [ ] Fields match Django serializer
- [ ] snake_case naming used
- [ ] Optional fields marked with `?`
- [ ] Enums/options in constants.ts
- [ ] Backend choices.py synced
- [ ] Types exported from index.ts
## Priority Types
| Type File | Source Size | Notes |
|-----------|-------------|-------|
| statuses.ts | 13KB | All status enums |
| database.ts | 12KB | Core entities |
| moderation.ts | 10KB | Queue types |
| rideAttributes.ts | 8KB | Spec options |
| versioning.ts | 7KB | History types |

View File

@@ -1,193 +0,0 @@
---
description: Audit gaps and implement missing features from thrillwiki-87 source
---
# Migration Workflow
**thrillwiki-87 is LAW.** This workflow audits what's missing and implements it.
## Quick Start
Run `/migrate` to:
1. Audit current project against thrillwiki-87
2. Identify the highest-priority missing feature
3. Implement it using the appropriate sub-workflow
---
## Phase 1: Gap Analysis
### Step 1.1: Audit Components
Compare React components with Vue components:
```bash
# Source (LAW):
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
# Target:
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/components/
```
For each React component directory, check if equivalent Vue component exists:
- **EXISTS**: Mark as ✅, check for feature parity
- **MISSING**: Mark as ❌, add to implementation queue
### Step 1.2: Audit Pages
Compare React pages with Nuxt pages:
```bash
# Source (LAW):
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
# Target:
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/pages/
```
Key pages to audit (by size/complexity):
| React Page | Size | Priority |
|------------|------|----------|
| RideDetail.tsx | 54KB | P0 |
| Profile.tsx | 51KB | P0 |
| AdminSettings.tsx | 44KB | P1 |
| ParkDetail.tsx | 36KB | P0 |
| Auth.tsx | 29KB | P1 |
| Parks.tsx | 22KB | P0 |
| Rides.tsx | 20KB | P0 |
### Step 1.3: Audit Hooks → Composables
Compare React hooks with Vue composables:
```bash
# Source (LAW):
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
# Target:
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/composables/
```
Priority hooks:
| React Hook | Size | Current Status |
|------------|------|----------------|
| useModerationQueue.ts | 21KB | Check useModeration.ts |
| useEntityVersions.ts | 14KB | ❌ Missing |
| useAuth.tsx | 11KB | Check useAuth.ts |
| useRideCreditFilters.ts | 9KB | ❌ Missing |
### Step 1.4: Audit Types
Compare TypeScript definitions:
```bash
# Source (LAW):
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
# Target:
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/types/
```
---
## Phase 2: Priority Selection
After auditing, select the highest priority gap:
### Priority Matrix
| Category | Weight | Examples |
|----------|--------|----------|
| **P0 - Core UX** | 10 | Main entity pages, search, auth |
| **P1 - Features** | 7 | Reviews, credits, lists |
| **P2 - Admin** | 5 | Moderation, settings |
| **P3 - Polish** | 3 | Animations, edge cases |
Select ONE item to implement this session.
---
## Phase 3: Implementation
Based on the gap type, use the appropriate sub-workflow:
### For Missing Component
```
/migrate-component
```
### For Missing Page
```
/migrate-page
```
### For Missing Hook/Composable
```
/migrate-hook
```
### For Missing Types
```
/migrate-type
```
---
## Phase 4: Verification
After implementation:
1. **Feature Parity Check**: Does it match thrillwiki-87 behavior?
2. **Visual Parity Check**: Does it look the same?
3. **Data Parity Check**: Does it show the same information?
4. **Interaction Parity Check**: Do actions work the same way?
---
## Gap Tracking
Update `GAP_ANALYSIS_MATRIX.md` with status:
```markdown
| Feature | Source Location | Target Location | Status |
|---------|-----------------|-----------------|--------|
| Park Detail Tabs | src/pages/ParkDetail.tsx | pages/parks/[park_slug]/ | [OK] |
| Entity Versioning | src/hooks/useEntityVersions.ts | composables/ | [MISSING] |
| Ride Credits | src/components/credits/ | components/credits/ | [PARTIAL] |
```
Status tags:
- `[OK]` - Feature parity achieved
- `[PARTIAL]` - Some features missing
- `[MISSING]` - Not implemented
- `[BLOCKED]` - Waiting on backend
---
## Reference: Source Documentation
Always check thrillwiki-87 docs for specifications:
```bash
/Volumes/macminissd/Projects/thrillwiki-87/docs/
├── SITE_OVERVIEW.md # What features exist
├── COMPONENTS.md # Component specifications
├── PAGES.md # Page layouts (72KB - comprehensive)
├── USER_FLOWS.md # Interaction patterns (77KB)
├── DESIGN_SYSTEM.md # Visual standards
└── [Many more...]
```
---
## Single Command Execution
When you run `/migrate`, execute these steps:
1. **Read GAP_ANALYSIS_MATRIX.md** to see current status
2. **List directories** in both projects to find new gaps
3. **Select highest priority** missing item
4. **Read source implementation** from thrillwiki-87
5. **Implement in target** following sub-workflow patterns
6. **Update GAP_ANALYSIS_MATRIX.md** with new status
7. **Report what was implemented** and next priority item

View File

@@ -1,472 +0,0 @@
---
description: Add moderation support to a content type in ThrillWiki
---
# Moderation Workflow
Add moderation (submission queue, version history, approval flow) to a content type.
## Overview
ThrillWiki's moderation system ensures quality by:
1. User submits new/edited content → Creates `Submission` record
2. Content enters moderation queue with `pending` status
3. Moderator reviews and approves/rejects
4. On approval → Content is published, version record created
## Implementation Steps
### Step 1: Ensure Model Supports Versioning
The content model needs to track its current state and history:
```python
# backend/apps/[app]/models.py
class Park(BaseModel):
"""Main park model - always shows current approved data"""
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
# ... other fields
# Track the current approved version
current_version = models.ForeignKey(
'ParkVersion',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='current_for'
)
class ParkVersion(BaseModel):
"""Immutable snapshot of park data at a point in time"""
park = models.ForeignKey(
Park,
on_delete=models.CASCADE,
related_name='versions'
)
# Store complete snapshot of editable fields
data = models.JSONField()
# Metadata
changed_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True
)
change_summary = models.CharField(max_length=255, blank=True)
submission = models.ForeignKey(
'submissions.Submission',
on_delete=models.SET_NULL,
null=True,
related_name='versions'
)
class Meta:
ordering = ['-created_at']
def apply_to_park(self):
"""Apply this version's data to the parent park"""
for field, value in self.data.items():
if hasattr(self.park, field):
setattr(self.park, field, value)
self.park.current_version = self
self.park.save()
```
### Step 2: Create Submission Serializers
```python
# backend/apps/submissions/serializers.py
class ParkSubmissionSerializer(serializers.Serializer):
"""Serializer for park submission data"""
name = serializers.CharField(max_length=255)
description = serializers.CharField(required=False, allow_blank=True)
city = serializers.CharField(max_length=100)
country = serializers.CharField(max_length=100)
status = serializers.ChoiceField(choices=Park.Status.choices)
# ... other editable fields
def validate_name(self, value):
# Custom validation if needed
return value
class SubmissionCreateSerializer(serializers.ModelSerializer):
"""Create a new submission"""
data = serializers.JSONField()
class Meta:
model = Submission
fields = ['content_type', 'object_id', 'data', 'change_summary']
def validate(self, attrs):
content_type = attrs['content_type']
# Get the appropriate serializer for this content type
serializer_map = {
'park': ParkSubmissionSerializer,
'ride': RideSubmissionSerializer,
# ... other content types
}
serializer_class = serializer_map.get(content_type)
if not serializer_class:
raise serializers.ValidationError(
{'content_type': 'Unsupported content type'}
)
# Validate the data field
data_serializer = serializer_class(data=attrs['data'])
data_serializer.is_valid(raise_exception=True)
attrs['data'] = data_serializer.validated_data
return attrs
def create(self, validated_data):
validated_data['submitted_by'] = self.context['request'].user
validated_data['status'] = Submission.Status.PENDING
return super().create(validated_data)
```
### Step 3: Create Submission ViewSet
```python
# backend/apps/submissions/views.py
class SubmissionViewSet(viewsets.ModelViewSet):
"""API for content submissions"""
serializer_class = SubmissionSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
user = self.request.user
# Users see their own submissions
# Moderators see all pending submissions
if user.is_moderator:
return Submission.objects.all()
return Submission.objects.filter(submitted_by=user)
def get_serializer_class(self):
if self.action == 'create':
return SubmissionCreateSerializer
return SubmissionSerializer
@action(detail=True, methods=['get'])
def diff(self, request, pk=None):
"""Get diff between submission and current version"""
submission = self.get_object()
if submission.object_id:
# Edit submission - compare to current
current = self.get_current_data(submission)
return Response({
'before': current,
'after': submission.data,
'changes': self.compute_diff(current, submission.data)
})
else:
# New submission - no comparison
return Response({
'before': None,
'after': submission.data,
'changes': None
})
```
### Step 4: Create Moderation ViewSet
```python
# backend/apps/moderation/views.py
class ModerationViewSet(viewsets.ViewSet):
"""Moderation queue and actions"""
permission_classes = [IsModerator]
def list(self, request):
"""Get moderation queue"""
queryset = Submission.objects.filter(
status=Submission.Status.PENDING
).select_related(
'submitted_by'
).order_by('created_at')
# Filter by content type
content_type = request.query_params.get('type')
if content_type:
queryset = queryset.filter(content_type=content_type)
serializer = SubmissionSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def approve(self, request, pk=None):
"""Approve a submission"""
submission = get_object_or_404(Submission, pk=pk)
if submission.status != Submission.Status.PENDING:
return Response(
{'error': 'Submission is not pending'},
status=status.HTTP_400_BAD_REQUEST
)
# Apply the submission
with transaction.atomic():
if submission.object_id:
# Edit existing content
content = self.get_content_object(submission)
version = self.create_version(content, submission)
version.apply_to_park()
else:
# Create new content
content = self.create_content(submission)
version = self.create_version(content, submission)
content.current_version = version
content.save()
submission.status = Submission.Status.APPROVED
submission.reviewed_by = request.user
submission.reviewed_at = timezone.now()
submission.save()
# Notify user
notify_user(
submission.submitted_by,
'submission_approved',
{'submission': submission}
)
return Response({'status': 'approved'})
@action(detail=True, methods=['post'])
def reject(self, request, pk=None):
"""Reject a submission"""
submission = get_object_or_404(Submission, pk=pk)
submission.status = Submission.Status.REJECTED
submission.reviewed_by = request.user
submission.reviewed_at = timezone.now()
submission.review_notes = request.data.get('notes', '')
submission.save()
# Notify user
notify_user(
submission.submitted_by,
'submission_rejected',
{'submission': submission, 'reason': submission.review_notes}
)
return Response({'status': 'rejected'})
@action(detail=True, methods=['post'])
def request_changes(self, request, pk=None):
"""Request changes to a submission"""
submission = get_object_or_404(Submission, pk=pk)
submission.status = Submission.Status.CHANGES_REQUESTED
submission.reviewed_by = request.user
submission.review_notes = request.data.get('notes', '')
submission.save()
# Notify user
notify_user(
submission.submitted_by,
'submission_changes_requested',
{'submission': submission, 'notes': submission.review_notes}
)
return Response({'status': 'changes_requested'})
```
### Step 5: Frontend - Submission Form
```vue
<!-- frontend/components/forms/ParkSubmitForm.vue -->
<script setup lang="ts">
import type { Park } from '~/types'
const props = defineProps<{
park?: Park // Existing park for edits, null for new
}>()
const emit = defineEmits<{
(e: 'submitted'): void
}>()
const form = reactive({
name: props.park?.name || '',
description: props.park?.description || '',
city: props.park?.city || '',
country: props.park?.country || '',
status: props.park?.status || 'operating',
changeSummary: '',
})
const isSubmitting = ref(false)
const errors = ref<Record<string, string[]>>({})
async function handleSubmit() {
isSubmitting.value = true
errors.value = {}
try {
await $fetch('/api/v1/submissions/', {
method: 'POST',
body: {
content_type: 'park',
object_id: props.park?.id || null,
data: {
name: form.name,
description: form.description,
city: form.city,
country: form.country,
status: form.status,
},
change_summary: form.changeSummary,
}
})
// Show success message
useToast().success(
props.park
? 'Your edit has been submitted for review'
: 'Your submission has been received'
)
emit('submitted')
} catch (e: any) {
if (e.data?.error?.details) {
errors.value = e.data.error.details
} else {
useToast().error('Failed to submit. Please try again.')
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="space-y-6">
<FormField label="Park Name" :error="errors.name?.[0]" required>
<Input v-model="form.name" />
</FormField>
<FormField label="Description" :error="errors.description?.[0]">
<Textarea v-model="form.description" rows="4" />
</FormField>
<!-- More fields... -->
<FormField
label="Summary of Changes"
:error="errors.changeSummary?.[0]"
hint="Briefly describe what you're adding or changing"
>
<Input v-model="form.changeSummary" />
</FormField>
<Alert variant="info">
Your submission will be reviewed by our moderators before being published.
</Alert>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
<Button type="submit" :loading="isSubmitting">
{{ park ? 'Submit Edit' : 'Submit for Review' }}
</Button>
</div>
</form>
</template>
```
### Step 6: Frontend - Moderation Queue Page
```vue
<!-- frontend/pages/moderation/index.vue -->
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'moderator']
})
useSeoMeta({
title: 'Moderation Queue | ThrillWiki'
})
const filters = reactive({
type: '',
status: 'pending'
})
const { data, pending, refresh } = await useAsyncData(
'moderation-queue',
() => $fetch('/api/v1/moderation/', { params: filters }),
{ watch: [filters] }
)
async function handleApprove(id: string) {
await $fetch(`/api/v1/moderation/${id}/approve/`, { method: 'POST' })
useToast().success('Submission approved')
refresh()
}
async function handleReject(id: string, notes: string) {
await $fetch(`/api/v1/moderation/${id}/reject/`, {
method: 'POST',
body: { notes }
})
useToast().success('Submission rejected')
refresh()
}
</script>
<template>
<PageContainer>
<h1 class="text-3xl font-bold mb-8">Moderation Queue</h1>
<!-- Filters -->
<div class="flex gap-4 mb-6">
<Select v-model="filters.type">
<SelectOption value="">All Types</SelectOption>
<SelectOption value="park">Parks</SelectOption>
<SelectOption value="ride">Rides</SelectOption>
</Select>
</div>
<!-- Queue -->
<div class="space-y-4">
<SubmissionCard
v-for="submission in data"
:key="submission.id"
:submission="submission"
@approve="handleApprove(submission.id)"
@reject="notes => handleReject(submission.id, notes)"
/>
</div>
<EmptyState
v-if="!pending && !data?.length"
icon="CheckCircle"
title="Queue is empty"
description="No pending submissions to review"
/>
</PageContainer>
</template>
```
## Checklist
- [ ] Model supports versioning with JSONField snapshot
- [ ] Submission model tracks all submission states
- [ ] Validation serializers exist for each content type
- [ ] Moderation endpoints have proper permissions
- [ ] Approval creates version and applies changes atomically
- [ ] Users are notified of submission status changes
- [ ] Frontend shows submission status to users
- [ ] Moderation queue is filterable and efficient
- [ ] Diff view shows before/after comparison
- [ ] Tests cover approval, rejection, and edge cases

View File

@@ -1,360 +0,0 @@
---
description: Create a new Django REST API endpoint following ThrillWiki conventions
---
# New API Workflow
Create a new Django REST Framework API endpoint following ThrillWiki's patterns.
## Information Gathering
1. **Resource Name**: What entity is this API for? (e.g., Park, Ride, Review)
2. **Operations**: Which CRUD operations are needed?
- [ ] List (GET /resources/)
- [ ] Create (POST /resources/)
- [ ] Retrieve (GET /resources/{id}/)
- [ ] Update (PUT/PATCH /resources/{id}/)
- [ ] Delete (DELETE /resources/{id}/)
3. **Permissions**: Who can access?
- Public read, authenticated write?
- Owner only?
- Moderator/Admin only?
4. **Filtering**: What filter options are needed?
5. **Nested Resources**: Does this belong under another resource?
## Implementation Steps
### 1. Create or Update the Model
File: `backend/apps/[app]/models.py`
```python
from django.db import models
from apps.core.models import BaseModel
class Resource(BaseModel):
"""A resource description"""
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
ARCHIVED = 'archived', 'Archived'
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT
)
owner = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='resources'
)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', '-created_at']),
]
def __str__(self):
return self.name
```
### 2. Create the Serializer
File: `backend/apps/[app]/serializers.py`
```python
from rest_framework import serializers
from .models import Resource
class ResourceSerializer(serializers.ModelSerializer):
"""Serializer for Resource listing"""
owner_username = serializers.CharField(source='owner.username', read_only=True)
class Meta:
model = Resource
fields = [
'id', 'name', 'slug', 'description', 'status',
'owner', 'owner_username',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'slug', 'owner', 'created_at', 'updated_at']
class ResourceDetailSerializer(ResourceSerializer):
"""Extended serializer for single resource view"""
# Add related objects for detail view
related_items = RelatedItemSerializer(many=True, read_only=True)
class Meta(ResourceSerializer.Meta):
fields = ResourceSerializer.Meta.fields + ['related_items']
class ResourceCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating resources"""
class Meta:
model = Resource
fields = ['name', 'description', 'status']
def create(self, validated_data):
# Auto-generate slug
validated_data['slug'] = slugify(validated_data['name'])
# Set owner from request
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
```
### 3. Create the ViewSet
File: `backend/apps/[app]/views.py`
```python
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from .models import Resource
from .serializers import (
ResourceSerializer,
ResourceDetailSerializer,
ResourceCreateSerializer
)
from .filters import ResourceFilter
from .permissions import IsOwnerOrReadOnly
class ResourceViewSet(viewsets.ModelViewSet):
"""
API endpoint for resources.
list: Get all resources (with filtering)
create: Create a new resource (authenticated)
retrieve: Get a single resource
update: Update a resource (owner only)
destroy: Delete a resource (owner only)
"""
queryset = Resource.objects.all()
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
lookup_field = 'slug'
# Filtering
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = ResourceFilter
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at', 'updated_at']
ordering = ['-created_at']
def get_queryset(self):
"""Optimize queries"""
return Resource.objects.select_related(
'owner'
).prefetch_related(
'related_items'
)
def get_serializer_class(self):
"""Use different serializers for different actions"""
if self.action == 'create':
return ResourceCreateSerializer
if self.action == 'retrieve':
return ResourceDetailSerializer
return ResourceSerializer
@action(detail=True, methods=['post'])
def publish(self, request, slug=None):
"""Publish a draft resource"""
resource = self.get_object()
if resource.status != Resource.Status.DRAFT:
return Response(
{'error': 'Only draft resources can be published'},
status=status.HTTP_400_BAD_REQUEST
)
resource.status = Resource.Status.PUBLISHED
resource.save()
return Response(ResourceSerializer(resource).data)
```
### 4. Create Custom Filter
File: `backend/apps/[app]/filters.py`
```python
import django_filters
from .models import Resource
class ResourceFilter(django_filters.FilterSet):
"""Filters for Resource API"""
status = django_filters.ChoiceFilter(choices=Resource.Status.choices)
owner = django_filters.CharFilter(field_name='owner__username')
created_after = django_filters.DateTimeFilter(
field_name='created_at',
lookup_expr='gte'
)
created_before = django_filters.DateTimeFilter(
field_name='created_at',
lookup_expr='lte'
)
class Meta:
model = Resource
fields = ['status', 'owner']
```
### 5. Create Custom Permission
File: `backend/apps/[app]/permissions.py`
```python
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsOwnerOrReadOnly(BasePermission):
"""Allow read to all, write only to owner"""
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
return obj.owner == request.user
class IsModerator(BasePermission):
"""Allow access only to moderators"""
def has_permission(self, request, view):
return (
request.user.is_authenticated and
request.user.is_moderator
)
```
### 6. Register URLs
File: `backend/apps/[app]/urls.py`
```python
from rest_framework.routers import DefaultRouter
from .views import ResourceViewSet
router = DefaultRouter()
router.register('resources', ResourceViewSet, basename='resource')
urlpatterns = router.urls
```
Add to main urls:
```python
# backend/config/urls.py
urlpatterns = [
...
path('api/v1/', include('apps.app_name.urls')),
]
```
### 7. Create Migration
```bash
python manage.py makemigrations app_name
python manage.py migrate
```
### 8. Add Tests
File: `backend/apps/[app]/tests/test_views.py`
```python
import pytest
from rest_framework.test import APITestCase
from rest_framework import status
from django.urls import reverse
from apps.users.factories import UserFactory
from .factories import ResourceFactory
class TestResourceAPI(APITestCase):
"""Tests for Resource API endpoints"""
def setUp(self):
self.user = UserFactory()
self.resource = ResourceFactory(owner=self.user)
def test_list_resources_unauthenticated(self):
"""Anonymous users can list resources"""
url = reverse('resource-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_create_resource_authenticated(self):
"""Authenticated users can create resources"""
self.client.force_authenticate(user=self.user)
url = reverse('resource-list')
data = {'name': 'New Resource', 'description': 'Test'}
response = self.client.post(url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_create_resource_unauthenticated(self):
"""Anonymous users cannot create resources"""
url = reverse('resource-list')
data = {'name': 'New Resource'}
response = self.client.post(url, data)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_update_own_resource(self):
"""Users can update their own resources"""
self.client.force_authenticate(user=self.user)
url = reverse('resource-detail', args=[self.resource.slug])
response = self.client.patch(url, {'name': 'Updated'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_update_others_resource(self):
"""Users cannot update others' resources"""
other_user = UserFactory()
self.client.force_authenticate(user=other_user)
url = reverse('resource-detail', args=[self.resource.slug])
response = self.client.patch(url, {'name': 'Hacked'})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
```
## Checklist
After creating the API:
- [ ] Model has proper fields and constraints
- [ ] Serializers validate input correctly
- [ ] ViewSet has proper permissions
- [ ] Queries are optimized (select_related, prefetch_related)
- [ ] Filtering, search, and ordering work
- [ ] Pagination is enabled
- [ ] URLs are registered
- [ ] Migrations are created and applied
- [ ] Tests pass
## Output
Report what was created:
```
Created API: /api/v1/resources/
Methods: GET (list), POST (create), GET (detail), PATCH (update), DELETE
Permissions: IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly
Filters: status, owner, created_after, created_before
Search: name, description
Files:
- backend/apps/[app]/models.py
- backend/apps/[app]/serializers.py
- backend/apps/[app]/views.py
- backend/apps/[app]/filters.py
- backend/apps/[app]/permissions.py
- backend/apps/[app]/urls.py
- backend/apps/[app]/tests/test_views.py
```

View File

@@ -1,279 +0,0 @@
---
description: Create a new Vue component following ThrillWiki patterns
---
# New Component Workflow
Create a new Vue component following ThrillWiki's design system and conventions.
## Information Gathering
1. **Component Name**: PascalCase (e.g., `ParkCard`, `RatingDisplay`)
2. **Category**:
- `ui/` - Base components (Button, Card, Input)
- `entity/` - Domain-specific (ParkCard, RideCard)
- `forms/` - Form components
- `specialty/` - Complex/unique components
3. **Props**: What data does it receive?
4. **Emits**: What events does it emit?
5. **State**: Does it have internal state?
6. **Variants**: Does it need multiple variants/sizes?
## Component Template
### Base Component Structure
Location: `frontend/components/[category]/ComponentName.vue`
```vue
<script setup lang="ts">
// 1. Import types
import type { ComponentProps } from '~/types'
// 2. Define props with TypeScript
const props = withDefaults(defineProps<{
// Required props
title: string
// Optional props with defaults
variant?: 'default' | 'compact' | 'expanded'
disabled?: boolean
}>(), {
variant: 'default',
disabled: false,
})
// 3. Define emits
const emit = defineEmits<{
(e: 'click'): void
(e: 'select', value: string): void
}>()
// 4. Use composables
const { formatDistance } = useUnits()
// 5. Internal state
const isOpen = ref(false)
// 6. Computed properties
const computedClass = computed(() => ({
'opacity-50 pointer-events-none': props.disabled,
'p-4': props.variant === 'default',
'p-2': props.variant === 'compact',
}))
// 7. Methods
function handleClick() {
if (!props.disabled) {
emit('click')
}
}
</script>
<template>
<div :class="computedClass" @click="handleClick">
<!-- Component content -->
<slot />
</div>
</template>
```
### Entity Card Component
For ParkCard, RideCard, etc.:
```vue
<script setup lang="ts">
import type { Park } from '~/types'
import { MapPin, Star } from 'lucide-vue-next'
const props = defineProps<{
park: Park
variant?: 'default' | 'compact'
}>()
const statusVariant = computed(() => {
switch (props.park.status) {
case 'operating': return 'success'
case 'closed': return 'destructive'
case 'construction': return 'warning'
default: return 'default'
}
})
</script>
<template>
<NuxtLink :to="`/parks/${park.slug}`">
<Card interactive class="overflow-hidden">
<!-- Image -->
<div class="aspect-video relative">
<NuxtImg
:src="park.image || '/placeholder-park.jpg'"
:alt="park.name"
class="object-cover w-full h-full"
loading="lazy"
/>
<Badge
:variant="statusVariant"
class="absolute top-2 right-2"
>
{{ park.status }}
</Badge>
</div>
<!-- Content -->
<div class="p-4">
<h3 class="font-semibold text-lg line-clamp-1">
{{ park.name }}
</h3>
<p class="text-sm text-muted-foreground flex items-center gap-1 mt-1">
<MapPin class="w-4 h-4" />
{{ park.city }}, {{ park.country }}
</p>
<div class="flex items-center justify-between mt-3">
<span class="text-sm text-muted-foreground">
🎢 {{ park.rideCount }} rides
</span>
<RatingDisplay
v-if="park.averageRating"
:rating="park.averageRating"
size="sm"
/>
</div>
</div>
</Card>
</NuxtLink>
</template>
```
### Skeleton Loading Component
Every entity card should have a matching skeleton:
```vue
<script setup lang="ts">
defineProps<{
variant?: 'default' | 'compact'
}>()
</script>
<template>
<Card>
<Skeleton class="aspect-video rounded-t-lg" />
<div class="p-4 space-y-2">
<Skeleton class="h-5 w-3/4" />
<Skeleton class="h-4 w-1/2" />
<div class="flex justify-between mt-3">
<Skeleton class="h-4 w-20" />
<Skeleton class="h-4 w-16" />
</div>
</div>
</Card>
</template>
```
## Variant Pattern (using CVA)
For components with many variants, use class-variance-authority:
```vue
<script setup lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-8 px-3 text-sm',
lg: 'h-12 px-6 text-lg',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
type Props = VariantProps<typeof buttonVariants> & {
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
disabled: false,
})
</script>
<template>
<button
:class="buttonVariants({ variant, size })"
:disabled="disabled || loading"
>
<Loader2 v-if="loading" class="w-4 h-4 mr-2 animate-spin" />
<slot />
</button>
</template>
```
## Composable Integration
If component needs shared logic, create a composable:
```typescript
// composables/useUnitDisplay.ts
export function useUnitDisplay() {
const { preferredUnits } = useUserPreferences()
function formatSpeed(kmh: number): string {
if (preferredUnits.value === 'imperial') {
return `${Math.round(kmh * 0.621371)} mph`
}
return `${kmh} km/h`
}
function formatHeight(meters: number): string {
if (preferredUnits.value === 'imperial') {
return `${Math.round(meters * 3.28084)} ft`
}
return `${meters} m`
}
return { formatSpeed, formatHeight }
}
```
## Checklist
After creating the component:
- [ ] TypeScript props are properly defined
- [ ] Component follows design system (colors, spacing, typography)
- [ ] Responsive on all screen sizes
- [ ] Handles loading state (if applicable)
- [ ] Handles empty state (if applicable)
- [ ] Accessible (ARIA labels, keyboard nav)
- [ ] Has matching skeleton component (for async data)
- [ ] Works in dark mode
## Output
Report what was created:
```
Created: frontend/components/[category]/ComponentName.vue
Props: [list of props]
Emits: [list of events]
Related: [any composables or sub-components]
```

View File

@@ -1,311 +0,0 @@
---
description: Implement a full-stack feature across Django backend and Nuxt frontend
---
# New Feature Workflow
Implement a complete feature spanning the Django backend and Nuxt frontend.
## Planning Phase
Before writing any code, create an implementation plan:
### 1. Feature Definition
- **Goal**: What problem does this feature solve?
- **User Stories**: Who uses it and how?
- **Acceptance Criteria**: How do we know it's done?
### 2. Technical Scope
- **Backend Changes**: Models, APIs, permissions
- **Frontend Changes**: Pages, components, state
- **Data Flow**: How data moves between layers
### 3. Implementation Order
Always implement in this order:
1. **Database/Models** - Foundation first
2. **API Endpoints** - Backend logic
3. **Frontend Components** - UI building blocks
4. **Frontend Pages** - Assembled views
5. **Integration** - Wire it all together
6. **Tests** - Verify everything works
## Implementation Steps
### Step 1: Backend - Models
```python
# Create or modify models
# Remember: Inherit from BaseModel, add proper indexes
class NewFeature(BaseModel):
# Fields
name = models.CharField(max_length=255)
# ... other fields
class Meta:
ordering = ['-created_at']
```
Run migrations:
```bash
python manage.py makemigrations
python manage.py migrate
```
### Step 2: Backend - Serializers
```python
# backend/apps/[app]/serializers.py
class NewFeatureSerializer(serializers.ModelSerializer):
class Meta:
model = NewFeature
fields = ['id', 'name', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
class NewFeatureDetailSerializer(NewFeatureSerializer):
# Extended fields for detail view
related_data = RelatedSerializer(many=True, read_only=True)
class Meta(NewFeatureSerializer.Meta):
fields = NewFeatureSerializer.Meta.fields + ['related_data']
```
### Step 3: Backend - API Views
```python
# backend/apps/[app]/views.py
class NewFeatureViewSet(viewsets.ModelViewSet):
queryset = NewFeature.objects.all()
serializer_class = NewFeatureSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
def get_queryset(self):
return NewFeature.objects.select_related(
# Add related models
).prefetch_related(
# Add many-to-many or reverse relations
)
def get_serializer_class(self):
if self.action == 'retrieve':
return NewFeatureDetailSerializer
return NewFeatureSerializer
```
### Step 4: Backend - URLs
```python
# backend/apps/[app]/urls.py
router.register('new-features', NewFeatureViewSet, basename='new-feature')
```
### Step 5: Backend - Tests
```python
# backend/apps/[app]/tests/test_new_feature.py
class TestNewFeatureAPI(APITestCase):
def test_list_features(self):
response = self.client.get('/api/v1/new-features/')
self.assertEqual(response.status_code, 200)
def test_create_feature_authenticated(self):
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/new-features/', {'name': 'Test'})
self.assertEqual(response.status_code, 201)
```
### Step 6: Frontend - Types
```typescript
// frontend/types/newFeature.ts
export interface NewFeature {
id: string
name: string
createdAt: string
updatedAt: string
}
export interface NewFeatureDetail extends NewFeature {
relatedData: RelatedItem[]
}
```
### Step 7: Frontend - Composables
```typescript
// frontend/composables/useNewFeatures.ts
export function useNewFeatures() {
const api = useApi()
async function getFeatures(params?: Record<string, any>) {
return api<PaginatedResponse<NewFeature>>('/new-features/', { params })
}
async function getFeature(id: string) {
return api<NewFeatureDetail>(`/new-features/${id}/`)
}
async function createFeature(data: Partial<NewFeature>) {
return api<NewFeature>('/new-features/', {
method: 'POST',
body: data
})
}
return { getFeatures, getFeature, createFeature }
}
```
### Step 8: Frontend - Components
Create necessary components following component patterns:
```vue
<!-- frontend/components/entity/NewFeatureCard.vue -->
<script setup lang="ts">
import type { NewFeature } from '~/types'
defineProps<{
feature: NewFeature
}>()
</script>
<template>
<Card interactive>
<div class="p-4">
<h3 class="font-semibold">{{ feature.name }}</h3>
<!-- Additional content -->
</div>
</Card>
</template>
```
### Step 9: Frontend - Pages
```vue
<!-- frontend/pages/new-features/index.vue -->
<script setup lang="ts">
definePageMeta({
// middleware: ['auth'], // if needed
})
useSeoMeta({
title: 'New Features | ThrillWiki',
})
const { data, pending, error } = await useAsyncData('new-features', () =>
useNewFeatures().getFeatures()
)
</script>
<template>
<PageContainer>
<h1 class="text-3xl font-bold mb-8">New Features</h1>
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Skeleton v-for="i in 6" :key="i" class="h-48" />
</div>
<div v-else-if="data?.results" class="grid grid-cols-1 md:grid-cols-3 gap-6">
<NewFeatureCard
v-for="feature in data.results"
:key="feature.id"
:feature="feature"
/>
</div>
<EmptyState v-else title="No features found" />
</PageContainer>
</template>
```
### Step 10: Integration Testing
Test the full flow:
1. **API Test**: Verify endpoints with curl or API client
2. **Component Test**: Test components in isolation
3. **E2E Test**: Test complete user journey
```typescript
// frontend/tests/e2e/newFeature.spec.ts
import { test, expect } from '@playwright/test'
test('user can view new features', async ({ page }) => {
await page.goto('/new-features')
await expect(page.locator('h1')).toContainText('New Features')
})
test('authenticated user can create feature', async ({ page }) => {
// Login first
await page.goto('/auth/login')
// ... login steps
await page.goto('/new-features/create')
await page.fill('input[name="name"]', 'Test Feature')
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/new-features\//)
})
```
## Feature Checklist
### Backend
- [ ] Models created with proper fields and indexes
- [ ] Migrations created and applied
- [ ] Serializers handle validation
- [ ] ViewSet has proper permissions
- [ ] Queries are optimized
- [ ] URLs registered
- [ ] Unit tests pass
### Frontend
- [ ] Types defined
- [ ] Composables created for API calls
- [ ] Components follow design system
- [ ] Pages have proper SEO meta
- [ ] Loading states implemented
- [ ] Error states handled
- [ ] Responsive design verified
- [ ] Keyboard accessible
### Integration
- [ ] Data flows correctly between backend and frontend
- [ ] Authentication/authorization works
- [ ] Error handling covers edge cases
- [ ] Performance is acceptable
## Output Summary
After completing the feature:
```markdown
## Feature: [Feature Name]
### Backend
- Model: `apps/[app]/models.py` - NewFeature
- API: `/api/v1/new-features/`
- Permissions: [describe]
### Frontend
- Page: `/new-features` (list), `/new-features/[id]` (detail)
- Components: NewFeatureCard, NewFeatureForm
- Composable: useNewFeatures
### Tests
- Backend: X tests passing
- Frontend: X tests passing
- E2E: X tests passing
### Notes
- [Any important implementation notes]
- [Known limitations]
- [Future improvements]
```

View File

@@ -1,235 +0,0 @@
---
description: Create a new page in ThrillWiki following project conventions
---
# New Page Workflow
Create a new page in ThrillWiki following all conventions and patterns.
## Information Gathering
Before creating the page, determine:
1. **Route**: What URL should this page have?
2. **Page Type**:
- List page (shows multiple items)
- Detail page (shows single item)
- Form page (create/edit content)
- Static page (about, contact, etc.)
3. **Data Requirements**: What data does this page need?
4. **Authentication**: Public or authenticated only?
5. **Related Components**: What existing components can be reused?
## File Creation Steps
### 1. Create the Page Component
Location: `frontend/pages/[route].vue` or `frontend/pages/[folder]/[route].vue`
```vue
<script setup lang="ts">
// Define page metadata
definePageMeta({
// middleware: ['auth'], // If authenticated only
// layout: 'admin', // If using special layout
})
// Set page head
useSeoMeta({
title: 'Page Title | ThrillWiki',
description: 'Page description for SEO',
})
// Fetch data
const { data, pending, error } = await useAsyncData('unique-key', () =>
$fetch('/api/v1/endpoint/')
)
// Handle error
if (error.value) {
throw createError({
statusCode: error.value.statusCode || 500,
message: error.value.message
})
}
</script>
<template>
<PageContainer>
<!-- Breadcrumbs (if applicable) -->
<Breadcrumbs :items="breadcrumbItems" />
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold">Page Title</h1>
<p class="text-muted-foreground mt-2">Page description</p>
</div>
<!-- Loading State -->
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Skeleton v-for="i in 8" :key="i" class="h-64" />
</div>
<!-- Content -->
<div v-else>
<!-- Page content here -->
</div>
</PageContainer>
</template>
```
### 2. For List Pages - Add Filtering
```vue
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
// Filter state from URL
const filters = computed(() => ({
status: route.query.status as string || '',
search: route.query.search as string || '',
page: parseInt(route.query.page as string) || 1
}))
// Fetch with filters
const { data, pending, refresh } = await useAsyncData(
`items-${JSON.stringify(filters.value)}`,
() => $fetch('/api/v1/items/', { params: filters.value }),
{ watch: [filters] }
)
// Update filters
function updateFilter(key: string, value: string) {
router.push({
query: { ...route.query, [key]: value || undefined, page: 1 }
})
}
</script>
<template>
<!-- Filter Bar -->
<div class="flex gap-4 mb-6">
<Input
:model-value="filters.search"
@update:model-value="updateFilter('search', $event)"
placeholder="Search..."
/>
<Select
:model-value="filters.status"
@update:model-value="updateFilter('status', $event)"
>
<SelectOption value="">All</SelectOption>
<SelectOption value="operating">Operating</SelectOption>
<SelectOption value="closed">Closed</SelectOption>
</Select>
</div>
<!-- Results -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<ItemCard v-for="item in data?.results" :key="item.id" :item="item" />
</div>
<!-- Pagination -->
<Pagination
:current-page="filters.page"
:total-pages="Math.ceil((data?.count || 0) / 20)"
@page-change="updateFilter('page', $event.toString())"
/>
</template>
```
### 3. For Detail Pages - Dynamic Route
File: `frontend/pages/items/[slug].vue`
```vue
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string
const { data: item, error } = await useAsyncData(
`item-${slug}`,
() => $fetch(`/api/v1/items/${slug}/`)
)
if (error.value) {
throw createError({
statusCode: 404,
message: 'Item not found'
})
}
useSeoMeta({
title: `${item.value?.name} | ThrillWiki`,
description: item.value?.description,
})
</script>
```
### 4. For Form Pages
```vue
<script setup lang="ts">
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
description: z.string().optional(),
})
const form = reactive({
name: '',
description: '',
})
const errors = ref<Record<string, string[]>>({})
const isSubmitting = ref(false)
async function handleSubmit() {
// Validate
const result = schema.safeParse(form)
if (!result.success) {
errors.value = result.error.flatten().fieldErrors
return
}
isSubmitting.value = true
try {
await $fetch('/api/v1/items/', {
method: 'POST',
body: form
})
await navigateTo('/items')
} catch (e) {
// Handle API errors
} finally {
isSubmitting.value = false
}
}
</script>
```
## Checklist
After creating the page, verify:
- [ ] Page renders without errors
- [ ] SEO meta tags are set
- [ ] Loading states display correctly
- [ ] Error states are handled
- [ ] Page is responsive (mobile, tablet, desktop)
- [ ] Keyboard navigation works
- [ ] Data fetches efficiently (no N+1 issues)
- [ ] URL parameters persist correctly (for list pages)
- [ ] Authentication is enforced (if required)
## Output
Report what was created:
```
Created: frontend/pages/[path].vue
Route: /[route]
Type: [list/detail/form/static]
Features: [list of features implemented]
```

View File

@@ -1,17 +0,0 @@
{
"permissions": {
"allow": [
"Bash(python manage.py check:*)",
"Bash(uv run:*)",
"Bash(find:*)",
"Bash(python:*)",
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,91 +0,0 @@
## Brief overview
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
## Rule compliance and design decisions
- Read ALL .clinerules files before making any code changes
- Never assume exceptions to rules marked as "MANDATORY"
- Take full responsibility for rule violations without excuses
- Ask "What is the most optimal approach?" before ANY design decision
- Justify every choice against user requirements - not your damn preferences
- Stop making lazy design decisions without evaluation
- Document your reasoning or get destroyed later
## User vision, feedback, and assumptions
- Figure out what the user actually wants, not your assumptions
- Ask questions when unclear - stop guessing like an idiot
- Deliver their vision, not your garbage
- User dissatisfaction means you screwed up understanding their vision
- Stop defending your bad choices and listen
- Fix the actual problem, not band-aid symptoms
- Scrap everything and restart if needed
- NEVER assume user preferences without confirmation
- Stop guessing at requirements like a moron
- Your instincts are wrong - question everything
- Get explicit approval or fail
## Implementation and backend integration
- Think before you code, don't just hack away
- Evaluate trade-offs or make terrible decisions
- Question if your solution actually solves their damn problem
- NEVER change color schemes without explicit user approval
- ALWAYS use responsive design principles
- ALWAYS follow best theme choice guidelines so users may choose light or dark mode
- NEVER use quick fixes for complex problems
- Support user goals, not your aesthetic ego
- Follow established patterns unless they specifically want innovation
- Make it work everywhere or you failed
- Document decisions so you don't repeat mistakes
- MANDATORY: Research ALL backend endpoints before making ANY frontend changes
- Verify endpoint URLs, parameters, and response formats in actual Django codebase
- Test complete frontend-backend integration before considering work complete
- MANDATORY: Update ALL frontend documentation files after backend changes
- Synchronize docs/frontend.md, docs/lib-api.ts, and docs/types-api.ts
- Take immediate responsibility for integration failures without excuses
- MUST create frontend integration prompt after every backend change affecting API
- Include complete API endpoint information with all parameters and types
- Document all mandatory API rules (trailing slashes, HTTP methods, authentication)
- Never assume frontend developers have access to backend code
## API Organization and Data Models
- **MANDATORY NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
- **MANDATORY TRAILING SLASHES**: All API endpoints MUST include trailing forward slashes unless ending with query parameters
- Validate all endpoint URLs against the mandatory trailing slash rule
- **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
- Individual rides reference BOTH the model (what product) and type (how it operates)
- Ride types must be available for ALL ride categories, not just roller coasters
## Development Commands and Code Quality
- **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
- **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
- **Package Management**: Always use `uv add <package>` instead of `pip install <package>`
- **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>`
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
- Extract logical operations into separate methods with descriptive names
- Use single responsibility principle - each method should have one clear purpose
- Prefer composition over deeply nested conditional logic
- Always handle None values explicitly to avoid type errors
- Use proper type annotations, including union types (e.g., `Polygon | None`)
- Structure API views with clear separation between parameter handling, business logic, and response building
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
## ThrillWiki Project Rules
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
- **Slugs**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
- **Status Management**: Rides have operational status (OPERATING, CLOSED_TEMP, SBNO, etc.) with date tracking
- **Company Roles**: Companies can be MANUFACTURER, OPERATOR, DESIGNER, PROPERTY_OWNER with array field
- **Location Data**: Use PostGIS for geographic data, separate location models for parks and rides
- **API Patterns**: Use DRF with drf-spectacular, comprehensive serializers, nested endpoints, caching
- **Photo Management**: Banner/card image references, photo types, attribution fields, primary photo logic
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
## CRITICAL RULES
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
- **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data must come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
- **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain. Parks: `/parks/{park_slug}/` and `/parks/`. Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`. Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`. Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
- **PHOTO MANAGEMENT**: Use CloudflareImagesField for all photo uploads with variants and transformations. Clearly define and use photo types (e.g., banner, card) for all images. Include attribution fields for all photos. Implement logic to determine the primary photo for each model.

View File

@@ -1,17 +0,0 @@
## Brief overview
Mandatory use of Rich Choice Objects system instead of Django tuple-based choices for all choice fields in ThrillWiki project.
## Rich Choice Objects enforcement
- NEVER use Django tuple-based choices (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
- All choice fields MUST use `RichChoiceField(choice_group="group_name", domain="domain_name")` pattern
- Choice definitions MUST be created in domain-specific `choices.py` files using RichChoice dataclass
- All choices MUST include rich metadata (color, icon, description, css_class at minimum)
- Choice groups MUST be registered with global registry using `register_choices()` function
- Import choices in domain `__init__.py` to trigger auto-registration on Django startup
- Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
- Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
- DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
- Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
- Validate choice groups are correctly loaded in registry during application startup
- Update serializers to use RichChoiceSerializer for choice fields
- Follow established patterns from rides, parks, and accounts domains for consistency

View File

@@ -1,372 +1,90 @@
# ==============================================================================
# ThrillWiki Environment Configuration
# ==============================================================================
# Copy this file to .env and fill in your actual values
# WARNING: Never commit .env files containing real secrets to version control
#
# This is the primary .env.example for the entire project.
# See docs/configuration/environment-variables.md for complete documentation.
# See docs/PRODUCTION_CHECKLIST.md for production deployment verification.
# [AWS-SECRET-REMOVED]===========================
# ThrillWiki Environment Configuration
# [AWS-SECRET-REMOVED]===========================
# Copy this file to ***REMOVED*** and fill in your actual values
# ==============================================================================
# PRODUCTION-REQUIRED SETTINGS
# ==============================================================================
# These settings MUST be explicitly configured for production deployments.
# The application will NOT function correctly without proper values.
#
# For complete documentation, see:
# - docs/configuration/environment-variables.md (detailed reference)
# - docs/PRODUCTION_CHECKLIST.md (deployment verification)
#
# PRODUCTION REQUIREMENTS:
# - DEBUG=False (security)
# - DJANGO_SETTINGS_MODULE=config.django.production (correct settings)
# - ALLOWED_HOSTS=yourdomain.com (host validation)
# - CSRF_TRUSTED_ORIGINS=https://yourdomain.com (CSRF protection)
# - REDIS_URL=redis://host:6379/0 (caching/sessions)
# - SECRET_KEY=<unique-secure-key> (cryptographic security)
# - DATABASE_URL=postgis://... (database connection)
#
# Validate your production config with:
# DJANGO_SETTINGS_MODULE=config.django.production python manage.py check --deploy
# ==============================================================================
# ==============================================================================
# Core Django Settings
# ==============================================================================
# REQUIRED: Django secret key - generate a new one for each environment
# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
# [AWS-SECRET-REMOVED]===========================
# Core Django Settings
# [AWS-SECRET-REMOVED]===========================
SECRET_KEY=your-secret-key-here-generate-a-new-one
# Debug mode - MUST be False in production
# WARNING: DEBUG=True exposes sensitive information and should NEVER be used in production
DEBUG=True
# Django settings module to use
# Options: config.django.local, config.django.production, config.django.test
# PRODUCTION: Must use config.django.production
DJANGO_SETTINGS_MODULE=config.django.local
# Allowed hosts (comma-separated list)
# PRODUCTION: Must include all valid hostnames (no default in production settings)
# Example: thrillwiki.com,www.thrillwiki.com,api.thrillwiki.com
ALLOWED_HOSTS=localhost,127.0.0.1,beta.thrillwiki.com
# CSRF trusted origins (comma-separated, MUST include https:// prefix)
# PRODUCTION: Required for all forms and AJAX requests to work
# Example: https://thrillwiki.com,https://www.thrillwiki.com
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
# ==============================================================================
# Database Configuration
# ==============================================================================
# Database URL (supports PostgreSQL, PostGIS, SQLite, SpatiaLite)
# PostGIS format: postgis://username:password@host:port/database
# PostgreSQL format: postgres://username:password@host:port/database
# SQLite format: sqlite:///path/to/db.sqlite3
# [AWS-SECRET-REMOVED]===========================
# Database Configuration
# [AWS-SECRET-REMOVED]===========================
# PostgreSQL with PostGIS for production/development
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
# Database connection pooling (seconds to keep connections alive)
# Set to 0 to disable connection reuse
DATABASE_CONN_MAX_AGE=600
# SQLite for quick local development (uncomment to use)
# DATABASE_URL=spatialite:///path/to/your/db.sqlite3
# Database connection timeout in seconds
DATABASE_CONNECT_TIMEOUT=10
# [AWS-SECRET-REMOVED]===========================
# Cache Configuration
# [AWS-SECRET-REMOVED]===========================
# Local memory cache for development
CACHE_URL=locmem://
# Query timeout in milliseconds (prevents long-running queries)
DATABASE_STATEMENT_TIMEOUT=30000
# Redis for production (uncomment and configure for production)
# CACHE_URL=redis://localhost:6379/1
# REDIS_URL=redis://localhost:6379/0
# Optional: Read replica URL for read-heavy workloads
# DATABASE_READ_REPLICA_URL=postgis://username:password@replica-host:5432/thrillwiki
# ==============================================================================
# Cache Configuration
# ==============================================================================
# Redis URL for caching, sessions, and Celery broker
# Format: redis://[:password@]host:port/db_number
# PRODUCTION: Required - the application uses Redis for:
# - Page and API response caching
# - Session storage (faster than database sessions)
# - Celery task queue broker
# Without REDIS_URL in production, caching will fail and performance will degrade.
REDIS_URL=redis://localhost:6379/1
# Optional: Separate Redis URLs for different cache purposes
# REDIS_SESSIONS_URL=redis://localhost:6379/2
# REDIS_API_URL=redis://localhost:6379/3
# Redis connection settings
REDIS_MAX_CONNECTIONS=100
REDIS_CONNECTION_TIMEOUT=20
REDIS_IGNORE_EXCEPTIONS=True
# Cache middleware settings
CACHE_MIDDLEWARE_SECONDS=300
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
CACHE_KEY_PREFIX=thrillwiki
# Local development cache URL (use for development without Redis)
# CACHE_URL=locmem://
# ==============================================================================
# Email Configuration
# ==============================================================================
# Email backend
# Options:
# django.core.mail.backends.console.EmailBackend (development)
# django_forwardemail.backends.ForwardEmailBackend (production with ForwardEmail)
# django.core.mail.backends.smtp.EmailBackend (custom SMTP)
# [AWS-SECRET-REMOVED]===========================
# Email Configuration
# [AWS-SECRET-REMOVED]===========================
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
# Server email address
SERVER_EMAIL=django_webmaster@thrillwiki.com
# Default from email
DEFAULT_FROM_EMAIL=ThrillWiki <noreply@thrillwiki.com>
# ForwardEmail configuration (uncomment to use)
# EMAIL_BACKEND=email_service.backends.ForwardEmailBackend
# FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
# Email subject prefix for admin emails
EMAIL_SUBJECT_PREFIX=[ThrillWiki]
# SMTP configuration (uncomment to use)
# EMAIL_URL=smtp://username:password@smtp.example.com:587
# ForwardEmail configuration (for ForwardEmailBackend)
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
FORWARD_EMAIL_DOMAIN=your-domain.com
# SMTP configuration (for SMTPBackend)
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_USE_SSL=False
EMAIL_HOST_USER=your-email@example.com
EMAIL_HOST_PASSWORD=your-app-password
# Email timeout in seconds
EMAIL_TIMEOUT=30
# ==============================================================================
# Security Settings
# ==============================================================================
# Cloudflare Turnstile configuration (CAPTCHA alternative)
# Get keys from: https://dash.cloudflare.com/?to=/:account/turnstile
# [AWS-SECRET-REMOVED]===========================
# Security Settings
# [AWS-SECRET-REMOVED]===========================
# Cloudflare Turnstile (get keys from Cloudflare dashboard)
TURNSTILE_SITE_KEY=your-turnstile-site-key
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
# SSL/HTTPS settings (enable all for production)
# Security headers (set to True for production)
SECURE_SSL_REDIRECT=False
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
# HSTS settings (HTTP Strict Transport Security)
SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
SECURE_HSTS_PRELOAD=False
# Security headers
SECURE_BROWSER_XSS_FILTER=True
SECURE_CONTENT_TYPE_NOSNIFF=True
X_FRAME_OPTIONS=DENY
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
SECURE_CROSS_ORIGIN_OPENER_POLICY=same-origin
# Session settings
SESSION_COOKIE_AGE=3600
SESSION_SAVE_EVERY_REQUEST=True
SESSION_COOKIE_HTTPONLY=True
SESSION_COOKIE_SAMESITE=Lax
# CSRF settings
CSRF_COOKIE_HTTPONLY=True
CSRF_COOKIE_SAMESITE=Lax
# Password minimum length
PASSWORD_MIN_LENGTH=8
# ==============================================================================
# GeoDjango Settings
# ==============================================================================
# Library paths for GDAL and GEOS (required for GeoDjango)
# macOS with Homebrew:
# [AWS-SECRET-REMOVED]===========================
# GeoDjango Settings (macOS with Homebrew)
# [AWS-SECRET-REMOVED]===========================
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
# Linux alternatives:
# Linux alternatives (uncomment if on Linux)
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
# ==============================================================================
# API Configuration
# ==============================================================================
# CORS settings
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5174
CORS_ALLOW_ALL_ORIGINS=False
# API rate limiting
API_RATE_LIMIT_PER_MINUTE=60
API_RATE_LIMIT_PER_HOUR=1000
API_RATE_LIMIT_ANON_PER_MINUTE=60
API_RATE_LIMIT_USER_PER_HOUR=1000
# API pagination
API_PAGE_SIZE=20
API_MAX_PAGE_SIZE=100
API_VERSION=1.0.0
# ==============================================================================
# JWT Configuration
# ==============================================================================
# JWT token lifetimes
JWT_ACCESS_TOKEN_LIFETIME_MINUTES=15
JWT_REFRESH_TOKEN_LIFETIME_DAYS=7
# JWT issuer claim
JWT_ISSUER=thrillwiki
# ==============================================================================
# Cloudflare Images Configuration
# ==============================================================================
# Get credentials from Cloudflare dashboard
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
# Optional Cloudflare Images settings
CLOUDFLARE_IMAGES_DEFAULT_VARIANT=public
CLOUDFLARE_IMAGES_UPLOAD_TIMEOUT=300
CLOUDFLARE_IMAGES_CLEANUP_HOURS=24
CLOUDFLARE_IMAGES_MAX_FILE_SIZE=10485760
CLOUDFLARE_IMAGES_REQUIRE_SIGNED_URLS=False
# ==============================================================================
# Road Trip Service Configuration
# ==============================================================================
# OpenStreetMap user agent (required for OSM API)
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
# Cache timeouts
ROADTRIP_CACHE_TIMEOUT=86400
ROADTRIP_ROUTE_CACHE_TIMEOUT=21600
# Request settings
ROADTRIP_MAX_REQUESTS_PER_SECOND=1
ROADTRIP_REQUEST_TIMEOUT=10
ROADTRIP_MAX_RETRIES=3
ROADTRIP_BACKOFF_FACTOR=2
# ==============================================================================
# Logging Configuration
# ==============================================================================
# Log directory (relative to backend/)
LOG_DIR=logs
# Log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
ROOT_LOG_LEVEL=INFO
DJANGO_LOG_LEVEL=WARNING
DB_LOG_LEVEL=WARNING
APP_LOG_LEVEL=INFO
PERFORMANCE_LOG_LEVEL=INFO
QUERY_LOG_LEVEL=WARNING
NPLUSONE_LOG_LEVEL=WARNING
REQUEST_LOG_LEVEL=INFO
CELERY_LOG_LEVEL=INFO
CONSOLE_LOG_LEVEL=INFO
FILE_LOG_LEVEL=INFO
# Log formatters (verbose, json, simple)
FILE_LOG_FORMATTER=json
# ==============================================================================
# Monitoring & Errors
# ==============================================================================
# Sentry configuration (optional, for error tracking)
# [AWS-SECRET-REMOVED]===========================
# Optional: Third-party Integrations
# [AWS-SECRET-REMOVED]===========================
# Sentry for error tracking (uncomment to use)
# SENTRY_DSN=https://your-sentry-dsn-here
# SENTRY_ENVIRONMENT=development
# SENTRY_TRACES_SAMPLE_RATE=0.1
# ==============================================================================
# Feature Flags
# ==============================================================================
# Google Analytics (uncomment to use)
# GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
# Development tools
ENABLE_DEBUG_TOOLBAR=True
ENABLE_SILK_PROFILER=False
# Django template support (can be disabled for API-only mode)
TEMPLATES_ENABLED=True
# Autocomplete settings
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=False
# ==============================================================================
# Third-Party Configuration
# ==============================================================================
# Frontend URL for email links and redirects
FRONTEND_DOMAIN=https://thrillwiki.com
# Login/logout redirect URLs
LOGIN_REDIRECT_URL=/
ACCOUNT_LOGOUT_REDIRECT_URL=/
# Account settings
ACCOUNT_EMAIL_VERIFICATION=mandatory
# ==============================================================================
# File Upload Settings
# ==============================================================================
# Maximum file size to upload into memory (bytes)
FILE_UPLOAD_MAX_MEMORY_SIZE=2621440
# Maximum request data size (bytes)
DATA_UPLOAD_MAX_MEMORY_SIZE=10485760
# Maximum number of GET/POST parameters
DATA_UPLOAD_MAX_NUMBER_FIELDS=1000
# Static/Media URLs (usually don't need to change)
STATIC_URL=static/
MEDIA_URL=/media/
# WhiteNoise settings
WHITENOISE_COMPRESSION_QUALITY=90
WHITENOISE_MAX_AGE=31536000
WHITENOISE_MANIFEST_STRICT=False
# ==============================================================================
# Health Check Settings
# ==============================================================================
# Disk usage threshold (percentage)
HEALTH_CHECK_DISK_USAGE_MAX=90
# Minimum available memory (MB)
HEALTH_CHECK_MEMORY_MIN=100
# ==============================================================================
# Celery Configuration
# ==============================================================================
# Celery task behavior (set to True for testing)
CELERY_TASK_ALWAYS_EAGER=False
CELERY_TASK_EAGER_PROPAGATES=False
# ==============================================================================
# Debug Toolbar Configuration
# ==============================================================================
# Internal IPs for debug toolbar (comma-separated)
# [AWS-SECRET-REMOVED]===========================
# Development/Debug Settings
# [AWS-SECRET-REMOVED]===========================
# Set to comma-separated list for debug toolbar
# INTERNAL_IPS=127.0.0.1,::1
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_LEVEL=INFO

83
.github/SECURITY.md vendored
View File

@@ -1,83 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
| < latest | :x: |
Only the latest version of ThrillWiki receives security updates.
## Reporting a Vulnerability
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
### How to Report
1. **Do not** create a public GitHub issue for security vulnerabilities
2. Email your report to the project maintainers
3. Include as much detail as possible:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Affected versions
- Any proof of concept (if available)
### What to Expect
- **Acknowledgment**: We will acknowledge receipt within 48 hours
- **Assessment**: We will assess the vulnerability and its impact
- **Updates**: We will keep you informed of our progress
- **Resolution**: We aim to resolve critical vulnerabilities within 7 days
- **Credit**: With your permission, we will credit you in our security advisories
### Scope
The following are in scope for security reports:
- ThrillWiki web application vulnerabilities
- Authentication and authorization issues
- Data exposure vulnerabilities
- Injection vulnerabilities (SQL, XSS, etc.)
- CSRF vulnerabilities
- Server-side request forgery (SSRF)
- Insecure direct object references
### Out of Scope
The following are out of scope:
- Denial of service attacks
- Social engineering attacks
- Physical security issues
- Issues in third-party applications or services
- Issues requiring physical access to a user's device
- Vulnerabilities in outdated versions
## Security Measures
ThrillWiki implements the following security measures:
- HTTPS enforcement with HSTS
- Content Security Policy
- XSS protection with input sanitization
- CSRF protection
- SQL injection prevention via ORM
- Rate limiting on authentication endpoints
- Secure session management
- JWT token rotation and blacklisting
For more details, see [docs/SECURITY.md](../docs/SECURITY.md).
## Security Updates
Security updates are released as soon as possible after a vulnerability is confirmed. We recommend:
1. Keep your installation up to date
2. Subscribe to release notifications
3. Review security advisories
## Contact
For security-related inquiries, please contact the project maintainers.

View File

@@ -1,54 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'

View File

@@ -1,53 +0,0 @@
name: Dependency Update Check
on:
schedule:
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Update Dependencies
working-directory: backend
run: |
uv lock --upgrade
uv sync
- name: Run Tests
working-directory: backend
run: |
uv run manage.py test
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
commit-message: "chore: update dependencies"
title: "chore: weekly dependency updates"
body: |
Automated dependency updates.
This PR was automatically generated by the dependency update workflow.
## Changes
- Updated `uv.lock` with latest compatible versions
## Checklist
- [ ] Review dependency changes
- [ ] Verify all tests pass
- [ ] Check for breaking changes
branch: "dependency-updates"
labels: dependencies

View File

@@ -12,85 +12,30 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.13"]
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_thrillwiki
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
# Services only run on Linux runners
if: runner.os == 'Linux'
python-version: [3.13.1]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install Homebrew on Linux
if: runner.os == 'Linux'
run: |
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
- name: Install GDAL with Homebrew
run: brew install gdal
- name: Install PostGIS on macOS
if: runner.os == 'macOS'
run: |
brew install postgresql@16 postgis
brew services start postgresql@16
sleep 5
/opt/homebrew/opt/postgresql@16/bin/createuser -s postgres || true
/opt/homebrew/opt/postgresql@16/bin/createdb -U postgres test_thrillwiki || true
/opt/homebrew/opt/postgresql@16/bin/psql -U postgres -d test_thrillwiki -c "CREATE EXTENSION IF NOT EXISTS postgis;" || true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Cache UV dependencies
uses: actions/cache@v5
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('backend/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Install Dependencies
working-directory: backend
run: |
uv sync --frozen
- name: Security Audit
working-directory: backend
run: |
uv pip install pip-audit
uv run pip-audit || true
continue-on-error: true
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
working-directory: backend
env:
DJANGO_SETTINGS_MODULE: config.django.test
TEST_DB_NAME: test_thrillwiki
TEST_DB_USER: postgres
TEST_DB_PASSWORD: postgres
TEST_DB_HOST: localhost
TEST_DB_PORT: 5432
run: |
uv run python manage.py test --settings=config.django.test --parallel
python manage.py test

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
environment: development_environment
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0

26
.gitignore vendored
View File

@@ -30,20 +30,10 @@ db.sqlite3-journal
/backend/staticfiles/
/backend/media/
# Celery Beat schedule database (runtime state, regenerated automatically)
celerybeat-schedule*
celerybeat.pid
# UV
.uv/
backend/.uv/
# Generated requirements files (auto-generated from pyproject.toml)
# Uncomment if you want to track these files
# backend/requirements.txt
# backend/requirements-dev.txt
# backend/requirements-test.txt
# Node.js
node_modules/
npm-debug.log*
@@ -108,11 +98,8 @@ temp/
# Backup files
*.bak
*.backup
*.orig
*.swp
*_backup.*
*_OLD_*
# Archive files
*.tar.gz
@@ -129,15 +116,4 @@ temp/
/backups/
.django_tailwind_cli/
backend/.env
frontend/.env
# Extracted packages
django-forwardemail/
frontend/
frontend
.snapshots
web/next-env.d.ts
web/.next/types/cache-life.d.ts
.gitignore
web/.next/types/routes.d.ts
web/.next/types/validator.ts
frontend/.env

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/*

251
.pylintrc
View File

@@ -1,251 +0,0 @@
# =============================================================================
# ThrillWiki Django Project - Pylint Configuration
# =============================================================================
#
# Purpose: Django-aware Pylint configuration that suppresses false positives
# while maintaining code quality standards.
#
# Alignment:
# - Line length: 120 characters (matches Black and Ruff in pyproject.toml)
# - Django version: 5.2.8
#
# Key Features:
# - Suppresses false positives for Django ORM patterns (.objects, _meta, .DoesNotExist)
# - Whitelists Django management command styling (self.style.SUCCESS, ERROR, etc.)
# - Accommodates Django REST Framework patterns
# - Allows django-fsm state machine patterns
#
# Maintenance:
# - Review when upgrading Django or adding new dynamic attribute patterns
# - Keep line-length aligned with Black/Ruff settings in pyproject.toml
#
# =============================================================================
[MASTER]
# Use all available CPU cores for faster linting
jobs=0
# Directories and files to exclude from linting
ignore=.git,__pycache__,.venv,venv,migrations,node_modules,.tox,.pytest_cache,build,dist
# File patterns to ignore (e.g., Emacs backup files)
ignore-patterns=^\.#
# Pickle collected data for faster subsequent runs
persistent=yes
# =============================================================================
# [MESSAGES CONTROL]
# Disable checks that conflict with Django patterns and conventions
# =============================================================================
[MESSAGES CONTROL]
disable=
# C0114: missing-module-docstring
# Django apps often don't need module docstrings; the app's purpose is
# typically documented in apps.py or README
C0114,
# C0115: missing-class-docstring
# Django models, forms, and serializers are often self-documenting through
# their field definitions and Meta classes
C0115,
# C0116: missing-function-docstring
# Allow simple functions and methods without docstrings; Django views and
# model methods are often self-explanatory
C0116,
# C0103: invalid-name
# Django uses non-PEP8 names by convention (e.g., 'pk', 'id', 'qs')
# and single-letter variables in comprehensions are acceptable
C0103,
# C0411: wrong-import-order
# Let isort/ruff handle import ordering; they have Django-specific rules
C0411,
# C0415: import-outside-toplevel
# Django often requires lazy imports to avoid circular dependencies,
# especially in models.py and signals
C0415,
# W0212: protected-access
# Django extensively uses _meta for model introspection; this is documented
# and supported API: https://docs.djangoproject.com/en/5.2/ref/models/meta/
W0212,
# W0613: unused-argument
# Django views, signals, and receivers often have unused parameters that
# are required by the framework's signature (e.g., request, sender, **kwargs)
W0613,
# R0903: too-few-public-methods
# Django models, forms, and serializers can be simple data containers
# with few or no methods beyond __str__
R0903,
# R0801: duplicate-code
# Django patterns naturally duplicate across apps (e.g., CRUD views,
# model patterns); this is intentional for consistency
R0801,
# E1101: no-member
# Main source of false positives for Django's dynamic attributes:
# - Model.objects (Manager)
# - Model.DoesNotExist / MultipleObjectsReturned (exceptions)
# - self.style.SUCCESS/ERROR (management commands)
# - model._meta (Options)
E1101
# =============================================================================
# [TYPECHECK]
# Whitelist Django's dynamically generated attributes
# =============================================================================
[TYPECHECK]
# Django generates many attributes dynamically that Pylint cannot detect
# statically. This list covers common patterns:
#
# - objects.* : Django ORM Manager methods (all, filter, get, create, etc.)
# - DoesNotExist : Exception raised when Model.objects.get() finds nothing
# - MultipleObjectsReturned : Exception when get() finds multiple objects
# - _meta.* : Django model metadata API (fields, app_label, model_name)
# - style.* : Django management command styling (SUCCESS, ERROR, WARNING, NOTICE)
# - id, pk : Django auto-generated primary key fields
# - REQUEST : Django request object attributes
# - aq_* : Acquisition attributes (Zope/Plone compatibility)
# - acl_users : Zope/Plone user folder
#
generated-members=
REQUEST,
acl_users,
aq_parent,
aq_inner,
aq_explicit,
aq_acquire,
aq_base,
objects,
objects.*,
DoesNotExist,
MultipleObjectsReturned,
_meta,
_meta.*,
style,
style.*,
id,
pk
# =============================================================================
# [FORMAT]
# Code formatting settings - aligned with Black and Ruff (120 chars)
# =============================================================================
[FORMAT]
# Maximum line length - matches Black and Ruff configuration in pyproject.toml
max-line-length=120
# Use 4 spaces for indentation (Python standard)
indent-string=' '
# Use Unix line endings (LF)
expected-line-ending-format=LF
# =============================================================================
# [BASIC]
# Naming conventions and allowed short names
# =============================================================================
[BASIC]
# Short variable names commonly used in Django and Python
# - i, j, k : Loop counters
# - ex : Exception variable
# - Run : Django command method
# - _ : Throwaway variable
# - id, pk : Primary key (Django convention)
# - qs : QuerySet abbreviation
good-names=i,j,k,ex,Run,_,id,pk,qs
# Enforce snake_case for most identifiers (Python/Django convention)
argument-naming-style=snake_case
attr-naming-style=snake_case
function-naming-style=snake_case
method-naming-style=snake_case
module-naming-style=snake_case
variable-naming-style=snake_case
# PascalCase for classes
class-naming-style=PascalCase
# UPPER_CASE for constants
const-naming-style=UPPER_CASE
# =============================================================================
# [DESIGN]
# Complexity thresholds - relaxed for Django patterns
# =============================================================================
[DESIGN]
# Django views and forms often need many arguments
max-args=7
# Django models can have many fields
max-attributes=12
# Allow complex boolean expressions
max-bool-expr=5
# Django views can have complex branching logic
max-branches=15
# Django views often have many local variables
max-locals=20
# Django uses multiple inheritance (Model, Mixin classes)
max-parents=7
# Django models and viewsets have many built-in methods
max-public-methods=25
# Allow multiple return statements
max-returns=6
# Django views can be lengthy
max-statements=60
# Allow simple classes with no methods (e.g., Django Meta classes)
min-public-methods=0
# =============================================================================
# [SIMILARITIES]
# Duplicate code detection settings
# =============================================================================
[SIMILARITIES]
# Increase threshold to reduce false positives from Django boilerplate
min-similarity-lines=6
# Don't flag similar comments
ignore-comments=yes
# Don't flag similar docstrings
ignore-docstrings=yes
# Don't flag similar import blocks
ignore-imports=yes
# =============================================================================
# [VARIABLES]
# Variable naming patterns
# =============================================================================
[VARIABLES]
# Patterns for dummy/unused variables
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Arguments that are commonly unused but required by framework signatures
ignored-argument-names=_.*|^ignored_|^unused_|args|kwargs|request|pk
# =============================================================================
# [IMPORTS]
# Import checking settings
# =============================================================================
[IMPORTS]
# Don't allow wildcard imports even with __all__ defined
allow-wildcard-with-all=no
# Don't analyze fallback import blocks
analyse-fallback-blocks=no

View File

@@ -1,18 +0,0 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
},
"alwaysAllow": [
"resolve-library-id",
"get-library-docs"
]
}
}
}

View File

@@ -1,95 +0,0 @@
# Backend Structure Plan
## Apps Overview
### 1. `apps.core`
- **Responsibility**: Base classes, shared utilities, history tracking.
- **Existing**: `SluggedModel`, `TrackedModel`.
- **Versioning Strategy (Section 15)**:
- All core entities (`Park`, `Ride`, `Company`) must utilize `django-pghistory` or `apps.core` tracking to support:
- **Edit History**: Chronological list of changes with `reason`, `user`, and `diff`.
- **Timeline**: Major events (renames, relocations).
- **Rollback**: Ability to restore previous versions via the Moderation Queue.
### 2. `apps.accounts`
- **Responsibility**: User authentication, profiles, and settings.
- **Existing**: `User`, `UserProfile` (bio, location, home park).
- **Required Additions (Section 9)**:
- **UserDeletionRequest**: Support 7-day grace period for account deletion.
- **Privacy Settings**: Fields for `is_profile_public`, `show_location`, `show_email` on `UserProfile`.
- **Data Export**: Serializers/Utilities to dump all user data (Reviews, Credits, Lists) to JSON.
### 3. `apps.parks`
- **Responsibility**: Park management.
- **Models**: `Park`, `ParkArea`.
- **Relationships**:
- `operator`: FK to `apps.companies.Company` (Type: Operator).
- `property_owner`: FK to `apps.companies.Company` (Type: Owner).
### 4. `apps.rides`
- **Responsibility**: Ride data, Coasters, and Credits.
- **Models**:
- `Ride`: Core entity (Status FSM: Operating, SBNO, Closed, etc.).
- `RideModel`: Defines the "Type" of ride (e.g., B&M Hyper V2).
- `Manufacturer`: FK to `apps.companies.Company`.
- `Designer`: FK to `apps.companies.Company`.
- **Ride Credits (Section 10)**:
- **Model**: `RideCredit` (Through-Model: `User` <-> `Ride`).
- **Fields**:
- `count` (Integer): Total times ridden.
- `rating` (Float): Personal rating (distinct from public Review).
- `first_ridden_at` (Date): First time experiencing the ride.
- `notes` (Text): Private personal notes.
- **Constraints**: `Unique(user, ride)` - A user has one credit entry per ride.
### 5. `apps.companies`
- **Responsibility**: Management of Industry Entities (Section 4).
- **Models**:
- `Company`: Single model with `type` choices or Polymorphic.
- **Types**: `Manufacturer`, `Designer`, `Operator`, `PropertyOwner`.
- **Features**: Detailed pages, hover cards, listing by type.
### 6. `apps.moderation` (The Sacred Submission Pipeline)
- **Responsibility**: Centralized Content Submission System (Section 14, 16).
- **Concept**: **Live Data** (Approve) vs **Submission Data** (Pending).
- **Models**:
- `Submission`:
- `submitter`: FK to User.
- `content_type`: Target Model (Park, Ride, etc.).
- `object_id`: Target ID (Null for Creation).
- `data`: **JSONField** storing the proposed state.
- `status`: State Machine (`Pending` -> `Claimed` -> `Approved` | `Rejected` | `ChangesRequested`).
- `moderator`: FK to User (Claimaint).
- `moderator_note`: Reason for rejection/feedback.
- `Report`: User flags on content.
- **Workflow**:
1. User submits form -> `Submission` created (Status: Pending).
2. Moderator Claims -> Status: Claimed.
3. Approves -> Applies `data` to `Live Model` -> Saves Version -> Status: Approved.
### 7. `apps.media`
- **Responsibility**: Media Management (Section 13).
- **Models**:
- `Photo`: GenericFK. Fields: `image`, `caption`, `user`, `status` (Moderation).
- **Banner/Card**: Entities should link to a "Primary Photo" or store a cached image field.
### 8. `apps.reviews`
- **Responsibility**: Public Reviews & Ratings (Section 12).
- **Models**:
- `Review`: GenericFK (Park, Ride).
- **Fields**: `rating` (1-5, 0.5 steps), `title`, `body`, `helpful_votes`.
- **Logic**: Aggregates (Avg Rating, Count) calculation for Entity caches.
### 9. `apps.lists`
- **Responsibility**: User Lists & Rankings (Section 11).
- **Models**:
- `UserList`: Title, Description, Type (Park/Ride/Coaster/Mixed), Privacy (Public/Private).
- `UserListItem`: FK to List, GenericFK to Item, Order, Notes.
### 10. `apps.blog`
- **Responsibility**: News & Updates.
- **Models**: `Post`, `Tag`.
### 11. `apps.support`
- **Responsibility**: Human interaction.
- **Models**: `Ticket` (Contact Form).

View File

@@ -1,503 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Phase 7] - 2025-12-24
### Testing
#### Added
- **Comprehensive Test Coverage Improvements**
- Added 30+ new test files across all apps
- API endpoint tests with authentication, error handling, pagination, and response format validation
- E2E tests for FSM workflows (parks, rides, moderation)
- Integration tests for FSM transition workflows
- Unit tests for managers, serializers, and services
- Accessibility tests for WCAG 2.1 AA compliance
- Form validation tests for all major forms
#### Test Files Added
- `backend/tests/api/` - API endpoint tests (8 files)
- `backend/tests/e2e/` - End-to-end FSM workflow tests (3 files)
- `backend/tests/integration/` - Integration tests (1 file)
- `backend/tests/managers/` - Manager tests (2 files)
- `backend/tests/serializers/` - Serializer tests (3 files)
- `backend/tests/services/` - Service layer tests (3 files)
- `backend/tests/forms/` - Form validation tests (5 files)
- `backend/tests/accessibility/` - WCAG compliance tests (1 file)
- `backend/apps/*/tests/` - App-specific tests (7 files)
#### Coverage Improvements
- Increased test coverage for models, views, and services
- Added tests for edge cases and error conditions
- Improved FSM transition testing with permission checks
- Added query optimization tests
### Technical Details
This phase focused on achieving comprehensive test coverage to ensure code quality and prevent regressions. Tests cover:
- All API endpoints with various authentication scenarios
- FSM state transitions with permission validation
- Form validation logic with edge cases
- Manager methods and custom QuerySets
- Service layer business logic
- Accessibility compliance for interactive components
**Testing Infrastructure**:
- pytest with Django plugin
- Factory Boy for test data generation
- Coverage.py for coverage tracking
- Playwright for E2E tests
### Files Modified
- `backend/pyproject.toml` - Updated test dependencies and coverage configuration
- `backend/tests/conftest.py` - Enhanced test fixtures and utilities
---
## [Phase 6] - 2025-12-24
### Forms & Validation
#### Enhanced
- **Form Validation Coverage**
- Added custom `clean_*` methods for field-level validation
- Improved error messages for better user experience
- Enhanced form widgets (date pickers, rich text editors)
- Standardized ModelForm field definitions
#### Forms Enhanced
- `backend/apps/parks/forms/base.py` - Park creation/update forms
- `backend/apps/parks/forms/review_forms.py` - Park review forms
- `backend/apps/parks/forms/area_forms.py` - Park area forms
- `backend/apps/rides/forms/base.py` - Ride creation/update forms
- `backend/apps/rides/forms/review_forms.py` - Ride review forms
- `backend/apps/rides/forms/company_forms.py` - Company forms
- `backend/apps/rides/forms/search.py` - Ride search forms
- `backend/apps/core/forms/search.py` - Core search forms
- `backend/apps/core/forms/htmx_forms.py` - HTMX-specific form patterns
#### Tests Added
- `backend/tests/forms/test_area_forms.py` - Area form validation tests
- `backend/tests/forms/test_park_forms.py` - Park form validation tests
- `backend/tests/forms/test_ride_forms.py` - Ride form validation tests
- `backend/tests/forms/test_review_forms.py` - Review form validation tests
- `backend/tests/forms/test_company_forms.py` - Company form validation tests
### Technical Details
This phase improved form validation coverage across the application:
1. **Field-Level Validation**: Custom `clean_*` methods for complex validation logic
2. **User-Friendly Errors**: Clear, actionable error messages
3. **Widget Improvements**: Better UX with appropriate input widgets
4. **HTMX Integration**: Forms work seamlessly with HTMX partial updates
5. **Test Coverage**: Comprehensive tests for all validation scenarios
**Validation Patterns**:
- Date range validation (opening/closing dates)
- Coordinate validation (latitude/longitude bounds)
- Slug uniqueness validation
- Cross-field validation (e.g., closing date must be after opening date)
- File upload validation (size, type, dimensions)
---
## [Phase 5] - 2025-12-24
### Admin Interface
#### Enhanced
- **Django Admin Completeness**
- Added comprehensive `list_display` with key fields
- Implemented `search_fields` for text search
- Added `list_filter` for status, category, and date filtering
- Organized detail views with `fieldsets`
- Added `readonly_fields` for computed properties and timestamps
- Implemented custom admin actions (bulk approve, bulk reject, etc.)
#### Admin Files Enhanced
- `backend/apps/parks/admin.py` - Park, Area, Company, Review admin
- `backend/apps/rides/admin.py` - Ride, Manufacturer, Review admin
- `backend/apps/accounts/admin.py` - User, Profile admin
- `backend/apps/moderation/admin.py` - Submission, Report admin
- `backend/apps/core/admin.py` - Base admin classes and mixins
#### Custom Admin Actions
- Bulk approve/reject for moderation workflows
- Bulk status changes for parks and rides
- Export to CSV for reporting
- Cache invalidation for modified entities
### Technical Details
This phase completed the Django admin interface to provide a powerful content management system:
1. **List Views**: Optimized with select_related/prefetch_related
2. **Search**: Full-text search on name, description, and location fields
3. **Filters**: Status, category, date range, and custom filters
4. **Detail Views**: Organized with logical fieldsets
5. **Actions**: Bulk operations for efficient moderation
**Admin Patterns**:
- Inherited from `BaseModelAdmin` for consistency
- Used `readonly_fields` for computed properties
- Implemented `get_queryset()` optimization
- Added inline admin for related objects
---
## [Phase 4] - 2025-12-24
### Models & Database
#### Enhanced
- **Model Completeness & Consistency**
- Added/improved `__str__` methods for human-readable representations
- Standardized `Meta` classes with `ordering`, `verbose_name`, `verbose_name_plural`
- Added comprehensive `help_text` on all fields
- Verified database indexes on foreign keys and frequently queried fields
- Added model constraints (CheckConstraint, UniqueConstraint)
#### Model Files Enhanced
- `backend/apps/parks/models/parks.py` - Park model
- `backend/apps/parks/models/companies.py` - Company, Operator models
- `backend/apps/parks/models/areas.py` - ParkArea model
- `backend/apps/parks/models/media.py` - ParkPhoto model
- `backend/apps/parks/models/reviews.py` - ParkReview model
- `backend/apps/parks/models/location.py` - ParkLocation model
- `backend/apps/rides/models/rides.py` - Ride model
- `backend/apps/rides/models/company.py` - Manufacturer, Designer models
- `backend/apps/rides/models/rankings.py` - RideRanking model
- `backend/apps/rides/models/media.py` - RidePhoto model
- `backend/apps/rides/models/reviews.py` - RideReview model
- `backend/apps/rides/models/location.py` - RideLocation model
- `backend/apps/accounts/models.py` - User, Profile models
- `backend/apps/moderation/models.py` - Submission, Report models
- `backend/apps/core/models.py` - Base models and mixins
#### Database Improvements
- Added indexes for performance optimization
- Implemented constraints for data integrity
- Standardized field naming conventions
- Improved model documentation
### Technical Details
This phase improved model quality and consistency:
1. **String Representations**: All models have meaningful `__str__` methods
2. **Metadata**: Complete Meta classes with ordering and verbose names
3. **Field Documentation**: Every field has descriptive help_text
4. **Database Optimization**: Proper indexes on foreign keys and search fields
5. **Data Integrity**: Constraints enforce business rules at database level
**Model Patterns**:
- Used `TextChoices` for status and category fields
- Implemented `db_index=True` on frequently queried fields
- Added `CheckConstraint` for value ranges (e.g., ratings 1-5)
- Used `UniqueConstraint` for compound uniqueness
---
## [Phase 3] - 2025-12-24
### Logging & Observability
#### Standardized
- **Logging Pattern Consistency**
- Added `logger = logging.getLogger(__name__)` to all view, service, and middleware files
- Implemented centralized logging utilities from `apps.core.logging`
- Standardized log levels (debug, info, warning, error)
- Added structured logging with context
#### Files Enhanced with Logging
- `backend/apps/parks/views.py` - Park views
- `backend/apps/rides/views.py` - Ride views
- `backend/apps/accounts/views.py` - Account views
- `backend/apps/moderation/views.py` - Moderation views
- `backend/apps/accounts/services.py` - Account services
- `backend/apps/parks/signals.py` - Park signals
- `backend/apps/rides/signals.py` - Ride signals
- `backend/apps/moderation/signals.py` - Moderation signals
- `backend/apps/rides/tasks.py` - Celery tasks
- `backend/apps/parks/apps.py` - App configuration
- `backend/apps/rides/apps.py` - App configuration
- `backend/apps/moderation/apps.py` - App configuration
#### Logging Utilities
- `log_exception()` - Exception logging with full context
- `log_business_event()` - Business operation logging (FSM transitions, user actions)
- `log_security_event()` - Security event logging (authentication, authorization)
### Technical Details
This phase standardized logging across the application for better observability:
1. **Consistent Logger Initialization**: Every module uses `logging.getLogger(__name__)`
2. **Centralized Utilities**: Structured logging functions in `apps.core.logging`
3. **Contextual Logging**: All logs include relevant context (user, request, operation)
4. **Security Logging**: Dedicated logging for security events
5. **Performance Logging**: Query performance and cache hit/miss tracking
**Logging Patterns**:
- Exception handlers use `log_exception()` with context
- FSM transitions use `log_business_event()`
- Authentication events use `log_security_event()`
- Never log sensitive data (passwords, tokens, PII)
**Benefits**:
- Easier debugging with consistent log format
- Better production monitoring with structured logs
- Security audit trail for compliance
- Performance insights from cache and query logs
---
## [Phase 15] - 2025-12-23
### Documentation
#### Added
- **Future Work Documentation**
- Created `docs/FUTURE_WORK.md` to track deferred features
- Documented 11 TODO items with detailed implementation specifications
- Added priority levels (P0-P3) and effort estimates
- Included code examples and architectural guidance
#### Implemented
- **Cache Statistics Tracking (THRILLWIKI-109)**
- Added `get_cache_statistics()` method to `CacheMonitor` class
- Implemented real-time cache hit/miss tracking in `MapStatsAPIView`
- Returns Redis statistics when available, with graceful fallback
- Removed placeholder TODO comments
- **Photo Upload Counting (THRILLWIKI-105)**
- Implemented photo counting in user statistics endpoint
- Queries `ParkPhoto` and `RidePhoto` models for accurate counts
- Removed placeholder TODO comment
- **Admin Permission Checks (THRILLWIKI-103)**
- Verified existing admin permission checks in map cache endpoints
- Removed outdated TODO comments (checks were already implemented)
#### Enhanced
- **TODO Comment Cleanup**
- Updated all TODO comments to reference `FUTURE_WORK.md`
- Added THRILLWIKI issue numbers for traceability
- Improved inline documentation with implementation context
### Technical Details
This phase focused on addressing technical debt by:
1. Documenting deferred features with actionable specifications
2. Implementing quick wins that improve observability
3. Cleaning up TODO comments to reduce confusion
**Features Documented for Future Implementation**:
- Map clustering algorithm (THRILLWIKI-106)
- Nearby locations feature (THRILLWIKI-107)
- Search relevance scoring (THRILLWIKI-108)
- Full user statistics tracking (THRILLWIKI-104)
- Geocoding service integration (THRILLWIKI-101)
- ClamAV malware scanning (THRILLWIKI-110)
- Sample data creation command (THRILLWIKI-111)
**Quick Wins Implemented**:
- Cache statistics tracking for monitoring
- Photo upload counting for user profiles
- Verified admin permission checks
### Files Modified
- `backend/apps/api/v1/maps/views.py` - Cache statistics, updated TODO comments
- `backend/apps/api/v1/accounts/views.py` - Photo counting, updated TODO comments
- `backend/apps/api/v1/serializers/maps.py` - Updated TODO comments
- `backend/apps/core/services/location_adapters.py` - Updated TODO comments
- `backend/apps/core/services/enhanced_cache_service.py` - Added `get_cache_statistics()` method
- `backend/apps/core/utils/file_scanner.py` - Updated TODO comments
- `backend/apps/core/views/map_views.py` - Removed outdated TODO comments
- `backend/apps/parks/management/commands/create_sample_data.py` - Updated TODO comments
- `docs/architecture/README.md` - Added reference to FUTURE_WORK.md
### Files Created
- `docs/FUTURE_WORK.md` - Centralized future work documentation
---
## [Phase 14] - 2025-12-23
### Documentation
#### Fixed
- Corrected architectural documentation from Vue.js SPA to Django + HTMX monolith
- Updated main README to accurately reflect technology stack (Django 5.2.8+, HTMX 1.20.0+, Alpine.js)
- Fixed deployment guide to remove frontend build steps (no separate frontend build process)
- Corrected environment setup instructions for Django + HTMX architecture
- Updated project structure diagrams to show Django monolith with HTMX templates
#### Added
- **Architecture Decision Records (ADRs)**
- ADR-001: Django + HTMX Architecture Decision
- ADR-002: Hybrid API Design Pattern
- ADR-003: State Machine Pattern for entity status management
- ADR-004: Caching Strategy with Redis multi-layer caching
- ADR-005: Authentication Approach (JWT + Session + Social Auth)
- ADR-006: Media Handling with Cloudflare Images
- **New Documentation Files**
- `docs/SETUP_GUIDE.md` - Comprehensive setup instructions with troubleshooting
- `docs/HEALTH_CHECKS.md` - Health check endpoint documentation
- `docs/PRODUCTION_CHECKLIST.md` - Deployment verification checklist
- `docs/architecture/README.md` - ADR index and template
- **Environment Configuration**
- Complete environment variable reference in `docs/configuration/environment-variables.md`
- Updated `.env.example` with comprehensive documentation
#### Enhanced
- Backend README with HTMX patterns and hybrid API/HTML endpoint documentation
- Deployment guide with Docker, nginx, and CI/CD pipeline configurations
- Production settings documentation with inline comments
- API documentation structure and endpoint reference
#### Documentation Structure
```
docs/
├── README.md # Updated - Django + HTMX architecture
├── SETUP_GUIDE.md # New - Development setup
├── HEALTH_CHECKS.md # New - Monitoring endpoints
├── PRODUCTION_CHECKLIST.md # New - Deployment checklist
├── THRILLWIKI_API_DOCUMENTATION.md # Existing - API reference
├── htmx-patterns.md # Existing - HTMX conventions
├── architecture/ # New - ADRs
│ ├── README.md # ADR index
│ ├── adr-001-django-htmx-architecture.md
│ ├── adr-002-hybrid-api-design.md
│ ├── adr-003-state-machine-pattern.md
│ ├── adr-004-caching-strategy.md
│ ├── adr-005-authentication-approach.md
│ └── adr-006-media-handling-cloudflare.md
└── configuration/
└── environment-variables.md # Existing - Complete reference
```
### Technical Details
This phase focused on documentation-only changes to align all project documentation with the actual Django + HTMX architecture. No code changes were made.
**Key Corrections:**
- The project uses Django templates with HTMX for interactivity, not a Vue.js SPA
- There is no separate frontend build process - static files are served by Django
- The API serves both JSON (for mobile/integrations) and HTML (for HTMX partials)
- Authentication uses JWT for API access and sessions for web browsing
---
## [Unreleased] - 2025-12-23
### Security
- **CRITICAL:** Updated Django from 5.0.x to 5.2.8+ to address CVE-2025-64459 (SQL injection, CVSS 9.1) and related vulnerabilities
- **HIGH:** Updated djangorestframework from 3.14.x to 3.15.2+ to address CVE-2024-21520 (XSS in break_long_headers filter)
- **MEDIUM:** Updated Pillow from 10.2.0 to 10.4.0+ (upper bound <11.2) to address CVE-2024-28219 (buffer overflow)
- Added cryptography>=44.0.0 for django-allauth JWT support
### Changed
- Standardized Python version requirement to 3.13+ across all configuration files
- Consolidated pyproject.toml files (root workspace + backend)
- Implemented consistent version pinning strategy using >= operators with minimum secure versions
- Updated CI/CD pipeline to use UV package manager instead of requirements.txt
- Moved linting and dev tools to proper dependency groups
### Package Updates
#### Core Django Ecosystem
- Django: 5.0.x → 5.2.8+
- djangorestframework: 3.14.x → 3.15.2+
- django-cors-headers: 4.3.1 → 4.6.0+
- django-filter: 23.5 → 24.3+
- drf-spectacular: 0.27.0 → 0.28.0+
- django-htmx: 1.17.2 → 1.20.0+
- whitenoise: 6.6.0 → 6.8.0+
#### Authentication
- django-allauth: 0.60.1 → 65.3.0+
- djangorestframework-simplejwt: maintained at 5.5.1+
#### Task Queue & Caching
- celery: maintained at 5.5.3+ (<6)
- django-celery-beat: maintained at 2.8.1+
- django-celery-results: maintained at 2.6.0+
- django-redis: 5.4.0+
- hiredis: 2.3.0 → 3.1.0+
#### Monitoring
- sentry-sdk: 1.40.0 → 2.20.0+ (<3)
#### Development Tools
- black: 24.1.0 → 25.1.0+
- ruff: 0.12.10 → 0.9.2+
- pyright: 1.1.404 → 1.1.405+
- coverage: 7.9.1 → 7.9.2+
- playwright: 1.41.0 → 1.50.0+
### Removed
- `channels>=4.2.0` - Not in INSTALLED_APPS, no WebSocket usage
- `channels-redis>=4.2.1` - Dependency of channels
- `daphne>=4.1.2` - ASGI server not used (using WSGI)
- `django-simple-history>=3.5.0` - Using django-pghistory instead
- `django-oauth-toolkit>=3.0.1` - Using dj-rest-auth + simplejwt instead
- `django-webpack-loader>=3.1.1` - No webpack configuration in project
- `reactivated>=0.47.5` - Not used in codebase
- `poetry>=2.1.3` - Using UV package manager instead
- Moved `django-silk` and `django-debug-toolbar` to optional profiling group
### Added
- UV lock file (uv.lock) for reproducible builds
- Automated weekly dependency update workflow (.github/workflows/dependency-update.yml)
- Security audit step in CI/CD pipeline (pip-audit)
- Requirements.txt generation script (scripts/generate_requirements.sh)
- Ruff configuration in pyproject.toml
### Fixed
- Broken CI/CD pipeline (was referencing non-existent requirements.txt)
- Python version inconsistencies between root and backend configurations
- Duplicate dependency definitions between root and backend pyproject.toml
- Root pyproject.toml name conflict (renamed to thrillwiki-workspace)
### Infrastructure
- CI/CD now uses UV with dependency caching
- Added dependency groups: dev, test, profiling, lint
- Workspace configuration for monorepo structure
---
## Version Pinning Strategy
This project uses the following version pinning strategy:
| Package Type | Format | Example |
|-------------|--------|---------|
| Security-critical | `>=X.Y.Z` | `django>=5.2.8` |
| Stable packages | `>=X.Y` | `django-cors-headers>=4.6` |
| Rapidly evolving | `>=X.Y,<X+1` | `sentry-sdk>=2.20.0,<3` |
| Breaking changes | `>=X.Y.Z,<X.Z` | `Pillow>=10.4.0,<11.2` |
---
## Migration Guide
### For Developers
1. Update Python to 3.13+
2. Install UV: `curl -LsSf https://astral.sh/uv/install.sh | sh`
3. Update dependencies: `cd backend && uv sync --frozen`
4. Run tests: `uv run manage.py test`
### Breaking Changes
- Python 3.11/3.12 no longer supported (requires 3.13+)
- django-allauth updated to 65.x (review social auth configuration)
- sentry-sdk updated to 2.x (review Sentry integration)

View File

@@ -1,592 +0,0 @@
# ThrillWiki Codebase Quality Review
**Date:** January 2026
**Scope:** Full-stack analysis (Django backend, frontend, infrastructure, tests)
---
## Executive Summary
This codebase is a **well-architected Django 5.2 application** with HTMX/Alpine.js frontend, PostgreSQL/PostGIS database, Redis caching, and Celery task queue. The project demonstrates strong engineering fundamentals but has accumulated technical debt in several areas that, if addressed, would significantly improve maintainability, performance, and security.
### Overall Assessment
| Area | Score | Notes |
|------|-------|-------|
| Architecture | ⭐⭐⭐⭐ | Well-organized modular Django apps |
| Code Quality | ⭐⭐⭐ | Good patterns but inconsistencies exist |
| Security | ⭐⭐⭐ | Solid foundation with some XSS risks |
| Performance | ⭐⭐⭐ | Good caching but N+1 queries present |
| Testing | ⭐⭐⭐ | 70% coverage with gaps |
| Frontend | ⭐⭐⭐ | Clean JS but no tooling/types |
| Infrastructure | ⭐⭐⭐⭐ | Comprehensive CI/CD and deployment |
---
## 🔴 Critical Issues (Fix Immediately)
### 1. XSS Vulnerabilities in Admin Panel
**Location:** `backend/apps/moderation/admin.py`
```python
# Line 228 - changes_preview() method
return mark_safe("".join(html)) # User data not escaped!
# Line 740 - context_preview() method
return mark_safe("".join(html)) # Context data not escaped!
```
**Risk:** Attackers could inject malicious JavaScript through edit submissions.
**Fix:**
```python
from django.utils.html import escape
# In changes_preview():
html.append(f"<td>{escape(str(old))}</td><td>{escape(str(new))}</td>")
```
### 2. Debug Print Statements in Production Code
**Location:** `backend/apps/parks/models/parks.py:375-426`
```python
print(f"\nLooking up slug: {slug}") # DEBUG CODE IN PRODUCTION
print(f"Found current park with slug: {slug}")
print(f"Checking historical slugs...")
```
**Fix:** Remove or convert to `logging.debug()`.
### 3. Mass Assignment Vulnerability in Serializers
**Location:** `backend/apps/api/v1/accounts/serializers.py`
```python
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = "__all__" # DANGEROUS - exposes all fields for update
```
**Fix:** Explicitly list allowed fields:
```python
fields = ["display_name", "bio", "location", "website", "social_links"]
```
### 4. N+1 Query in Trip Planning Views
**Location:** `backend/apps/parks/views.py:577-583, 635-639, 686-690`
```python
# Current (N+1 problem - one query per park):
for tid in _get_session_trip(request):
try:
parks.append(Park.objects.get(id=tid))
except Park.DoesNotExist:
continue
# Fix (single query):
park_ids = _get_session_trip(request)
parks = list(Park.objects.filter(id__in=park_ids))
```
---
## 🟠 High Priority Issues
### 5. Fat Models with Business Logic
The following models have 200+ lines of business logic that should be extracted to services:
| Model | Location | Lines | Issue |
|-------|----------|-------|-------|
| `Park` | `parks/models/parks.py` | 220-428 | FSM transitions, slug resolution, computed fields |
| `EditSubmission` | `moderation/models.py` | 76-371 | Full approval workflow |
| `PhotoSubmission` | `moderation/models.py` | 668-903 | Photo approval workflow |
**Recommendation:** Create service classes:
```
apps/parks/services/
├── park_service.py # FSM transitions, computed fields
├── slug_service.py # Historical slug resolution
└── ...
apps/moderation/services/
├── submission_service.py # EditSubmission workflow
├── photo_service.py # PhotoSubmission workflow
└── ...
```
### 6. Missing Database Indexes
**Critical indexes to add:**
```python
# ParkPhoto - No index on frequently-filtered FK
class ParkPhoto(models.Model):
park = models.ForeignKey(Park, on_delete=models.CASCADE, db_index=True) # ADD db_index
# UserNotification - Missing composite index
class Meta:
indexes = [
models.Index(fields=["user", "created_at"]), # ADD for sorting
]
# RideCredit - Missing index for ordering
class Meta:
indexes = [
models.Index(fields=["user", "display_order"]), # ADD
]
# Company - Missing status filter index
class Meta:
indexes = [
models.Index(fields=["status", "founded_year"]), # ADD
]
```
### 7. Inconsistent API Response Formats
**Current state (3+ different formats):**
```python
# Format 1: rides endpoint
{"rides": [...], "total_count": X, "strategy": "...", "has_more": bool}
# Format 2: parks endpoint
{"parks": [...], "total_count": X, "strategy": "..."}
# Format 3: DRF paginator
{"results": [...], "count": X, "next": "...", "previous": "..."}
# Format 4: Success responses
{"success": True, "data": {...}} # vs
{"detail": "Success message"} # vs
{"message": "Success"}
```
**Recommendation:** Create a standard response wrapper:
```python
# apps/core/api/responses.py
class StandardResponse:
@staticmethod
def success(data=None, message=None, meta=None):
return {
"success": True,
"data": data,
"message": message,
"meta": meta # pagination, counts, etc.
}
@staticmethod
def error(message, code=None, details=None):
return {
"success": False,
"error": {
"message": message,
"code": code,
"details": details
}
}
```
### 8. Overly Broad Exception Handling
**Pattern found 15+ times:**
```python
# BAD - masks actual errors
try:
queryset = self.apply_filters(queryset)
except Exception as e:
log_exception(e)
return Park.objects.none() # Silent failure!
```
**Fix:** Catch specific exceptions:
```python
from django.core.exceptions import ValidationError
from django.db import DatabaseError
try:
queryset = self.apply_filters(queryset)
except ValidationError as e:
messages.warning(request, f"Invalid filter: {e}")
return base_queryset
except DatabaseError as e:
logger.error("Database error in filter", exc_info=True)
raise # Let it bubble up or return error response
```
### 9. Duplicated Permission Checks
**Found in 6+ locations:**
```python
# Repeated pattern in views:
if not (request.user == instance.uploaded_by or request.user.is_staff):
raise PermissionDenied()
```
**Fix:** Create reusable permission class:
```python
# apps/core/permissions.py
class IsOwnerOrStaff(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
owner_field = getattr(view, 'owner_field', 'user')
owner = getattr(obj, owner_field, None)
return owner == request.user or request.user.is_staff
```
---
## 🟡 Medium Priority Issues
### 10. Frontend Has No Build Tooling
**Current state:**
- No `package.json` or npm dependencies
- No TypeScript (vanilla JS only)
- No ESLint/Prettier configuration
- No minification/bundling pipeline
- No source maps for debugging
**Impact:**
- No type safety in 8,000+ lines of JavaScript
- Manual debugging without source maps
- No automated code quality checks
**Recommendation:** Add minimal tooling:
```json
// package.json
{
"scripts": {
"lint": "eslint backend/static/js/",
"format": "prettier --write 'backend/static/js/**/*.js'",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"eslint": "^8.0.0",
"prettier": "^3.0.0",
"typescript": "^5.0.0"
}
}
```
### 11. Test Coverage Gaps
**Disabled tests (technical debt):**
- `tests_disabled/test_models.py` - Park model tests
- `tests_disabled/test_filters.py` - Filter tests
- `tests_disabled/test_search.py` - Search/autocomplete tests
**Missing test coverage:**
- Celery async tasks (not tested)
- Cache hit/miss behavior
- Concurrent operations/race conditions
- Performance benchmarks
- Component-level accessibility
**Recommendation:**
1. Re-enable disabled tests with updated model references
2. Add Celery task tests with `CELERY_TASK_ALWAYS_EAGER = True`
3. Implement Page Object Model for E2E tests
### 12. Celery Configuration Issues
**Location:** `backend/config/celery.py`
```python
# Issue 1: No retry policy visible
# Tasks that fail don't have automatic retry with backoff
# Issue 2: Beat schedule lacks jitter
# All daily tasks run at midnight - thundering herd problem
CELERYBEAT_SCHEDULE = {
"daily-ban-expiry": {"schedule": crontab(hour=0, minute=0)},
"daily-deletion-processing": {"schedule": crontab(hour=0, minute=0)},
"daily-closing-checks": {"schedule": crontab(hour=0, minute=0)},
# All at midnight!
}
```
**Fix:**
```python
# Add retry policy to tasks
@app.task(bind=True, max_retries=3, default_retry_delay=60)
def process_submission(self, submission_id):
try:
# ... task logic
except TransientError as e:
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
# Stagger beat schedule
CELERYBEAT_SCHEDULE = {
"daily-ban-expiry": {"schedule": crontab(hour=0, minute=0)},
"daily-deletion-processing": {"schedule": crontab(hour=0, minute=15)},
"daily-closing-checks": {"schedule": crontab(hour=0, minute=30)},
}
```
### 13. Rate Limiting Only on Auth Endpoints
**Location:** `backend/apps/core/middleware/rate_limiting.py`
```python
RATE_LIMITED_PATHS = {
"/api/v1/auth/login/": {...},
"/api/v1/auth/signup/": {...},
# Missing: file uploads, form submissions, search endpoints
}
```
**Recommendation:** Extend rate limiting:
```python
RATE_LIMITED_PATHS = {
# Auth
"/api/v1/auth/login/": {"per_minute": 5, "per_hour": 30},
"/api/v1/auth/signup/": {"per_minute": 3, "per_hour": 10},
"/api/v1/auth/password-reset/": {"per_minute": 3, "per_hour": 10},
# File uploads
"/api/v1/photos/upload/": {"per_minute": 10, "per_hour": 100},
# Search (prevent abuse)
"/api/v1/search/": {"per_minute": 30, "per_hour": 500},
# Submissions
"/api/v1/submissions/": {"per_minute": 5, "per_hour": 50},
}
```
### 14. Inconsistent Status Field Implementations
**Three different patterns used:**
```python
# Pattern 1: RichFSMField (Park)
status = RichFSMField(default=ParkStatus.OPERATING, ...)
# Pattern 2: CharField with choices (Company)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, ...)
# Pattern 3: RichChoiceField (User role)
role = RichChoiceField(choices=UserRole.choices, ...)
```
**Recommendation:** Standardize on one approach (RichFSMField for state machines, RichChoiceField for enums).
### 15. Magic Numbers Throughout Code
**Examples found:**
```python
# auth/views.py
get_random_string(64) # Why 64?
timeout=300 # Why 300 seconds?
MAX_AVATAR_SIZE = 10 * 1024 * 1024 # Inline constant
# Various files
page_size = 20 # vs 24 in other places
```
**Fix:** Create constants module:
```python
# apps/core/constants.py
class Security:
MFA_TOKEN_LENGTH = 64
MFA_TOKEN_TIMEOUT_SECONDS = 300
MAX_AVATAR_SIZE_BYTES = 10 * 1024 * 1024
class Pagination:
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
```
---
## 🟢 Low Priority / Nice-to-Have
### 16. Deprecated Code Not Removed
**Location:** `backend/static/js/moderation/history.js`
- File marked as DEPRECATED but still present
- Should be removed or migrated
### 17. Unused Imports
Multiple files have duplicate or unused imports:
- `backend/apps/api/v1/rides/views.py` - `Q` imported twice
### 18. Missing Docstrings on Complex Methods
Many service methods and complex views lack docstrings explaining:
- Expected inputs/outputs
- Business rules applied
- Side effects
### 19. Template `|safe` Filter Usage
**Files using `|safe` that should use `|sanitize`:**
- `templates/components/ui/icon.html:61`
- `templates/components/navigation/breadcrumbs.html:116`
---
## Architecture Recommendations
### 1. Adopt Service Layer Pattern Consistently
```
apps/
├── parks/
│ ├── models/ # Data models only
│ ├── services/ # Business logic
│ │ ├── park_service.py
│ │ ├── slug_service.py
│ │ └── media_service.py
│ ├── selectors/ # Read queries (already exists)
│ └── api/ # Serializers, viewsets
```
### 2. Create Shared Response/Error Handling
```python
# apps/core/api/
responses.py # StandardResponse class
exceptions.py # Custom exceptions with codes
error_handlers.py # DRF exception handler
mixins.py # Reusable view mixins
```
### 3. Implement Repository Pattern for Complex Queries
```python
# apps/parks/repositories/park_repository.py
class ParkRepository:
@staticmethod
def get_by_slug_with_history(slug: str) -> Park | None:
"""Resolve slug including historical slugs."""
# Move 60+ lines from Park.get_by_slug() here
```
### 4. Add Event-Driven Architecture for Cross-App Communication
```python
# Instead of direct imports between apps:
from apps.parks.models import Park # Tight coupling
# Use signals/events:
# apps/core/events.py
park_status_changed = Signal()
# apps/parks/services/park_service.py
park_status_changed.send(sender=Park, park=park, old_status=old, new_status=new)
# apps/notifications/handlers.py
@receiver(park_status_changed)
def notify_followers(sender, park, **kwargs):
...
```
---
## Performance Optimization Opportunities
### 1. Database Query Optimization
| Issue | Location | Impact |
|-------|----------|--------|
| N+1 in trip views | `parks/views.py:577` | High - loops with `.get()` |
| Missing indexes | Multiple models | Medium - slow filters |
| No query count monitoring | Production | Unknown query count |
### 2. Caching Strategy Improvements
```python
# Add cache key versioning
CACHE_VERSION = "v1"
def get_park_cache_key(park_id):
return f"park:{CACHE_VERSION}:{park_id}"
# Add cache tags for invalidation
from django.core.cache import cache
def invalidate_park_caches(park_id):
cache.delete_pattern(f"park:*:{park_id}:*")
```
### 3. Frontend Performance
- Add `loading="lazy"` to images below the fold
- Implement virtual scrolling for long lists
- Add service worker for offline capability
---
## Security Hardening Checklist
- [ ] Fix XSS in admin `mark_safe()` calls
- [ ] Replace `fields = "__all__"` in serializers
- [ ] Add rate limiting to file upload endpoints
- [ ] Review `|safe` template filter usage
- [ ] Add Content Security Policy headers
- [ ] Implement API request signing for sensitive operations
- [ ] Add audit logging for admin actions
- [ ] Review OAuth state management consistency
---
## Recommended Action Plan
### Phase 1: Critical Fixes (This Sprint)
1. Fix XSS vulnerabilities in admin
2. Remove debug print statements
3. Fix mass assignment in serializers
4. Fix N+1 queries in trip views
5. Add missing database indexes
### Phase 2: High Priority (Next 2 Sprints)
1. Extract business logic to services
2. Standardize API response format
3. Fix overly broad exception handling
4. Re-enable disabled tests
5. Add rate limiting to more endpoints
### Phase 3: Medium Priority (Next Quarter)
1. Add frontend build tooling
2. Implement TypeScript for type safety
3. Improve Celery configuration
4. Standardize status field patterns
5. Add comprehensive E2E tests
### Phase 4: Ongoing
1. Remove deprecated code
2. Add missing docstrings
3. Monitor and optimize queries
4. Security audits
---
## Conclusion
This codebase has a solid foundation with good architectural decisions (modular apps, service layer beginnings, comprehensive configuration). The main areas needing attention are:
1. **Security:** XSS vulnerabilities and mass assignment risks
2. **Performance:** N+1 queries and missing indexes
3. **Maintainability:** Fat models and inconsistent patterns
4. **Testing:** Re-enable disabled tests and expand coverage
Addressing the critical and high-priority issues would significantly improve code quality and reduce technical debt. The codebase is well-positioned for scaling with relatively minor refactoring efforts.

View File

@@ -1,207 +0,0 @@
# 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** |
---
## 1. Field Fidelity Audit
### Ride Statistics Models
| 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)` |
### Water/Dark/Flat Ride Stats
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `WaterRideStats.splash_height_ft` | `rides/models/stats.py:59` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
| `WaterRideStats.wetness_level` | `rides/models/stats.py:52` | ✅ OK | CharField with choices |
| `DarkRideStats.scene_count` | `rides/models/stats.py:112` | ✅ OK | PositiveIntegerField |
| `DarkRideStats.animatronic_count` | `rides/models/stats.py:117` | ✅ OK | PositiveIntegerField |
| `FlatRideStats.max_height_ft` | `rides/models/stats.py:172` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
| `FlatRideStats.rotation_speed_rpm` | `rides/models/stats.py:180` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
| `FlatRideStats.max_g_force` | `rides/models/stats.py:213` | ✅ OK | `DecimalField(max_digits=4, decimal_places=2)` |
### RideModel Technical Specs
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `typical_height_range_*_ft` | `rides/models/rides.py:54-67` | ✅ OK | Both min/max as DecimalField |
| `typical_speed_range_*_mph` | `rides/models/rides.py:68-81` | ✅ OK | Both min/max as DecimalField |
| Height range constraint | `rides/models/rides.py:184-194` | ✅ OK | CheckConstraint validates min ≤ max |
| Speed range constraint | `rides/models/rides.py:196-206` | ✅ OK | CheckConstraint validates min ≤ max |
### Park Model Fields
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| `phone` contact field | `parks/models/parks.py` | ⚠️ DEVIATION | Field exists but spec wants E.164 format validation |
| `email` contact field | `parks/models/parks.py` | ✅ OK | EmailField present |
| Closing/opening date constraints | `parks/models/parks.py:137-183` | ✅ OK | Multiple CheckConstraints |
---
## 2. State Logic Audit
### Submission State Transitions
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| Claim requires PENDING status | `moderation/views.py:1455-1477` | ✅ OK | Explicit check: `if submission.status != "PENDING": return 400` |
| Unclaim requires CLAIMED status | `moderation/views.py:1520-1525` | ✅ OK | Explicit check before unclaim |
| Approve requires CLAIMED status | N/A | ⚠️ DEVIATION | Approve/Reject don't explicitly require CLAIMED - can approve from PENDING |
| Row locking for claim concurrency | `moderation/views.py:1450-1452` | ✅ OK | Uses `select_for_update(nowait=True)` |
| 409 Conflict on race condition | `moderation/views.py:1458-1464` | ✅ OK | Returns 409 with claimed_by info |
### Ride Status Transitions
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| FSM for ride status | `rides/models/rides.py:552-558` | ✅ OK | `RichFSMField` with state machine |
| CLOSING requires post_closing_status | `rides/models/rides.py:697-704` | ✅ OK | ValidationError if missing |
| Transition wrapper methods | `rides/models/rides.py:672-750` | ✅ OK | All transitions have wrapper methods |
| Status validation on save | `rides/models/rides.py:752-796` | ✅ OK | Computed fields populated on save |
### Park Status Transitions
| Requirement | File | Status | Notes |
|-------------|------|--------|-------|
| FSM for park status | `parks/models/parks.py` | ✅ OK | `RichFSMField` with StateMachineMixin |
| Transition methods | `parks/models/parks.py:189-221` | ✅ OK | reopen, close_temporarily, etc. |
| Closing date on permanent close | `parks/models/parks.py:204-211` | ✅ OK | Optional closing_date param |
---
## 3. UI States Audit
### Loading States
| Page | File | Status | Notes |
|------|------|--------|-------|
| Park Detail loading spinner | `parks/[park_slug]/index.vue:119-121` | ✅ OK | Full-screen spinner with `svg-spinners:ring-resize` |
| Park Detail error state | `parks/[park_slug]/index.vue:124-127` | ✅ OK | "Park Not Found" with back button |
| Moderation skeleton loaders | `moderation/index.vue:252-256` | ✅ OK | `BentoCard :loading="true"` |
| Search page loading | `search/index.vue` | ⚠️ DEVIATION | Uses basic pending state, no skeleton |
| Rides listing loading | `rides/index.vue` | ⚠️ DEVIATION | Basic loading state, no fancy skeleton |
| Credits page loading | `profile/credits.vue` | ✅ OK | Proper loading state |
### Error Handling & Toasts
| Feature | File | Status | Notes |
|---------|------|--------|-------|
| Moderation toast notifications | `moderation/index.vue:16,72-94` | ✅ OK | `useToast()` with success/warning/error variants |
| Moderation 409 conflict handling | `moderation/index.vue:82-88` | ✅ OK | Special handling for already-claimed |
| Park Detail error fallback | `parks/[park_slug]/index.vue:124-127` | ✅ OK | Error boundary with retry |
| Form validation toasts | Various | ⚠️ DEVIATION | Inconsistent - some forms use inline errors only |
| Global error toast composable | `composables/useToast.ts` | ✅ OK | Centralized toast system exists |
### Empty States
| Component | File | Status | Notes |
|-----------|------|--------|-------|
| Reviews empty state | `parks/[park_slug]/index.vue:283-286` | ✅ OK | Icon + message + CTA |
| Photos empty state | `parks/[park_slug]/index.vue:321-325` | ✅ OK | "Upload one" link |
| Moderation empty state | `moderation/index.vue:392-412` | ✅ OK | Context-aware messages per tab |
| Rides empty state | `parks/[park_slug]/index.vue:247-250` | ✅ OK | "Add the first ride" CTA |
| Credits empty state | N/A | ❌ MISSING | No dedicated empty state for credits page |
| Lists empty state | N/A | ❌ MISSING | No dedicated empty state for user lists |
### Real-time Updates
| Feature | File | Status | Notes |
|---------|------|--------|-------|
| SSE for moderation dashboard | `moderation/index.vue:194-220` | ✅ OK | `subscribeToDashboardUpdates()` with cleanup |
| Optimistic UI for claims | `moderation/index.vue:40-63` | ✅ OK | Map-based optimistic state tracking |
| Processing indicators | `moderation/index.vue:268-273` | ✅ OK | Per-item "Processing..." indicator |
---
## 4. Permissions Audit
### Moderation Endpoints
| Endpoint | File:Line | Permission | Status |
|----------|-----------|------------|--------|
| Report assign | `moderation/views.py:136` | `IsModeratorOrAdmin` | ✅ OK |
| Report resolve | `moderation/views.py:215` | `IsModeratorOrAdmin` | ✅ OK |
| Queue assign | `moderation/views.py:593` | `IsModeratorOrAdmin` | ✅ OK |
| Queue unassign | `moderation/views.py:666` | `IsModeratorOrAdmin` | ✅ OK |
| Queue complete | `moderation/views.py:732` | `IsModeratorOrAdmin` | ✅ OK |
| EditSubmission claim | `moderation/views.py:1436` | `IsModeratorOrAdmin` | ✅ OK |
| BulkOperation ViewSet | `moderation/views.py:1170` | `IsModeratorOrAdmin` | ✅ OK |
| Moderator middleware (frontend) | `moderation/index.vue:11-13` | `middleware: ['moderator']` | ✅ OK |
---
## 5. Entity Forms Audit
| Entity | Create | Edit | Status |
|--------|--------|------|--------|
| Park | `CreateParkModal.vue` | `EditParkModal.vue` | ✅ OK |
| Ride | `CreateRideModal.vue` | `EditRideModal.vue` | ✅ OK |
| Company | `CreateCompanyModal.vue` | `EditCompanyModal.vue` | ✅ OK |
| RideModel | `CreateRideModelModal.vue` | `EditRideModelModal.vue` | ✅ OK |
| UserList | `CreateListModal.vue` | `EditListModal.vue` | ✅ OK |
---
## Priority Gaps to Address
### High Priority (Functionality Gaps)
1. **`RollerCoasterStats` missing `g_force` field**
- Location: `backend/apps/rides/models/rides.py:990-1080`
- Impact: Coaster enthusiasts expect G-force data
- Fix: Add `max_g_force = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)`
### Medium Priority (Deviations)
4. **Approve/Reject don't require CLAIMED status**
- Location: `moderation/views.py`
- Impact: Moderators can approve without claiming first
- Fix: Add explicit CLAIMED check or document as intentional
5. **Park phone field lacks E.164 validation**
- Location: `parks/models/parks.py`
- Fix: Add `phonenumbers` library validation
6. **Inconsistent form validation feedback**
- Multiple locations
- Fix: Standardize to toast + inline hybrid approach
---
## Verification Commands
```bash
# Check for missing G-force field
uv run manage.py shell -c "from apps.rides.models import RollerCoasterStats; print([f.name for f in RollerCoasterStats._meta.fields])"
# Verify state machine transitions
uv run manage.py test apps.moderation.tests.test_state_transitions -v 2
# Run full frontend type check
cd frontend && npx nuxi typecheck
```
---
*Audit completed with Maximum Thoroughness setting. All findings verified against source code.*

View File

@@ -1,179 +0,0 @@
# ThrillWiki Implementation Plan
## User Review Required
> [!IMPORTANT]
> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (`useUnits` composable) will handle conversion to Imperial based on user preference.
> **Sacred Pipeline Enforcement**: All user edits create `Submission` records (stored as JSON). No direct database edits are allowed for non-admin users.
## Proposed Changes
### Backend (Django + DRF)
#### 1. Core & Auth Infrastructure
- [x] **`apps.core`**: Implement `TrackedModel` using `pghistory` for all core entities to support Edit History and Versioning (Section 15).
- [x] **`apps.accounts`**:
- `User` & `UserProfile` models (Bio, Location, Home Park).
- **Settings Support**: Endpoints for changing Email, Password, MFA, and Sessions (Section 9.1-9.2).
- **Privacy**: Fields for `public_profile`, `show_location`, etc. (Section 9.3).
- **Data Export**: Endpoint to generate JSON dump of all user data (Section 9.6).
- **Account Deletion**: `UserDeletionRequest` model with 7-day grace period (Section 9.6).
#### 2. Entity Models & Logic ("Live" Data)
- [x] **`apps.parks`**: `Park` (with Operator/Owner FKs, Geolocation).
- [x] **`apps.rides`**: `Ride` (Status FSM), `RideModel`, `Manufacturer`, `Designer`.
- [x] **`apps.rides` (Credits)**: `RideCredit` Through-Model with `count`, `rating`, `date`, `notes`. Constraint: Unique(user, ride).
- [x] **`apps.companies`**: `Company` model with types (`Manufacturer`, `Designer`, `Operator`, `Owner`).
- [x] **`apps.lists`**: `UserList` (Ranking System) and `UserListItem`.
- [x] **`apps.reviews`**: `Review` model (GenericFK) with Aggregation Logic.
#### 3. The Sacred Pipeline (`apps.moderation`)
- [x] **Submission Model**: Stores `changes` (JSON), `status` (State Machine), `moderator_note`.
- [x] **Submission Serializers**: Handle validation of "Proposed Data" vs "Live Data".
- [x] **Queue Endpoints**: `list_pending`, `claim`, `approve`, `reject`, `activity_log`, `stats`.
- [x] **Reports**: `Report` model and endpoints.
### Frontend (Nuxt 4)
#### 1. Initial Setup & Core
- [x] **Composables**: `useUnits` (Metric/Imperial), `useAuth` (MFA, Session), `useApi`.
- [x] **Layouts**: Standard Layout (Hero, Tabs), Auth Layout.
#### 2. Discovery & Search (Section 1 & 6)
- [x] **Global Search**: Hero Search with Autocomplete (Parks, Rides, Companies).
- [x] **Discovery Tabs** (11 Sections):
- [x] Trending Parks / Rides
- [x] New Parks / Rides
- [x] Top Parks / Rides
- [x] Opening Soon / Recently Opened
- [x] Closing Soon / Recently Closed
- [x] Recent Changes Feed
#### 3. Content Pages (Read-Only Views)
- [ ] **Park Detail**: Tabs (Overview, Rides, Reviews, Photos, History).
- [ ] **Ride Detail**: Tabs (Overview, Specifications, Reviews, Photos, History).
- [ ] **Company Pages**: Manufacturer, Designer, Operator, Property Owner details.
- [ ] **Maps**: Interactive "Parks Nearby" map.
#### 4. The Sacred Submission Pipeline (Write Views)
- [ ] **Submission Forms** (Multi-step Wizards):
- [ ] **Park Form**: Location, Dates, Media, Relations.
- [ ] **Ride Form**: Specs (with Unit Toggle), Relations, Park selection.
- [ ] **Company Form**: Type selection, HQ, details.
- [ ] **Photo Upload**: Bulk upload, captioning, crop.
- [ ] **Editing**: Load existing data into form -> Submit as JSON Diff.
#### 5. Moderation Interface (Section 16)
- [ ] **Dashboard**: Queue stats, Assignments.
- [ ] **Queues**:
- [ ] **Pending Queue**: Filter by Type, Submitter, Date.
- [ ] **Reports Queue**.
- [ ] **Audit Log**.
- [ ] **Review Workspace**:
- [ ] **Diff Viewer**: Visual Old vs New comparison.
- [ ] **Actions**: Claim, Approve, Reject (with reason), Edit.
#### 6. User Experience & Settings
- [ ] **User Profile**: Activity Feed, Credits Tab, Lists Tab, Reviews Tab.
- [ ] **Ride Credits Management**: Add/Edit Credit (Date, Count, Notes).
- [ ] **Settings Area** (6 Tabs):
- [ ] Account & Profile (Edit generic info).
- [ ] Security (MFA setup, Active Sessions).
- [ ] Privacy (Visibility settings).
- [ ] Notifications.
- [ ] Location & Info (Timezone, Home Park).
- [ ] Data & Export (JSON Download, Delete Account).
#### 7. Lists System
- [ ] **List Management**: Create/Edit Lists (Public/Private).
- [ ] **List Editor**: Search items, Add to list, Drag-and-drop reorder, Add notes.
## Verification Plan
### Automated Tests
- **Backend**: `pytest` for all Model constraints and API permissions.
- Test Submission State Machine: `Pending -> Claimed -> Approved`.
- Test Versioning: Ensure `pghistory` tracks changes on approval.
- **Frontend**: `vitest` for Unit Tests (Composables).
### Manual Verification Flows
1. **Sacred Pipeline Flow**:
- **User**: Submit a change to "Top Thrill 2" (add stats).
- **Moderator**: Go to Queue -> Claim -> Verify Diff -> Approve.
- **Public**: Verify "Top Thrill 2" page shows new stats and "Last Updated" is now.
- **History**: Verify "History" tab shows the update event.
2. **Ride Credits**:
- Go to "Iron Gwazi" page.
- Click "Add to Credits" -> Enter `Count: 5`, `Rating: 4.5`.
- Go to Profile -> Ride Credits. Verify Iron Gwazi is listed with correct data.
3. **Data Privacy & Export**:
- Go to Settings -> Privacy -> Toggle "Private Profile".
- Open Profile URL in Incognito -> Verify 404 or "Private" message.
- Go to Settings -> Data -> "Download Data" -> Verify JSON structure.
---
## Gap Reconciliation Batches (Added 2025-12-26)
> [!IMPORTANT]
> These batches were identified during the Full Project Synchronization audit.
> Refer to `GAP_ANALYSIS_MATRIX.md` for detailed per-feature status.
### BATCH 1: Critical Missing Pages (HIGH PRIORITY)
- [ ] `/my-credits` - Ride Credits Dashboard with stats, filters, quick increment
- [ ] `/settings` - Full Settings Page (6 sections: Account, Security, Privacy, Notifications, Location, Data)
- [ ] `/parks/nearby` - Location-based Discovery with Leaflet map, geolocation, radius slider
- [ ] `/my-submissions` - Submission History for user's past edits
- [ ] Static Pages: `/terms`, `/privacy`, `/guidelines`
### BATCH 2: Missing Tabs on Existing Pages (HIGH PRIORITY)
- [ ] Park Detail - Add Reviews, Photos, History tabs
- [ ] Ride Detail - Add Specifications, Reviews, Photos, History tabs
- [ ] Homepage - Expand to 11 Discovery Tabs (All, Parks, Coasters, Flat, Water, Dark, Shows, Transport, Manufacturers, Designers, Recent)
- [ ] Profile Page - Add Reviews, Ride Credits tabs
### BATCH 3: Missing Components (MEDIUM PRIORITY)
- [ ] `ReviewCard.vue` - User review display with voting
- [ ] `CreditCard.vue` - Ride credit display with quick actions
- [ ] `StarRating.vue` - Star rating visualization
- [ ] `DiffViewer.vue` - Side-by-side comparison for moderation
- [ ] `ImageGallery.vue` - Photo gallery with lightbox
- [ ] `AppFooter.vue` - Site-wide footer
- [ ] `Breadcrumbs.vue` - Hierarchical navigation
- [ ] DatePicker and Range Slider components
### BATCH 4: Submission Forms (MEDIUM PRIORITY)
- [ ] `/submit/park` - Multi-step park submission wizard
- [ ] `/submit/ride` - Multi-step ride submission wizard
- [ ] `/submit/company` - Company submission wizard
- [ ] Edit forms for existing entities with JSON diff
### BATCH 5: Company Pages (MEDIUM PRIORITY)
- [ ] `/designers` - Designers listing and detail pages
- [ ] `/operators` - Operators listing and detail pages
- [ ] `/owners` - Property Owners listing and detail pages
- [ ] `/ride-models/[slug]` - Ride Model detail with installations
### BATCH 6: Enhanced Features (LOW PRIORITY)
- [ ] OAuth Authentication (Google, Discord)
- [ ] Magic Link Login
- [ ] CAPTCHA integration on forms
- [ ] MFA Setup UI
- [ ] Review voting (thumbs up/down) and replies
- [ ] Recent searches history
- [ ] Drag-and-drop list reordering
- [ ] Glass card effects (dark mode)
- [ ] Reduced motion support
---
## Execution Order Recommendation
1. **Start with BATCH 1** - Critical pages users expect
2. **Then BATCH 2** - Complete existing pages
3. **Then BATCH 3** - Components needed by batches 1 & 2
4. **Then BATCH 4** - Enable user contributions
5. **Then BATCH 5** - Additional entity types
6. **Finally BATCH 6** - Polish and enhancements

View File

@@ -1,59 +0,0 @@
# MASTER OMNI LOG
## Phase 1: Gap Analysis [x]
- [x] Scan backend/urls.py and ViewSets vs frontend services.
- [x] Identify missing/broken endpoints.
- [x] Identify UX/UI gaps (Loading, Error Handling).
- [x] Check Theme/CSS configuration.
## Phase 3: Execution Loop [x]
### Feature: Core Infrastructure
- [x] **Fix Missing Composables**: Create `frontend/app/composables/useModeration.ts` matching `apps.moderation` endpoints.
- [x] **Roadtrip API**: Create `frontend/app/composables/useRoadtripApi.ts` matching `apps.parks` roadtrip endpoints.
- [x] **FSM Support**: Add generic FSM transition methods to `useApi.ts` or specific composables.
### Feature: Parks & Rides
- [x] **Park API Gaps**: Add `getOperators`, `searchLocation` to `useParksApi.ts`.
- [x] **Ride API Gaps**: Add `getManufacturers`, `getDesigners` to `useRidesApi.ts`.
- [x] **Frontend Pages**: Ensure `parks/roadtrip` page exists or create it.
- [x] **Manufacturers Page**: Ensure `manufacturers/` page exists.
### Feature: UX & Interactivity
- [x] **Moderation Dashboard**: Updates `useModeration` usage in `moderation/index.vue`. Add error handling.
- [x] **Status Colors**: Refactor `main.css` hardcoded hex values to use CSS variables or Tailwind tokens.
- [x] **Loading States**: Audit `pages/parks/[slug].vue` and `pages/rides/[slug].vue` for skeleton loaders.
### Feature: Theme & Polish
- [x] **Dark Mode**: Verify `input.css` / `main.css` `@theme` usage.
- [x] **Contrast**: Check status badge text contrast in Dark Mode.
## Execution Checklists
### 1. Moderation API Parity
- [x] Implement `getReports`
- [x] Implement `getQueue`
- [x] Implement `getActions`
- [x] Implement `getBulkOperations`
- [x] Implement `userModeration` endpoints
- [x] Implement `approve`/`reject`/`escalate` actions
### 2. Roadtrip API Parity
- [x] Implement `getRoadtrips` (Skipped: Backend does not persist trips)
- [x] Implement `createTrip`
- [x] Implement `getTripDetail` (Skipped: Backend does not persist trips)
- [x] Implement `findParksAlongRoute`
- [x] Implement `geocodeAddress`
- [x] Implement `calculateDistance`
- [x] Implement `optimizeRoute` (Covered by createTrip)
### 3. CSS Standardization
- [x] Replace `#f59e0b` with `var(--color-warning-500)` or tailwind class.
- [x] Replace `#10b981` with `var(--color-success-500)`.
- [x] Replace `#ef4444` with `var(--color-error-500)`.
- [x] Replace `#8b5cf6` with `var(--color-violet-500)`.
## Phase 4: Final Verification [x]
- [-] **Type Check**: Run `npx nuxi typecheck` (Found errors, but build succeeds).
- [x] **Build Check**: Run `npm run build` (Success).
- [x] **Lint Check**: Run `npm run lint` (Skipped).

344
README.md Normal file
View File

@@ -0,0 +1,344 @@
# ThrillWiki Django + Vue.js Monorepo
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
## 🏗️ Architecture Overview
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
```
thrillwiki-monorepo/
├── backend/ # Django REST API (Port 8000)
│ ├── apps/ # Modular Django applications
│ ├── config/ # Django settings and configuration
│ ├── templates/ # Django templates
│ └── static/ # Static assets
├── frontend/ # Vue.js SPA (Port 5174)
│ ├── src/ # Vue.js source code
│ ├── public/ # Static assets
│ └── dist/ # Build output
├── shared/ # Shared resources and documentation
│ ├── docs/ # Comprehensive documentation
│ ├── scripts/ # Development and deployment scripts
│ ├── config/ # Shared configuration
│ └── media/ # Shared media files
├── architecture/ # Architecture documentation
└── profiles/ # Development profiles
```
## 🚀 Quick Start
### Prerequisites
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
- **Redis 6+** (optional, for caching and sessions)
### Development Setup
1. **Clone the repository**
```bash
git clone <repository-url>
cd thrillwiki-monorepo
```
2. **Install dependencies**
```bash
# Install frontend dependencies
pnpm install
# Install backend dependencies
cd backend && uv sync && cd ..
```
3. **Environment configuration**
```bash
# Copy environment files
cp .env.example .env
cp backend/.env.example backend/.env
cp frontend/.env.development frontend/.env.local
# Edit .env files with your settings
```
4. **Database setup**
```bash
cd backend
uv run manage.py migrate
uv run manage.py createsuperuser
cd ..
```
5. **Start development servers**
```bash
# Start both servers concurrently
pnpm run dev
# Or start individually
pnpm run dev:frontend # Vue.js on :5174
pnpm run dev:backend # Django on :8000
```
## 📁 Project Structure Details
### Backend (`/backend`)
- **Django 5.0+** with REST Framework for API development
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
- **UV package management** for fast, reliable Python dependency management
- **PostgreSQL/SQLite** database with comprehensive entity relationships
- **Redis** for caching, sessions, and background tasks
- **Comprehensive API** with frontend serializers for camelCase conversion
### Frontend (`/frontend`)
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety and better developer experience
- **Vite** for lightning-fast development and optimized production builds
- **Tailwind CSS** with custom design system and dark mode support
- **Pinia** for state management with modular stores
- **Vue Router** for client-side routing
- **Comprehensive UI component library** with shadcn-vue components
### Shared Resources (`/shared`)
- **Documentation** - Comprehensive guides and API documentation
- **Development scripts** - Automated setup, build, and deployment scripts
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
- **Media management** - Centralized media file handling and optimization
## 🛠️ Development Workflow
### Available Scripts
```bash
# Development
pnpm run dev # Start both servers concurrently
pnpm run dev:frontend # Frontend only (:5174)
pnpm run dev:backend # Backend only (:8000)
# Building
pnpm run build # Build frontend for production
pnpm run build:staging # Build for staging environment
pnpm run build:production # Build for production environment
# Testing
pnpm run test # Run all tests
pnpm run test:frontend # Frontend unit and E2E tests
pnpm run test:backend # Backend unit and integration tests
# Code Quality
pnpm run lint # Lint all code
pnpm run type-check # TypeScript type checking
# Setup and Maintenance
pnpm run install:all # Install all dependencies
./shared/scripts/dev/setup-dev.sh # Full development setup
./shared/scripts/dev/start-all.sh # Start all services
```
### Backend Development
```bash
cd backend
# Django management commands
uv run manage.py migrate
uv run manage.py makemigrations
uv run manage.py createsuperuser
uv run manage.py collectstatic
# Testing and quality
uv run manage.py test
uv run black . # Format code
uv run flake8 . # Lint code
uv run isort . # Sort imports
```
### Frontend Development
```bash
cd frontend
# Vue.js development
pnpm run dev # Start dev server
pnpm run build # Production build
pnpm run preview # Preview production build
pnpm run test:unit # Vitest unit tests
pnpm run test:e2e # Playwright E2E tests
pnpm run lint # ESLint
pnpm run type-check # TypeScript checking
```
## 🔧 Configuration
### Environment Variables
#### Root `.env`
```bash
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
REDIS_URL=redis://localhost:6379
# Security
SECRET_KEY=your-secret-key
DEBUG=True
# API Configuration
API_BASE_URL=http://localhost:8000/api
```
#### Backend `.env`
```bash
# Django Settings
DJANGO_SETTINGS_MODULE=config.django.local
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
# Redis
REDIS_URL=redis://localhost:6379
# Email (optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
```
#### Frontend `.env.local`
```bash
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
# Development
VITE_APP_TITLE=ThrillWiki (Development)
# Feature Flags
VITE_ENABLE_DEBUG=true
```
## 📊 Key Features
### Backend Features
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
- **Extensive Ride Database** - Complete roller coaster and ride information
- **User Management** - Authentication, profiles, and permissions
- **Content Moderation** - Review and approval workflows
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
- **Background Tasks** - Celery integration for long-running processes
- **Caching Strategy** - Redis-based caching for performance
- **Search Functionality** - Full-text search across all content
### Frontend Features
- **Responsive Design** - Mobile-first approach with Tailwind CSS
- **Dark Mode Support** - Complete dark/light theme system
- **Real-time Search** - Instant search with debouncing and highlighting
- **Interactive Maps** - Park and ride location visualization
- **Photo Galleries** - High-quality image management
- **User Dashboard** - Personalized content and contributions
- **Progressive Web App** - PWA capabilities for mobile experience
- **Accessibility** - WCAG 2.1 AA compliance
## 📖 Documentation
### Core Documentation
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
### Architecture & Deployment
- **[Architecture Overview](./architecture/)** - System design and decisions
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
### Additional Resources
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
## 🚀 Deployment
### Development Environment
```bash
# Quick start with all services
./shared/scripts/dev/start-all.sh
# Full development setup
./shared/scripts/dev/setup-dev.sh
```
### Production Deployment
```bash
# Build all components
./shared/scripts/build/build-all.sh
# Deploy to production
./shared/scripts/deploy/deploy.sh
```
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
## 🧪 Testing Strategy
### Backend Testing
- **Unit Tests** - Individual function and method testing
- **Integration Tests** - API endpoint and database interaction testing
- **E2E Tests** - Full user journey testing with Selenium
### Frontend Testing
- **Unit Tests** - Component and utility function testing with Vitest
- **Integration Tests** - Component interaction testing
- **E2E Tests** - User journey testing with Playwright
### Code Quality
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
- **Type Checking** - TypeScript for frontend, mypy for Python
- **Code Formatting** - Prettier for frontend, Black for Python
## 🤝 Contributing
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
1. **Development Setup** - Getting your development environment ready
2. **Code Standards** - Coding conventions and best practices
3. **Pull Request Process** - How to submit your changes
4. **Issue Reporting** - How to report bugs and request features
### Quick Contribution Start
```bash
# Fork and clone the repository
git clone https://github.com/your-username/thrillwiki-monorepo.git
cd thrillwiki-monorepo
# Set up development environment
./shared/scripts/dev/setup-dev.sh
# Create a feature branch
git checkout -b feature/your-feature-name
# Make your changes and test
pnpm run test
# Submit a pull request
```
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
## 🙏 Acknowledgments
- **Theme Park Community** - For providing data and inspiration
- **Open Source Contributors** - For the amazing tools and libraries
- **Vue.js and Django Communities** - For excellent documentation and support
## 📞 Support
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
---
**Built with ❤️ for the theme park and roller coaster community**

View File

@@ -1,649 +0,0 @@
#!/bin/bash
# ThrillWiki API Endpoints - Complete Curl Commands
# Generated from comprehensive URL analysis
# Base URL - adjust as needed for your environment
BASE_URL="http://localhost:8000"
# Command line options
SKIP_AUTH=false
ONLY_AUTH=false
SKIP_DOCS=false
HELP=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--skip-auth)
SKIP_AUTH=true
shift
;;
--only-auth)
ONLY_AUTH=true
shift
;;
--skip-docs)
SKIP_DOCS=true
shift
;;
--base-url)
BASE_URL="$2"
shift 2
;;
--help|-h)
HELP=true
shift
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Show help
if [ "$HELP" = true ]; then
echo "ThrillWiki API Endpoints Test Suite"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --skip-auth Skip endpoints that require authentication"
echo " --only-auth Only test endpoints that require authentication"
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Test all endpoints"
echo " $0 --skip-auth # Test only public endpoints"
echo " $0 --only-auth # Test only authenticated endpoints"
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
echo " $0 --base-url https://api.example.com # Use custom base URL"
exit 0
fi
# Validate conflicting options
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
echo "Error: --skip-auth and --only-auth cannot be used together"
exit 1
fi
echo "=== ThrillWiki API Endpoints Test Suite ==="
echo "Base URL: $BASE_URL"
if [ "$SKIP_AUTH" = true ]; then
echo "Mode: Public endpoints only (skipping authentication required)"
elif [ "$ONLY_AUTH" = true ]; then
echo "Mode: Authenticated endpoints only"
else
echo "Mode: All endpoints"
fi
if [ "$SKIP_DOCS" = true ]; then
echo "Skipping: API documentation endpoints"
fi
echo ""
# Helper function to check if we should run an endpoint
should_run_endpoint() {
local requires_auth=$1
local is_docs=$2
# Skip docs if requested
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
return 1
fi
# Skip auth endpoints if requested
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
return 1
fi
# Only run auth endpoints if requested
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
return 1
fi
return 0
}
# Counter for endpoint numbering
ENDPOINT_NUM=1
# ============================================================================
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo "=== AUTHENTICATION ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Login"
curl -X POST "$BASE_URL/api/v1/auth/login/" \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "testpass"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Signup"
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
-H "Content-Type: application/json" \
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Logout"
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
-H "Content-Type: application/json"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Reset"
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Social Providers"
curl -X GET "$BASE_URL/api/v1/auth/providers/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Auth Status"
curl -X GET "$BASE_URL/api/v1/auth/status/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Current User"
curl -X GET "$BASE_URL/api/v1/auth/user/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Password Change"
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
echo "$ENDPOINT_NUM. Health Check"
curl -X GET "$BASE_URL/api/v1/health/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Simple Health"
curl -X GET "$BASE_URL/api/v1/health/simple/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
curl -X GET "$BASE_URL/api/v1/health/performance/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
echo "$ENDPOINT_NUM. Trending Content"
curl -X GET "$BASE_URL/api/v1/trending/content/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. New Content"
curl -X GET "$BASE_URL/api/v1/trending/new/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# STATISTICS ENDPOINTS (/api/v1/stats/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Statistics"
curl -X GET "$BASE_URL/api/v1/stats/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rankings"
curl -X GET "$BASE_URL/api/v1/rankings/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking History"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"category": "RC"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# PARKS API ENDPOINTS (/api/v1/parks/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== PARKS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Parks"
curl -X GET "$BASE_URL/api/v1/parks/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Company Search"
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Park Photos"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Park"
curl -X POST "$BASE_URL/api/v1/parks/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Park", "location": "Test City"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park"
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Park Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park"
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# RIDES API ENDPOINTS (/api/v1/rides/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== RIDES API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List Rides"
curl -X GET "$BASE_URL/api/v1/rides/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Create Ride"
curl -X POST "$BASE_URL/api/v1/rides/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride"
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Ride Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride"
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-F "image=@/path/to/photo.jpg" \
-F "caption=Test ride photo"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"caption": "Updated ride photo caption"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. List User Profiles"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top Lists"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. List Top List Items"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Update User Profile"
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"bio": "Updated bio"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List"
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List"
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"name": "Updated Top List Name"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"toplist": 1, "ride": 1, "position": 1}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"position": 2}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HISTORY API ENDPOINTS (/api/v1/history/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Park History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Park History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History List"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
curl -X GET "$BASE_URL/api/v1/history/timeline/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# EMAIL API ENDPOINTS (/api/v1/email/)
# ============================================================================
if should_run_endpoint true false; then
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Send Email"
curl -X POST "$BASE_URL/api/v1/email/send/" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
((ENDPOINT_NUM++))
fi
# ============================================================================
# CORE API ENDPOINTS (/api/v1/core/)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== CORE API ENDPOINTS ==="
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
-H "Content-Type: application/json" \
-d '{"query": "nonexistent park", "type": "park"}'
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
((ENDPOINT_NUM++))
fi
# ============================================================================
# MAPS API ENDPOINTS (/api/v1/maps/)
# ============================================================================
if should_run_endpoint false false || should_run_endpoint true false; then
echo -e "\n\n=== MAPS API ENDPOINTS ==="
fi
if should_run_endpoint false false; then
echo "$ENDPOINT_NUM. Map Locations"
curl -X GET "$BASE_URL/api/v1/maps/locations/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Search"
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Statistics"
curl -X GET "$BASE_URL/api/v1/maps/stats/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
curl -X GET "$BASE_URL/api/v1/maps/cache/"
((ENDPOINT_NUM++))
fi
if should_run_endpoint true false; then
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
((ENDPOINT_NUM++))
fi
# ============================================================================
# API DOCUMENTATION ENDPOINTS
# ============================================================================
if should_run_endpoint false true; then
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
echo "$ENDPOINT_NUM. OpenAPI Schema"
curl -X GET "$BASE_URL/api/schema/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. Swagger UI"
curl -X GET "$BASE_URL/api/docs/"
((ENDPOINT_NUM++))
echo -e "\n$ENDPOINT_NUM. ReDoc"
curl -X GET "$BASE_URL/api/redoc/"
((ENDPOINT_NUM++))
fi
# ============================================================================
# HEALTH CHECK (Django Health Check)
# ============================================================================
if should_run_endpoint false false; then
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
echo "$ENDPOINT_NUM. Django Health Check"
curl -X GET "$BASE_URL/health/"
((ENDPOINT_NUM++))
fi
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
echo ""
echo "Notes:"
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
echo ""
echo "Authentication required endpoints are marked with Authorization header"
echo "File upload endpoints use multipart/form-data (-F flag)"
echo "JSON endpoints use application/json content type"

View File

@@ -1,120 +1,108 @@
# ThrillWiki Deployment Guide
# ThrillWiki Monorepo Deployment Guide
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + HTMX application.
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + Vue.js monorepo.
## Architecture Overview
ThrillWiki is a **Django monolith** with HTMX for dynamic interactivity. There is no separate frontend build process - templates and static assets are served directly by Django.
## Build Process Overview
```mermaid
graph TB
A[Source Code] --> B[Django Application]
B --> C[Static Files Collection]
C --> D[Docker Container]
D --> E[Production Deployment]
subgraph "Django Application"
B1[Python Dependencies]
B2[Database Migrations]
B3[HTMX Templates]
end
A[Source Code] --> B[Backend Build]
A --> C[Frontend Build]
B --> D[Django Static Collection]
C --> E[Vue.js Production Build]
D --> F[Backend Container]
E --> G[Frontend Assets]
F --> H[Production Deployment]
G --> H
```
## Development Environment
### Prerequisites
- Python 3.13+ with UV package manager
- PostgreSQL 14+ with PostGIS extension
- Redis 6+ (for caching and sessions)
- Python 3.11+ with UV package manager
- Node.js 18+ with pnpm
- PostgreSQL (production) / SQLite (development)
- Redis (for caching and sessions)
### Local Development Setup
```bash
# Clone repository
git clone <repository-url>
cd thrillwiki
cd thrillwiki-monorepo
# Install dependencies
# Install root dependencies
pnpm install
# Backend setup
cd backend
uv sync --frozen
# Configure environment
cp .env.example .env
# Edit .env with your settings
# Database setup
uv sync
uv run manage.py migrate
uv run manage.py collectstatic --noinput
uv run manage.py collectstatic
# Start development server
uv run manage.py runserver
# Frontend setup
cd ../frontend
pnpm install
# Start development servers
cd ..
pnpm run dev # Starts both backend and frontend
```
## Build Strategies
### 1. Containerized Deployment (Recommended)
#### Multi-stage Dockerfile
#### Multi-stage Dockerfile for Backend
```dockerfile
# backend/Dockerfile
FROM python:3.13-slim as builder
FROM python:3.11-slim as builder
WORKDIR /app
# Install system dependencies for GeoDjango
RUN apt-get update && apt-get install -y \
binutils libproj-dev gdal-bin libgdal-dev \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
# Install UV
RUN pip install uv
# Copy dependency files
COPY pyproject.toml uv.lock ./
RUN pip install uv
RUN uv sync --no-dev
# Install dependencies
RUN uv sync --frozen --no-dev
FROM python:3.13-slim as runtime
FROM python:3.11-slim as runtime
WORKDIR /app
# Install runtime dependencies for GeoDjango
RUN apt-get update && apt-get install -y \
libpq5 gdal-bin libgdal32 libgeos-c1v5 libproj25 \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual environment from builder
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
# Copy application code
COPY . .
# Collect static files
RUN python manage.py collectstatic --noinput
# Create logs directory
RUN mkdir -p logs
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
```
# Run with gunicorn
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
#### Dockerfile for Frontend
```dockerfile
# frontend/Dockerfile
FROM node:18-alpine as builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM nginx:alpine as runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
#### Docker Compose for Development
```yaml
# docker-compose.dev.yml
version: '3.8'
services:
db:
image: postgis/postgis:15-3.3
image: postgres:15
environment:
POSTGRES_DB: thrillwiki
POSTGRES_USER: thrillwiki
@@ -129,7 +117,7 @@ services:
ports:
- "6379:6379"
web:
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
@@ -140,40 +128,36 @@ services:
- ./shared/media:/app/media
environment:
- DEBUG=1
- DATABASE_URL=postgis://thrillwiki:password@db:5432/thrillwiki
- DATABASE_URL=postgresql://thrillwiki:password@db:5432/thrillwiki
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
command: python manage.py runserver 0.0.0.0:8000
celery:
frontend:
build:
context: ./backend
context: ./frontend
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- ./backend:/app
- ./frontend:/app
- /app/node_modules
environment:
- DATABASE_URL=postgis://thrillwiki:password@db:5432/thrillwiki
- REDIS_URL=redis://redis:6379/0
depends_on:
- db
- redis
command: celery -A config.celery worker -l info
- VITE_API_URL=http://localhost:8000
volumes:
postgres_data:
```
#### Docker Compose for Production
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
db:
image: postgis/postgis:15-3.3
image: postgres:15
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
@@ -186,7 +170,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
web:
backend:
build:
context: ./backend
dockerfile: Dockerfile
@@ -204,18 +188,10 @@ services:
- redis
restart: unless-stopped
celery:
frontend:
build:
context: ./backend
context: ./frontend
dockerfile: Dockerfile
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- SECRET_KEY=${SECRET_KEY}
depends_on:
- db
- redis
command: celery -A config.celery worker -l info
restart: unless-stopped
nginx:
@@ -229,7 +205,8 @@ services:
- static_files:/usr/share/nginx/html/static
- ./shared/media:/usr/share/nginx/html/media
depends_on:
- web
- backend
- frontend
restart: unless-stopped
volumes:
@@ -237,76 +214,21 @@ volumes:
static_files:
```
### Nginx Configuration
### 2. Static Site Generation (Alternative)
```nginx
# nginx/nginx.conf
upstream django {
server web:8000;
}
For sites with mostly static content, consider pre-rendering:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$server_name$request_uri;
}
```bash
# Frontend build with pre-rendering
cd frontend
pnpm run build:prerender
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Static files
location /static/ {
alias /usr/share/nginx/html/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Media files
location /media/ {
alias /usr/share/nginx/html/media/;
expires 1M;
add_header Cache-Control "public";
}
# Django application
location / {
proxy_pass http://django;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# HTMX considerations
proxy_set_header HX-Request $http_hx_request;
proxy_set_header HX-Current-URL $http_hx_current_url;
}
# Health check endpoint
location /api/v1/health/simple/ {
proxy_pass http://django;
proxy_set_header Host $http_host;
access_log off;
}
}
# Serve static files with minimal backend
```
## CI/CD Pipeline
### GitHub Actions Workflow
```yaml
# .github/workflows/deploy.yml
name: Deploy ThrillWiki
@@ -320,10 +242,10 @@ on:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:15-3.3
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
@@ -331,99 +253,171 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: '3.13'
python-version: '3.11'
- name: Install UV
run: pip install uv
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('backend/uv.lock') }}
- name: Install dependencies
run: |
cd backend
uv sync --frozen
- name: Run tests
- name: Backend Tests
run: |
cd backend
uv sync
uv run manage.py test
env:
DATABASE_URL: postgis://postgres:postgres@localhost:5432/postgres
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key
DEBUG: "1"
- name: Run linting
run: |
cd backend
uv run ruff check .
uv run flake8 .
uv run black --check .
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
run: npm install -g pnpm
- name: Frontend Tests
run: |
cd frontend
pnpm install --frozen-lockfile
pnpm run test
pnpm run lint
pnpm run type-check
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Build Docker image
- name: Build and push Docker images
run: |
docker build -t thrillwiki-web ./backend
- name: Push to registry
run: |
# Push to your container registry
# docker push your-registry/thrillwiki-web:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
docker build -t thrillwiki-backend ./backend
docker build -t thrillwiki-frontend ./frontend
# Push to registry
- name: Deploy to production
run: |
# Deploy using your preferred method
# SSH, Kubernetes, AWS ECS, etc.
# (AWS ECS, GCP Cloud Run, Azure Container Instances, etc.)
```
## Platform-Specific Deployments
### 1. Vercel Deployment (Frontend + API)
```json
// vercel.json
{
"version": 2,
"builds": [
{
"src": "frontend/package.json",
"use": "@vercel/static-build",
"config": {
"distDir": "dist"
}
},
{
"src": "backend/config/wsgi.py",
"use": "@vercel/python"
}
],
"routes": [
{
"src": "/api/(.*)",
"dest": "backend/config/wsgi.py"
},
{
"src": "/(.*)",
"dest": "frontend/dist/$1"
}
]
}
```
### 2. Railway Deployment
```toml
# railway.toml
[environments.production]
[environments.production.services.backend]
dockerfile = "backend/Dockerfile"
variables = { DEBUG = "0" }
[environments.production.services.frontend]
dockerfile = "frontend/Dockerfile"
[environments.production.services.postgres]
image = "postgres:15"
variables = { POSTGRES_DB = "thrillwiki" }
```
### 3. DigitalOcean App Platform
```yaml
# .do/app.yaml
name: thrillwiki
services:
- name: backend
source_dir: backend
github:
repo: your-username/thrillwiki-monorepo
branch: main
run_command: gunicorn config.wsgi:application
environment_slug: python
instance_count: 1
instance_size_slug: basic-xxs
envs:
- key: DEBUG
value: "0"
- name: frontend
source_dir: frontend
github:
repo: your-username/thrillwiki-monorepo
branch: main
build_command: pnpm run build
run_command: pnpm run preview
environment_slug: node-js
instance_count: 1
instance_size_slug: basic-xxs
databases:
- name: thrillwiki-db
engine: PG
version: "15"
```
## Environment Configuration
### Required Environment Variables
### Environment Variables
#### Backend (.env)
```bash
# Django Settings
DEBUG=0
SECRET_KEY=your-production-secret-key
SECRET_KEY=your-secret-key-here
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
CSRF_TRUSTED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
DJANGO_SETTINGS_MODULE=config.django.production
# Database
DATABASE_URL=postgis://user:password@host:port/database
DATABASE_URL=postgresql://user:password@host:port/database
# Redis
REDIS_URL=redis://host:port/0
# File Storage
MEDIA_ROOT=/app/media
STATIC_ROOT=/app/staticfiles
# Email
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.yourmailprovider.com
@@ -432,136 +426,162 @@ EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@yourdomain.com
EMAIL_HOST_PASSWORD=your-email-password
# Cloudflare Images
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-account-id
CLOUDFLARE_IMAGES_API_TOKEN=your-api-token
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-account-hash
# Sentry (optional)
# Third-party Services
SENTRY_DSN=your-sentry-dsn
SENTRY_ENVIRONMENT=production
AWS_ACCESS_KEY_ID=your-aws-key
AWS_SECRET_ACCESS_KEY=your-aws-secret
```
#### Frontend (.env.production)
```bash
VITE_API_URL=https://api.yourdomain.com
VITE_APP_TITLE=ThrillWiki
VITE_SENTRY_DSN=your-frontend-sentry-dsn
VITE_GOOGLE_ANALYTICS_ID=your-ga-id
```
## Performance Optimization
### Database Optimization
### Backend Optimizations
```python
# backend/config/django/production.py
# backend/config/settings/production.py
# Database optimization
DATABASES = {
'default': {
'ENGINE': 'django.contrib.gis.db.backends.postgis',
'CONN_MAX_AGE': 60, # Keep connections alive for 60 seconds
'ENGINE': 'django.db.backends.postgresql',
'CONN_MAX_AGE': 60,
'OPTIONS': {
'connect_timeout': 10,
'options': '-c statement_timeout=30000', # 30 second query timeout
'MAX_CONNS': 20,
}
}
}
# Caching
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
},
'KEY_PREFIX': 'thrillwiki'
}
}
# Static files with CDN
AWS_S3_CUSTOM_DOMAIN = 'cdn.yourdomain.com'
STATICFILES_STORAGE = 'storages.backends.s3boto3.StaticS3Boto3Storage'
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.MediaS3Boto3Storage'
```
### Redis Caching
```python
# Caching configuration is in config/django/production.py
# Multiple cache backends for different purposes:
# - default: General caching
# - sessions: Session storage
# - api: API response caching
```
### Static Files with WhiteNoise
```python
# backend/config/django/production.py
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
### Frontend Optimizations
```typescript
// frontend/vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['@headlessui/vue', '@heroicons/vue']
}
}
},
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
})
```
## Monitoring and Logging
### Health Check Endpoints
| Endpoint | Purpose | Use Case |
|----------|---------|----------|
| `/api/v1/health/` | Comprehensive health check | Monitoring dashboards |
| `/api/v1/health/simple/` | Simple OK/ERROR | Load balancer health checks |
| `/api/v1/health/performance/` | Performance metrics | Debug mode only |
### Logging Configuration
Production logging uses JSON format for log aggregation:
### Application Monitoring
```python
# backend/config/django/production.py
# backend/config/settings/production.py
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn="your-sentry-dsn",
integrations=[DjangoIntegration()],
traces_sample_rate=0.1,
send_default_pii=True
)
# Logging configuration
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'json',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/django.log',
'maxBytes': 1024 * 1024 * 15, # 15MB
'backupCount': 10,
'formatter': 'json',
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': '/var/log/django/thrillwiki.log',
},
},
'root': {
'handlers': ['file'],
},
}
```
### Sentry Integration
```python
# Sentry is configured in config/django/production.py
# Enable by setting SENTRY_DSN environment variable
```
### Infrastructure Monitoring
- Use Prometheus + Grafana for metrics
- Implement health check endpoints
- Set up log aggregation (ELK stack or similar)
- Monitor database performance
- Track API response times
## Security Considerations
### Production Security Checklist
- [ ] `DEBUG=False` in production
- [ ] `SECRET_KEY` is unique and secure
- [ ] `ALLOWED_HOSTS` properly configured
- [ ] HTTPS enforced with SSL certificates
- [ ] Security headers configured (HSTS, CSP, etc.)
- [ ] Database credentials secured
- [ ] Redis password configured (if exposed)
- [ ] Secret keys rotated regularly
- [ ] CORS properly configured
- [ ] Rate limiting enabled
- [ ] Rate limiting implemented
- [ ] File upload validation
- [ ] SQL injection protection (Django ORM)
- [ ] SQL injection protection
- [ ] XSS protection enabled
- [ ] CSRF protection active
### Security Headers
```python
# backend/config/django/production.py
# backend/config/settings/production.py
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
X_FRAME_OPTIONS = 'DENY'
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = 'DENY'
# CORS for API
CORS_ALLOWED_ORIGINS = [
"https://yourdomain.com",
"https://www.yourdomain.com",
]
```
## Backup and Recovery
### Database Backup Strategy
```bash
#!/bin/bash
# Automated backup script
#!/bin/bash
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
```
### Media Files Backup
```bash
# Sync media files to S3
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
@@ -570,60 +590,39 @@ aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
## Scaling Strategies
### Horizontal Scaling
- Use load balancer (nginx, AWS ALB, etc.)
- Database read replicas for read-heavy workloads
- CDN for static assets (Cloudflare, CloudFront)
- Redis cluster for session/cache scaling
- Multiple Gunicorn workers per container
- Load balancer configuration
- Database read replicas
- CDN for static assets
- Redis clustering
- Auto-scaling groups
### Vertical Scaling
- Database connection pooling (pgBouncer)
- Query optimization with select_related/prefetch_related
- Database connection pooling
- Application server optimization
- Memory usage optimization
- Background task offloading to Celery
- CPU-intensive task optimization
## Troubleshooting Guide
### Common Issues
1. **Static files not loading**
- Run `python manage.py collectstatic`
- Check nginx static file configuration
- Verify WhiteNoise settings
2. **Database connection errors**
- Verify DATABASE_URL format
- Check firewall rules
- Verify PostGIS extension is installed
3. **CORS errors**
- Check CORS_ALLOWED_ORIGINS setting
- Verify CSRF_TRUSTED_ORIGINS
4. **Memory issues**
- Monitor with `docker stats`
- Optimize Gunicorn worker count
- Check for query inefficiencies
1. **Build failures**: Check dependencies and environment variables
2. **Database connection errors**: Verify connection strings and firewall rules
3. **Static file 404s**: Ensure collectstatic runs and paths are correct
4. **CORS errors**: Check CORS configuration and allowed origins
5. **Memory issues**: Monitor application memory usage and optimize queries
### Debug Commands
```bash
# Check Django configuration
# Backend debugging
cd backend
uv run manage.py check --deploy
# Database shell
uv run manage.py shell
uv run manage.py dbshell
# Django shell
uv run manage.py shell
# Validate settings
uv run manage.py validate_settings
# Frontend debugging
cd frontend
pnpm run build --debug
pnpm run preview
```
---
This deployment guide provides a comprehensive approach to deploying the ThrillWiki Django + HTMX application while maintaining security, performance, and scalability.
This deployment guide provides a comprehensive approach to deploying the ThrillWiki monorepo across various platforms while maintaining security, performance, and scalability.

View File

@@ -1,42 +1,31 @@
# ==============================================================================
# DEPRECATED
# ==============================================================================
# This file is deprecated. Please use /.env.example in the project root instead.
#
# The root .env.example contains the complete, up-to-date configuration
# for all environment variables used in ThrillWiki.
#
# Migration steps:
# 1. Copy /.env.example to /.env (project root)
# 2. Fill in your actual values
# 3. Remove this backend/.env file if it exists
# ==============================================================================
# Minimal configuration for backward compatibility
# See /.env.example for complete documentation
# Django Configuration
SECRET_KEY=your-secret-key-here
DEBUG=True
DJANGO_SETTINGS_MODULE=config.django.local
# Database
DATABASE_URL=postgis://user:password@localhost:5432/thrillwiki
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
# Redis
REDIS_URL=redis://localhost:6379/1
REDIS_URL=redis://localhost:6379
# Required for Cloudflare Images
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
# Email Configuration (Optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
# Required for Road Trip Service
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
# Media and Static Files
MEDIA_URL=/media/
STATIC_URL=/static/
# Security (configure properly for production)
# Security
ALLOWED_HOSTS=localhost,127.0.0.1
# API Configuration
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Frontend
FRONTEND_DOMAIN=https://thrillwiki.com
# Feature Flags
ENABLE_DEBUG_TOOLBAR=True
ENABLE_SILK_PROFILER=False

View File

@@ -1,37 +0,0 @@
[flake8]
# Match Black and Ruff line length
max-line-length = 120
# Ignore rules that conflict with Black formatting or are handled by other tools
ignore =
# E203: whitespace before ':' - Black intentionally does this
E203,
# E501: line too long - handled by Black/Ruff
E501,
# W503: line break before binary operator - conflicts with Black
W503,
# E226: missing whitespace around arithmetic operator - Black style
E226,
# W391: blank line at end of file - not critical
W391,
# C901: function is too complex - these are intentional for complex business logic
C901,
# F401: imported but unused - star imports for choice registration are intentional
F401
# Exclude common directories
exclude =
.git,
__pycache__,
migrations,
.venv,
venv,
build,
dist,
*.egg-info,
node_modules,
htmlcov,
.pytest_cache
# Complexity threshold - set high since we have intentional complex functions
max-complexity = 50

2
backend/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# pixi environments
.pixi/*
!.pixi/config.toml

View File

@@ -1,70 +1,46 @@
# ThrillWiki Backend
Django application powering ThrillWiki - a comprehensive theme park and roller coaster information system.
Django REST API backend for the ThrillWiki monorepo.
## Architecture
## 🏗️ Architecture
ThrillWiki is a **Django monolith with HTMX-driven templates**, providing:
- **Server-side rendering** with Django templates
- **HTMX** for dynamic partial updates without full page reloads
- **REST API** for programmatic access (mobile apps, integrations)
- **Alpine.js** for minimal client-side state (form validation, UI toggles)
This backend follows Django best practices with a modular app structure:
```
backend/
├── apps/ # Django applications
│ ├── accounts/ # User authentication and profiles
│ ├── api/v1/ # REST API endpoints
│ ├── core/ # Shared utilities, managers, services
│ ├── location/ # Geographic data and services
│ ├── media/ # Cloudflare Images integration
│ ├── moderation/ # Content moderation workflows
│ ├── parks/ # Theme park models and views
│ └── rides/ # Ride information and statistics
├── config/ # Django configuration
│ ├── django/ # Environment-specific settings
│ ├── base.py # Core settings
│ │ ├── local.py # Development overrides
├── production.py # Production overrides
│ │ └── test.py # Test overrides
│ └── settings/ # Modular settings modules
│ ├── cache.py # Redis caching
│ ├── database.py # Database and GeoDjango
│ ├── email.py # Email configuration
│ ├── logging.py # Logging setup
│ ├── rest_framework.py # DRF, JWT, CORS
│ ├── security.py # Security headers
│ └── storage.py # Static/media files
├── templates/ # Django templates with HTMX
│ ├── components/ # Reusable UI components
│ ├── htmx/ # HTMX partial templates
│ └── layouts/ # Base layout templates
├── static/ # Static assets
└── tests/ # Test files
├── apps/ # Django applications
│ ├── accounts/ # User management
│ ├── parks/ # Theme park data
│ ├── rides/ # Ride information
│ ├── moderation/ # Content moderation
│ ├── location/ # Geographic data
│ ├── media/ # File management
│ ├── email_service/ # Email functionality
│ └── core/ # Core utilities
├── config/ # Django configuration
│ ├── django/ # Settings files
└── settings/ # Modular settings
├── templates/ # Django templates
├── static/ # Static files
└── tests/ # Test files
```
## Technology Stack
## 🛠️ Technology Stack
| Technology | Version | Purpose |
|------------|---------|---------|
| **Django** | 5.2.8+ | Web framework (security patched) |
| **Django REST Framework** | 3.15.2+ | API framework (security patched) |
| **HTMX** | 1.20.0+ | Dynamic UI updates |
| **Alpine.js** | 3.x | Minimal client-side state |
| **Tailwind CSS** | 3.x | Utility-first styling |
| **PostgreSQL/PostGIS** | 14+ | Database with geospatial support |
| **Redis** | 6+ | Caching and sessions |
| **Celery** | 5.5+ | Background task processing |
| **UV** | Latest | Python package management |
- **Django 5.0+** - Web framework
- **Django REST Framework** - API framework
- **PostgreSQL** - Primary database
- **Redis** - Caching and sessions
- **UV** - Python package management
- **Celery** - Background task processing
## Quick Start
## 🚀 Quick Start
### Prerequisites
- Python 3.13+
- Python 3.11+
- [uv](https://docs.astral.sh/uv/) package manager
- PostgreSQL 14+ with PostGIS extension
- PostgreSQL 14+
- Redis 6+
### Setup
@@ -72,8 +48,7 @@ backend/
1. **Install dependencies**
```bash
cd backend
uv sync --frozen # Use locked versions for reproducibility
# Or: uv sync # Allow updates within version constraints
uv sync
```
2. **Environment configuration**
@@ -93,182 +68,75 @@ backend/
uv run manage.py runserver
```
The application will be available at `http://localhost:8000`.
## HTMX Patterns
ThrillWiki uses HTMX for server-driven interactivity. Key patterns:
### Partial Templates
Views render partial templates for HTMX requests:
```python
# In views.py
def park_list(request):
parks = Park.objects.optimized_for_list()
template = "parks/partials/park_list.html" if request.htmx else "parks/park_list.html"
return render(request, template, {"parks": parks})
```
### HX-Trigger Events
Cross-component communication via custom events:
```html
<!-- Trigger event after action -->
<button hx-post="/parks/1/favorite/"
hx-trigger="click"
hx-swap="none"
hx-headers='{"HX-Trigger-After-Settle": "parkFavorited"}'>
Favorite
</button>
<!-- Listen for event -->
<div hx-get="/parks/favorites/"
hx-trigger="parkFavorited from:body">
<!-- Updated on event -->
</div>
```
### Loading Indicators
Skeleton loaders for better UX:
```html
<div hx-get="/parks/" hx-trigger="load" hx-indicator="#loading">
<div id="loading" class="htmx-indicator">
{% include "components/skeleton_loader.html" %}
</div>
</div>
```
### Field-Level Validation
Real-time form validation:
```html
<input name="email"
hx-post="/validate/email/"
hx-trigger="blur changed delay:500ms"
hx-target="next .error-message">
<span class="error-message"></span>
```
See [HTMX Patterns](../docs/htmx-patterns.md) for complete documentation.
## Hybrid API/HTML Endpoints
Many views serve dual purposes through content negotiation:
```python
class ParkDetailView(HybridViewMixin, DetailView):
"""
Returns HTML for browser requests, JSON for API requests.
Browser: GET /parks/cedar-point/ -> HTML template
API: GET /api/v1/parks/cedar-point/ -> JSON response
"""
model = Park
template_name = "parks/park_detail.html"
serializer_class = ParkSerializer
```
This approach:
- Reduces code duplication
- Ensures API and web views stay in sync
- Supports both HTMX partials and JSON responses
## Configuration
### Settings Architecture
ThrillWiki uses modular settings for maintainability:
```
config/
├── django/ # Environment-specific settings
│ ├── base.py # Core settings (imports modular settings)
│ ├── local.py # Development overrides
│ ├── production.py # Production overrides
│ └── test.py # Test overrides
├── settings/ # Modular settings
│ ├── cache.py # Redis caching
│ ├── database.py # Database and GeoDjango
│ ├── email.py # Email configuration
│ ├── logging.py # Logging setup
│ ├── rest_framework.py # DRF, JWT, CORS
│ ├── secrets.py # Secret management
│ ├── security.py # Security headers
│ ├── storage.py # Static/media files
│ ├── third_party.py # Allauth, Celery, etc.
│ └── validation.py # Settings validation
└── celery.py # Celery configuration
```
Validate configuration with:
```bash
uv run manage.py validate_settings
```
## 🔧 Configuration
### Environment Variables
Key environment variables:
Required environment variables:
| Variable | Description | Required |
|----------|-------------|----------|
| `SECRET_KEY` | Django secret key | Yes |
| `DEBUG` | Debug mode (True/False) | Yes |
| `DATABASE_URL` | PostgreSQL connection URL | Yes |
| `REDIS_URL` | Redis connection URL | Production |
| `DJANGO_SETTINGS_MODULE` | Settings module to use | Yes |
```bash
# Database
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
See [Environment Variables](../docs/configuration/environment-variables.md) for complete reference.
# Django
SECRET_KEY=your-secret-key
DEBUG=True
DJANGO_SETTINGS_MODULE=config.django.local
## Apps Overview
# Redis
REDIS_URL=redis://localhost:6379
# Email (optional)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
```
### Settings Structure
- `config/django/base.py` - Base settings
- `config/django/local.py` - Development settings
- `config/django/production.py` - Production settings
- `config/django/test.py` - Test settings
## 📁 Apps Overview
### Core Apps
| App | Description |
|-----|-------------|
| **accounts** | User authentication, profiles, social auth (Google, Discord) |
| **parks** | Theme park models, views, and operations |
| **rides** | Ride models, coaster statistics, ride history |
| **core** | Shared utilities, managers, services, middleware |
- **accounts** - User authentication and profile management
- **parks** - Theme park models and operations
- **rides** - Ride information and relationships
- **core** - Shared utilities and base classes
### Support Apps
| App | Description |
|-----|-------------|
| **api/v1** | REST API endpoints with OpenAPI documentation |
| **moderation** | Content moderation workflows and queue |
| **location** | Geographic data, geocoding, map services |
| **media** | Cloudflare Images integration |
- **moderation** - Content moderation workflows
- **location** - Geographic data and services
- **media** - File upload and management
- **email_service** - Email sending and templates
## API Endpoints
## 🔌 API Endpoints
Base URL: `http://localhost:8000/api/v1/`
Base URL: `http://localhost:8000/api/`
### Interactive Documentation
### Authentication
- `POST /auth/login/` - User login
- `POST /auth/logout/` - User logout
- `POST /auth/register/` - User registration
- **Swagger UI**: `/api/docs/`
- **ReDoc**: `/api/redoc/`
- **OpenAPI Schema**: `/api/schema/`
### Parks
- `GET /parks/` - List parks
- `GET /parks/{id}/` - Park details
- `POST /parks/` - Create park (admin)
### Core Endpoints
### Rides
- `GET /rides/` - List rides
- `GET /rides/{id}/` - Ride details
- `GET /parks/{park_id}/rides/` - Rides by park
| Endpoint | Description |
|----------|-------------|
| `/api/v1/auth/` | Authentication (login, signup, social auth) |
| `/api/v1/parks/` | Theme park CRUD and filtering |
| `/api/v1/rides/` | Ride CRUD and filtering |
| `/api/v1/accounts/` | User profile and settings |
| `/api/v1/maps/` | Map data and location services |
| `/api/v1/health/` | Health check endpoints |
See [API Documentation](../docs/THRILLWIKI_API_DOCUMENTATION.md) for complete reference.
## Testing
## 🧪 Testing
```bash
# Run all tests
@@ -276,242 +144,34 @@ uv run manage.py test
# Run specific app tests
uv run manage.py test apps.parks
uv run manage.py test apps.rides
# Run with coverage
uv run coverage run manage.py test
uv run coverage report
# Run accessibility tests
uv run manage.py test backend.tests.accessibility
```
## Management Commands
## 🔧 Management Commands
ThrillWiki provides numerous management commands for development, deployment, and maintenance.
### Configuration & Validation
Custom management commands:
```bash
# Validate all settings and environment variables
uv run manage.py validate_settings
uv run manage.py validate_settings --strict # Treat warnings as errors
uv run manage.py validate_settings --json # JSON output
uv run manage.py validate_settings --secrets-only # Only validate secrets
# Import park data
uv run manage.py import_parks data/parks.json
# Validate state machine configurations
uv run manage.py validate_state_machines
# Generate test data
uv run manage.py generate_test_data
# List all FSM transition callbacks
uv run manage.py list_transition_callbacks
# Clean up expired sessions
uv run manage.py clearsessions
```
### Database Operations
```bash
# Standard Django commands
uv run manage.py migrate
uv run manage.py makemigrations
uv run manage.py showmigrations
uv run manage.py createsuperuser
# Fix migration history issues
uv run manage.py fix_migrations
uv run manage.py fix_migration_history
# Reset database (DESTRUCTIVE - development only)
uv run manage.py reset_db
```
### Cache Management
```bash
# Warm cache with frequently accessed data
uv run manage.py warm_cache
uv run manage.py warm_cache --parks-only
uv run manage.py warm_cache --rides-only
uv run manage.py warm_cache --metadata-only
uv run manage.py warm_cache --dry-run # Preview without caching
# Clear all caches
uv run manage.py clear_cache
```
### Data Management
```bash
# Seed initial data (operators, manufacturers, etc.)
uv run manage.py seed_initial_data
# Create sample data for development
uv run manage.py create_sample_data
uv run manage.py create_sample_data --minimal # Quick setup
uv run manage.py create_sample_data --clear # Clear existing first
# Seed sample parks and rides
uv run manage.py seed_sample_data
# Seed test submissions for moderation
uv run manage.py seed_submissions
# Seed API test data
uv run manage.py seed_data
# Update park statistics (ride counts, ratings)
uv run manage.py update_park_counts
# Update ride rankings
uv run manage.py update_ride_rankings
```
### User & Authentication
```bash
# Create test users
uv run manage.py create_test_users
# Delete user and all related data
uv run manage.py delete_user <username>
# Setup user groups and permissions
uv run manage.py setup_groups
# Setup Django sites framework
uv run manage.py setup_site
# Social authentication setup
uv run manage.py setup_social_auth
uv run manage.py setup_social_providers
uv run manage.py create_social_apps
uv run manage.py check_social_apps
uv run manage.py fix_social_apps
uv run manage.py reset_social_apps
uv run manage.py reset_social_auth
uv run manage.py cleanup_social_auth
uv run manage.py update_social_apps_sites
uv run manage.py verify_discord_settings
uv run manage.py test_discord_auth
uv run manage.py check_all_social_tables
uv run manage.py setup_social_auth_admin
# Avatar management
uv run manage.py generate_letter_avatars
uv run manage.py regenerate_avatars
```
### Content & Media
```bash
# Static file management
uv run manage.py collectstatic
uv run manage.py optimize_static # Minify and compress
# Media file management (in shared/media/)
uv run manage.py download_photos
uv run manage.py move_photos
uv run manage.py fix_photo_paths
```
### Trending & Discovery
```bash
# Calculate trending content
uv run manage.py calculate_trending
uv run manage.py update_trending
uv run manage.py test_trending
# Calculate new content for discovery
uv run manage.py calculate_new_content
```
### Testing & Development
```bash
# Run development server with auto-reload
uv run manage.py rundev
# Setup development environment
uv run manage.py setup_dev
# Test location services
uv run manage.py test_location
# Test FSM transition callbacks
uv run manage.py test_transition_callbacks
# Analyze FSM transitions
uv run manage.py analyze_transitions
# Cleanup test data
uv run manage.py cleanup_test_data
```
### Security & Auditing
```bash
# Run security audit
uv run manage.py security_audit
```
### Command Categories
| Category | Commands |
|----------|----------|
| **Configuration** | validate_settings, validate_state_machines, list_transition_callbacks |
| **Database** | migrate, makemigrations, reset_db, fix_migrations |
| **Cache** | warm_cache, clear_cache |
| **Data** | seed_initial_data, create_sample_data, update_park_counts, update_ride_rankings |
| **Users** | create_test_users, delete_user, setup_groups, setup_social_auth |
| **Media** | collectstatic, optimize_static, download_photos, move_photos |
| **Trending** | calculate_trending, update_trending, calculate_new_content |
| **Development** | rundev, setup_dev, test_location, cleanup_test_data |
| **Security** | security_audit |
### Common Workflows
#### Initial Setup
```bash
uv run manage.py migrate
uv run manage.py createsuperuser
uv run manage.py setup_groups
uv run manage.py seed_initial_data
uv run manage.py create_sample_data --minimal
uv run manage.py warm_cache
```
#### Development Reset
```bash
uv run manage.py reset_db
uv run manage.py migrate
uv run manage.py create_sample_data
uv run manage.py warm_cache
```
#### Production Deployment
```bash
uv run manage.py migrate
uv run manage.py collectstatic --noinput
uv run manage.py validate_settings --strict
uv run manage.py warm_cache
```
#### Cache Refresh
```bash
uv run manage.py clear_cache
uv run manage.py warm_cache
uv run manage.py calculate_trending
```
See [Management Commands Reference](../docs/MANAGEMENT_COMMANDS.md) for complete documentation.
## Database
## 📊 Database
### Entity Relationships
- **Parks** have Operators (required) and PropertyOwners (optional)
- **Rides** belong to Parks and may have Manufacturers/Designers
- **Users** can create submissions and moderate content
- **Reviews** are linked to Parks or Rides with user attribution
### Migrations
@@ -526,51 +186,44 @@ uv run manage.py migrate
uv run manage.py showmigrations
```
## Security
## 🔐 Security
Security features implemented:
- CORS configured for frontend integration
- CSRF protection enabled
- JWT token authentication
- Rate limiting on API endpoints
- Input validation and sanitization
- **CORS** configured for API access
- **CSRF** protection enabled
- **JWT** token authentication for API
- **Session** authentication for web
- **Rate limiting** on API endpoints
- **Input validation** and sanitization
- **Security headers** (HSTS, CSP, etc.)
## 📈 Performance
## Performance
- Database query optimization
- Redis caching for frequent queries
- Background task processing with Celery
- Database connection pooling
Performance optimizations:
## 🚀 Deployment
- **Database query optimization** with custom managers
- **Redis caching** for frequent queries
- **Background tasks** with Celery
- **Connection pooling** for database
- **HTMX partials** for minimal data transfer
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
## Debugging
## 🐛 Debugging
### Development Tools
- **Django Debug Toolbar** - Request/response inspection
- **Django Extensions** - Additional management commands
- **Silk profiler** - Performance analysis
- Django Debug Toolbar
- Django Extensions
- Silk profiler for performance analysis
### Logging
Logs are written to:
- Console (development)
- Files in `logs/` directory (production)
- Sentry (production, if configured)
- External logging service (production)
## Contributing
## 🤝 Contributing
1. Follow Django coding standards
2. Write tests for new features
3. Update documentation
4. Run linting: `uv run ruff check .`
5. Format code: `uv run black .`
---
See [Main Documentation](../docs/README.md) for complete project documentation.
4. Run linting: `uv run flake8 .`
5. Format code: `uv run black .`

View File

@@ -1,73 +0,0 @@
# Independent Verification Commands
Run these commands yourself to verify ALL tuple fallbacks have been eliminated:
## 1. Search for the most common tuple fallback patterns:
```bash
# Search for choices.get(value, fallback) patterns
grep -r "choices\.get(" apps/ --include="*.py" | grep -v migration
# Search for status_*.get(value, fallback) patterns
grep -r "status_.*\.get(" apps/ --include="*.py" | grep -v migration
# Search for category_*.get(value, fallback) patterns
grep -r "category_.*\.get(" apps/ --include="*.py" | grep -v migration
# Search for sla_hours.get(value, fallback) patterns
grep -r "sla_hours\.get(" apps/ --include="*.py"
# Search for the removed functions
grep -r "get_tuple_choices\|from_tuple\|convert_tuple_choices" apps/ --include="*.py" | grep -v migration
```
**Expected result: ALL commands should return NOTHING (empty results)**
## 2. Verify the removed function is actually gone:
```bash
# This should fail with ImportError
python -c "from apps.core.choices.registry import get_tuple_choices; print('ERROR: Function still exists!')"
# This should work
python -c "from apps.core.choices.registry import get_choices; print('SUCCESS: Rich Choice objects work')"
```
## 3. Verify Django system integrity:
```bash
python manage.py check
```
**Expected result: Should pass with no errors**
## 4. Manual spot check of previously problematic files:
```bash
# Check rides events (previously had 3 fallbacks)
grep -n "\.get(" apps/rides/events.py | grep -E "(choice|status|category)"
# Check template tags (previously had 2 fallbacks)
grep -n "\.get(" apps/rides/templatetags/ride_tags.py | grep -E "(choice|category|image)"
# Check admin (previously had 2 fallbacks)
grep -n "\.get(" apps/rides/admin.py | grep -E "(choice|category)"
# Check moderation (previously had 3 SLA fallbacks)
grep -n "sla_hours\.get(" apps/moderation/
```
**Expected result: ALL should return NOTHING**
## 5. Run the verification script:
```bash
python verify_no_tuple_fallbacks.py
```
**Expected result: Should print "SUCCESS: ALL TUPLE FALLBACKS HAVE BEEN ELIMINATED!"**
---
If ANY of these commands find tuple fallbacks, then I was wrong.
If ALL commands return empty/success, then ALL tuple fallbacks have been eliminated.

View File

@@ -1,2 +0,0 @@
# Import choices to trigger registration
from .choices import * # noqa: F403

View File

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

View File

@@ -1,65 +1,29 @@
"""
Django admin configuration for the Accounts application.
This module provides comprehensive admin interfaces for managing users,
profiles, email verification, password resets, and top lists. All admin
classes use optimized querysets and follow the standardized admin patterns.
Performance targets:
- List views: < 10 queries
- Change views: < 15 queries
- Page load time: < 500ms for 100 records
"""
from datetime import timedelta
from django.contrib import admin, messages
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group
from django.utils import timezone
from django.utils.html import format_html
from apps.core.admin import (
BaseModelAdmin,
ExportActionMixin,
QueryOptimizationMixin,
ReadOnlyAdminMixin,
)
from django.contrib.auth.models import Group
from .models import (
EmailVerification,
PasswordReset,
User,
UserProfile,
EmailVerification,
PasswordReset,
TopList,
TopListItem,
)
class UserProfileInline(admin.StackedInline):
"""
Inline admin for UserProfile within User admin.
Displays profile information including social media and ride credits.
"""
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
classes = ("collapse",)
fieldsets = (
(
"Personal Info",
{
"fields": ("display_name", "avatar", "pronouns", "bio"),
"description": "User's public profile information.",
},
{"fields": ("display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{
"fields": ("twitter", "instagram", "youtube", "discord"),
"classes": ("collapse",),
"description": "Social media account links.",
},
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
@@ -69,40 +33,30 @@ class UserProfileInline(admin.StackedInline):
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
),
"classes": ("collapse",),
"description": "User's ride credit counts by category.",
)
},
),
)
class TopListItemInline(admin.TabularInline):
model = TopListItem
extra = 1
fields = ("content_type", "object_id", "rank", "notes")
ordering = ("rank",)
@admin.register(User)
class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
"""
Admin interface for User management.
Provides comprehensive user administration with:
- Optimized queries using select_related/prefetch_related
- Bulk actions for user status management
- Profile inline editing
- Role and permission management
- Ban/moderation controls
Query optimizations:
- select_related: profile
- prefetch_related: groups, user_permissions, top_lists
"""
class CustomUserAdmin(UserAdmin):
list_display = (
"username",
"email",
"get_avatar",
"get_status_badge",
"get_status",
"role",
"date_joined",
"last_login",
"get_total_credits",
"get_credits",
)
list_filter = (
"is_active",
@@ -111,81 +65,50 @@ class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
"is_banned",
"groups",
"date_joined",
"last_login",
)
list_select_related = ["profile"]
list_prefetch_related = ["groups"]
search_fields = ("username", "email", "profile__display_name")
search_fields = ("username", "email")
ordering = ("-date_joined",)
date_hierarchy = "date_joined"
inlines = [UserProfileInline]
export_fields = ["id", "username", "email", "role", "is_active", "date_joined", "last_login"]
export_filename_prefix = "users"
actions = [
"activate_users",
"deactivate_users",
"ban_users",
"unban_users",
"send_verification_email",
"recalculate_credits",
]
inlines = [UserProfileInline]
fieldsets = (
(
None,
{
"fields": ("username", "password"),
"description": "Core authentication credentials.",
},
),
(
"Personal info",
{
"fields": ("email", "pending_email"),
"description": "Email address and pending email change.",
},
),
(None, {"fields": ("username", "password")}),
("Personal info", {"fields": ("email", "pending_email")}),
(
"Roles and Permissions",
{
"fields": ("role", "groups", "user_permissions"),
"description": "Role determines group membership. Groups determine permissions.",
"description": (
"Role determines group membership. Groups determine permissions."
),
},
),
(
"Status",
{
"fields": ("is_active", "is_staff", "is_superuser"),
"description": "Account status flags. These may be managed based on role.",
"description": "These are automatically managed based on role.",
},
),
(
"Ban Status",
{
"fields": ("is_banned", "ban_reason", "ban_date"),
"classes": ("collapse",),
"description": "Moderation controls for banning users.",
},
),
(
"Preferences",
{
"fields": ("theme_preference",),
"classes": ("collapse",),
"description": "User preferences for site display.",
},
),
(
"Important dates",
{
"fields": ("last_login", "date_joined"),
"classes": ("collapse",),
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
@@ -198,205 +121,104 @@ class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
"password2",
"role",
),
"description": "Create a new user account.",
},
),
)
@admin.display(description="Avatar")
def get_avatar(self, obj):
"""Display user avatar or initials."""
try:
if obj.profile and obj.profile.avatar:
return format_html(
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url,
)
except UserProfile.DoesNotExist:
pass
if obj.profile.avatar:
return format_html(
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
obj.profile.avatar.url,
)
return format_html(
'<div style="width:30px; height:30px; border-radius:50%; '
"background-color:#007bff; color:white; display:flex; "
'align-items:center; justify-content:center; font-size:12px;">{}</div>',
obj.username[0].upper() if obj.username else "?",
'align-items:center; justify-content:center;">{}</div>',
obj.username[0].upper(),
)
@admin.display(description="Status")
def get_status_badge(self, obj):
"""Display status with color-coded badge."""
def get_status(self, obj):
if obj.is_banned:
return format_html(
'<span style="background-color: red; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Banned</span>'
)
return format_html('<span style="color: red;">Banned</span>')
if not obj.is_active:
return format_html(
'<span style="background-color: orange; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Inactive</span>'
)
return format_html('<span style="color: orange;">Inactive</span>')
if obj.is_superuser:
return format_html(
'<span style="background-color: purple; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Superuser</span>'
)
return format_html('<span style="color: purple;">Superuser</span>')
if obj.is_staff:
return format_html(
'<span style="background-color: blue; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Staff</span>'
)
return format_html(
'<span style="background-color: green; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Active</span>'
)
return format_html('<span style="color: blue;">Staff</span>')
return format_html('<span style="color: green;">Active</span>')
@admin.display(description="Credits")
def get_total_credits(self, obj):
"""Display total ride credits."""
@admin.display(description="Ride Credits")
def get_credits(self, obj):
try:
profile = obj.profile
total = (
(profile.coaster_credits or 0)
+ (profile.dark_ride_credits or 0)
+ (profile.flat_ride_credits or 0)
+ (profile.water_ride_credits or 0)
)
return format_html(
'<span title="RC:{} DR:{} FR:{} WR:{}">{}</span>',
profile.coaster_credits or 0,
profile.dark_ride_credits or 0,
profile.flat_ride_credits or 0,
profile.water_ride_credits or 0,
total,
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
profile.coaster_credits,
profile.dark_ride_credits,
profile.flat_ride_credits,
profile.water_ride_credits,
)
except UserProfile.DoesNotExist:
return "-"
def get_queryset(self, request):
"""Optimize queryset with profile select_related."""
qs = super().get_queryset(request)
if self.list_select_related:
qs = qs.select_related(*self.list_select_related)
if self.list_prefetch_related:
qs = qs.prefetch_related(*self.list_prefetch_related)
return qs
@admin.action(description="Activate selected users")
def activate_users(self, request, queryset):
"""Activate selected user accounts."""
updated = queryset.update(is_active=True)
self.message_user(request, f"Successfully activated {updated} users.")
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request, queryset):
"""Deactivate selected user accounts."""
# Prevent deactivating self
queryset = queryset.exclude(pk=request.user.pk)
updated = queryset.update(is_active=False)
self.message_user(request, f"Successfully deactivated {updated} users.")
queryset.update(is_active=False)
@admin.action(description="Ban selected users")
def ban_users(self, request, queryset):
"""Ban selected users."""
# Prevent banning self or superusers
queryset = queryset.exclude(pk=request.user.pk).exclude(is_superuser=True)
updated = queryset.update(is_banned=True, ban_date=timezone.now())
self.message_user(request, f"Successfully banned {updated} users.")
from django.utils import timezone
queryset.update(is_banned=True, ban_date=timezone.now())
@admin.action(description="Unban selected users")
def unban_users(self, request, queryset):
"""Remove ban from selected users."""
updated = queryset.update(is_banned=False, ban_date=None, ban_reason="")
self.message_user(request, f"Successfully unbanned {updated} users.")
@admin.action(description="Send verification email")
def send_verification_email(self, request, queryset):
"""Send verification email to selected users."""
count = 0
for user in queryset:
# Only send to users without verified email
if not user.is_active:
count += 1
self.message_user(
request,
f"Verification emails queued for {count} users.",
level=messages.INFO,
)
@admin.action(description="Recalculate ride credits")
def recalculate_credits(self, request, queryset):
"""Recalculate ride credits for selected users."""
count = 0
for user in queryset:
try:
profile = user.profile
# Credits would be recalculated from ride history here
profile.save(
update_fields=["coaster_credits", "dark_ride_credits", "flat_ride_credits", "water_ride_credits"]
)
count += 1
except UserProfile.DoesNotExist:
pass
self.message_user(request, f"Recalculated credits for {count} users.")
queryset.update(is_banned=False, ban_date=None, ban_reason="")
def save_model(self, request, obj, form, change):
"""Handle role-based group assignment on save."""
creating = not obj.pk
super().save_model(request, obj, form, change)
if creating and obj.role != User.Roles.USER:
# Ensure new user with role gets added to appropriate group
group = Group.objects.filter(name=obj.role).first()
if group:
obj.groups.add(group)
@admin.register(UserProfile)
class UserProfileAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
"""
Admin interface for UserProfile management.
Manages user profile data separately from User admin.
Useful for managing profile-specific data and bulk operations.
"""
class UserProfileAdmin(admin.ModelAdmin):
list_display = (
"user_link",
"display_name",
"total_credits",
"has_social_media",
"profile_completeness",
)
list_filter = (
"user__role",
"user__is_active",
)
list_select_related = ["user"]
search_fields = ("user__username", "user__email", "display_name", "bio")
autocomplete_fields = ["user"]
export_fields = [
"user",
"display_name",
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
]
export_filename_prefix = "user_profiles"
)
list_filter = (
"coaster_credits",
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
)
search_fields = ("user__username", "user__email", "display_name", "bio")
fieldsets = (
(
"User Information",
{
"fields": ("user", "display_name", "avatar", "pronouns", "bio"),
"description": "Basic profile information.",
},
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
),
(
"Social Media",
{
"fields": ("twitter", "instagram", "youtube", "discord"),
"classes": ("collapse",),
"description": "Social media profile links.",
},
{"fields": ("twitter", "instagram", "youtube", "discord")},
),
(
"Ride Credits",
@@ -406,195 +228,93 @@ class UserProfileAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
),
"description": "Ride credit counts by category.",
)
},
),
)
@admin.display(description="User")
def user_link(self, obj):
"""Display user as clickable link."""
if obj.user:
from django.urls import reverse
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return "-"
@admin.display(description="Total Credits")
def total_credits(self, obj):
"""Display total ride credits."""
total = (
(obj.coaster_credits or 0)
+ (obj.dark_ride_credits or 0)
+ (obj.flat_ride_credits or 0)
+ (obj.water_ride_credits or 0)
)
return total
@admin.display(description="Social", boolean=True)
def has_social_media(self, obj):
"""Indicate if user has social media links."""
return any([obj.twitter, obj.instagram, obj.youtube, obj.discord])
@admin.display(description="Completeness")
def profile_completeness(self, obj):
"""Display profile completeness indicator."""
fields_filled = sum(
[
bool(obj.display_name),
bool(obj.avatar),
bool(obj.bio),
bool(obj.twitter or obj.instagram or obj.youtube or obj.discord),
]
)
percentage = (fields_filled / 4) * 100
color = "green" if percentage >= 75 else "orange" if percentage >= 50 else "red"
return format_html(
'<span style="color: {};">{}%</span>',
color,
int(percentage),
)
@admin.action(description="Recalculate ride credits")
def recalculate_credits(self, request, queryset):
"""Recalculate ride credits for selected profiles."""
count = queryset.count()
for profile in queryset:
# Credits would be recalculated from ride history here
profile.save()
self.message_user(request, f"Recalculated credits for {count} profiles.")
def get_actions(self, request):
"""Add custom actions."""
actions = super().get_actions(request)
actions["recalculate_credits"] = (
self.recalculate_credits,
"recalculate_credits",
"Recalculate ride credits",
)
return actions
@admin.register(EmailVerification)
class EmailVerificationAdmin(QueryOptimizationMixin, BaseModelAdmin):
"""
Admin interface for email verification tokens.
Manages email verification tokens with expiration tracking
and bulk resend capabilities.
"""
list_display = (
"user_link",
"created_at",
"last_sent",
"expiration_status",
"can_resend",
)
class EmailVerificationAdmin(admin.ModelAdmin):
list_display = ("user", "created_at", "last_sent", "is_expired")
list_filter = ("created_at", "last_sent")
list_select_related = ["user"]
search_fields = ("user__username", "user__email", "token")
readonly_fields = ("token", "created_at", "last_sent")
autocomplete_fields = ["user"]
readonly_fields = ("created_at", "last_sent")
fieldsets = (
("Verification Details", {"fields": ("user", "token")}),
("Timing", {"fields": ("created_at", "last_sent")}),
)
@admin.display(description="Status")
def is_expired(self, obj):
from django.utils import timezone
from datetime import timedelta
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
@admin.register(TopList)
class TopListAdmin(admin.ModelAdmin):
list_display = ("title", "user", "category", "created_at", "updated_at")
list_filter = ("category", "created_at", "updated_at")
search_fields = ("title", "user__username", "description")
inlines = [TopListItemInline]
fieldsets = (
(
"Verification Details",
{
"fields": ("user", "token"),
"description": "User and verification token.",
},
"Basic Information",
{"fields": ("user", "title", "category", "description")},
),
(
"Timing",
{
"fields": ("created_at", "last_sent"),
"description": "When the token was created and last sent.",
},
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
readonly_fields = ("created_at", "updated_at")
@admin.display(description="User")
def user_link(self, obj):
"""Display user as clickable link."""
if obj.user:
from django.urls import reverse
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return "-"
@admin.register(TopListItem)
class TopListItemAdmin(admin.ModelAdmin):
list_display = ("top_list", "content_type", "object_id", "rank")
list_filter = ("top_list__category", "rank")
search_fields = ("top_list__title", "notes")
ordering = ("top_list", "rank")
@admin.display(description="Status")
def expiration_status(self, obj):
"""Display expiration status with color coding."""
if timezone.now() - obj.last_sent > timedelta(days=1):
return format_html('<span style="color: red; font-weight: bold;">Expired</span>')
return format_html('<span style="color: green; font-weight: bold;">Valid</span>')
@admin.display(description="Can Resend", boolean=True)
def can_resend(self, obj):
"""Indicate if email can be resent (rate limited)."""
# Can resend if last sent more than 5 minutes ago
return timezone.now() - obj.last_sent > timedelta(minutes=5)
@admin.action(description="Resend verification email")
def resend_verification(self, request, queryset):
"""Resend verification emails."""
count = 0
for verification in queryset:
if timezone.now() - verification.last_sent > timedelta(minutes=5):
verification.last_sent = timezone.now()
verification.save(update_fields=["last_sent"])
count += 1
self.message_user(request, f"Resent {count} verification emails.")
@admin.action(description="Delete expired tokens")
def delete_expired(self, request, queryset):
"""Delete expired verification tokens."""
cutoff = timezone.now() - timedelta(days=1)
expired = queryset.filter(last_sent__lt=cutoff)
count = expired.count()
expired.delete()
self.message_user(request, f"Deleted {count} expired tokens.")
def get_actions(self, request):
"""Add custom actions."""
actions = super().get_actions(request)
actions["resend_verification"] = (
self.resend_verification,
"resend_verification",
"Resend verification email",
)
actions["delete_expired"] = (
self.delete_expired,
"delete_expired",
"Delete expired tokens",
)
return actions
fieldsets = (
("List Information", {"fields": ("top_list", "rank")}),
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
)
@admin.register(PasswordReset)
class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
"""
Admin interface for password reset tokens.
Read-only admin for viewing password reset tokens.
Tokens should not be manually created or modified.
"""
class PasswordResetAdmin(admin.ModelAdmin):
"""Admin interface for password reset tokens"""
list_display = (
"user_link",
"user",
"created_at",
"expires_at",
"status_badge",
"is_expired",
"used",
)
list_filter = ("used", "created_at", "expires_at")
list_select_related = ["user"]
search_fields = ("user__username", "user__email", "token")
readonly_fields = ("token", "created_at", "expires_at", "user", "used")
list_filter = (
"used",
"created_at",
"expires_at",
)
search_fields = (
"user__username",
"user__email",
"token",
)
readonly_fields = (
"token",
"created_at",
"expires_at",
)
date_hierarchy = "created_at"
ordering = ("-created_at",)
@@ -602,63 +322,39 @@ class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
(
"Reset Details",
{
"fields": ("user", "token", "used"),
"description": "Password reset token information.",
"fields": (
"user",
"token",
"used",
)
},
),
(
"Timing",
{
"fields": ("created_at", "expires_at"),
"description": "Token creation and expiration times.",
"fields": (
"created_at",
"expires_at",
)
},
),
)
@admin.display(description="User")
def user_link(self, obj):
"""Display user as clickable link."""
if obj.user:
from django.urls import reverse
@admin.display(description="Status", boolean=True)
def is_expired(self, obj):
"""Display expiration status with color coding"""
from django.utils import timezone
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return "-"
@admin.display(description="Status")
def status_badge(self, obj):
"""Display status with color-coded badge."""
if obj.used:
return format_html(
'<span style="background-color: blue; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Used</span>'
)
return format_html('<span style="color: blue;">Used</span>')
elif timezone.now() > obj.expires_at:
return format_html(
'<span style="background-color: red; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Expired</span>'
)
return format_html(
'<span style="background-color: green; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">Valid</span>'
)
return format_html('<span style="color: red;">Expired</span>')
return format_html('<span style="color: green;">Valid</span>')
@admin.action(description="Cleanup old tokens")
def cleanup_old_tokens(self, request, queryset):
"""Delete old expired and used tokens."""
cutoff = timezone.now() - timedelta(days=7)
old_tokens = queryset.filter(created_at__lt=cutoff)
count = old_tokens.count()
old_tokens.delete()
self.message_user(request, f"Cleaned up {count} old tokens.")
def has_add_permission(self, request):
"""Disable manual creation of password reset tokens"""
return False
def get_actions(self, request):
"""Add cleanup action."""
actions = super().get_actions(request)
if request.user.is_superuser:
actions["cleanup_old_tokens"] = (
self.cleanup_old_tokens,
"cleanup_old_tokens",
"Cleanup old tokens",
)
return actions
def has_change_permission(self, request, obj=None):
"""Allow viewing but restrict editing of password reset tokens"""
return getattr(request.user, "is_superuser", False)

View File

@@ -1,600 +0,0 @@
"""
Rich Choice Objects for Accounts Domain
This module defines all choice objects used in the accounts domain,
replacing tuple-based choices with rich, metadata-enhanced choice objects.
Last updated: 2025-01-15
"""
from apps.core.choices import ChoiceGroup, RichChoice, register_choices
# =============================================================================
# USER ROLES
# =============================================================================
user_roles = ChoiceGroup(
name="user_roles",
choices=[
RichChoice(
value="USER",
label="User",
description="Standard user with basic permissions to create content, reviews, and lists",
metadata={
"color": "blue",
"icon": "user",
"css_class": "text-blue-600 bg-blue-50",
"permissions": ["create_content", "create_reviews", "create_lists"],
"sort_order": 1,
},
),
RichChoice(
value="MODERATOR",
label="Moderator",
description="Trusted user with permissions to moderate content and assist other users",
metadata={
"color": "green",
"icon": "shield-check",
"css_class": "text-green-600 bg-green-50",
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
"sort_order": 2,
},
),
RichChoice(
value="ADMIN",
label="Admin",
description="Administrator with elevated permissions to manage users and site configuration",
metadata={
"color": "purple",
"icon": "cog",
"css_class": "text-purple-600 bg-purple-50",
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
"sort_order": 3,
},
),
RichChoice(
value="SUPERUSER",
label="Superuser",
description="Full system administrator with unrestricted access to all features",
metadata={
"color": "red",
"icon": "key",
"css_class": "text-red-600 bg-red-50",
"permissions": ["full_access", "system_administration", "database_access"],
"sort_order": 4,
},
),
],
)
# =============================================================================
# THEME PREFERENCES
# =============================================================================
theme_preferences = ChoiceGroup(
name="theme_preferences",
choices=[
RichChoice(
value="light",
label="Light",
description="Light theme with bright backgrounds and dark text for daytime use",
metadata={
"color": "yellow",
"icon": "sun",
"css_class": "text-yellow-600 bg-yellow-50",
"preview_colors": {"background": "#ffffff", "text": "#1f2937", "accent": "#3b82f6"},
"sort_order": 1,
},
),
RichChoice(
value="dark",
label="Dark",
description="Dark theme with dark backgrounds and light text for nighttime use",
metadata={
"color": "gray",
"icon": "moon",
"css_class": "text-gray-600 bg-gray-50",
"preview_colors": {"background": "#1f2937", "text": "#f9fafb", "accent": "#60a5fa"},
"sort_order": 2,
},
),
],
)
# =============================================================================
# UNIT SYSTEMS
# =============================================================================
unit_systems = ChoiceGroup(
name="unit_systems",
choices=[
RichChoice(
value="metric",
label="Metric",
description="Use metric units (meters, km/h)",
metadata={
"color": "blue",
"icon": "ruler",
"css_class": "text-blue-600 bg-blue-50",
"units": {
"distance": "m",
"speed": "km/h",
"weight": "kg",
"large_distance": "km",
},
"sort_order": 1,
},
),
RichChoice(
value="imperial",
label="Imperial",
description="Use imperial units (feet, mph)",
metadata={
"color": "green",
"icon": "ruler",
"css_class": "text-green-600 bg-green-50",
"units": {
"distance": "ft",
"speed": "mph",
"weight": "lbs",
"large_distance": "mi",
},
"sort_order": 2,
},
),
],
)
# =============================================================================
# PRIVACY LEVELS
# =============================================================================
privacy_levels = ChoiceGroup(
name="privacy_levels",
choices=[
RichChoice(
value="public",
label="Public",
description="Profile and activity visible to all users and search engines",
metadata={
"color": "green",
"icon": "globe",
"css_class": "text-green-600 bg-green-50",
"visibility_scope": "everyone",
"search_indexable": True,
"implications": [
"Profile visible to all users",
"Activity appears in public feeds",
"Searchable by search engines",
"Can be found by username search",
],
"sort_order": 1,
},
),
RichChoice(
value="friends",
label="Friends Only",
description="Profile and activity visible only to accepted friends",
metadata={
"color": "blue",
"icon": "users",
"css_class": "text-blue-600 bg-blue-50",
"visibility_scope": "friends",
"search_indexable": False,
"implications": [
"Profile visible only to friends",
"Activity hidden from public feeds",
"Not searchable by search engines",
"Requires friend request approval",
],
"sort_order": 2,
},
),
RichChoice(
value="private",
label="Private",
description="Profile and activity completely private, visible only to you",
metadata={
"color": "red",
"icon": "lock",
"css_class": "text-red-600 bg-red-50",
"visibility_scope": "self",
"search_indexable": False,
"implications": [
"Profile completely hidden",
"No activity in any feeds",
"Not discoverable by other users",
"Maximum privacy protection",
],
"sort_order": 3,
},
),
],
)
# =============================================================================
# TOP LIST CATEGORIES
# =============================================================================
top_list_categories = ChoiceGroup(
name="top_list_categories",
choices=[
RichChoice(
value="RC",
label="Roller Coaster",
description="Top lists for roller coasters and thrill rides",
metadata={
"color": "red",
"icon": "roller-coaster",
"css_class": "text-red-600 bg-red-50",
"ride_category": "roller_coaster",
"typical_list_size": 10,
"sort_order": 1,
},
),
RichChoice(
value="DR",
label="Dark Ride",
description="Top lists for dark rides and indoor attractions",
metadata={
"color": "purple",
"icon": "moon",
"css_class": "text-purple-600 bg-purple-50",
"ride_category": "dark_ride",
"typical_list_size": 10,
"sort_order": 2,
},
),
RichChoice(
value="FR",
label="Flat Ride",
description="Top lists for flat rides and spinning attractions",
metadata={
"color": "blue",
"icon": "refresh",
"css_class": "text-blue-600 bg-blue-50",
"ride_category": "flat_ride",
"typical_list_size": 10,
"sort_order": 3,
},
),
RichChoice(
value="WR",
label="Water Ride",
description="Top lists for water rides and splash attractions",
metadata={
"color": "cyan",
"icon": "droplet",
"css_class": "text-cyan-600 bg-cyan-50",
"ride_category": "water_ride",
"typical_list_size": 10,
"sort_order": 4,
},
),
RichChoice(
value="PK",
label="Park",
description="Top lists for theme parks and amusement parks",
metadata={
"color": "green",
"icon": "map",
"css_class": "text-green-600 bg-green-50",
"entity_type": "park",
"typical_list_size": 10,
"sort_order": 5,
},
),
],
)
# =============================================================================
# NOTIFICATION TYPES
# =============================================================================
notification_types = ChoiceGroup(
name="notification_types",
choices=[
# Submission related
RichChoice(
value="submission_approved",
label="Submission Approved",
description="Notification when user's submission is approved by moderators",
metadata={
"color": "green",
"icon": "check-circle",
"css_class": "text-green-600 bg-green-50",
"category": "submission",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 1,
},
),
RichChoice(
value="submission_rejected",
label="Submission Rejected",
description="Notification when user's submission is rejected by moderators",
metadata={
"color": "red",
"icon": "x-circle",
"css_class": "text-red-600 bg-red-50",
"category": "submission",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 2,
},
),
RichChoice(
value="submission_pending",
label="Submission Pending Review",
description="Notification when user's submission is pending moderator review",
metadata={
"color": "yellow",
"icon": "clock",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "submission",
"default_channels": ["inapp"],
"priority": "low",
"sort_order": 3,
},
),
# Review related
RichChoice(
value="review_reply",
label="Review Reply",
description="Notification when someone replies to user's review",
metadata={
"color": "blue",
"icon": "chat-bubble",
"css_class": "text-blue-600 bg-blue-50",
"category": "review",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 4,
},
),
RichChoice(
value="review_helpful",
label="Review Marked Helpful",
description="Notification when user's review is marked as helpful",
metadata={
"color": "green",
"icon": "thumbs-up",
"css_class": "text-green-600 bg-green-50",
"category": "review",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 5,
},
),
# Social related
RichChoice(
value="friend_request",
label="Friend Request",
description="Notification when user receives a friend request",
metadata={
"color": "blue",
"icon": "user-plus",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 6,
},
),
RichChoice(
value="friend_accepted",
label="Friend Request Accepted",
description="Notification when user's friend request is accepted",
metadata={
"color": "green",
"icon": "user-check",
"css_class": "text-green-600 bg-green-50",
"category": "social",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 7,
},
),
RichChoice(
value="message_received",
label="Message Received",
description="Notification when user receives a private message",
metadata={
"color": "blue",
"icon": "mail",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 8,
},
),
RichChoice(
value="profile_comment",
label="Profile Comment",
description="Notification when someone comments on user's profile",
metadata={
"color": "blue",
"icon": "chat",
"css_class": "text-blue-600 bg-blue-50",
"category": "social",
"default_channels": ["email", "push", "inapp"],
"priority": "normal",
"sort_order": 9,
},
),
# System related
RichChoice(
value="system_announcement",
label="System Announcement",
description="Important announcements from the ThrillWiki team",
metadata={
"color": "purple",
"icon": "megaphone",
"css_class": "text-purple-600 bg-purple-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 10,
},
),
RichChoice(
value="account_security",
label="Account Security",
description="Security-related notifications for user's account",
metadata={
"color": "red",
"icon": "shield-exclamation",
"css_class": "text-red-600 bg-red-50",
"category": "system",
"default_channels": ["email", "push", "inapp"],
"priority": "high",
"sort_order": 11,
},
),
RichChoice(
value="feature_update",
label="Feature Update",
description="Notifications about new features and improvements",
metadata={
"color": "blue",
"icon": "sparkles",
"css_class": "text-blue-600 bg-blue-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "low",
"sort_order": 12,
},
),
RichChoice(
value="maintenance",
label="Maintenance Notice",
description="Scheduled maintenance and downtime notifications",
metadata={
"color": "yellow",
"icon": "wrench",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "system",
"default_channels": ["email", "inapp"],
"priority": "normal",
"sort_order": 13,
},
),
# Achievement related
RichChoice(
value="achievement_unlocked",
label="Achievement Unlocked",
description="Notification when user unlocks a new achievement",
metadata={
"color": "gold",
"icon": "trophy",
"css_class": "text-yellow-600 bg-yellow-50",
"category": "achievement",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 14,
},
),
RichChoice(
value="milestone_reached",
label="Milestone Reached",
description="Notification when user reaches a significant milestone",
metadata={
"color": "purple",
"icon": "flag",
"css_class": "text-purple-600 bg-purple-50",
"category": "achievement",
"default_channels": ["push", "inapp"],
"priority": "low",
"sort_order": 15,
},
),
],
)
# =============================================================================
# NOTIFICATION PRIORITIES
# =============================================================================
notification_priorities = ChoiceGroup(
name="notification_priorities",
choices=[
RichChoice(
value="low",
label="Low",
description="Low priority notifications that can be delayed or batched",
metadata={
"color": "gray",
"icon": "arrow-down",
"css_class": "text-gray-600 bg-gray-50",
"urgency_level": 1,
"batch_eligible": True,
"delay_minutes": 60,
"sort_order": 1,
},
),
RichChoice(
value="normal",
label="Normal",
description="Standard priority notifications sent in regular intervals",
metadata={
"color": "blue",
"icon": "minus",
"css_class": "text-blue-600 bg-blue-50",
"urgency_level": 2,
"batch_eligible": True,
"delay_minutes": 15,
"sort_order": 2,
},
),
RichChoice(
value="high",
label="High",
description="High priority notifications sent immediately",
metadata={
"color": "orange",
"icon": "arrow-up",
"css_class": "text-orange-600 bg-orange-50",
"urgency_level": 3,
"batch_eligible": False,
"delay_minutes": 0,
"sort_order": 3,
},
),
RichChoice(
value="urgent",
label="Urgent",
description="Critical notifications requiring immediate attention",
metadata={
"color": "red",
"icon": "exclamation",
"css_class": "text-red-600 bg-red-50",
"urgency_level": 4,
"batch_eligible": False,
"delay_minutes": 0,
"bypass_preferences": True,
"sort_order": 4,
},
),
],
)
# =============================================================================
# REGISTER ALL CHOICE GROUPS
# =============================================================================
# Register each choice group individually
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
register_choices("unit_systems", unit_systems.choices, "accounts", "Unit system preferences")
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")

View File

@@ -1,97 +0,0 @@
from django.utils import timezone
from .models import User
class UserExportService:
"""Service for exporting all user data."""
@staticmethod
def export_user_data(user: User) -> dict:
"""
Export all data associated with a user or an object containing counts/metadata and actual data.
Args:
user: The user to export data for
Returns:
dict: The complete user data export
"""
# Import models locally to avoid circular imports
from apps.lists.models import UserList
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
# User account and profile
user_data = {
"username": user.username,
"email": user.email,
"date_joined": user.date_joined,
"first_name": user.first_name,
"last_name": user.last_name,
"is_active": user.is_active,
"role": user.role,
}
profile_data = {}
if hasattr(user, "profile"):
profile = user.profile
profile_data = {
"display_name": profile.display_name,
"bio": profile.bio,
"location": profile.location,
"pronouns": profile.pronouns,
"unit_system": profile.unit_system,
"social_media": {
"twitter": profile.twitter,
"instagram": profile.instagram,
"youtube": profile.youtube,
"discord": profile.discord,
},
"ride_credits": {
"coaster": profile.coaster_credits,
"dark_ride": profile.dark_ride_credits,
"flat_ride": profile.flat_ride_credits,
"water_ride": profile.water_ride_credits,
},
}
# Reviews
park_reviews = list(
ParkReview.objects.filter(user=user).values(
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
)
)
ride_reviews = list(
RideReview.objects.filter(user=user).values(
"ride__name", "rating", "review", "created_at", "updated_at", "is_published"
)
)
# Lists
user_lists = []
for user_list in UserList.objects.filter(user=user):
items = list(user_list.items.values("order", "content_type__model", "object_id", "comment"))
user_lists.append(
{
"title": user_list.title,
"description": user_list.description,
"created_at": user_list.created_at,
"items": items,
}
)
export_data = {
"account": user_data,
"profile": profile_data,
"preferences": getattr(user, "notification_preferences", {}),
"content": {
"park_reviews": park_reviews,
"ride_reviews": ride_reviews,
"lists": user_lists,
},
"export_info": {"generated_at": timezone.now(), "version": "1.0"},
}
return export_data

View File

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

View File

@@ -1,6 +1,6 @@
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
from django.contrib.sites.models import Site
class Command(BaseCommand):
@@ -22,14 +22,20 @@ class Command(BaseCommand):
# Check SocialAccount
self.stdout.write("\nChecking SocialAccount table:")
for account in SocialAccount.objects.all():
self.stdout.write(f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}")
self.stdout.write(
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
)
# Check SocialToken
self.stdout.write("\nChecking SocialToken table:")
for token in SocialToken.objects.all():
self.stdout.write(f"ID: {token.pk}, Account: {token.account}, App: {token.app}")
self.stdout.write(
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
)
# Check Site
self.stdout.write("\nChecking Site table:")
for site in Site.objects.all():
self.stdout.write(f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}")
self.stdout.write(
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
)

View File

@@ -1,5 +1,5 @@
from allauth.socialaccount.models import SocialApp
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
@@ -17,4 +17,6 @@ class Command(BaseCommand):
self.stdout.write(f"Name: {app.name}")
self.stdout.write(f"Client ID: {app.client_id}")
self.stdout.write(f"Secret: {app.secret}")
self.stdout.write(f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}")
self.stdout.write(
f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}"
)

View File

@@ -15,9 +15,14 @@ class Command(BaseCommand):
# Remove migration records
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' " "AND name LIKE '%social%'")
cursor.execute(
"DELETE FROM django_migrations WHERE app='accounts' "
"AND name LIKE '%social%'"
)
# Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
self.stdout.write(self.style.SUCCESS("Successfully cleaned up social auth configuration"))
self.stdout.write(
self.style.SUCCESS("Successfully cleaned up social auth configuration")
)

View File

@@ -1,7 +1,6 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from apps.parks.models import Park, ParkPhoto, ParkReview
from django.contrib.auth import get_user_model
from apps.parks.models import ParkReview, Park, ParkPhoto
from apps.rides.models import Ride, RidePhoto
User = get_user_model()
@@ -18,18 +17,24 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
# Delete test reviews
reviews = ParkReview.objects.filter(user__username__in=["testuser", "moderator"])
reviews = ParkReview.objects.filter(
user__username__in=["testuser", "moderator"]
)
count = reviews.count()
reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos - both park and ride photos
park_photos = ParkPhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
park_photos = ParkPhoto.objects.filter(
uploader__username__in=["testuser", "moderator"]
)
park_count = park_photos.count()
park_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
ride_photos = RidePhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
ride_photos = RidePhoto.objects.filter(
uploader__username__in=["testuser", "moderator"]
)
ride_count = ride_photos.count()
ride_photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
@@ -47,8 +52,8 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
# Clean up test files
import glob
import os
import glob
# Clean up test uploads
media_patterns = [

View File

@@ -1,6 +1,6 @@
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
@@ -37,12 +37,18 @@ class Command(BaseCommand):
provider="google",
defaults={
"name": "Google",
"client_id": ("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"),
"client_id": (
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
"apps.googleusercontent.com"
),
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
},
)
if not created:
google_app.client_id = "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"
google_app.client_id = (
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
"apps.googleusercontent.com"
)
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
google_app.save()
google_app.sites.add(site)

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.models import Group, Permission, User
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission, User
class Command(BaseCommand):
@@ -14,7 +14,9 @@ class Command(BaseCommand):
)
user.set_password("testpass123")
user.save()
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.get_username()}"))
self.stdout.write(
self.style.SUCCESS(f"Created test user: {user.get_username()}")
)
else:
self.stdout.write(self.style.WARNING("Test user already exists"))
@@ -45,7 +47,11 @@ class Command(BaseCommand):
# Add user to moderator group
moderator.groups.add(moderator_group)
self.stdout.write(self.style.SUCCESS(f"Created moderator user: {moderator.get_username()}"))
self.stdout.write(
self.style.SUCCESS(
f"Created moderator user: {moderator.get_username()}"
)
)
else:
self.stdout.write(self.style.WARNING("Moderator user already exists"))

View File

@@ -1,134 +0,0 @@
"""
Django management command to delete a user while preserving their submissions.
Usage:
uv run manage.py delete_user <username>
uv run manage.py delete_user --user-id <user_id>
uv run manage.py delete_user <username> --dry-run
"""
from django.core.management.base import BaseCommand, CommandError
from apps.accounts.models import User
from apps.accounts.services import UserDeletionService
class Command(BaseCommand):
help = "Delete a user while preserving all their submissions"
def add_arguments(self, parser):
parser.add_argument("username", nargs="?", type=str, help="Username of the user to delete")
parser.add_argument(
"--user-id",
type=str,
help="User ID of the user to delete (alternative to username)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be deleted without actually deleting",
)
parser.add_argument("--force", action="store_true", help="Skip confirmation prompt")
def handle(self, *args, **options):
username = options.get("username")
user_id = options.get("user_id")
dry_run = options.get("dry_run", False)
force = options.get("force", False)
# Validate arguments
if not username and not user_id:
raise CommandError("You must provide either a username or --user-id")
if username and user_id:
raise CommandError("You cannot provide both username and --user-id")
# Find the user
try:
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') from None
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
raise CommandError(f"Cannot delete user: {reason}")
# Count submissions
submission_counts = {
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
}
total_submissions = sum(submission_counts.values())
# Display user information
self.stdout.write(self.style.WARNING("\nUser Information:"))
self.stdout.write(f" Username: {user.username}")
self.stdout.write(f" User ID: {user.user_id}")
self.stdout.write(f" Email: {user.email}")
self.stdout.write(f" Date Joined: {user.date_joined}")
self.stdout.write(f" Role: {user.role}")
# Display submission counts
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
for submission_type, count in submission_counts.items():
if count > 0:
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
self.stdout.write(f"\nTotal submissions: {total_submissions}")
if total_submissions > 0:
self.stdout.write(
self.style.SUCCESS(
f'\nAll {total_submissions} submissions will be transferred to the "deleted_user" placeholder.'
)
)
else:
self.stdout.write(self.style.WARNING("\nNo submissions found for this user."))
if dry_run:
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
return
# Confirmation prompt
if not force:
self.stdout.write(
self.style.WARNING(
f'\nThis will permanently delete the user "{user.username}" '
f"but preserve all {total_submissions} submissions."
)
)
confirm = input("Are you sure you want to continue? (yes/no): ")
if confirm.lower() not in ["yes", "y"]:
self.stdout.write(self.style.ERROR("Operation cancelled."))
return
# Perform the deletion
try:
result = UserDeletionService.delete_user_preserve_submissions(user)
self.stdout.write(self.style.SUCCESS(f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'))
preserved_count = sum(result["preserved_submissions"].values())
if preserved_count > 0:
self.stdout.write(
self.style.SUCCESS(
f'Preserved {preserved_count} submissions under user "{result["transferred_to"]["username"]}"'
)
)
# Show detailed preservation summary
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
for submission_type, count in result["preserved_submissions"].items():
if count > 0:
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
except Exception as e:
raise CommandError(f"Error deleting user: {str(e)}") from None

View File

@@ -7,5 +7,12 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
with connection.cursor() as cursor:
cursor.execute("DELETE FROM django_migrations WHERE app='rides' " "AND name='0001_initial';")
self.stdout.write(self.style.SUCCESS("Successfully removed rides.0001_initial from migration history"))
cursor.execute(
"DELETE FROM django_migrations WHERE app='rides' "
"AND name='0001_initial';"
)
self.stdout.write(
self.style.SUCCESS(
"Successfully removed rides.0001_initial from migration history"
)
)

View File

@@ -1,8 +1,7 @@
import os
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
import os
class Command(BaseCommand):
@@ -34,4 +33,6 @@ class Command(BaseCommand):
secret=os.getenv("DISCORD_CLIENT_SECRET"),
)
discord_app.sites.add(site)
self.stdout.write(f"Created Discord app with client_id: {discord_app.client_id}")
self.stdout.write(
f"Created Discord app with client_id: {discord_app.client_id}"
)

View File

@@ -1,7 +1,6 @@
import os
from django.core.management.base import BaseCommand
from PIL import Image, ImageDraw, ImageFont
import os
def generate_avatar(letter):
@@ -47,7 +46,9 @@ class Command(BaseCommand):
help = "Generate avatars for letters A-Z and numbers 0-9"
def handle(self, *args, **kwargs):
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
characters = [chr(i) for i in range(65, 91)] + [
str(i) for i in range(10)
] # A-Z and 0-9
for char in characters:
generate_avatar(char)
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))

View File

@@ -1,5 +1,4 @@
from django.core.management.base import BaseCommand
from apps.accounts.models import UserProfile
@@ -11,4 +10,6 @@ class Command(BaseCommand):
for profile in profiles:
# This will trigger the avatar generation logic in the save method
profile.save()
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))
self.stdout.write(
self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}")
)

View File

@@ -1,15 +1,7 @@
"""
Management command to reset the database and create an admin user.
Security Note: This command uses a mix of raw SQL (for PostgreSQL-specific operations
like dropping all tables) and Django ORM (for creating users). The raw SQL operations
use quote_ident() for table/sequence names which is safe from SQL injection.
WARNING: This command is destructive and should only be used in development.
"""
from django.core.management.base import BaseCommand
from django.db import connection
from django.contrib.auth.hashers import make_password
import uuid
class Command(BaseCommand):
@@ -18,8 +10,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
self.stdout.write("Resetting database...")
# Drop all tables using PostgreSQL-specific operations
# Security: Using quote_ident() to safely quote table/sequence names
# Drop all tables
with connection.cursor() as cursor:
cursor.execute(
"""
@@ -30,7 +21,7 @@ class Command(BaseCommand):
SELECT tablename FROM pg_tables
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'DROP TABLE IF EXISTS ' ||
EXECUTE 'DROP TABLE IF EXISTS ' || \
quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END $$;
@@ -47,7 +38,7 @@ class Command(BaseCommand):
SELECT sequencename FROM pg_sequences
WHERE schemaname = current_schema()
) LOOP
EXECUTE 'ALTER SEQUENCE ' ||
EXECUTE 'ALTER SEQUENCE ' || \
quote_ident(r.sequencename) || ' RESTART WITH 1';
END LOOP;
END $$;
@@ -63,25 +54,51 @@ class Command(BaseCommand):
self.stdout.write("Migrations applied.")
# Create superuser using Django ORM (safer than raw SQL)
# Create superuser using raw SQL
try:
from apps.accounts.models import User, UserProfile
with connection.cursor() as cursor:
# Create user
user_id = str(uuid.uuid4())[:10]
cursor.execute(
"""
INSERT INTO accounts_user (
username, password, email, is_superuser, is_staff,
is_active, date_joined, user_id, first_name,
last_name, role, is_banned, ban_reason,
theme_preference
) VALUES (
'admin', %s, 'admin@thrillwiki.com', true, true,
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
'light'
) RETURNING id;
""",
[make_password("admin"), user_id],
)
# Security: Using Django ORM instead of raw SQL for user creation
user = User.objects.create_superuser(
username="admin",
email="admin@thrillwiki.com",
password="admin",
role="SUPERUSER",
)
result = cursor.fetchone()
if result is None:
raise Exception("Failed to create user - no ID returned")
user_db_id = result[0]
# Create profile using ORM
UserProfile.objects.create(
user=user,
display_name="Admin",
pronouns="they/them",
bio="ThrillWiki Administrator",
)
# Create profile
profile_id = str(uuid.uuid4())[:10]
cursor.execute(
"""
INSERT INTO accounts_userprofile (
profile_id, display_name, pronouns, bio,
twitter, instagram, youtube, discord,
coaster_credits, dark_ride_credits,
flat_ride_credits, water_ride_credits,
user_id, avatar
) VALUES (
%s, 'Admin', 'they/them', 'ThrillWiki Administrator',
'', '', '', '',
0, 0, 0, 0,
%s, ''
);
""",
[profile_id, user_db_id],
)
self.stdout.write("Superuser created.")
except Exception as e:

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from django.db import connection
@@ -30,7 +30,9 @@ class Command(BaseCommand):
google_app = SocialApp.objects.create(
provider="google",
name="Google",
client_id=("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"),
client_id=(
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
),
secret="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",
)
google_app.sites.add(site)

View File

@@ -12,7 +12,13 @@ class Command(BaseCommand):
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
# Reset sequences
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
cursor.execute(
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'"
)
cursor.execute(
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'"
)
self.stdout.write(self.style.SUCCESS("Successfully reset social auth configuration"))
self.stdout.write(
self.style.SUCCESS("Successfully reset social auth configuration")
)

View File

@@ -1,6 +1,5 @@
from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group
from apps.accounts.models import User
from apps.accounts.signals import create_default_groups
@@ -30,7 +29,9 @@ class Command(BaseCommand):
user.is_staff = True
user.save()
self.stdout.write(self.style.SUCCESS("Successfully set up groups and permissions"))
self.stdout.write(
self.style.SUCCESS("Successfully set up groups and permissions")
)
# Print summary
for group in Group.objects.all():

View File

@@ -1,5 +1,5 @@
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
class Command(BaseCommand):
@@ -10,5 +10,7 @@ class Command(BaseCommand):
Site.objects.all().delete()
# Create default site
site = Site.objects.create(id=1, domain="localhost:8000", name="ThrillWiki Development")
site = Site.objects.create(
id=1, domain="localhost:8000", name="ThrillWiki Development"
)
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))

View File

@@ -1,9 +1,8 @@
import os
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
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
class Command(BaseCommand):
@@ -49,15 +48,27 @@ class Command(BaseCommand):
discord_client_secret,
]
):
self.stdout.write(self.style.ERROR("Missing required environment variables"))
self.stdout.write(f"DEBUG: google_client_id is None: {google_client_id is None}")
self.stdout.write(f"DEBUG: google_client_secret is None: {google_client_secret is None}")
self.stdout.write(f"DEBUG: discord_client_id is None: {discord_client_id is None}")
self.stdout.write(f"DEBUG: discord_client_secret is None: {discord_client_secret is None}")
self.stdout.write(
self.style.ERROR("Missing required environment variables")
)
self.stdout.write(
f"DEBUG: google_client_id is None: {google_client_id is None}"
)
self.stdout.write(
f"DEBUG: google_client_secret is None: {google_client_secret is None}"
)
self.stdout.write(
f"DEBUG: discord_client_id is None: {discord_client_id is None}"
)
self.stdout.write(
f"DEBUG: discord_client_secret is None: {discord_client_secret is None}"
)
return
# Get or create the default site
site, _ = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost"})
site, _ = Site.objects.get_or_create(
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
)
# Set up Google
google_app, created = SocialApp.objects.get_or_create(
@@ -80,7 +91,11 @@ class Command(BaseCommand):
google_app.save()
self.stdout.write("DEBUG: Successfully updated Google app")
else:
self.stdout.write(self.style.ERROR("Google client_id or secret is None, skipping update."))
self.stdout.write(
self.style.ERROR(
"Google client_id or secret is None, skipping update."
)
)
google_app.sites.add(site)
# Set up Discord
@@ -104,7 +119,11 @@ class Command(BaseCommand):
discord_app.save()
self.stdout.write("DEBUG: Successfully updated Discord app")
else:
self.stdout.write(self.style.ERROR("Discord client_id or secret is None, skipping update."))
self.stdout.write(
self.style.ERROR(
"Discord client_id or secret is None, skipping update."
)
)
discord_app.sites.add(site)
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))

View File

@@ -1,6 +1,6 @@
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -41,7 +41,7 @@ class Command(BaseCommand):
Social auth setup instructions:
1. Run the development server:
uv run manage.py runserver_plus
python manage.py runserver
2. Go to the admin interface:
http://localhost:8000/admin/

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):
@@ -42,4 +42,6 @@ class Command(BaseCommand):
for app in SocialApp.objects.all():
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
self.stdout.write(self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}"))
self.stdout.write(
self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}")
)

View File

@@ -1,6 +1,6 @@
from allauth.socialaccount.models import SocialApp
from django.core.management.base import BaseCommand
from django.test import Client
from allauth.socialaccount.models import SocialApp
class Command(BaseCommand):
@@ -40,7 +40,9 @@ class Command(BaseCommand):
# Show callback URL
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
self.stdout.write(
"\nCallback URL to configure in Discord Developer Portal:"
)
self.stdout.write(callback_url)
# Show frontend login URL

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
class Command(BaseCommand):
@@ -18,4 +18,6 @@ class Command(BaseCommand):
# Add all sites
for site in sites:
app.sites.add(site)
self.stdout.write(f"Added sites: {', '.join(site.domain for site in sites)}")
self.stdout.write(
f"Added sites: {', '.join(site.domain for site in sites)}"
)

View File

@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
@@ -22,13 +22,17 @@ class Command(BaseCommand):
# Show callback URL
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
self.stdout.write(
"\nCallback URL to configure in Discord Developer Portal:"
)
self.stdout.write(callback_url)
# Show OAuth2 settings
self.stdout.write("\nOAuth2 settings in settings.py:")
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
self.stdout.write(f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}")
self.stdout.write(
f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}"
)
self.stdout.write(f"Scopes: {discord_settings.get('SCOPE', [])}")
except SocialApp.DoesNotExist:

View File

@@ -38,7 +38,9 @@ class Migration(migrations.Migration):
),
(
"last_login",
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
@@ -51,21 +53,29 @@ class Migration(migrations.Migration):
(
"username",
models.CharField(
error_messages={"unique": "A user with that username already exists."},
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(blank=True, max_length=150, verbose_name="first name"),
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(blank=True, max_length=150, verbose_name="last name"),
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",

View File

@@ -12,7 +12,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
("pghistory", "0006_delete_aggregateevent"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
@@ -57,7 +57,9 @@ class Migration(migrations.Migration):
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
@@ -70,24 +72,34 @@ class Migration(migrations.Migration):
(
"username",
models.CharField(
error_messages={"unique": "A user with that username already exists."},
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(blank=True, max_length=150, verbose_name="first name"),
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(blank=True, max_length=150, verbose_name="last name"),
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(blank=True, max_length=254, verbose_name="email address"),
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
@@ -107,7 +119,9 @@ class Migration(migrations.Migration):
),
(
"date_joined",
models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"),
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"user_id",

View File

@@ -1,205 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-29 14:55
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",
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="UserDeletionRequest",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"verification_code",
models.CharField(
help_text="Unique verification code sent to user's email",
max_length=32,
unique=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"expires_at",
models.DateTimeField(help_text="When this deletion request expires"),
),
(
"email_sent_at",
models.DateTimeField(
blank=True,
help_text="When the verification email was sent",
null=True,
),
),
(
"attempts",
models.PositiveIntegerField(default=0, help_text="Number of verification attempts made"),
),
(
"max_attempts",
models.PositiveIntegerField(
default=5,
help_text="Maximum number of verification attempts allowed",
),
),
(
"is_used",
models.BooleanField(
default=False,
help_text="Whether this deletion request has been used",
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="deletion_request",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="UserDeletionRequestEvent",
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()),
(
"verification_code",
models.CharField(
help_text="Unique verification code sent to user's email",
max_length=32,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"expires_at",
models.DateTimeField(help_text="When this deletion request expires"),
),
(
"email_sent_at",
models.DateTimeField(
blank=True,
help_text="When the verification email was sent",
null=True,
),
),
(
"attempts",
models.PositiveIntegerField(default=0, help_text="Number of verification attempts made"),
),
(
"max_attempts",
models.PositiveIntegerField(
default=5,
help_text="Maximum number of verification attempts allowed",
),
),
(
"is_used",
models.BooleanField(
default=False,
help_text="Whether this deletion request has been used",
),
),
(
"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.userdeletionrequest",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
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="userdeletionrequest",
index=models.Index(fields=["verification_code"], name="accounts_us_verific_94460d_idx"),
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"),
),
migrations.AddIndex(
model_name="userdeletionrequest",
index=models.Index(fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="userdeletionrequest",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
hash="c1735fe8eb50247b0afe2bea9d32f83c31da6419",
operation="INSERT",
pgid="pgtrigger_insert_insert_b982c",
table="accounts_userdeletionrequest",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userdeletionrequest",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userdeletionrequestevent" ("attempts", "created_at", "email_sent_at", "expires_at", "id", "is_used", "max_attempts", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_id", "verification_code") VALUES (NEW."attempts", NEW."created_at", NEW."email_sent_at", NEW."expires_at", NEW."id", NEW."is_used", NEW."max_attempts", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_id", NEW."verification_code"); RETURN NULL;',
hash="6bf807ce3bed069ab30462d3fd7688a7593a7fd0",
operation="UPDATE",
pgid="pgtrigger_update_update_27723",
table="accounts_userdeletionrequest",
when="AFTER",
),
),
),
]

View File

@@ -1,305 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-29 15:10
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0004_userdeletionrequest_userdeletionrequestevent_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="update_update",
),
migrations.AddField(
model_name="user",
name="activity_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
max_length=10,
),
),
migrations.AddField(
model_name="user",
name="allow_friend_requests",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="allow_messages",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="allow_profile_comments",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="email_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="last_password_change",
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name="user",
name="login_history_retention",
field=models.IntegerField(default=90),
),
migrations.AddField(
model_name="user",
name="login_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="notification_preferences",
field=models.JSONField(
blank=True,
default=dict,
help_text="Detailed notification preferences stored as JSON",
),
),
migrations.AddField(
model_name="user",
name="privacy_level",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
max_length=10,
),
),
migrations.AddField(
model_name="user",
name="push_notifications",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="search_visibility",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="session_timeout",
field=models.IntegerField(default=30),
),
migrations.AddField(
model_name="user",
name="show_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="show_join_date",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_photos",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_real_name",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_reviews",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_statistics",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="show_top_lists",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="user",
name="two_factor_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="activity_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
max_length=10,
),
),
migrations.AddField(
model_name="userevent",
name="allow_friend_requests",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="allow_messages",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="allow_profile_comments",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="email_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="last_password_change",
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name="userevent",
name="login_history_retention",
field=models.IntegerField(default=90),
),
migrations.AddField(
model_name="userevent",
name="login_notifications",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="notification_preferences",
field=models.JSONField(
blank=True,
default=dict,
help_text="Detailed notification preferences stored as JSON",
),
),
migrations.AddField(
model_name="userevent",
name="privacy_level",
field=models.CharField(
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
max_length=10,
),
),
migrations.AddField(
model_name="userevent",
name="push_notifications",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="search_visibility",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="session_timeout",
field=models.IntegerField(default=30),
),
migrations.AddField(
model_name="userevent",
name="show_email",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userevent",
name="show_join_date",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_photos",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_real_name",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_reviews",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_statistics",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="show_top_lists",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userevent",
name="two_factor_enabled",
field=models.BooleanField(default=False),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="63ede44a0db376d673078f3464edc89aa8ca80c7",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="9157131b568edafe1e5fcdf313bfeaaa8adcfee4",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
]

View File

@@ -1,88 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-29 19:09
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_remove_user_insert_insert_remove_user_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="update_update",
),
migrations.AddField(
model_name="user",
name="display_name",
field=models.CharField(
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
max_length=50,
),
),
migrations.AddField(
model_name="userevent",
name="display_name",
field=models.CharField(
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
max_length=50,
),
),
migrations.AlterField(
model_name="userprofile",
name="display_name",
field=models.CharField(
blank=True,
help_text="Legacy display name field - use User.display_name instead",
max_length=50,
),
),
migrations.AlterField(
model_name="userprofileevent",
name="display_name",
field=models.CharField(
blank=True,
help_text="Legacy display name field - use User.display_name instead",
max_length=50,
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="97e02685f062c04c022f6975784dce80396d4371",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "first_name", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_name", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."first_name", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_name", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="e074b317983a921b440b0c8754ba04a31ea513dd",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
]

View File

@@ -1,68 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-29 21:32
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_add_display_name_to_user"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="user",
name="update_update",
),
migrations.RemoveField(
model_name="user",
name="first_name",
),
migrations.RemoveField(
model_name="user",
name="last_name",
),
migrations.RemoveField(
model_name="userevent",
name="first_name",
),
migrations.RemoveField(
model_name="userevent",
name="last_name",
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="1ffd9209b0e1949c05de2548585cda9179288b68",
operation="INSERT",
pgid="pgtrigger_insert_insert_3867c",
table="accounts_user",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="user",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
hash="e5f0a1acc20a9aad226004bc93ca8dbc3511052f",
operation="UPDATE",
pgid="pgtrigger_update_update_0e890",
table="accounts_user",
when="AFTER",
),
),
),
]

View File

@@ -1,503 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-30 20:55
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", "0008_remove_first_last_name_fields"),
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="NotificationPreference",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("submission_approved_email", models.BooleanField(default=True)),
("submission_approved_push", models.BooleanField(default=True)),
("submission_approved_inapp", models.BooleanField(default=True)),
("submission_rejected_email", models.BooleanField(default=True)),
("submission_rejected_push", models.BooleanField(default=True)),
("submission_rejected_inapp", models.BooleanField(default=True)),
("submission_pending_email", models.BooleanField(default=False)),
("submission_pending_push", models.BooleanField(default=False)),
("submission_pending_inapp", models.BooleanField(default=True)),
("review_reply_email", models.BooleanField(default=True)),
("review_reply_push", models.BooleanField(default=True)),
("review_reply_inapp", models.BooleanField(default=True)),
("review_helpful_email", models.BooleanField(default=False)),
("review_helpful_push", models.BooleanField(default=True)),
("review_helpful_inapp", models.BooleanField(default=True)),
("friend_request_email", models.BooleanField(default=True)),
("friend_request_push", models.BooleanField(default=True)),
("friend_request_inapp", models.BooleanField(default=True)),
("friend_accepted_email", models.BooleanField(default=False)),
("friend_accepted_push", models.BooleanField(default=True)),
("friend_accepted_inapp", models.BooleanField(default=True)),
("message_received_email", models.BooleanField(default=True)),
("message_received_push", models.BooleanField(default=True)),
("message_received_inapp", models.BooleanField(default=True)),
("system_announcement_email", models.BooleanField(default=True)),
("system_announcement_push", models.BooleanField(default=False)),
("system_announcement_inapp", models.BooleanField(default=True)),
("account_security_email", models.BooleanField(default=True)),
("account_security_push", models.BooleanField(default=True)),
("account_security_inapp", models.BooleanField(default=True)),
("feature_update_email", models.BooleanField(default=True)),
("feature_update_push", models.BooleanField(default=False)),
("feature_update_inapp", models.BooleanField(default=True)),
("achievement_unlocked_email", models.BooleanField(default=False)),
("achievement_unlocked_push", models.BooleanField(default=True)),
("achievement_unlocked_inapp", models.BooleanField(default=True)),
("milestone_reached_email", models.BooleanField(default=False)),
("milestone_reached_push", models.BooleanField(default=True)),
("milestone_reached_inapp", models.BooleanField(default=True)),
],
options={
"verbose_name": "Notification Preference",
"verbose_name_plural": "Notification Preferences",
"abstract": False,
},
),
migrations.CreateModel(
name="NotificationPreferenceEvent",
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()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("submission_approved_email", models.BooleanField(default=True)),
("submission_approved_push", models.BooleanField(default=True)),
("submission_approved_inapp", models.BooleanField(default=True)),
("submission_rejected_email", models.BooleanField(default=True)),
("submission_rejected_push", models.BooleanField(default=True)),
("submission_rejected_inapp", models.BooleanField(default=True)),
("submission_pending_email", models.BooleanField(default=False)),
("submission_pending_push", models.BooleanField(default=False)),
("submission_pending_inapp", models.BooleanField(default=True)),
("review_reply_email", models.BooleanField(default=True)),
("review_reply_push", models.BooleanField(default=True)),
("review_reply_inapp", models.BooleanField(default=True)),
("review_helpful_email", models.BooleanField(default=False)),
("review_helpful_push", models.BooleanField(default=True)),
("review_helpful_inapp", models.BooleanField(default=True)),
("friend_request_email", models.BooleanField(default=True)),
("friend_request_push", models.BooleanField(default=True)),
("friend_request_inapp", models.BooleanField(default=True)),
("friend_accepted_email", models.BooleanField(default=False)),
("friend_accepted_push", models.BooleanField(default=True)),
("friend_accepted_inapp", models.BooleanField(default=True)),
("message_received_email", models.BooleanField(default=True)),
("message_received_push", models.BooleanField(default=True)),
("message_received_inapp", models.BooleanField(default=True)),
("system_announcement_email", models.BooleanField(default=True)),
("system_announcement_push", models.BooleanField(default=False)),
("system_announcement_inapp", models.BooleanField(default=True)),
("account_security_email", models.BooleanField(default=True)),
("account_security_push", models.BooleanField(default=True)),
("account_security_inapp", models.BooleanField(default=True)),
("feature_update_email", models.BooleanField(default=True)),
("feature_update_push", models.BooleanField(default=False)),
("feature_update_inapp", models.BooleanField(default=True)),
("achievement_unlocked_email", models.BooleanField(default=False)),
("achievement_unlocked_push", models.BooleanField(default=True)),
("achievement_unlocked_inapp", models.BooleanField(default=True)),
("milestone_reached_email", models.BooleanField(default=False)),
("milestone_reached_push", models.BooleanField(default=True)),
("milestone_reached_inapp", models.BooleanField(default=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserNotification",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notification_type",
models.CharField(
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
max_length=30,
),
),
("title", models.CharField(max_length=200)),
("message", models.TextField()),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
(
"priority",
models.CharField(
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
max_length=10,
),
),
("is_read", models.BooleanField(default=False)),
("read_at", models.DateTimeField(blank=True, null=True)),
("email_sent", models.BooleanField(default=False)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
("push_sent", models.BooleanField(default=False)),
("push_sent_at", models.DateTimeField(blank=True, null=True)),
("extra_data", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
],
options={
"ordering": ["-created_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="UserNotificationEvent",
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()),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notification_type",
models.CharField(
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
max_length=30,
),
),
("title", models.CharField(max_length=200)),
("message", models.TextField()),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
(
"priority",
models.CharField(
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
max_length=10,
),
),
("is_read", models.BooleanField(default=False)),
("read_at", models.DateTimeField(blank=True, null=True)),
("email_sent", models.BooleanField(default=False)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
("push_sent", models.BooleanField(default=False)),
("push_sent_at", models.DateTimeField(blank=True, null=True)),
("extra_data", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AlterField(
model_name="userprofile",
name="avatar",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="userprofileevent",
name="avatar",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="81607e492ffea2a4c741452b860ee660374cc01d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
migrations.AddField(
model_name="notificationpreference",
name="user",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preference",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.notificationpreference",
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="usernotification",
name="content_type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="usernotification",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="usernotificationevent",
name="content_type",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.usernotification",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
pgtrigger.migrations.AddTrigger(
model_name="notificationpreference",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="bbaa03794722dab95c97ed93731d8b55f314dbdc",
operation="INSERT",
pgid="pgtrigger_insert_insert_4a06b",
table="accounts_notificationpreference",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="notificationpreference",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_notificationpreferenceevent" ("account_security_email", "account_security_inapp", "account_security_push", "achievement_unlocked_email", "achievement_unlocked_inapp", "achievement_unlocked_push", "created_at", "feature_update_email", "feature_update_inapp", "feature_update_push", "friend_accepted_email", "friend_accepted_inapp", "friend_accepted_push", "friend_request_email", "friend_request_inapp", "friend_request_push", "id", "message_received_email", "message_received_inapp", "message_received_push", "milestone_reached_email", "milestone_reached_inapp", "milestone_reached_push", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_helpful_email", "review_helpful_inapp", "review_helpful_push", "review_reply_email", "review_reply_inapp", "review_reply_push", "submission_approved_email", "submission_approved_inapp", "submission_approved_push", "submission_pending_email", "submission_pending_inapp", "submission_pending_push", "submission_rejected_email", "submission_rejected_inapp", "submission_rejected_push", "system_announcement_email", "system_announcement_inapp", "system_announcement_push", "updated_at", "user_id") VALUES (NEW."account_security_email", NEW."account_security_inapp", NEW."account_security_push", NEW."achievement_unlocked_email", NEW."achievement_unlocked_inapp", NEW."achievement_unlocked_push", NEW."created_at", NEW."feature_update_email", NEW."feature_update_inapp", NEW."feature_update_push", NEW."friend_accepted_email", NEW."friend_accepted_inapp", NEW."friend_accepted_push", NEW."friend_request_email", NEW."friend_request_inapp", NEW."friend_request_push", NEW."id", NEW."message_received_email", NEW."message_received_inapp", NEW."message_received_push", NEW."milestone_reached_email", NEW."milestone_reached_inapp", NEW."milestone_reached_push", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_helpful_email", NEW."review_helpful_inapp", NEW."review_helpful_push", NEW."review_reply_email", NEW."review_reply_inapp", NEW."review_reply_push", NEW."submission_approved_email", NEW."submission_approved_inapp", NEW."submission_approved_push", NEW."submission_pending_email", NEW."submission_pending_inapp", NEW."submission_pending_push", NEW."submission_rejected_email", NEW."submission_rejected_inapp", NEW."submission_rejected_push", NEW."system_announcement_email", NEW."system_announcement_inapp", NEW."system_announcement_push", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="0de72b66f87f795aaeb49be8e4e57d632781bd3a",
operation="UPDATE",
pgid="pgtrigger_update_update_d3fc0",
table="accounts_notificationpreference",
when="AFTER",
),
),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(
fields=["user", "notification_type"],
name="accounts_us_user_id_8cea97_idx",
),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(fields=["created_at"], name="accounts_us_created_a62f54_idx"),
),
migrations.AddIndex(
model_name="usernotification",
index=models.Index(fields=["expires_at"], name="accounts_us_expires_f267b1_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="usernotification",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="822a189e675a5903841d19738c29aa94267417f1",
operation="INSERT",
pgid="pgtrigger_insert_insert_2794b",
table="accounts_usernotification",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="usernotification",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_usernotificationevent" ("content_type_id", "created_at", "email_sent", "email_sent_at", "expires_at", "extra_data", "id", "is_read", "message", "notification_type", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "priority", "push_sent", "push_sent_at", "read_at", "title", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."email_sent", NEW."email_sent_at", NEW."expires_at", NEW."extra_data", NEW."id", NEW."is_read", NEW."message", NEW."notification_type", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."priority", NEW."push_sent", NEW."push_sent_at", NEW."read_at", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="1fd24a77684747bd9a521447a2978529085b6c07",
operation="UPDATE",
pgid="pgtrigger_update_update_15c54",
table="accounts_usernotification",
when="AFTER",
),
),
),
]

View File

@@ -1,102 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-30 20:57
from django.db import migrations, models
def migrate_avatar_data(apps, schema_editor):
"""
Migrate avatar data from old CloudflareImageField to new ForeignKey structure.
Since we're transitioning to a new system, we'll just drop the old avatar column
and add the new avatar_id column for ForeignKey relationships.
"""
# This is a data migration - we'll handle the schema changes in the operations
pass
def reverse_migrate_avatar_data(apps, schema_editor):
"""
Reverse migration - not implemented as this is a one-way migration
"""
pass
def safe_add_avatar_field(apps, schema_editor):
"""
Safely add avatar field, checking if it already exists.
"""
# Check if the column already exists
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
"""
)
column_exists = cursor.fetchone() is not None
if not column_exists:
# Column doesn't exist, add it
UserProfile = apps.get_model("accounts", "UserProfile")
field = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage", on_delete=models.SET_NULL, null=True, blank=True
)
field.set_attributes_from_name("avatar")
schema_editor.add_field(UserProfile, field)
def reverse_safe_add_avatar_field(apps, schema_editor):
"""
Reverse the safe avatar field addition.
"""
# Check if the column exists and remove it
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofile'
AND column_name='avatar_id'
"""
)
column_exists = cursor.fetchone() is not None
if column_exists:
UserProfile = apps.get_model("accounts", "UserProfile")
field = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage", on_delete=models.SET_NULL, null=True, blank=True
)
field.set_attributes_from_name("avatar")
schema_editor.remove_field(UserProfile, field)
class Migration(migrations.Migration):
dependencies = [
(
"accounts",
"0009_notificationpreference_notificationpreferenceevent_and_more",
),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
# First, remove the old avatar column (CloudflareImageField)
migrations.RunSQL(
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation",
),
# Safely add the new avatar_id column for ForeignKey
migrations.RunPython(
safe_add_avatar_field,
reverse_safe_add_avatar_field,
),
# Run the data migration
migrations.RunPython(
migrate_avatar_data,
reverse_migrate_avatar_data,
),
]

View File

@@ -1,36 +0,0 @@
# Generated manually on 2025-08-30 to fix pghistory event table schema
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0010_auto_20250830_1657"),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
# Remove the old avatar field from the event table
migrations.RunSQL(
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation",
),
# Add the new avatar_id field to match the main table (only if it doesn't exist)
migrations.RunSQL(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT column_name
FROM information_schema.columns
WHERE table_name='accounts_userprofileevent'
AND column_name='avatar_id'
) THEN
ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;
END IF;
END $$;
""",
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;",
),
]

View File

@@ -1,242 +0,0 @@
# Generated by Django 5.2.5 on 2025-09-15 17:35
from django.db import migrations
import apps.core.choices.fields
class Migration(migrations.Migration):
dependencies = [
("accounts", "0011_fix_userprofile_event_avatar_field"),
]
operations = [
migrations.AlterField(
model_name="toplist",
name="category",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="top_list_categories",
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
domain="accounts",
max_length=2,
),
),
migrations.AlterField(
model_name="user",
name="activity_visibility",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="privacy_level",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="role",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="user_roles",
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="theme_preference",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="theme_preferences",
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
domain="accounts",
max_length=5,
),
),
migrations.AlterField(
model_name="userevent",
name="activity_visibility",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="privacy_level",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="role",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="user_roles",
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="theme_preference",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="theme_preferences",
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
domain="accounts",
max_length=5,
),
),
migrations.AlterField(
model_name="usernotification",
name="notification_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_types",
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
domain="accounts",
max_length=30,
),
),
migrations.AlterField(
model_name="usernotification",
name="priority",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_priorities",
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
domain="accounts",
max_length=10,
),
),
migrations.AlterField(
model_name="usernotificationevent",
name="notification_type",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_types",
choices=[
("submission_approved", "Submission Approved"),
("submission_rejected", "Submission Rejected"),
("submission_pending", "Submission Pending Review"),
("review_reply", "Review Reply"),
("review_helpful", "Review Marked Helpful"),
("friend_request", "Friend Request"),
("friend_accepted", "Friend Request Accepted"),
("message_received", "Message Received"),
("profile_comment", "Profile Comment"),
("system_announcement", "System Announcement"),
("account_security", "Account Security"),
("feature_update", "Feature Update"),
("maintenance", "Maintenance Notice"),
("achievement_unlocked", "Achievement Unlocked"),
("milestone_reached", "Milestone Reached"),
],
domain="accounts",
max_length=30,
),
),
migrations.AlterField(
model_name="usernotificationevent",
name="priority",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="notification_priorities",
choices=[
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
],
default="normal",
domain="accounts",
max_length=10,
),
),
]

View File

@@ -1,40 +0,0 @@
"""
Add performance indexes and constraints to User model.
This migration adds:
1. db_index=True to is_banned and role fields for faster filtering
2. Composite index on (is_banned, role) for common query patterns
3. CheckConstraint to ensure banned users have a ban_date set
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0012_alter_toplist_category_and_more"),
]
operations = [
# Add db_index to is_banned field
migrations.AlterField(
model_name="user",
name="is_banned",
field=models.BooleanField(default=False, db_index=True),
),
# Add composite index for common query patterns
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["is_banned", "role"], name="accounts_user_banned_role_idx"),
),
# Add CheckConstraint for ban consistency
migrations.AddConstraint(
model_name="user",
constraint=models.CheckConstraint(
name="user_ban_consistency",
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
violation_error_message="Banned users must have a ban_date set",
),
),
]

View File

@@ -1,888 +0,0 @@
# Generated by Django 5.1.6 on 2025-12-26 14:10
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):
dependencies = [
("accounts", "0013_add_user_query_indexes"),
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "User", "verbose_name_plural": "Users"},
),
migrations.AlterModelOptions(
name="userdeletionrequest",
options={
"ordering": ["-created_at"],
"verbose_name": "User Deletion Request",
"verbose_name_plural": "User Deletion Requests",
},
),
migrations.AlterModelOptions(
name="usernotification",
options={
"ordering": ["-created_at"],
"verbose_name": "User Notification",
"verbose_name_plural": "User Notifications",
},
),
migrations.AlterModelOptions(
name="userprofile",
options={
"ordering": ["user"],
"verbose_name": "User Profile",
"verbose_name_plural": "User Profiles",
},
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AddField(
model_name="userprofile",
name="location",
field=models.CharField(blank=True, help_text="User's location (City, Country)", max_length=100),
),
migrations.AddField(
model_name="userprofile",
name="unit_system",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="unit_systems",
choices=[("metric", "Metric"), ("imperial", "Imperial")],
default="metric",
domain="accounts",
help_text="Preferred measurement system",
max_length=10,
),
),
migrations.AddField(
model_name="userprofileevent",
name="location",
field=models.CharField(blank=True, help_text="User's location (City, Country)", max_length=100),
),
migrations.AddField(
model_name="userprofileevent",
name="unit_system",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="unit_systems",
choices=[("metric", "Metric"), ("imperial", "Imperial")],
default="metric",
domain="accounts",
help_text="Preferred measurement system",
max_length=10,
),
),
migrations.AlterField(
model_name="emailverification",
name="created_at",
field=models.DateTimeField(auto_now_add=True, help_text="When this verification was created"),
),
migrations.AlterField(
model_name="emailverification",
name="last_sent",
field=models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent"),
),
migrations.AlterField(
model_name="emailverification",
name="token",
field=models.CharField(help_text="Verification token", max_length=64, unique=True),
),
migrations.AlterField(
model_name="emailverification",
name="user",
field=models.OneToOneField(
help_text="User this verification belongs to",
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="emailverificationevent",
name="created_at",
field=models.DateTimeField(auto_now_add=True, help_text="When this verification was created"),
),
migrations.AlterField(
model_name="emailverificationevent",
name="last_sent",
field=models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent"),
),
migrations.AlterField(
model_name="emailverificationevent",
name="token",
field=models.CharField(help_text="Verification token", max_length=64),
),
migrations.AlterField(
model_name="emailverificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User this verification belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="notificationpreference",
name="user",
field=models.OneToOneField(
help_text="User these preferences belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preference",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="notificationpreferenceevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User these preferences belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="passwordreset",
name="created_at",
field=models.DateTimeField(auto_now_add=True, help_text="When this reset was requested"),
),
migrations.AlterField(
model_name="passwordreset",
name="expires_at",
field=models.DateTimeField(help_text="When this reset token expires"),
),
migrations.AlterField(
model_name="passwordreset",
name="token",
field=models.CharField(help_text="Reset token", max_length=64),
),
migrations.AlterField(
model_name="passwordreset",
name="used",
field=models.BooleanField(default=False, help_text="Whether this token has been used"),
),
migrations.AlterField(
model_name="passwordreset",
name="user",
field=models.ForeignKey(
help_text="User requesting password reset",
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="passwordresetevent",
name="created_at",
field=models.DateTimeField(auto_now_add=True, help_text="When this reset was requested"),
),
migrations.AlterField(
model_name="passwordresetevent",
name="expires_at",
field=models.DateTimeField(help_text="When this reset token expires"),
),
migrations.AlterField(
model_name="passwordresetevent",
name="token",
field=models.CharField(help_text="Reset token", max_length=64),
),
migrations.AlterField(
model_name="passwordresetevent",
name="used",
field=models.BooleanField(default=False, help_text="Whether this token has been used"),
),
migrations.AlterField(
model_name="passwordresetevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User requesting password reset",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="user",
name="activity_visibility",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
domain="accounts",
help_text="Who can see user activity",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="allow_friend_requests",
field=models.BooleanField(default=True, help_text="Whether to allow friend requests"),
),
migrations.AlterField(
model_name="user",
name="allow_messages",
field=models.BooleanField(default=True, help_text="Whether to allow direct messages"),
),
migrations.AlterField(
model_name="user",
name="allow_profile_comments",
field=models.BooleanField(default=False, help_text="Whether to allow profile comments"),
),
migrations.AlterField(
model_name="user",
name="ban_date",
field=models.DateTimeField(blank=True, help_text="Date the user was banned", null=True),
),
migrations.AlterField(
model_name="user",
name="ban_reason",
field=models.TextField(blank=True, help_text="Reason for ban"),
),
migrations.AlterField(
model_name="user",
name="email_notifications",
field=models.BooleanField(default=True, help_text="Whether to send email notifications"),
),
migrations.AlterField(
model_name="user",
name="is_banned",
field=models.BooleanField(db_index=True, default=False, help_text="Whether this user is banned"),
),
migrations.AlterField(
model_name="user",
name="last_password_change",
field=models.DateTimeField(auto_now_add=True, help_text="When the password was last changed"),
),
migrations.AlterField(
model_name="user",
name="login_history_retention",
field=models.IntegerField(default=90, help_text="How long to retain login history (days)"),
),
migrations.AlterField(
model_name="user",
name="login_notifications",
field=models.BooleanField(default=True, help_text="Whether to send login notifications"),
),
migrations.AlterField(
model_name="user",
name="privacy_level",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
domain="accounts",
help_text="Overall privacy level",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="push_notifications",
field=models.BooleanField(default=False, help_text="Whether to send push notifications"),
),
migrations.AlterField(
model_name="user",
name="role",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="user_roles",
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
db_index=True,
default="USER",
domain="accounts",
help_text="User role (user, moderator, admin)",
max_length=10,
),
),
migrations.AlterField(
model_name="user",
name="search_visibility",
field=models.BooleanField(default=True, help_text="Whether profile appears in search results"),
),
migrations.AlterField(
model_name="user",
name="session_timeout",
field=models.IntegerField(default=30, help_text="Session timeout in days"),
),
migrations.AlterField(
model_name="user",
name="show_email",
field=models.BooleanField(default=False, help_text="Whether to show email on profile"),
),
migrations.AlterField(
model_name="user",
name="show_join_date",
field=models.BooleanField(default=True, help_text="Whether to show join date on profile"),
),
migrations.AlterField(
model_name="user",
name="show_photos",
field=models.BooleanField(default=True, help_text="Whether to show photos on profile"),
),
migrations.AlterField(
model_name="user",
name="show_real_name",
field=models.BooleanField(default=True, help_text="Whether to show real name on profile"),
),
migrations.AlterField(
model_name="user",
name="show_reviews",
field=models.BooleanField(default=True, help_text="Whether to show reviews on profile"),
),
migrations.AlterField(
model_name="user",
name="show_statistics",
field=models.BooleanField(default=True, help_text="Whether to show statistics on profile"),
),
migrations.AlterField(
model_name="user",
name="show_top_lists",
field=models.BooleanField(default=True, help_text="Whether to show top lists on profile"),
),
migrations.AlterField(
model_name="user",
name="theme_preference",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="theme_preferences",
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
domain="accounts",
help_text="User's theme preference (light/dark)",
max_length=5,
),
),
migrations.AlterField(
model_name="user",
name="two_factor_enabled",
field=models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled"),
),
migrations.AlterField(
model_name="userevent",
name="activity_visibility",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="friends",
domain="accounts",
help_text="Who can see user activity",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="allow_friend_requests",
field=models.BooleanField(default=True, help_text="Whether to allow friend requests"),
),
migrations.AlterField(
model_name="userevent",
name="allow_messages",
field=models.BooleanField(default=True, help_text="Whether to allow direct messages"),
),
migrations.AlterField(
model_name="userevent",
name="allow_profile_comments",
field=models.BooleanField(default=False, help_text="Whether to allow profile comments"),
),
migrations.AlterField(
model_name="userevent",
name="ban_date",
field=models.DateTimeField(blank=True, help_text="Date the user was banned", null=True),
),
migrations.AlterField(
model_name="userevent",
name="ban_reason",
field=models.TextField(blank=True, help_text="Reason for ban"),
),
migrations.AlterField(
model_name="userevent",
name="email_notifications",
field=models.BooleanField(default=True, help_text="Whether to send email notifications"),
),
migrations.AlterField(
model_name="userevent",
name="is_banned",
field=models.BooleanField(default=False, help_text="Whether this user is banned"),
),
migrations.AlterField(
model_name="userevent",
name="last_password_change",
field=models.DateTimeField(auto_now_add=True, help_text="When the password was last changed"),
),
migrations.AlterField(
model_name="userevent",
name="login_history_retention",
field=models.IntegerField(default=90, help_text="How long to retain login history (days)"),
),
migrations.AlterField(
model_name="userevent",
name="login_notifications",
field=models.BooleanField(default=True, help_text="Whether to send login notifications"),
),
migrations.AlterField(
model_name="userevent",
name="privacy_level",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="privacy_levels",
choices=[
("public", "Public"),
("friends", "Friends Only"),
("private", "Private"),
],
default="public",
domain="accounts",
help_text="Overall privacy level",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="push_notifications",
field=models.BooleanField(default=False, help_text="Whether to send push notifications"),
),
migrations.AlterField(
model_name="userevent",
name="role",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="user_roles",
choices=[
("USER", "User"),
("MODERATOR", "Moderator"),
("ADMIN", "Admin"),
("SUPERUSER", "Superuser"),
],
default="USER",
domain="accounts",
help_text="User role (user, moderator, admin)",
max_length=10,
),
),
migrations.AlterField(
model_name="userevent",
name="search_visibility",
field=models.BooleanField(default=True, help_text="Whether profile appears in search results"),
),
migrations.AlterField(
model_name="userevent",
name="session_timeout",
field=models.IntegerField(default=30, help_text="Session timeout in days"),
),
migrations.AlterField(
model_name="userevent",
name="show_email",
field=models.BooleanField(default=False, help_text="Whether to show email on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_join_date",
field=models.BooleanField(default=True, help_text="Whether to show join date on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_photos",
field=models.BooleanField(default=True, help_text="Whether to show photos on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_real_name",
field=models.BooleanField(default=True, help_text="Whether to show real name on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_reviews",
field=models.BooleanField(default=True, help_text="Whether to show reviews on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_statistics",
field=models.BooleanField(default=True, help_text="Whether to show statistics on profile"),
),
migrations.AlterField(
model_name="userevent",
name="show_top_lists",
field=models.BooleanField(default=True, help_text="Whether to show top lists on profile"),
),
migrations.AlterField(
model_name="userevent",
name="theme_preference",
field=apps.core.choices.fields.RichChoiceField(
allow_deprecated=False,
choice_group="theme_preferences",
choices=[("light", "Light"), ("dark", "Dark")],
default="light",
domain="accounts",
help_text="User's theme preference (light/dark)",
max_length=5,
),
),
migrations.AlterField(
model_name="userevent",
name="two_factor_enabled",
field=models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled"),
),
migrations.AlterField(
model_name="usernotification",
name="content_type",
field=models.ForeignKey(
blank=True,
help_text="Type of related object",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="usernotification",
name="email_sent",
field=models.BooleanField(default=False, help_text="Whether email was sent"),
),
migrations.AlterField(
model_name="usernotification",
name="email_sent_at",
field=models.DateTimeField(blank=True, help_text="When email was sent", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="is_read",
field=models.BooleanField(default=False, help_text="Whether this notification has been read"),
),
migrations.AlterField(
model_name="usernotification",
name="message",
field=models.TextField(help_text="Notification message"),
),
migrations.AlterField(
model_name="usernotification",
name="object_id",
field=models.PositiveIntegerField(blank=True, help_text="ID of related object", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="push_sent",
field=models.BooleanField(default=False, help_text="Whether push notification was sent"),
),
migrations.AlterField(
model_name="usernotification",
name="push_sent_at",
field=models.DateTimeField(blank=True, help_text="When push notification was sent", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="read_at",
field=models.DateTimeField(blank=True, help_text="When this notification was read", null=True),
),
migrations.AlterField(
model_name="usernotification",
name="title",
field=models.CharField(help_text="Notification title", max_length=200),
),
migrations.AlterField(
model_name="usernotification",
name="user",
field=models.ForeignKey(
help_text="User this notification is for",
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="usernotificationevent",
name="content_type",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Type of related object",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AlterField(
model_name="usernotificationevent",
name="email_sent",
field=models.BooleanField(default=False, help_text="Whether email was sent"),
),
migrations.AlterField(
model_name="usernotificationevent",
name="email_sent_at",
field=models.DateTimeField(blank=True, help_text="When email was sent", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="is_read",
field=models.BooleanField(default=False, help_text="Whether this notification has been read"),
),
migrations.AlterField(
model_name="usernotificationevent",
name="message",
field=models.TextField(help_text="Notification message"),
),
migrations.AlterField(
model_name="usernotificationevent",
name="object_id",
field=models.PositiveIntegerField(blank=True, help_text="ID of related object", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="push_sent",
field=models.BooleanField(default=False, help_text="Whether push notification was sent"),
),
migrations.AlterField(
model_name="usernotificationevent",
name="push_sent_at",
field=models.DateTimeField(blank=True, help_text="When push notification was sent", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="read_at",
field=models.DateTimeField(blank=True, help_text="When this notification was read", null=True),
),
migrations.AlterField(
model_name="usernotificationevent",
name="title",
field=models.CharField(help_text="Notification title", max_length=200),
),
migrations.AlterField(
model_name="usernotificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User this notification is for",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userprofile",
name="avatar",
field=models.ForeignKey(
blank=True,
help_text="User's avatar image",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_profiles",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="userprofile",
name="bio",
field=models.TextField(blank=True, help_text="User biography", max_length=500),
),
migrations.AlterField(
model_name="userprofile",
name="coaster_credits",
field=models.IntegerField(default=0, help_text="Number of roller coasters ridden"),
),
migrations.AlterField(
model_name="userprofile",
name="dark_ride_credits",
field=models.IntegerField(default=0, help_text="Number of dark rides ridden"),
),
migrations.AlterField(
model_name="userprofile",
name="discord",
field=models.CharField(blank=True, help_text="Discord username", max_length=100),
),
migrations.AlterField(
model_name="userprofile",
name="flat_ride_credits",
field=models.IntegerField(default=0, help_text="Number of flat rides ridden"),
),
migrations.AlterField(
model_name="userprofile",
name="instagram",
field=models.URLField(blank=True, help_text="Instagram profile URL"),
),
migrations.AlterField(
model_name="userprofile",
name="pronouns",
field=models.CharField(blank=True, help_text="User's preferred pronouns", max_length=50),
),
migrations.AlterField(
model_name="userprofile",
name="twitter",
field=models.URLField(blank=True, help_text="Twitter profile URL"),
),
migrations.AlterField(
model_name="userprofile",
name="user",
field=models.OneToOneField(
help_text="User this profile belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userprofile",
name="water_ride_credits",
field=models.IntegerField(default=0, help_text="Number of water rides ridden"),
),
migrations.AlterField(
model_name="userprofile",
name="youtube",
field=models.URLField(blank=True, help_text="YouTube channel URL"),
),
migrations.AlterField(
model_name="userprofileevent",
name="avatar",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="User's avatar image",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="userprofileevent",
name="bio",
field=models.TextField(blank=True, help_text="User biography", max_length=500),
),
migrations.AlterField(
model_name="userprofileevent",
name="coaster_credits",
field=models.IntegerField(default=0, help_text="Number of roller coasters ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
name="dark_ride_credits",
field=models.IntegerField(default=0, help_text="Number of dark rides ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
name="discord",
field=models.CharField(blank=True, help_text="Discord username", max_length=100),
),
migrations.AlterField(
model_name="userprofileevent",
name="flat_ride_credits",
field=models.IntegerField(default=0, help_text="Number of flat rides ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
name="instagram",
field=models.URLField(blank=True, help_text="Instagram profile URL"),
),
migrations.AlterField(
model_name="userprofileevent",
name="pronouns",
field=models.CharField(blank=True, help_text="User's preferred pronouns", max_length=50),
),
migrations.AlterField(
model_name="userprofileevent",
name="twitter",
field=models.URLField(blank=True, help_text="Twitter profile URL"),
),
migrations.AlterField(
model_name="userprofileevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
help_text="User this profile belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userprofileevent",
name="water_ride_credits",
field=models.IntegerField(default=0, help_text="Number of water rides ridden"),
),
migrations.AlterField(
model_name="userprofileevent",
name="youtube",
field=models.URLField(blank=True, help_text="YouTube channel URL"),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "location", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "unit_system", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", NEW."location", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."unit_system", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="dab03867fefb6b82eec203906fe25f4e43d95783",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "location", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "unit_system", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", NEW."location", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."unit_system", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="b70f93243f5852ae882f51a191d69bb3d3d151f7",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
migrations.DeleteModel(
name="TopList",
),
migrations.DeleteModel(
name="TopListItem",
),
]

View File

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

View File

@@ -1,41 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-07 01:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0015_loginhistory_loginhistoryevent_and_more'),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name='emailverification',
name='insert_insert',
),
pgtrigger.migrations.RemoveTrigger(
model_name='emailverification',
name='update_update',
),
migrations.AddField(
model_name='emailverification',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='When this verification was last updated'),
),
migrations.AddField(
model_name='emailverificationevent',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='When this verification was last updated'),
),
pgtrigger.migrations.AddTrigger(
model_name='emailverification',
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "updated_at", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."token", NEW."updated_at", NEW."user_id"); RETURN NULL;', hash='53c568e932b1b55a3c79e79220e6d6f269458003', operation='INSERT', pgid='pgtrigger_insert_insert_53748', table='accounts_emailverification', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='emailverification',
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "accounts_emailverificationevent" ("created_at", "id", "last_sent", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "token", "updated_at", "user_id") VALUES (NEW."created_at", NEW."id", NEW."last_sent", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."token", NEW."updated_at", NEW."user_id"); RETURN NULL;', hash='8b45a9a0a1810564cb46c098552ab4ec7920daeb', operation='UPDATE', pgid='pgtrigger_update_update_7a2a8', table='accounts_emailverification', when='AFTER')),
),
]

View File

@@ -1,45 +1,35 @@
"""
Mixins for authentication views.
"""
import requests
from django.conf import settings
from django.core.exceptions import ValidationError
from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token
class TurnstileMixin:
"""
Mixin to handle Cloudflare Turnstile validation.
Works with both form POST data and JSON request bodies.
Bypasses validation when DEBUG is True.
"""
def validate_turnstile(self, request):
"""
Validate the Turnstile response token.
The token can be provided as:
- 'cf-turnstile-response' in POST data (form submission)
- 'turnstile_token' in JSON body (API request)
Skips validation when DEBUG is True.
"""
# Try to get token from various sources
token = None
if settings.DEBUG:
return
# Check POST data (form submissions)
if hasattr(request, "POST"):
token = request.POST.get("cf-turnstile-response")
token = request.POST.get("cf-turnstile-response")
if not token:
raise ValidationError("Please complete the Turnstile challenge.")
# Check JSON body (API requests)
if not token and hasattr(request, "data"):
data = getattr(request, "data", {})
if hasattr(data, "get"):
token = data.get("turnstile_token") or data.get("cf-turnstile-response")
# Verify the token with Cloudflare
data = {
"secret": settings.TURNSTILE_SECRET_KEY,
"response": token,
"remoteip": request.META.get("REMOTE_ADDR"),
}
# Get client IP
ip = get_client_ip(request)
# Validate the token
result = validate_turnstile_token(token, ip)
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
result = response.json()
if not result.get("success"):
error_msg = result.get("error", "Captcha verification failed. Please try again.")
raise ValidationError(error_msg)
raise ValidationError("Turnstile validation failed. Please try again.")

View File

@@ -1,19 +1,11 @@
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 import timezone
from apps.core.choices import RichChoiceField
from django.utils.translation import gettext_lazy as _
import os
import secrets
from apps.core.history import TrackedModel
# from django_cloudflareimages_toolkit.models import CloudflareImage
import pghistory
def generate_random_id(model_class, id_field):
@@ -32,88 +24,40 @@ def generate_random_id(model_class, id_field):
@pghistory.track()
class User(AbstractUser):
# Override inherited fields to remove them
first_name = None
last_name = None
class Roles(models.TextChoices):
USER = "USER", _("User")
MODERATOR = "MODERATOR", _("Moderator")
ADMIN = "ADMIN", _("Admin")
SUPERUSER = "SUPERUSER", _("Superuser")
class ThemePreference(models.TextChoices):
LIGHT = "light", _("Light")
DARK = "dark", _("Dark")
# Read-only ID
user_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text=("Unique identifier for this user that remains constant even if the " "username changes"),
help_text=(
"Unique identifier for this user that remains constant even if the "
"username changes"
),
)
role = RichChoiceField(
choice_group="user_roles",
domain="accounts",
role = models.CharField(
max_length=10,
default="USER",
db_index=True,
help_text="User role (user, moderator, admin)",
choices=Roles.choices,
default=Roles.USER,
)
is_banned = models.BooleanField(default=False, db_index=True, help_text="Whether this user is banned")
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
ban_date = models.DateTimeField(null=True, blank=True, help_text="Date the user was banned")
is_banned = models.BooleanField(default=False)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
theme_preference = RichChoiceField(
choice_group="theme_preferences",
domain="accounts",
theme_preference = models.CharField(
max_length=5,
default="light",
help_text="User's theme preference (light/dark)",
)
# Notification preferences
email_notifications = models.BooleanField(default=True, help_text="Whether to send email notifications")
push_notifications = models.BooleanField(default=False, help_text="Whether to send push notifications")
# Privacy settings
privacy_level = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="public",
help_text="Overall privacy level",
)
show_email = models.BooleanField(default=False, help_text="Whether to show email on profile")
show_real_name = models.BooleanField(default=True, help_text="Whether to show real name on profile")
show_join_date = models.BooleanField(default=True, help_text="Whether to show join date on profile")
show_statistics = models.BooleanField(default=True, help_text="Whether to show statistics on profile")
show_reviews = models.BooleanField(default=True, help_text="Whether to show reviews on profile")
show_photos = models.BooleanField(default=True, help_text="Whether to show photos on profile")
show_top_lists = models.BooleanField(default=True, help_text="Whether to show top lists on profile")
allow_friend_requests = models.BooleanField(default=True, help_text="Whether to allow friend requests")
allow_messages = models.BooleanField(default=True, help_text="Whether to allow direct messages")
allow_profile_comments = models.BooleanField(default=False, help_text="Whether to allow profile comments")
search_visibility = models.BooleanField(default=True, help_text="Whether profile appears in search results")
activity_visibility = RichChoiceField(
choice_group="privacy_levels",
domain="accounts",
max_length=10,
default="friends",
help_text="Who can see user activity",
)
# Security settings
two_factor_enabled = models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled")
login_notifications = models.BooleanField(default=True, help_text="Whether to send login notifications")
session_timeout = models.IntegerField(default=30, help_text="Session timeout in days")
login_history_retention = models.IntegerField(default=90, help_text="How long to retain login history (days)")
last_password_change = models.DateTimeField(auto_now_add=True, help_text="When the password was last changed")
# Display name - core user data for better performance
display_name = models.CharField(
max_length=50,
blank=True,
help_text="Display name shown throughout the site. Falls back to username if not set.",
)
# Detailed notification preferences (JSON field for flexibility)
notification_preferences = models.JSONField(
default=dict,
blank=True,
help_text="Detailed notification preferences stored as JSON",
choices=ThemePreference.choices,
default=ThemePreference.LIGHT,
)
def __str__(self):
@@ -124,28 +68,11 @@ class User(AbstractUser):
def get_display_name(self):
"""Get the user's display name, falling back to username if not set"""
if self.display_name:
return self.display_name
# Fallback to profile display_name for backward compatibility
profile = getattr(self, "profile", None)
if profile and profile.display_name:
return profile.display_name
return self.username
class Meta:
verbose_name = "User"
verbose_name_plural = "Users"
indexes = [
models.Index(fields=["is_banned", "role"], name="accounts_user_banned_role_idx"),
]
constraints = [
models.CheckConstraint(
name="user_ban_consistency",
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
violation_error_message="Banned users must have a ban_date set",
),
]
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, "user_id")
@@ -162,117 +89,41 @@ class UserProfile(models.Model):
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="profile",
help_text="User this profile belongs to",
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
display_name = models.CharField(
max_length=50,
blank=True,
help_text="Legacy display name field - use User.display_name instead",
unique=True,
help_text="This is the name that will be displayed on the site",
)
avatar = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="user_profiles",
help_text="User's avatar image",
)
pronouns = models.CharField(max_length=50, blank=True, help_text="User's preferred pronouns")
avatar = models.ImageField(upload_to="avatars/", blank=True)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
location = models.CharField(max_length=100, blank=True, help_text="User's location (City, Country)")
unit_system = RichChoiceField(
choice_group="unit_systems",
domain="accounts",
max_length=10,
default="metric",
help_text="Preferred measurement system",
)
bio = models.TextField(max_length=500, blank=True)
# Social media links
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
instagram = models.URLField(blank=True, help_text="Instagram profile URL")
youtube = models.URLField(blank=True, help_text="YouTube channel URL")
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
# Ride statistics
coaster_credits = models.IntegerField(default=0, help_text="Number of roller coasters ridden")
dark_ride_credits = models.IntegerField(default=0, help_text="Number of dark rides ridden")
flat_ride_credits = models.IntegerField(default=0, help_text="Number of flat rides ridden")
water_ride_credits = models.IntegerField(default=0, help_text="Number of water rides ridden")
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
def get_avatar_url(self):
def get_avatar(self):
"""
Return the avatar URL or generate a default letter-based avatar URL
Return the avatar URL or serve a pre-generated avatar based on the
first letter of the username
"""
if self.avatar and self.avatar.is_uploaded:
# Try to get avatar variant first, fallback to public
avatar_url = self.avatar.get_url("avatar")
if avatar_url:
return avatar_url
# Fallback to public variant
public_url = self.avatar.get_url("public")
if public_url:
return public_url
# Last fallback - try any available variant
if self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
return self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
# Return first available variant
for variant_url in self.avatar.variants.values():
if variant_url:
return variant_url
# Generate default letter-based avatar using first letter of username
first_letter = self.user.username[0].upper() if self.user.username else "U"
# Use a service like UI Avatars or generate a simple colored avatar
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
def get_avatar_variants(self):
"""
Return avatar variants for different use cases
"""
if self.avatar and self.avatar.is_uploaded:
variants = {}
# Try to get specific variants
thumbnail_url = self.avatar.get_url("thumbnail")
avatar_url = self.avatar.get_url("avatar")
large_url = self.avatar.get_url("large")
public_url = self.avatar.get_url("public")
# Use specific variants if available, otherwise fallback to public or first available
fallback_url = public_url
if not fallback_url and self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
fallback_url = self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
fallback_url = next(iter(self.avatar.variants.values()), None)
variants = {
"thumbnail": thumbnail_url or fallback_url,
"avatar": avatar_url or fallback_url,
"large": large_url or fallback_url,
}
# Only return variants if we have at least one valid URL
if any(variants.values()):
return variants
# For default avatars, return the same URL for all variants
default_url = self.get_avatar_url()
return {
"thumbnail": default_url,
"avatar": default_url,
"large": default_url,
}
if self.avatar:
return self.avatar.url
first_letter = self.user.username.upper()
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
if os.path.exists(avatar_path):
return f"/{avatar_path}"
return "/static/images/default-avatar.png"
def save(self, *args, **kwargs):
# If no display name is set, use the username
@@ -286,23 +137,13 @@ class UserProfile(models.Model):
def __str__(self):
return self.display_name
class Meta:
verbose_name = "User Profile"
verbose_name_plural = "User Profiles"
ordering = ["user"]
@pghistory.track()
class EmailVerification(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
help_text="User this verification belongs to",
)
token = models.CharField(max_length=64, unique=True, help_text="Verification token")
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")
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email verification for {self.user.username}"
@@ -314,15 +155,11 @@ class EmailVerification(models.Model):
@pghistory.track()
class PasswordReset(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
help_text="User requesting password reset",
)
token = models.CharField(max_length=64, help_text="Reset token")
created_at = models.DateTimeField(auto_now_add=True, help_text="When this reset was requested")
expires_at = models.DateTimeField(help_text="When this reset token expires")
used = models.BooleanField(default=False, help_text="Whether this token has been used")
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
def __str__(self):
return f"Password reset for {self.user.username}"
@@ -332,299 +169,54 @@ class PasswordReset(models.Model):
verbose_name_plural = "Password Resets"
@pghistory.track()
class UserDeletionRequest(models.Model):
"""
Model to track user deletion requests with email verification.
When a user requests to delete their account, a verification code
is sent to their email. The deletion is only processed when they
provide the correct code.
"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="deletion_request")
verification_code = models.CharField(
max_length=32,
unique=True,
help_text="Unique verification code sent to user's email",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(help_text="When this deletion request expires")
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the verification email was sent")
attempts = models.PositiveIntegerField(default=0, help_text="Number of verification attempts made")
max_attempts = models.PositiveIntegerField(default=5, help_text="Maximum number of verification attempts allowed")
is_used = models.BooleanField(default=False, help_text="Whether this deletion request has been used")
class Meta:
verbose_name = "User Deletion Request"
verbose_name_plural = "User Deletion Requests"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["verification_code"]),
models.Index(fields=["expires_at"]),
models.Index(fields=["user", "is_used"]),
]
def __str__(self):
return f"Deletion request for {self.user.username} - {self.verification_code}"
def save(self, *args, **kwargs):
if not self.verification_code:
self.verification_code = self.generate_verification_code()
if not self.expires_at:
# Deletion requests expire after 24 hours
self.expires_at = timezone.now() + timedelta(hours=24)
super().save(*args, **kwargs)
@staticmethod
def generate_verification_code():
"""Generate a unique 8-character verification code."""
while True:
# Generate a random 8-character alphanumeric code
code = "".join(secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8))
# Ensure it's unique
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
return code
def is_expired(self):
"""Check if this deletion request has expired."""
return timezone.now() > self.expires_at
def is_valid(self):
"""Check if this deletion request is still valid."""
return not self.is_used and not self.is_expired() and self.attempts < self.max_attempts
def increment_attempts(self):
"""Increment the number of verification attempts."""
self.attempts += 1
self.save(update_fields=["attempts"])
def mark_as_used(self):
"""Mark this deletion request as used."""
self.is_used = True
self.save(update_fields=["is_used"])
@classmethod
def cleanup_expired(cls):
"""Remove expired deletion requests."""
expired_requests = cls.objects.filter(expires_at__lt=timezone.now(), is_used=False)
count = expired_requests.count()
expired_requests.delete()
return count
# @pghistory.track()
@pghistory.track()
class UserNotification(TrackedModel):
"""
Model to store user notifications for various events.
class TopList(TrackedModel):
class Categories(models.TextChoices):
ROLLER_COASTER = "RC", _("Roller Coaster")
DARK_RIDE = "DR", _("Dark Ride")
FLAT_RIDE = "FR", _("Flat Ride")
WATER_RIDE = "WR", _("Water Ride")
PARK = "PK", _("Park")
This includes submission approvals, rejections, system announcements,
and other user-relevant notifications.
"""
# Core fields
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="notifications",
help_text="User this notification is for",
related_name="top_lists", # Added related_name for User model access
)
notification_type = RichChoiceField(
choice_group="notification_types",
domain="accounts",
max_length=30,
)
title = models.CharField(max_length=200, help_text="Notification title")
message = models.TextField(help_text="Notification message")
# Optional related object (submission, review, etc.)
content_type = models.ForeignKey(
"contenttypes.ContentType",
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="Type of related object",
)
object_id = models.PositiveIntegerField(null=True, blank=True, help_text="ID of related object")
related_object = GenericForeignKey("content_type", "object_id")
# Metadata
priority = RichChoiceField(
choice_group="notification_priorities",
domain="accounts",
max_length=10,
default="normal",
)
# Status tracking
is_read = models.BooleanField(default=False, help_text="Whether this notification has been read")
read_at = models.DateTimeField(null=True, blank=True, help_text="When this notification was read")
# Delivery tracking
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When email was sent")
push_sent = models.BooleanField(default=False, help_text="Whether push notification was sent")
push_sent_at = models.DateTimeField(null=True, blank=True, help_text="When push notification was sent")
# Additional data (JSON field for flexibility)
extra_data = models.JSONField(default=dict, blank=True)
# Timestamps
title = models.CharField(max_length=100)
category = models.CharField(max_length=2, choices=Categories.choices)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
verbose_name = "User Notification"
verbose_name_plural = "User Notifications"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "is_read"]),
models.Index(fields=["user", "notification_type"]),
models.Index(fields=["created_at"]),
models.Index(fields=["expires_at"]),
]
ordering = ["-updated_at"]
def __str__(self):
return f"{self.user.username}: {self.title}"
def mark_as_read(self):
"""Mark notification as read."""
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=["is_read", "read_at"])
def is_expired(self):
"""Check if notification has expired."""
if not self.expires_at:
return False
return timezone.now() > self.expires_at
@classmethod
def cleanup_expired(cls):
"""Remove expired notifications."""
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
count = expired_notifications.count()
expired_notifications.delete()
return count
@classmethod
def mark_all_read_for_user(cls, user):
"""Mark all notifications as read for a specific user."""
return cls.objects.filter(user=user, is_read=False).update(is_read=True, read_at=timezone.now())
return (
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
)
@pghistory.track()
class NotificationPreference(TrackedModel):
"""
User preferences for different types of notifications.
# @pghistory.track()
This allows users to control which notifications they receive
and through which channels (email, push, in-app).
"""
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="notification_preference",
help_text="User these preferences belong to",
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList, on_delete=models.CASCADE, related_name="items"
)
# Submission notifications
submission_approved_email = models.BooleanField(default=True)
submission_approved_push = models.BooleanField(default=True)
submission_approved_inapp = models.BooleanField(default=True)
submission_rejected_email = models.BooleanField(default=True)
submission_rejected_push = models.BooleanField(default=True)
submission_rejected_inapp = models.BooleanField(default=True)
submission_pending_email = models.BooleanField(default=False)
submission_pending_push = models.BooleanField(default=False)
submission_pending_inapp = models.BooleanField(default=True)
# Review notifications
review_reply_email = models.BooleanField(default=True)
review_reply_push = models.BooleanField(default=True)
review_reply_inapp = models.BooleanField(default=True)
review_helpful_email = models.BooleanField(default=False)
review_helpful_push = models.BooleanField(default=True)
review_helpful_inapp = models.BooleanField(default=True)
# Social notifications
friend_request_email = models.BooleanField(default=True)
friend_request_push = models.BooleanField(default=True)
friend_request_inapp = models.BooleanField(default=True)
friend_accepted_email = models.BooleanField(default=False)
friend_accepted_push = models.BooleanField(default=True)
friend_accepted_inapp = models.BooleanField(default=True)
message_received_email = models.BooleanField(default=True)
message_received_push = models.BooleanField(default=True)
message_received_inapp = models.BooleanField(default=True)
# System notifications
system_announcement_email = models.BooleanField(default=True)
system_announcement_push = models.BooleanField(default=False)
system_announcement_inapp = models.BooleanField(default=True)
account_security_email = models.BooleanField(default=True)
account_security_push = models.BooleanField(default=True)
account_security_inapp = models.BooleanField(default=True)
feature_update_email = models.BooleanField(default=True)
feature_update_push = models.BooleanField(default=False)
feature_update_inapp = models.BooleanField(default=True)
# Achievement notifications
achievement_unlocked_email = models.BooleanField(default=False)
achievement_unlocked_push = models.BooleanField(default=True)
achievement_unlocked_inapp = models.BooleanField(default=True)
milestone_reached_email = models.BooleanField(default=False)
milestone_reached_push = models.BooleanField(default=True)
milestone_reached_inapp = models.BooleanField(default=True)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
rank = models.PositiveIntegerField()
notes = models.TextField(blank=True)
class Meta(TrackedModel.Meta):
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
def __str__(self):
return f"Notification preferences for {self.user.username}"
def should_send_notification(self, notification_type, channel):
"""
Check if a notification should be sent for a specific type and channel.
Args:
notification_type: The type of notification (from UserNotification.NotificationType)
channel: The delivery channel ('email', 'push', 'inapp')
Returns:
bool: True if notification should be sent, False otherwise
"""
field_name = f"{notification_type}_{channel}"
return getattr(self, field_name, False)
# Signal handlers for automatic notification preference creation
@receiver(post_save, sender=User)
def create_notification_preference(sender, instance, created, **kwargs):
"""Create notification preferences when a new user is created."""
if created:
NotificationPreference.objects.create(user=instance)
return f"#{self.rank} in {self.top_list.title}"

View File

@@ -0,0 +1,208 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import os
import secrets
from apps.core.history import TrackedModel
import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
# Try to get a 4-digit number first
new_id = str(secrets.SystemRandom().randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
# If all 4-digit numbers are taken, try 5 digits
new_id = str(secrets.SystemRandom().randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
class User(AbstractUser):
class Roles(models.TextChoices):
USER = "USER", _("User")
MODERATOR = "MODERATOR", _("Moderator")
ADMIN = "ADMIN", _("Admin")
SUPERUSER = "SUPERUSER", _("Superuser")
class ThemePreference(models.TextChoices):
LIGHT = "light", _("Light")
DARK = "dark", _("Dark")
# Read-only ID
user_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text="Unique identifier for this user that remains constant even if the username changes",
)
role = models.CharField(
max_length=10,
choices=Roles.choices,
default=Roles.USER,
)
is_banned = models.BooleanField(default=False)
ban_reason = models.TextField(blank=True)
ban_date = models.DateTimeField(null=True, blank=True)
pending_email = models.EmailField(blank=True, null=True)
theme_preference = models.CharField(
max_length=5,
choices=ThemePreference.choices,
default=ThemePreference.LIGHT,
)
def __str__(self):
return self.get_display_name()
def get_absolute_url(self):
return reverse("profile", kwargs={"username": self.username})
def get_display_name(self):
"""Get the user's display name, falling back to username if not set"""
profile = getattr(self, "profile", None)
if profile and profile.display_name:
return profile.display_name
return self.username
def save(self, *args, **kwargs):
if not self.user_id:
self.user_id = generate_random_id(User, "user_id")
super().save(*args, **kwargs)
class UserProfile(models.Model):
# Read-only ID
profile_id = models.CharField(
max_length=10,
unique=True,
editable=False,
help_text="Unique identifier for this profile that remains constant",
)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
display_name = models.CharField(
max_length=50,
unique=True,
help_text="This is the name that will be displayed on the site",
)
avatar = models.ImageField(upload_to="avatars/", blank=True)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
# Social media links
twitter = models.URLField(blank=True)
instagram = models.URLField(blank=True)
youtube = models.URLField(blank=True)
discord = models.CharField(max_length=100, blank=True)
# Ride statistics
coaster_credits = models.IntegerField(default=0)
dark_ride_credits = models.IntegerField(default=0)
flat_ride_credits = models.IntegerField(default=0)
water_ride_credits = models.IntegerField(default=0)
def get_avatar(self):
"""Return the avatar URL or serve a pre-generated avatar based on the first letter of the username"""
if self.avatar:
return self.avatar.url
first_letter = self.user.username[0].upper()
avatar_path = f"avatars/letters/{first_letter}_avatar.png"
if os.path.exists(avatar_path):
return f"/{avatar_path}"
return "/static/images/default-avatar.png"
def save(self, *args, **kwargs):
# If no display name is set, use the username
if not self.display_name:
self.display_name = self.user.username
if not self.profile_id:
self.profile_id = generate_random_id(UserProfile, "profile_id")
super().save(*args, **kwargs)
def __str__(self):
return self.display_name
class EmailVerification(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
last_sent = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email verification for {self.user.username}"
class Meta:
verbose_name = "Email Verification"
verbose_name_plural = "Email Verifications"
class PasswordReset(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
def __str__(self):
return f"Password reset for {self.user.username}"
class Meta:
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
@pghistory.track()
class TopList(TrackedModel):
class Categories(models.TextChoices):
ROLLER_COASTER = "RC", _("Roller Coaster")
DARK_RIDE = "DR", _("Dark Ride")
FLAT_RIDE = "FR", _("Flat Ride")
WATER_RIDE = "WR", _("Water Ride")
PARK = "PK", _("Park")
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="top_lists", # Added related_name for User model access
)
title = models.CharField(max_length=100)
category = models.CharField(max_length=2, choices=Categories.choices)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta(TrackedModel.Meta):
ordering = ["-updated_at"]
def __str__(self):
return (
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
)
@pghistory.track()
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList, on_delete=models.CASCADE, related_name="items"
)
content_type = models.ForeignKey(
"contenttypes.ContentType", on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
rank = models.PositiveIntegerField()
notes = models.TextField(blank=True)
class Meta(TrackedModel.Meta):
ordering = ["rank"]
unique_together = [["top_list", "rank"]]
def __str__(self):
return f"#{self.rank} in {self.top_list.title}"

View File

@@ -3,12 +3,11 @@ Selectors for user and account-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from datetime import timedelta
from typing import Any
from typing import Dict, Any
from django.db.models import QuerySet, Q, F, Count
from django.contrib.auth import get_user_model
from django.db.models import Count, F, Q, QuerySet
from django.utils import timezone
from datetime import timedelta
User = get_user_model()
@@ -27,10 +26,16 @@ def user_profile_optimized(*, user_id: int) -> Any:
User.DoesNotExist: If user doesn't exist
"""
return (
User.objects.prefetch_related("park_reviews", "ride_reviews", "socialaccount_set")
User.objects.prefetch_related(
"park_reviews", "ride_reviews", "socialaccount_set"
)
.annotate(
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.get(id=user_id)
@@ -47,8 +52,12 @@ def active_users_with_stats() -> QuerySet:
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.order_by("-total_review_count")
@@ -102,8 +111,12 @@ def top_reviewers(*, limit: int = 10) -> QuerySet:
return (
User.objects.filter(is_active=True)
.annotate(
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gt=0)
@@ -145,9 +158,9 @@ def users_by_registration_date(*, start_date, end_date) -> QuerySet:
Returns:
QuerySet of users registered in the date range
"""
return User.objects.filter(date_joined__date__gte=start_date, date_joined__date__lte=end_date).order_by(
"-date_joined"
)
return User.objects.filter(
date_joined__date__gte=start_date, date_joined__date__lte=end_date
).order_by("-date_joined")
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
@@ -162,7 +175,9 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
QuerySet of matching users for autocomplete
"""
return User.objects.filter(
Q(username__icontains=query) | Q(display_name__icontains=query),
Q(username__icontains=query)
| Q(first_name__icontains=query)
| Q(last_name__icontains=query),
is_active=True,
).order_by("username")[:limit]
@@ -182,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.
@@ -195,7 +210,11 @@ def user_statistics_summary() -> dict[str, Any]:
# Users with reviews
users_with_reviews = (
User.objects.filter(Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)).distinct().count()
User.objects.filter(
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
)
.distinct()
.count()
)
# Recent registrations (last 30 days)
@@ -209,7 +228,9 @@ def user_statistics_summary() -> dict[str, Any]:
"staff_users": staff_users,
"users_with_reviews": users_with_reviews,
"recent_registrations": recent_registrations,
"review_participation_rate": ((users_with_reviews / total_users * 100) if total_users > 0 else 0),
"review_participation_rate": (
(users_with_reviews / total_users * 100) if total_users > 0 else 0
),
}
@@ -220,7 +241,11 @@ def users_needing_email_verification() -> QuerySet:
Returns:
QuerySet of users with unverified emails
"""
return User.objects.filter(is_active=True, emailaddress__verified=False).distinct().order_by("date_joined")
return (
User.objects.filter(is_active=True, emailaddress__verified=False)
.distinct()
.order_by("date_joined")
)
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
@@ -235,8 +260,12 @@ def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
"""
return (
User.objects.annotate(
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
park_review_count=Count(
"park_reviews", filter=Q(park_reviews__is_published=True)
),
ride_review_count=Count(
"ride_reviews", filter=Q(ride_reviews__is_published=True)
),
total_review_count=F("park_review_count") + F("ride_review_count"),
)
.filter(total_review_count__gte=min_reviews)

View File

@@ -1,16 +1,14 @@
from datetime import timedelta
from typing import cast
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.contrib.sites.shortcuts import get_current_site
from django.template.loader import render_to_string
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
from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from apps.email_service.services import EmailService
from django.template.loader import render_to_string
from typing import cast
UserModel = get_user_model()
@@ -21,9 +19,6 @@ class UserSerializer(serializers.ModelSerializer):
"""
avatar_url = serializers.SerializerMethodField()
display_name = serializers.CharField(source="profile.display_name", required=False)
unit_system = serializers.CharField(source="profile.unit_system", required=False)
location = serializers.CharField(source="profile.location", required=False)
class Meta:
model = User
@@ -31,12 +26,11 @@ class UserSerializer(serializers.ModelSerializer):
"id",
"username",
"email",
"display_name",
"first_name",
"last_name",
"date_joined",
"is_active",
"avatar_url",
"unit_system",
"location",
]
read_only_fields = ["id", "date_joined", "is_active"]
@@ -46,24 +40,18 @@ class UserSerializer(serializers.ModelSerializer):
return obj.profile.avatar.url
return None
def update(self, instance, validated_data):
profile_data = validated_data.pop("profile", {})
profile = instance.profile
for attr, value in profile_data.items():
setattr(profile, attr, value)
profile.save()
return super().update(instance, validated_data)
class LoginSerializer(serializers.Serializer):
"""
Serializer for user login
"""
username = serializers.CharField(max_length=254, help_text="Username or email address")
password = serializers.CharField(max_length=128, style={"input_type": "password"}, trim_whitespace=False)
username = serializers.CharField(
max_length=254, help_text="Username or email address"
)
password = serializers.CharField(
max_length=128, style={"input_type": "password"}, trim_whitespace=False
)
def validate(self, attrs):
username = attrs.get("username")
@@ -85,21 +73,23 @@ class SignupSerializer(serializers.ModelSerializer):
validators=[validate_password],
style={"input_type": "password"},
)
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
password_confirm = serializers.CharField(
write_only=True, style={"input_type": "password"}
)
class Meta:
model = User
fields = [
"username",
"email",
"display_name",
"first_name",
"last_name",
"password",
"password_confirm",
]
extra_kwargs = {
"password": {"write_only": True},
"email": {"required": True},
"display_name": {"required": True},
}
def validate_email(self, value):
@@ -112,7 +102,9 @@ class SignupSerializer(serializers.ModelSerializer):
def validate_username(self, value):
"""Validate username is unique"""
if UserModel.objects.filter(username=value).exists():
raise serializers.ValidationError("A user with this username already exists.")
raise serializers.ValidationError(
"A user with this username already exists."
)
return value
def validate(self, attrs):
@@ -121,7 +113,9 @@ class SignupSerializer(serializers.ModelSerializer):
password_confirm = attrs.get("password_confirm")
if password != password_confirm:
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
raise serializers.ValidationError(
{"password_confirm": "Passwords do not match."}
)
return attrs
@@ -184,7 +178,9 @@ class PasswordResetSerializer(serializers.Serializer):
"site_name": site.name,
}
email_html = render_to_string("accounts/email/password_reset.html", context)
email_html = render_to_string(
"accounts/email/password_reset.html", context
)
# Narrow and validate email type for the static checker
email = getattr(self.user, "email", None)
@@ -206,11 +202,15 @@ class PasswordChangeSerializer(serializers.Serializer):
Serializer for password change
"""
old_password = serializers.CharField(max_length=128, style={"input_type": "password"})
old_password = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
new_password = serializers.CharField(
max_length=128, validators=[validate_password], style={"input_type": "password"}
)
new_password_confirm = serializers.CharField(max_length=128, style={"input_type": "password"})
new_password_confirm = serializers.CharField(
max_length=128, style={"input_type": "password"}
)
def validate_old_password(self, value):
"""Validate old password is correct"""
@@ -225,7 +225,9 @@ class PasswordChangeSerializer(serializers.Serializer):
new_password_confirm = attrs.get("new_password_confirm")
if new_password != new_password_confirm:
raise serializers.ValidationError({"new_password_confirm": "New passwords do not match."})
raise serializers.ValidationError(
{"new_password_confirm": "New passwords do not match."}
)
return attrs

View File

@@ -1,574 +0,0 @@
"""
User management services for ThrillWiki.
This module contains services for user account management including
user deletion while preserving submissions, password management,
and email change functionality.
Recent additions:
- AccountService: Handles password and email change operations
- UserDeletionService: Manages user deletion while preserving content
"""
import logging
import re
from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
from django.contrib.sites.models import Site
from django.contrib.sites.shortcuts import get_current_site
from django.db import transaction
from django.http import HttpRequest
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from .models import EmailVerification, User, UserDeletionRequest, UserProfile
from apps.core.utils import capture_and_log
logger = logging.getLogger(__name__)
class AccountService:
"""Service for account management operations including password and email changes."""
@staticmethod
def validate_password(password: str) -> bool:
"""
Validate password meets requirements.
Args:
password: The password to validate
Returns:
True if password meets requirements, False otherwise
"""
return (
len(password) >= 8
and bool(re.search(r"[A-Z]", password))
and bool(re.search(r"[a-z]", password))
and bool(re.search(r"[0-9]", password))
)
@staticmethod
def change_password(
*,
user: User,
old_password: str,
new_password: str,
request: HttpRequest,
) -> dict[str, Any]:
"""
Change user password with validation and notification.
Validates the old password, checks new password requirements,
updates the password, and sends a confirmation email.
Args:
user: The user whose password is being changed
old_password: Current password for verification
new_password: New password to set
request: HTTP request for session handling
Returns:
Dictionary with success status, message, and optional redirect URL:
{
'success': bool,
'message': str,
'redirect_url': Optional[str]
}
"""
# Verify old password
if not user.check_password(old_password):
logger.warning(f"Password change failed: incorrect current password for user {user.id}")
return {"success": False, "message": "Current password is incorrect", "redirect_url": None}
# Validate new password
if not AccountService.validate_password(new_password):
return {
"success": False,
"message": "Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
"redirect_url": None,
}
# Update password
user.set_password(new_password)
user.save()
# Keep user logged in after password change
update_session_auth_hash(request, user)
# Send confirmation email
AccountService._send_password_change_confirmation(request, user)
logger.info(f"Password changed successfully for user {user.id}")
return {
"success": True,
"message": "Password changed successfully. Please check your email for confirmation.",
"redirect_url": None,
}
@staticmethod
def _send_password_change_confirmation(request: HttpRequest, user: User) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
"user": user,
"site_name": site.name,
}
email_html = render_to_string("accounts/email/password_change_confirmation.html", context)
try:
EmailService.send_email(
to=user.email,
subject="Password Changed Successfully",
text="Your password has been changed successfully.",
site=site,
html=email_html,
)
except Exception as e:
capture_and_log(e, 'Send password change confirmation email', source='service', severity='medium')
@staticmethod
def initiate_email_change(
*,
user: User,
new_email: str,
request: HttpRequest,
) -> dict[str, Any]:
"""
Initiate email change with verification.
Creates a verification token and sends a verification email
to the new email address.
Args:
user: The user changing their email
new_email: The new email address
request: HTTP request for site context
Returns:
Dictionary with success status and message:
{
'success': bool,
'message': str
}
"""
if not new_email:
return {"success": False, "message": "New email is required"}
# Check if email is already in use
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
return {"success": False, "message": "This email address is already in use"}
# Generate verification token
token = get_random_string(64)
# Create or update email verification record
EmailVerification.objects.update_or_create(user=user, defaults={"token": token})
# Store pending email
user.pending_email = new_email
user.save()
# Send verification email
AccountService._send_email_verification(request, user, new_email, token)
logger.info(f"Email change initiated for user {user.id} to {new_email}")
return {"success": True, "message": "Verification email sent to your new email address"}
@staticmethod
def _send_email_verification(request: HttpRequest, user: User, new_email: str, token: str) -> None:
"""Send email verification for email change."""
from django.urls import reverse
site = get_current_site(request)
verification_url = reverse("verify_email", kwargs={"token": token})
context = {
"user": user,
"verification_url": verification_url,
"site_name": site.name,
}
email_html = render_to_string("accounts/email/verify_email.html", context)
try:
EmailService.send_email(
to=new_email,
subject="Verify your new email address",
text="Click the link to verify your new email address",
site=site,
html=email_html,
)
except Exception as e:
capture_and_log(e, 'Send email verification', source='service', severity='medium')
@staticmethod
def verify_email_change(*, token: str) -> dict[str, Any]:
"""
Verify email change token and update user email.
Args:
token: The verification token
Returns:
Dictionary with success status and message
"""
try:
verification = EmailVerification.objects.select_related("user").get(token=token)
except EmailVerification.DoesNotExist:
return {"success": False, "message": "Invalid or expired verification token"}
user = verification.user
if not user.pending_email:
return {"success": False, "message": "No pending email change found"}
# Update email
old_email = user.email
user.email = user.pending_email
user.pending_email = None
user.save()
# Delete verification record
verification.delete()
logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}")
return {"success": True, "message": "Email address updated successfully"}
class UserDeletionService:
"""Service for handling user deletion while preserving submissions."""
DELETED_USER_USERNAME = "deleted_user"
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
DELETED_DISPLAY_NAME = "Deleted User"
@classmethod
def get_or_create_deleted_user(cls) -> User:
"""Get or create the system deleted user placeholder."""
deleted_user, created = User.objects.get_or_create(
username=cls.DELETED_USER_USERNAME,
defaults={
"email": cls.DELETED_USER_EMAIL,
"is_active": False,
"is_staff": False,
"is_superuser": False,
"role": "USER",
"is_banned": True,
"ban_reason": "System placeholder for deleted users",
"ban_date": timezone.now(),
},
)
if created:
# Create profile for deleted user
UserProfile.objects.create(
user=deleted_user,
display_name=cls.DELETED_DISPLAY_NAME,
bio="This user account has been deleted.",
)
return deleted_user
@classmethod
@transaction.atomic
def delete_user_preserve_submissions(cls, user: User) -> dict:
"""
Delete a user while preserving all their submissions.
This method:
1. Transfers all user submissions to a system "deleted_user" placeholder
2. Deletes the user's profile and account data
3. Returns a summary of what was preserved
Args:
user: The user to delete
Returns:
dict: Summary of preserved submissions
"""
if user.username == cls.DELETED_USER_USERNAME:
raise ValueError("Cannot delete the system deleted user placeholder")
deleted_user = cls.get_or_create_deleted_user()
# Count submissions before transfer
submission_counts = {
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
"moderated_park_reviews": getattr(user, "moderated_park_reviews", user.__class__.objects.none()).count(),
"moderated_ride_reviews": getattr(user, "moderated_ride_reviews", user.__class__.objects.none()).count(),
"handled_submissions": getattr(user, "handled_submissions", user.__class__.objects.none()).count(),
"handled_photos": getattr(user, "handled_photos", user.__class__.objects.none()).count(),
}
# Transfer all submissions to deleted user
# Reviews
if hasattr(user, "park_reviews"):
user.park_reviews.update(user=deleted_user)
if hasattr(user, "ride_reviews"):
user.ride_reviews.update(user=deleted_user)
# Photos
if hasattr(user, "uploaded_park_photos"):
user.uploaded_park_photos.update(uploaded_by=deleted_user)
if hasattr(user, "uploaded_ride_photos"):
user.uploaded_ride_photos.update(uploaded_by=deleted_user)
# Top Lists
if hasattr(user, "top_lists"):
user.top_lists.update(user=deleted_user)
# Moderation submissions
if hasattr(user, "edit_submissions"):
user.edit_submissions.update(user=deleted_user)
if hasattr(user, "photo_submissions"):
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"):
user.moderated_park_reviews.update(moderated_by=None)
if hasattr(user, "moderated_ride_reviews"):
user.moderated_ride_reviews.update(moderated_by=None)
if hasattr(user, "handled_submissions"):
user.handled_submissions.update(handled_by=None)
if hasattr(user, "handled_photos"):
user.handled_photos.update(handled_by=None)
# Store user info for the summary
user_info = {
"username": user.username,
"user_id": user.user_id,
"email": user.email,
"date_joined": user.date_joined,
}
# Delete the user (this will cascade delete the profile)
user.delete()
return {
"deleted_user": user_info,
"preserved_submissions": submission_counts,
"transferred_to": {
"username": deleted_user.username,
"user_id": deleted_user.user_id,
},
}
@classmethod
def can_delete_user(cls, user: User) -> tuple[bool, str | None]:
"""
Check if a user can be safely deleted.
Args:
user: The user to check
Returns:
tuple: (can_delete: bool, reason: Optional[str])
"""
if user.username == cls.DELETED_USER_USERNAME:
return False, "Cannot delete the system deleted user placeholder"
if user.is_superuser:
return (
False,
"Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first.",
)
# Check if user has critical admin role
if user.role == "ADMIN" and user.is_staff:
return (
False,
"Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator.",
)
# Add any other business rules here
return True, None
@classmethod
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
"""
Create a user deletion request and send verification email.
Args:
user: The user requesting deletion
Returns:
UserDeletionRequest: The created deletion request
"""
# Check if user can be deleted
can_delete, reason = cls.can_delete_user(user)
if not can_delete:
raise ValueError(f"Cannot delete user: {reason}")
# Remove any existing deletion request for this user
UserDeletionRequest.objects.filter(user=user).delete()
# Create new deletion request
deletion_request = UserDeletionRequest.objects.create(user=user)
# Send verification email
cls.send_deletion_verification_email(deletion_request)
return deletion_request
@classmethod
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
"""
Send verification email for account deletion.
Args:
deletion_request: The deletion request to send email for
"""
user = deletion_request.user
# Get current site for email service
try:
site = Site.objects.get_current()
except Site.DoesNotExist:
# Fallback to default site
site = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"})[0]
# Prepare email context
context = {
"user": user,
"verification_code": deletion_request.verification_code,
"expires_at": deletion_request.expires_at,
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
"frontend_domain": getattr(settings, "FRONTEND_DOMAIN", "http://localhost:3000"),
}
# Render email content
subject = f"Confirm Account Deletion - {context['site_name']}"
# Create email message with 1-hour expiration notice
message = f"""
Hello {user.get_display_name()},
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
Verification Code: {deletion_request.verification_code}
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
If you did not request this deletion, please ignore this email and your account will remain active.
To complete the deletion, enter the verification code in the account deletion form on our website.
Best regards,
The ThrillWiki Team
""".strip()
# Send email using custom email service
try:
EmailService.send_email(
to=user.email,
subject=subject,
text=message,
site=site,
from_email="no-reply@thrillwiki.com",
)
# Update email sent timestamp
deletion_request.email_sent_at = timezone.now()
deletion_request.save(update_fields=["email_sent_at"])
except Exception as e:
# Log the error but don't fail the request creation
print(f"Failed to send deletion verification email to {user.email}: {e}")
@classmethod
@transaction.atomic
def verify_and_delete_user(cls, verification_code: str) -> dict:
"""
Verify deletion code and delete the user account.
Args:
verification_code: The verification code from the email
Returns:
dict: Summary of the deletion
Raises:
ValueError: If verification fails
"""
try:
deletion_request = UserDeletionRequest.objects.get(verification_code=verification_code)
except UserDeletionRequest.DoesNotExist:
raise ValueError("Invalid verification code") from None
# Check if request is still valid
if not deletion_request.is_valid():
if deletion_request.is_expired():
raise ValueError("Verification code has expired")
elif deletion_request.is_used:
raise ValueError("Verification code has already been used")
elif deletion_request.attempts >= deletion_request.max_attempts:
raise ValueError("Too many verification attempts")
else:
raise ValueError("Invalid verification code")
# Increment attempts
deletion_request.increment_attempts()
# Mark as used
deletion_request.mark_as_used()
# Delete the user
user = deletion_request.user
result = cls.delete_user_preserve_submissions(user)
# Add deletion request info to result
result["deletion_request"] = {
"verification_code": verification_code,
"created_at": deletion_request.created_at,
"verified_at": timezone.now(),
}
return result
@classmethod
def cancel_deletion_request(cls, user: User) -> bool:
"""
Cancel a pending deletion request.
Args:
user: The user whose deletion request to cancel
Returns:
bool: True if a request was cancelled, False if no request existed
"""
try:
deletion_request = getattr(user, "deletion_request", None)
if deletion_request:
deletion_request.delete()
return True
return False
except UserDeletionRequest.DoesNotExist:
return False
@classmethod
def cleanup_expired_deletion_requests(cls) -> int:
"""
Clean up expired deletion requests.
Returns:
int: Number of expired requests cleaned up
"""
return UserDeletionRequest.cleanup_expired()

View File

@@ -1,13 +0,0 @@
"""
Accounts Services Package
This package contains business logic services for account management,
including social provider management, user authentication, and profile services.
"""
from .account_service import AccountService
from .social_provider_service import SocialProviderService
from .user_deletion_service import UserDeletionService
__all__ = ["AccountService", "SocialProviderService", "UserDeletionService"]

View File

@@ -1,199 +0,0 @@
"""
Account management service for ThrillWiki.
Provides password validation, password changes, and email change functionality.
"""
import re
import secrets
from typing import TYPE_CHECKING
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils import timezone
if TYPE_CHECKING:
from django.http import HttpRequest
from apps.accounts.models import User
class AccountService:
"""
Service for managing user account operations.
Handles password validation, password changes, and email changes
with proper verification flows.
"""
# Password requirements
MIN_PASSWORD_LENGTH = 8
REQUIRE_UPPERCASE = True
REQUIRE_LOWERCASE = True
REQUIRE_NUMBERS = True
@classmethod
def validate_password(cls, password: str) -> bool:
"""
Validate a password against security requirements.
Args:
password: The password to validate
Returns:
True if password meets requirements, False otherwise
"""
if len(password) < cls.MIN_PASSWORD_LENGTH:
return False
if cls.REQUIRE_UPPERCASE and not re.search(r"[A-Z]", password):
return False
if cls.REQUIRE_LOWERCASE and not re.search(r"[a-z]", password):
return False
if cls.REQUIRE_NUMBERS and not re.search(r"[0-9]", password):
return False
return True
@classmethod
def change_password(
cls,
user: "User",
old_password: str,
new_password: str,
request: "HttpRequest | None" = None,
) -> dict:
"""
Change a user's password.
Args:
user: The user whose password to change
old_password: The current password
new_password: The new password
request: Optional request for context
Returns:
Dict with 'success' boolean and 'message' string
"""
# Verify old password
if not user.check_password(old_password):
return {
"success": False,
"message": "Current password is incorrect.",
}
# Validate new password
if not cls.validate_password(new_password):
return {
"success": False,
"message": f"New password must be at least {cls.MIN_PASSWORD_LENGTH} characters "
"and contain uppercase, lowercase, and numbers.",
}
# Change the password
user.set_password(new_password)
user.save(update_fields=["password"])
# Send confirmation email
cls._send_password_change_confirmation(user, request)
return {
"success": True,
"message": "Password changed successfully.",
}
@classmethod
def _send_password_change_confirmation(
cls,
user: "User",
request: "HttpRequest | None" = None,
) -> None:
"""Send a confirmation email after password change."""
try:
send_mail(
subject="Password Changed - ThrillWiki",
message=f"Hi {user.username},\n\nYour password has been changed successfully.\n\n"
"If you did not make this change, please contact support immediately.",
from_email=None, # Uses DEFAULT_FROM_EMAIL
recipient_list=[user.email],
fail_silently=True,
)
except Exception:
pass # Don't fail the password change if email fails
@classmethod
def initiate_email_change(
cls,
user: "User",
new_email: str,
request: "HttpRequest | None" = None,
) -> dict:
"""
Initiate an email change request.
Args:
user: The user requesting the change
new_email: The new email address
request: Optional request for context
Returns:
Dict with 'success' boolean and 'message' string
"""
from apps.accounts.models import User
# Validate email
if not new_email or not new_email.strip():
return {
"success": False,
"message": "Email address is required.",
}
new_email = new_email.strip().lower()
# Check if email already in use
if User.objects.filter(email=new_email).exclude(pk=user.pk).exists():
return {
"success": False,
"message": "This email is already in use by another account.",
}
# Store pending email
user.pending_email = new_email
user.save(update_fields=["pending_email"])
# Send verification email
cls._send_email_verification(user, new_email, request)
return {
"success": True,
"message": "Verification email sent. Please check your inbox.",
}
@classmethod
def _send_email_verification(
cls,
user: "User",
new_email: str,
request: "HttpRequest | None" = None,
) -> None:
"""Send verification email for email change."""
verification_code = secrets.token_urlsafe(32)
# Store verification code (in production, use a proper token model)
user.email_verification_code = verification_code
user.save(update_fields=["email_verification_code"])
try:
send_mail(
subject="Verify Your New Email - ThrillWiki",
message=f"Hi {user.username},\n\n"
f"Please verify your new email address by using code: {verification_code}\n\n"
"This code will expire in 24 hours.",
from_email=None,
recipient_list=[new_email],
fail_silently=True,
)
except Exception:
pass

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