This commit is contained in:
pacnpal
2026-01-02 07:58:58 -05:00
parent b243b17af7
commit 1adba1b804
36 changed files with 6345 additions and 6 deletions

View File

@@ -0,0 +1,73 @@
# 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

@@ -0,0 +1,83 @@
# 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

@@ -0,0 +1,329 @@
# 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

@@ -0,0 +1,306 @@
# 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

@@ -0,0 +1,191 @@
# 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

@@ -0,0 +1,254 @@
# 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

@@ -0,0 +1,204 @@
# 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

@@ -0,0 +1,85 @@
---
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

@@ -0,0 +1,168 @@
---
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

@@ -0,0 +1,223 @@
---
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

@@ -0,0 +1,183 @@
---
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

@@ -0,0 +1,201 @@
---
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 |

193
.agent/workflows/migrate.md Normal file
View File

@@ -0,0 +1,193 @@
---
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

@@ -0,0 +1,472 @@
---
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

360
.agent/workflows/new-api.md Normal file
View File

@@ -0,0 +1,360 @@
---
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

@@ -0,0 +1,279 @@
---
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

@@ -0,0 +1,311 @@
---
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

@@ -0,0 +1,235 @@
---
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

@@ -233,12 +233,16 @@ class HybridParkSerializer(serializers.ModelSerializer):
# Company fields # Company fields
operator_name = serializers.CharField(source="operator.name", read_only=True) operator_name = serializers.CharField(source="operator.name", read_only=True)
operator_id = serializers.IntegerField(source="operator.id", read_only=True, allow_null=True)
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True) property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
# Image URLs for display # Image URLs for display
banner_image_url = serializers.SerializerMethodField() banner_image_url = serializers.SerializerMethodField()
card_image_url = serializers.SerializerMethodField() card_image_url = serializers.SerializerMethodField()
# Computed property
is_closing = serializers.SerializerMethodField()
# Computed fields for filtering # Computed fields for filtering
opening_year = serializers.IntegerField(read_only=True) opening_year = serializers.IntegerField(read_only=True)
search_text = serializers.CharField(read_only=True) search_text = serializers.CharField(read_only=True)
@@ -309,6 +313,11 @@ class HybridParkSerializer(serializers.ModelSerializer):
return obj.card_image.image.url return obj.card_image.image.url
return None return None
@extend_schema_field(serializers.BooleanField())
def get_is_closing(self, obj):
"""Check if park has an announced closing date in the future."""
return obj.is_closing
class Meta: class Meta:
model = Park model = Park
fields = [ fields = [
@@ -321,7 +330,10 @@ class HybridParkSerializer(serializers.ModelSerializer):
"park_type", "park_type",
# Dates and computed fields # Dates and computed fields
"opening_date", "opening_date",
"opening_date_precision",
"closing_date", "closing_date",
"closing_date_precision",
"is_closing",
"opening_year", "opening_year",
"operating_season", "operating_season",
# Location fields # Location fields
@@ -333,12 +345,17 @@ class HybridParkSerializer(serializers.ModelSerializer):
"longitude", "longitude",
# Company relationships # Company relationships
"operator_name", "operator_name",
"operator_id",
"property_owner_name", "property_owner_name",
# Statistics # Statistics
"size_acres", "size_acres",
"average_rating", "average_rating",
"ride_count", "ride_count",
"coaster_count", "coaster_count",
# Contact info
"phone",
"email",
"timezone",
# Images # Images
"banner_image_url", "banner_image_url",
"card_image_url", "card_image_url",

View File

@@ -491,6 +491,374 @@ class HybridRideSerializer(serializers.ModelSerializer):
return obj.card_image.image.url return obj.card_image.image.url
return None return None
# Computed property
is_closing = serializers.SerializerMethodField()
@extend_schema_field(serializers.BooleanField())
def get_is_closing(self, obj):
"""Check if ride has an announced closing date in the future."""
return obj.is_closing
# Water ride stats fields
water_wetness_level = serializers.SerializerMethodField()
water_splash_height_ft = serializers.SerializerMethodField()
water_has_splash_zone = serializers.SerializerMethodField()
water_boat_capacity = serializers.SerializerMethodField()
water_uses_flume = serializers.SerializerMethodField()
water_rapids_sections = serializers.SerializerMethodField()
@extend_schema_field(serializers.CharField(allow_null=True))
def get_water_wetness_level(self, obj):
try:
if hasattr(obj, "water_stats") and obj.water_stats:
return obj.water_stats.wetness_level
return None
except AttributeError:
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_water_splash_height_ft(self, obj):
try:
if hasattr(obj, "water_stats") and obj.water_stats:
return float(obj.water_stats.splash_height_ft) if obj.water_stats.splash_height_ft else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_water_has_splash_zone(self, obj):
try:
if hasattr(obj, "water_stats") and obj.water_stats:
return obj.water_stats.has_splash_zone
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_water_boat_capacity(self, obj):
try:
if hasattr(obj, "water_stats") and obj.water_stats:
return obj.water_stats.boat_capacity
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_water_uses_flume(self, obj):
try:
if hasattr(obj, "water_stats") and obj.water_stats:
return obj.water_stats.uses_flume
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_water_rapids_sections(self, obj):
try:
if hasattr(obj, "water_stats") and obj.water_stats:
return obj.water_stats.rapids_sections
return None
except AttributeError:
return None
# Dark ride stats fields
dark_scene_count = serializers.SerializerMethodField()
dark_animatronic_count = serializers.SerializerMethodField()
dark_has_projection_technology = serializers.SerializerMethodField()
dark_is_interactive = serializers.SerializerMethodField()
dark_ride_system = serializers.SerializerMethodField()
dark_uses_practical_effects = serializers.SerializerMethodField()
dark_uses_motion_base = serializers.SerializerMethodField()
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_dark_scene_count(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.scene_count
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_dark_animatronic_count(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.animatronic_count
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_dark_has_projection_technology(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.has_projection_technology
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_dark_is_interactive(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.is_interactive
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_dark_ride_system(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.ride_system
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_dark_uses_practical_effects(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.uses_practical_effects
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_dark_uses_motion_base(self, obj):
try:
if hasattr(obj, "dark_stats") and obj.dark_stats:
return obj.dark_stats.uses_motion_base
return None
except AttributeError:
return None
# Flat ride stats fields
flat_max_height_ft = serializers.SerializerMethodField()
flat_rotation_speed_rpm = serializers.SerializerMethodField()
flat_swing_angle_degrees = serializers.SerializerMethodField()
flat_motion_type = serializers.SerializerMethodField()
flat_arm_count = serializers.SerializerMethodField()
flat_seats_per_gondola = serializers.SerializerMethodField()
flat_max_g_force = serializers.SerializerMethodField()
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_flat_max_height_ft(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return float(obj.flat_stats.max_height_ft) if obj.flat_stats.max_height_ft else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_flat_rotation_speed_rpm(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return float(obj.flat_stats.rotation_speed_rpm) if obj.flat_stats.rotation_speed_rpm else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_flat_swing_angle_degrees(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return obj.flat_stats.swing_angle_degrees
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_flat_motion_type(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return obj.flat_stats.motion_type
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_flat_arm_count(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return obj.flat_stats.arm_count
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_flat_seats_per_gondola(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return obj.flat_stats.seats_per_gondola
return None
except AttributeError:
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_flat_max_g_force(self, obj):
try:
if hasattr(obj, "flat_stats") and obj.flat_stats:
return float(obj.flat_stats.max_g_force) if obj.flat_stats.max_g_force else None
return None
except (AttributeError, TypeError):
return None
# Kiddie ride stats fields
kiddie_min_age = serializers.SerializerMethodField()
kiddie_max_age = serializers.SerializerMethodField()
kiddie_educational_theme = serializers.SerializerMethodField()
kiddie_character_theme = serializers.SerializerMethodField()
kiddie_guardian_required = serializers.SerializerMethodField()
kiddie_adult_ride_along = serializers.SerializerMethodField()
kiddie_seats_per_vehicle = serializers.SerializerMethodField()
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_kiddie_min_age(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.min_age
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_kiddie_max_age(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.max_age
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_kiddie_educational_theme(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.educational_theme or None
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_kiddie_character_theme(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.character_theme or None
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_kiddie_guardian_required(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.guardian_required
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_kiddie_adult_ride_along(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.adult_ride_along
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_kiddie_seats_per_vehicle(self, obj):
try:
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
return obj.kiddie_stats.seats_per_vehicle
return None
except AttributeError:
return None
# Transportation stats fields
transport_type = serializers.SerializerMethodField()
transport_route_length_ft = serializers.SerializerMethodField()
transport_stations_count = serializers.SerializerMethodField()
transport_vehicle_capacity = serializers.SerializerMethodField()
transport_vehicles_count = serializers.SerializerMethodField()
transport_round_trip_duration_minutes = serializers.SerializerMethodField()
transport_scenic_highlights = serializers.SerializerMethodField()
transport_is_one_way = serializers.SerializerMethodField()
@extend_schema_field(serializers.CharField(allow_null=True))
def get_transport_type(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.transport_type
return None
except AttributeError:
return None
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_transport_route_length_ft(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return float(obj.transport_stats.route_length_ft) if obj.transport_stats.route_length_ft else None
return None
except (AttributeError, TypeError):
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_transport_stations_count(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.stations_count
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_transport_vehicle_capacity(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.vehicle_capacity
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_transport_vehicles_count(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.vehicles_count
return None
except AttributeError:
return None
@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_transport_round_trip_duration_minutes(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.round_trip_duration_minutes
return None
except AttributeError:
return None
@extend_schema_field(serializers.CharField(allow_null=True))
def get_transport_scenic_highlights(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.scenic_highlights or None
return None
except AttributeError:
return None
@extend_schema_field(serializers.BooleanField(allow_null=True))
def get_transport_is_one_way(self, obj):
try:
if hasattr(obj, "transport_stats") and obj.transport_stats:
return obj.transport_stats.is_one_way
return None
except AttributeError:
return None
class Meta: class Meta:
model = Ride model = Ride
fields = [ fields = [
@@ -504,7 +872,10 @@ class HybridRideSerializer(serializers.ModelSerializer):
"post_closing_status", "post_closing_status",
# Dates and computed fields # Dates and computed fields
"opening_date", "opening_date",
"opening_date_precision",
"closing_date", "closing_date",
"closing_date_precision",
"is_closing",
"status_since", "status_since",
"opening_year", "opening_year",
# Park fields # Park fields
@@ -533,6 +904,9 @@ class HybridRideSerializer(serializers.ModelSerializer):
"capacity_per_hour", "capacity_per_hour",
"ride_duration_seconds", "ride_duration_seconds",
"average_rating", "average_rating",
# Additional classification
"ride_sub_type",
"age_requirement",
# Roller coaster stats # Roller coaster stats
"coaster_height_ft", "coaster_height_ft",
"coaster_length_ft", "coaster_length_ft",
@@ -548,6 +922,46 @@ class HybridRideSerializer(serializers.ModelSerializer):
"coaster_trains_count", "coaster_trains_count",
"coaster_cars_per_train", "coaster_cars_per_train",
"coaster_seats_per_car", "coaster_seats_per_car",
# Water ride stats
"water_wetness_level",
"water_splash_height_ft",
"water_has_splash_zone",
"water_boat_capacity",
"water_uses_flume",
"water_rapids_sections",
# Dark ride stats
"dark_scene_count",
"dark_animatronic_count",
"dark_has_projection_technology",
"dark_is_interactive",
"dark_ride_system",
"dark_uses_practical_effects",
"dark_uses_motion_base",
# Flat ride stats
"flat_max_height_ft",
"flat_rotation_speed_rpm",
"flat_swing_angle_degrees",
"flat_motion_type",
"flat_arm_count",
"flat_seats_per_gondola",
"flat_max_g_force",
# Kiddie ride stats
"kiddie_min_age",
"kiddie_max_age",
"kiddie_educational_theme",
"kiddie_character_theme",
"kiddie_guardian_required",
"kiddie_adult_ride_along",
"kiddie_seats_per_vehicle",
# Transportation stats
"transport_type",
"transport_route_length_ft",
"transport_stations_count",
"transport_vehicle_capacity",
"transport_vehicles_count",
"transport_round_trip_duration_minutes",
"transport_scenic_highlights",
"transport_is_one_way",
# Images # Images
"banner_image_url", "banner_image_url",
"card_image_url", "card_image_url",

View File

@@ -32,9 +32,18 @@ from .shared import ModelChoices
"roles": ["OPERATOR", "PROPERTY_OWNER"], "roles": ["OPERATOR", "PROPERTY_OWNER"],
"description": "Theme park operator based in Ohio", "description": "Theme park operator based in Ohio",
"website": "https://cedarfair.com", "website": "https://cedarfair.com",
"founded_date": "1983-01-01", "person_type": "CORPORATION",
"status": "ACTIVE",
"founded_year": 1983,
"founded_date": "1983-05-01",
"founded_date_precision": "MONTH",
"logo_url": "https://example.com/logo.png",
"banner_image_url": "https://example.com/banner.jpg",
"card_image_url": "https://example.com/card.jpg",
"average_rating": 4.5,
"review_count": 150,
"parks_count": 11,
"rides_count": 0, "rides_count": 0,
"coasters_count": 0,
}, },
) )
] ]
@@ -42,15 +51,35 @@ from .shared import ModelChoices
class CompanyDetailOutputSerializer(serializers.Serializer): class CompanyDetailOutputSerializer(serializers.Serializer):
"""Output serializer for company details.""" """Output serializer for company details."""
# Core fields
id = serializers.IntegerField() id = serializers.IntegerField()
name = serializers.CharField() name = serializers.CharField()
slug = serializers.CharField() slug = serializers.CharField()
roles = serializers.ListField(child=serializers.CharField()) roles = serializers.ListField(child=serializers.CharField())
description = serializers.CharField() description = serializers.CharField()
website = serializers.URLField() website = serializers.URLField(required=False, allow_blank=True)
# Entity type and status (ported from legacy)
person_type = serializers.CharField(required=False, allow_blank=True)
status = serializers.CharField()
# Founding information
founded_year = serializers.IntegerField(allow_null=True)
founded_date = serializers.DateField(allow_null=True) founded_date = serializers.DateField(allow_null=True)
founded_date_precision = serializers.CharField(required=False, allow_blank=True)
# Image URLs
logo_url = serializers.URLField(required=False, allow_blank=True)
banner_image_url = serializers.URLField(required=False, allow_blank=True)
card_image_url = serializers.URLField(required=False, allow_blank=True)
# Rating and review aggregates
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
review_count = serializers.IntegerField()
# Counts
parks_count = serializers.IntegerField()
rides_count = serializers.IntegerField() rides_count = serializers.IntegerField()
coasters_count = serializers.IntegerField()
# Metadata # Metadata
created_at = serializers.DateTimeField() created_at = serializers.DateTimeField()
@@ -67,7 +96,31 @@ class CompanyCreateInputSerializer(serializers.Serializer):
) )
description = serializers.CharField(allow_blank=True, default="") description = serializers.CharField(allow_blank=True, default="")
website = serializers.URLField(required=False, allow_blank=True) website = serializers.URLField(required=False, allow_blank=True)
# Entity type and status
person_type = serializers.ChoiceField(
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
required=False,
allow_blank=True,
)
status = serializers.ChoiceField(
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
default="ACTIVE",
)
# Founding information
founded_year = serializers.IntegerField(required=False, allow_null=True)
founded_date = serializers.DateField(required=False, allow_null=True) founded_date = serializers.DateField(required=False, allow_null=True)
founded_date_precision = serializers.ChoiceField(
choices=["YEAR", "MONTH", "DAY"],
required=False,
allow_blank=True,
)
# Image URLs
logo_url = serializers.URLField(required=False, allow_blank=True)
banner_image_url = serializers.URLField(required=False, allow_blank=True)
card_image_url = serializers.URLField(required=False, allow_blank=True)
class CompanyUpdateInputSerializer(serializers.Serializer): class CompanyUpdateInputSerializer(serializers.Serializer):
@@ -80,7 +133,31 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
) )
description = serializers.CharField(allow_blank=True, required=False) description = serializers.CharField(allow_blank=True, required=False)
website = serializers.URLField(required=False, allow_blank=True) website = serializers.URLField(required=False, allow_blank=True)
# Entity type and status
person_type = serializers.ChoiceField(
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
required=False,
allow_blank=True,
)
status = serializers.ChoiceField(
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
required=False,
)
# Founding information
founded_year = serializers.IntegerField(required=False, allow_null=True)
founded_date = serializers.DateField(required=False, allow_null=True) founded_date = serializers.DateField(required=False, allow_null=True)
founded_date_precision = serializers.ChoiceField(
choices=["YEAR", "MONTH", "DAY"],
required=False,
allow_blank=True,
)
# Image URLs
logo_url = serializers.URLField(required=False, allow_blank=True)
banner_image_url = serializers.URLField(required=False, allow_blank=True)
card_image_url = serializers.URLField(required=False, allow_blank=True)
# === RIDE MODEL SERIALIZERS === # === RIDE MODEL SERIALIZERS ===

View File

@@ -208,6 +208,9 @@ class RideDetailOutputSerializer(serializers.Serializer):
banner_image = serializers.SerializerMethodField() banner_image = serializers.SerializerMethodField()
card_image = serializers.SerializerMethodField() card_image = serializers.SerializerMethodField()
# Former names (name history)
former_names = serializers.SerializerMethodField()
# URL # URL
url = serializers.SerializerMethodField() url = serializers.SerializerMethodField()
@@ -406,6 +409,24 @@ class RideDetailOutputSerializer(serializers.Serializer):
return None return None
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_former_names(self, obj):
"""Get the former names (name history) for this ride."""
from apps.rides.models import RideNameHistory
former_names = RideNameHistory.objects.filter(ride=obj).order_by("-to_year", "-from_year")
return [
{
"id": entry.id,
"former_name": entry.former_name,
"from_year": entry.from_year,
"to_year": entry.to_year,
"reason": entry.reason,
}
for entry in former_names
]
class RideImageSettingsInputSerializer(serializers.Serializer): class RideImageSettingsInputSerializer(serializers.Serializer):
"""Input serializer for setting ride banner and card images.""" """Input serializer for setting ride banner and card images."""
@@ -841,3 +862,37 @@ class RideReviewUpdateInputSerializer(serializers.Serializer):
if value and value > timezone.now().date(): if value and value > timezone.now().date():
raise serializers.ValidationError("Visit date cannot be in the future") raise serializers.ValidationError("Visit date cannot be in the future")
return value return value
# === RIDE NAME HISTORY SERIALIZERS ===
class RideNameHistoryOutputSerializer(serializers.Serializer):
"""Output serializer for ride name history (former names)."""
id = serializers.IntegerField()
former_name = serializers.CharField()
from_year = serializers.IntegerField(allow_null=True)
to_year = serializers.IntegerField(allow_null=True)
reason = serializers.CharField()
created_at = serializers.DateTimeField()
class RideNameHistoryCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating ride name history entries."""
former_name = serializers.CharField(max_length=200)
from_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
to_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
reason = serializers.CharField(max_length=500, required=False, allow_blank=True, default="")
def validate(self, attrs):
"""Validate year range."""
from_year = attrs.get("from_year")
to_year = attrs.get("to_year")
if from_year and to_year and from_year > to_year:
raise serializers.ValidationError("From year cannot be after to year")
return attrs

View File

@@ -0,0 +1,251 @@
# Generated by Django 5.2.9 on 2026-01-02 00:10
import apps.core.state_machine.fields
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0026_remove_park_insert_insert_remove_park_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="company",
name="update_update",
),
migrations.AddField(
model_name="company",
name="average_rating",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating from reviews (auto-calculated)",
max_digits=3,
null=True,
),
),
migrations.AddField(
model_name="company",
name="banner_image_url",
field=models.URLField(blank=True, help_text="Banner image for company page header"),
),
migrations.AddField(
model_name="company",
name="card_image_url",
field=models.URLField(blank=True, help_text="Card/thumbnail image for listings"),
),
migrations.AddField(
model_name="company",
name="founded_date",
field=models.DateField(blank=True, help_text="Full founding date if known", null=True),
),
migrations.AddField(
model_name="company",
name="founded_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year only"), ("MONTH", "Month and year"), ("DAY", "Full date")],
help_text="Precision of the founding date",
max_length=10,
),
),
migrations.AddField(
model_name="company",
name="logo_url",
field=models.URLField(blank=True, help_text="Company logo image URL"),
),
migrations.AddField(
model_name="company",
name="person_type",
field=models.CharField(
blank=True,
choices=[
("INDIVIDUAL", "Individual"),
("FIRM", "Firm"),
("ORGANIZATION", "Organization"),
("CORPORATION", "Corporation"),
("PARTNERSHIP", "Partnership"),
("GOVERNMENT", "Government Entity"),
],
help_text="Type of entity (individual, firm, organization, etc.)",
max_length=20,
),
),
migrations.AddField(
model_name="company",
name="review_count",
field=models.PositiveIntegerField(default=0, help_text="Total number of reviews (auto-calculated)"),
),
migrations.AddField(
model_name="company",
name="status",
field=models.CharField(
choices=[
("ACTIVE", "Active"),
("DEFUNCT", "Defunct"),
("MERGED", "Merged"),
("ACQUIRED", "Acquired"),
("RENAMED", "Renamed"),
("DORMANT", "Dormant"),
],
default="ACTIVE",
help_text="Current operational status of the company",
max_length=20,
),
),
migrations.AddField(
model_name="companyevent",
name="average_rating",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating from reviews (auto-calculated)",
max_digits=3,
null=True,
),
),
migrations.AddField(
model_name="companyevent",
name="banner_image_url",
field=models.URLField(blank=True, help_text="Banner image for company page header"),
),
migrations.AddField(
model_name="companyevent",
name="card_image_url",
field=models.URLField(blank=True, help_text="Card/thumbnail image for listings"),
),
migrations.AddField(
model_name="companyevent",
name="founded_date",
field=models.DateField(blank=True, help_text="Full founding date if known", null=True),
),
migrations.AddField(
model_name="companyevent",
name="founded_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year only"), ("MONTH", "Month and year"), ("DAY", "Full date")],
help_text="Precision of the founding date",
max_length=10,
),
),
migrations.AddField(
model_name="companyevent",
name="logo_url",
field=models.URLField(blank=True, help_text="Company logo image URL"),
),
migrations.AddField(
model_name="companyevent",
name="person_type",
field=models.CharField(
blank=True,
choices=[
("INDIVIDUAL", "Individual"),
("FIRM", "Firm"),
("ORGANIZATION", "Organization"),
("CORPORATION", "Corporation"),
("PARTNERSHIP", "Partnership"),
("GOVERNMENT", "Government Entity"),
],
help_text="Type of entity (individual, firm, organization, etc.)",
max_length=20,
),
),
migrations.AddField(
model_name="companyevent",
name="review_count",
field=models.PositiveIntegerField(default=0, help_text="Total number of reviews (auto-calculated)"),
),
migrations.AddField(
model_name="companyevent",
name="status",
field=models.CharField(
choices=[
("ACTIVE", "Active"),
("DEFUNCT", "Defunct"),
("MERGED", "Merged"),
("ACQUIRED", "Acquired"),
("RENAMED", "Renamed"),
("DORMANT", "Dormant"),
],
default="ACTIVE",
help_text="Current operational status of the company",
max_length=20,
),
),
migrations.AlterField(
model_name="park",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="statuses",
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
domain="parks",
max_length=20,
),
),
migrations.AlterField(
model_name="parkevent",
name="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="statuses",
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
domain="parks",
max_length=20,
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_companyevent" ("average_rating", "banner_image_url", "card_image_url", "created_at", "description", "founded_date", "founded_date_precision", "founded_year", "id", "logo_url", "name", "parks_count", "person_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_count", "rides_count", "roles", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_url", NEW."card_image_url", NEW."created_at", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."founded_year", NEW."id", NEW."logo_url", NEW."name", NEW."parks_count", NEW."person_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_count", NEW."rides_count", NEW."roles", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="048dba77acb14b06b8ae12f5f05710f0da71e3fd",
operation="INSERT",
pgid="pgtrigger_insert_insert_35b57",
table="parks_company",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="company",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_companyevent" ("average_rating", "banner_image_url", "card_image_url", "created_at", "description", "founded_date", "founded_date_precision", "founded_year", "id", "logo_url", "name", "parks_count", "person_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_count", "rides_count", "roles", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_url", NEW."card_image_url", NEW."created_at", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."founded_year", NEW."id", NEW."logo_url", NEW."name", NEW."parks_count", NEW."person_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_count", NEW."rides_count", NEW."roles", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="4a93422ca4b79608f941be5baed899d691d88d97",
operation="UPDATE",
pgid="pgtrigger_update_update_d3286",
table="parks_company",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,96 @@
# Generated by Django 5.2.9 on 2026-01-02 02:30
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0027_add_company_entity_fields"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="park",
name="update_update",
),
migrations.AddField(
model_name="park",
name="closing_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the closing date",
max_length=10,
),
),
migrations.AddField(
model_name="park",
name="opening_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the opening date (YEAR for circa dates)",
max_length=10,
),
),
migrations.AddField(
model_name="parkevent",
name="closing_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the closing date",
max_length=10,
),
),
migrations.AddField(
model_name="parkevent",
name="opening_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the opening date (YEAR for circa dates)",
max_length=10,
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "closing_date_precision", "coaster_count", "created_at", "description", "email", "id", "name", "opening_date", "opening_date_precision", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."closing_date_precision", NEW."coaster_count", NEW."created_at", NEW."description", NEW."email", NEW."id", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."phone", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="ba8a1efa2a6987e5803856a7d583d15379374976",
operation="INSERT",
pgid="pgtrigger_insert_insert_66883",
table="parks_park",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="park",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkevent" ("average_rating", "banner_image_id", "card_image_id", "closing_date", "closing_date_precision", "coaster_count", "created_at", "description", "email", "id", "name", "opening_date", "opening_date_precision", "opening_year", "operating_season", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "phone", "property_owner_id", "ride_count", "search_text", "size_acres", "slug", "status", "timezone", "updated_at", "url", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."card_image_id", NEW."closing_date", NEW."closing_date_precision", NEW."coaster_count", NEW."created_at", NEW."description", NEW."email", NEW."id", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."opening_year", NEW."operating_season", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."phone", NEW."property_owner_id", NEW."ride_count", NEW."search_text", NEW."size_acres", NEW."slug", NEW."status", NEW."timezone", NEW."updated_at", NEW."url", NEW."website"); RETURN NULL;',
hash="03e752224a0f76749f4348abf48cb9e6929c9e19",
operation="UPDATE",
pgid="pgtrigger_update_update_19f56",
table="parks_park",
when="AFTER",
),
),
),
]

View File

@@ -14,6 +14,7 @@ class Company(TrackedModel):
objects = CompanyManager() objects = CompanyManager()
# Core Fields
name = models.CharField(max_length=255, help_text="Company name") name = models.CharField(max_length=255, help_text="Company name")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier") slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
roles = ArrayField( roles = ArrayField(
@@ -25,8 +26,72 @@ class Company(TrackedModel):
description = models.TextField(blank=True, help_text="Detailed company description") description = models.TextField(blank=True, help_text="Detailed company description")
website = models.URLField(blank=True, help_text="Company website URL") website = models.URLField(blank=True, help_text="Company website URL")
# Operator-specific fields # Person/Entity Type (ported from legacy thrillwiki-87)
PERSON_TYPES = [
("INDIVIDUAL", "Individual"),
("FIRM", "Firm"),
("ORGANIZATION", "Organization"),
("CORPORATION", "Corporation"),
("PARTNERSHIP", "Partnership"),
("GOVERNMENT", "Government Entity"),
]
person_type = models.CharField(
max_length=20,
choices=PERSON_TYPES,
blank=True,
help_text="Type of entity (individual, firm, organization, etc.)",
)
# Company Status (ported from legacy)
COMPANY_STATUSES = [
("ACTIVE", "Active"),
("DEFUNCT", "Defunct"),
("MERGED", "Merged"),
("ACQUIRED", "Acquired"),
("RENAMED", "Renamed"),
("DORMANT", "Dormant"),
]
status = models.CharField(
max_length=20,
choices=COMPANY_STATUSES,
default="ACTIVE",
help_text="Current operational status of the company",
)
# Founding Information (enhanced from just founded_year)
founded_year = models.PositiveIntegerField(blank=True, null=True, help_text="Year the company was founded") founded_year = models.PositiveIntegerField(blank=True, null=True, help_text="Year the company was founded")
founded_date = models.DateField(blank=True, null=True, help_text="Full founding date if known")
DATE_PRECISION_CHOICES = [
("YEAR", "Year only"),
("MONTH", "Month and year"),
("DAY", "Full date"),
]
founded_date_precision = models.CharField(
max_length=10,
choices=DATE_PRECISION_CHOICES,
blank=True,
help_text="Precision of the founding date",
)
# Image URLs (ported from legacy)
logo_url = models.URLField(blank=True, help_text="Company logo image URL")
banner_image_url = models.URLField(blank=True, help_text="Banner image for company page header")
card_image_url = models.URLField(blank=True, help_text="Card/thumbnail image for listings")
# Rating & Review Aggregates (computed fields, updated by triggers/signals)
average_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
blank=True,
null=True,
help_text="Average rating from reviews (auto-calculated)",
)
review_count = models.PositiveIntegerField(
default=0,
help_text="Total number of reviews (auto-calculated)",
)
# Counts (auto-calculated)
parks_count = models.IntegerField(default=0, help_text="Number of parks operated (auto-calculated)") parks_count = models.IntegerField(default=0, help_text="Number of parks operated (auto-calculated)")
rides_count = models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)") rides_count = models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)")

View File

@@ -54,7 +54,21 @@ class Park(StateMachineMixin, TrackedModel):
# Details # Details
opening_date = models.DateField(null=True, blank=True, help_text="Opening date") opening_date = models.DateField(null=True, blank=True, help_text="Opening date")
opening_date_precision = models.CharField(
max_length=10,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
blank=True,
help_text="Precision of the opening date (YEAR for circa dates)",
)
closing_date = models.DateField(null=True, blank=True, help_text="Closing date") closing_date = models.DateField(null=True, blank=True, help_text="Closing date")
closing_date_precision = models.CharField(
max_length=10,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
blank=True,
help_text="Precision of the closing date",
)
operating_season = models.CharField(max_length=255, blank=True, help_text="Operating season") operating_season = models.CharField(max_length=255, blank=True, help_text="Operating season")
size_acres = models.DecimalField( size_acres = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres" max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres"
@@ -310,6 +324,14 @@ class Park(StateMachineMixin, TrackedModel):
return self.location.formatted_address return self.location.formatted_address
return "" return ""
@property
def is_closing(self) -> bool:
"""Returns True if this park has a closing date in the future (announced closure)."""
from django.utils import timezone
if self.closing_date:
return self.closing_date > timezone.now().date()
return False
@property @property
def coordinates(self) -> list[float] | None: def coordinates(self) -> list[float] | None:
"""Returns coordinates as a list [latitude, longitude]""" """Returns coordinates as a list [latitude, longitude]"""

View File

@@ -0,0 +1,454 @@
# Generated by Django 5.2.9 on 2026-01-01 21:25
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0029_darkridestats_darkridestatsevent_flatridestats_and_more"),
]
operations = [
migrations.CreateModel(
name="KiddieRideStats",
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)),
(
"min_age",
models.PositiveIntegerField(blank=True, help_text="Minimum recommended age in years", null=True),
),
(
"max_age",
models.PositiveIntegerField(blank=True, help_text="Maximum recommended age in years", null=True),
),
(
"educational_theme",
models.CharField(
blank=True,
help_text="Educational theme if applicable (e.g., 'Dinosaurs', 'Space')",
max_length=200,
),
),
(
"character_theme",
models.CharField(
blank=True,
help_text="Character theme if applicable (e.g., 'Paw Patrol', 'Peppa Pig')",
max_length=200,
),
),
(
"guardian_required",
models.BooleanField(default=False, help_text="Whether a guardian must be present during the ride"),
),
(
"adult_ride_along",
models.BooleanField(default=True, help_text="Whether adults can ride along with children"),
),
(
"seats_per_vehicle",
models.PositiveIntegerField(blank=True, help_text="Number of seats per ride vehicle", null=True),
),
],
options={
"verbose_name": "Kiddie Ride Statistics",
"verbose_name_plural": "Kiddie Ride Statistics",
"ordering": ["ride"],
"abstract": False,
},
),
migrations.CreateModel(
name="KiddieRideStatsEvent",
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)),
(
"min_age",
models.PositiveIntegerField(blank=True, help_text="Minimum recommended age in years", null=True),
),
(
"max_age",
models.PositiveIntegerField(blank=True, help_text="Maximum recommended age in years", null=True),
),
(
"educational_theme",
models.CharField(
blank=True,
help_text="Educational theme if applicable (e.g., 'Dinosaurs', 'Space')",
max_length=200,
),
),
(
"character_theme",
models.CharField(
blank=True,
help_text="Character theme if applicable (e.g., 'Paw Patrol', 'Peppa Pig')",
max_length=200,
),
),
(
"guardian_required",
models.BooleanField(default=False, help_text="Whether a guardian must be present during the ride"),
),
(
"adult_ride_along",
models.BooleanField(default=True, help_text="Whether adults can ride along with children"),
),
(
"seats_per_vehicle",
models.PositiveIntegerField(blank=True, help_text="Number of seats per ride vehicle", null=True),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TransportationStats",
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)),
(
"transport_type",
models.CharField(
choices=[
("TRAIN", "Train"),
("MONORAIL", "Monorail"),
("SKYLIFT", "Skylift / Chairlift"),
("FERRY", "Ferry / Boat"),
("PEOPLEMOVER", "PeopleMover"),
("CABLE_CAR", "Cable Car"),
("TRAM", "Tram"),
],
default="TRAIN",
help_text="Type of transportation",
max_length=20,
),
),
(
"route_length_ft",
models.DecimalField(
blank=True, decimal_places=2, help_text="Total route length in feet", max_digits=8, null=True
),
),
(
"stations_count",
models.PositiveIntegerField(
blank=True, help_text="Number of stations/stops on the route", null=True
),
),
(
"vehicle_capacity",
models.PositiveIntegerField(blank=True, help_text="Passenger capacity per vehicle", null=True),
),
(
"vehicles_count",
models.PositiveIntegerField(
blank=True, help_text="Total number of vehicles in operation", null=True
),
),
(
"round_trip_duration_minutes",
models.PositiveIntegerField(
blank=True, help_text="Duration of a complete round trip in minutes", null=True
),
),
(
"scenic_highlights",
models.TextField(blank=True, help_text="Notable scenic views or attractions along the route"),
),
(
"is_one_way",
models.BooleanField(
default=False, help_text="Whether this is a one-way transportation (vs round-trip)"
),
),
],
options={
"verbose_name": "Transportation Statistics",
"verbose_name_plural": "Transportation Statistics",
"ordering": ["ride"],
"abstract": False,
},
),
migrations.CreateModel(
name="TransportationStatsEvent",
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)),
(
"transport_type",
models.CharField(
choices=[
("TRAIN", "Train"),
("MONORAIL", "Monorail"),
("SKYLIFT", "Skylift / Chairlift"),
("FERRY", "Ferry / Boat"),
("PEOPLEMOVER", "PeopleMover"),
("CABLE_CAR", "Cable Car"),
("TRAM", "Tram"),
],
default="TRAIN",
help_text="Type of transportation",
max_length=20,
),
),
(
"route_length_ft",
models.DecimalField(
blank=True, decimal_places=2, help_text="Total route length in feet", max_digits=8, null=True
),
),
(
"stations_count",
models.PositiveIntegerField(
blank=True, help_text="Number of stations/stops on the route", null=True
),
),
(
"vehicle_capacity",
models.PositiveIntegerField(blank=True, help_text="Passenger capacity per vehicle", null=True),
),
(
"vehicles_count",
models.PositiveIntegerField(
blank=True, help_text="Total number of vehicles in operation", null=True
),
),
(
"round_trip_duration_minutes",
models.PositiveIntegerField(
blank=True, help_text="Duration of a complete round trip in minutes", null=True
),
),
(
"scenic_highlights",
models.TextField(blank=True, help_text="Notable scenic views or attractions along the route"),
),
(
"is_one_way",
models.BooleanField(
default=False, help_text="Whether this is a one-way transportation (vs round-trip)"
),
),
],
options={
"abstract": False,
},
),
migrations.AlterModelOptions(
name="ridecredit",
options={
"ordering": ["display_order", "-last_ridden_at", "-first_ridden_at", "-created_at"],
"verbose_name": "Ride Credit",
"verbose_name_plural": "Ride Credits",
},
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridecredit",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridecredit",
name="update_update",
),
migrations.AddField(
model_name="ridecredit",
name="display_order",
field=models.PositiveIntegerField(default=0, help_text="User-defined display order for drag-drop sorting"),
),
migrations.AddField(
model_name="ridecreditevent",
name="display_order",
field=models.PositiveIntegerField(default=0, help_text="User-defined display order for drag-drop sorting"),
),
pgtrigger.migrations.AddTrigger(
model_name="ridecredit",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "display_order", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."display_order", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="680f93dab99a404aea8f73f8328eff04cd561254",
operation="INSERT",
pgid="pgtrigger_insert_insert_00439",
table="rides_ridecredit",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridecredit",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "display_order", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."display_order", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="e9889a572acd9261c1355ab47458f3eaf2b07c13",
operation="UPDATE",
pgid="pgtrigger_update_update_32a65",
table="rides_ridecredit",
when="AFTER",
),
),
),
migrations.AddField(
model_name="kiddieridestats",
name="ride",
field=models.OneToOneField(
help_text="Ride these kiddie ride statistics belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="kiddie_stats",
to="rides.ride",
),
),
migrations.AddField(
model_name="kiddieridestatsevent",
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="kiddieridestatsevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.kiddieridestats",
),
),
migrations.AddField(
model_name="kiddieridestatsevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride these kiddie ride statistics belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
migrations.AddField(
model_name="transportationstats",
name="ride",
field=models.OneToOneField(
help_text="Ride these transportation statistics belong to",
on_delete=django.db.models.deletion.CASCADE,
related_name="transport_stats",
to="rides.ride",
),
),
migrations.AddField(
model_name="transportationstatsevent",
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="transportationstatsevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.transportationstats",
),
),
migrations.AddField(
model_name="transportationstatsevent",
name="ride",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride these transportation statistics belong to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
pgtrigger.migrations.AddTrigger(
model_name="kiddieridestats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_kiddieridestatsevent" ("adult_ride_along", "character_theme", "created_at", "educational_theme", "guardian_required", "id", "max_age", "min_age", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "seats_per_vehicle", "updated_at") VALUES (NEW."adult_ride_along", NEW."character_theme", NEW."created_at", NEW."educational_theme", NEW."guardian_required", NEW."id", NEW."max_age", NEW."min_age", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."seats_per_vehicle", NEW."updated_at"); RETURN NULL;',
hash="b5d181566e5d0710c5b4093a5b61dc54591e5639",
operation="INSERT",
pgid="pgtrigger_insert_insert_9949f",
table="rides_kiddieridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="kiddieridestats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_kiddieridestatsevent" ("adult_ride_along", "character_theme", "created_at", "educational_theme", "guardian_required", "id", "max_age", "min_age", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "seats_per_vehicle", "updated_at") VALUES (NEW."adult_ride_along", NEW."character_theme", NEW."created_at", NEW."educational_theme", NEW."guardian_required", NEW."id", NEW."max_age", NEW."min_age", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."seats_per_vehicle", NEW."updated_at"); RETURN NULL;',
hash="672bb3e42cda03094d9300b6e0d9d89b2797bd05",
operation="UPDATE",
pgid="pgtrigger_update_update_fb19d",
table="rides_kiddieridestats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="transportationstats",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_transportationstatsevent" ("created_at", "id", "is_one_way", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "round_trip_duration_minutes", "route_length_ft", "scenic_highlights", "stations_count", "transport_type", "updated_at", "vehicle_capacity", "vehicles_count") VALUES (NEW."created_at", NEW."id", NEW."is_one_way", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_id", NEW."round_trip_duration_minutes", NEW."route_length_ft", NEW."scenic_highlights", NEW."stations_count", NEW."transport_type", NEW."updated_at", NEW."vehicle_capacity", NEW."vehicles_count"); RETURN NULL;',
hash="95a70a6e71eb5ea32726a19e5eb6286c20b19952",
operation="INSERT",
pgid="pgtrigger_insert_insert_c811e",
table="rides_transportationstats",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="transportationstats",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_transportationstatsevent" ("created_at", "id", "is_one_way", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_id", "round_trip_duration_minutes", "route_length_ft", "scenic_highlights", "stations_count", "transport_type", "updated_at", "vehicle_capacity", "vehicles_count") VALUES (NEW."created_at", NEW."id", NEW."is_one_way", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_id", NEW."round_trip_duration_minutes", NEW."route_length_ft", NEW."scenic_highlights", NEW."stations_count", NEW."transport_type", NEW."updated_at", NEW."vehicle_capacity", NEW."vehicles_count"); RETURN NULL;',
hash="901b16a5411e78635442895d7107b298634d25c3",
operation="UPDATE",
pgid="pgtrigger_update_update_ccccf",
table="rides_transportationstats",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,155 @@
# Generated by Django 5.2.9 on 2026-01-02 00:33
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0030_add_kiddie_and_transportation_stats"),
]
operations = [
migrations.CreateModel(
name="RideNameHistory",
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)),
("former_name", models.CharField(help_text="The previous name of the ride", max_length=200)),
(
"from_year",
models.PositiveSmallIntegerField(
blank=True, help_text="Year the ride started using this name", null=True
),
),
(
"to_year",
models.PositiveSmallIntegerField(
blank=True, help_text="Year the ride stopped using this name", null=True
),
),
(
"reason",
models.CharField(
blank=True, help_text="Reason for the name change (e.g., 'Retheme to Peanuts')", max_length=500
),
),
(
"ride",
models.ForeignKey(
help_text="The ride this name history entry belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="former_names",
to="rides.ride",
),
),
],
options={
"verbose_name": "Ride Name History",
"verbose_name_plural": "Ride Name Histories",
"ordering": ["-to_year", "-from_year"],
"abstract": False,
},
),
migrations.CreateModel(
name="RideNameHistoryEvent",
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)),
("former_name", models.CharField(help_text="The previous name of the ride", max_length=200)),
(
"from_year",
models.PositiveSmallIntegerField(
blank=True, help_text="Year the ride started using this name", null=True
),
),
(
"to_year",
models.PositiveSmallIntegerField(
blank=True, help_text="Year the ride stopped using this name", null=True
),
),
(
"reason",
models.CharField(
blank=True, help_text="Reason for the name change (e.g., 'Retheme to Peanuts')", max_length=500
),
),
(
"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="rides.ridenamehistory",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
help_text="The ride this name history entry belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="ridenamehistory",
index=models.Index(fields=["ride", "-to_year"], name="rides_riden_ride_id_b546e5_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="ridenamehistory",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridenamehistoryevent" ("created_at", "former_name", "from_year", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "ride_id", "to_year", "updated_at") VALUES (NEW."created_at", NEW."former_name", NEW."from_year", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."ride_id", NEW."to_year", NEW."updated_at"); RETURN NULL;',
hash="b79231914244e431999014db94fd52759cc41541",
operation="INSERT",
pgid="pgtrigger_insert_insert_ca1f7",
table="rides_ridenamehistory",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridenamehistory",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridenamehistoryevent" ("created_at", "former_name", "from_year", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "ride_id", "to_year", "updated_at") VALUES (NEW."created_at", NEW."former_name", NEW."from_year", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."ride_id", NEW."to_year", NEW."updated_at"); RETURN NULL;',
hash="c760d29ecb57f1f3c92e76c7f5d45027db136b84",
operation="UPDATE",
pgid="pgtrigger_update_update_99e4e",
table="rides_ridenamehistory",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,96 @@
# Generated by Django 5.2.9 on 2026-01-02 02:30
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0031_add_ride_name_history"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="closing_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the closing date",
max_length=10,
),
),
migrations.AddField(
model_name="ride",
name="opening_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the opening date",
max_length=10,
),
),
migrations.AddField(
model_name="rideevent",
name="closing_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the closing date",
max_length=10,
),
),
migrations.AddField(
model_name="rideevent",
name="opening_date_precision",
field=models.CharField(
blank=True,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
help_text="Precision of the opening date",
max_length=10,
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "closing_date_precision", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_date_precision", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."closing_date_precision", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="e0a64190a51762d71e695238a7ee9feedb95fd41",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "closing_date_precision", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_date_precision", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."closing_date_precision", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="e3991e794f1239191cfe9095bab207527123b94f",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,84 @@
# Generated by Django 5.2.9 on 2026-01-02 02:36
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0032_add_date_precision_fields"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
migrations.AddField(
model_name="ride",
name="age_requirement",
field=models.PositiveIntegerField(
blank=True, help_text="Minimum age requirement in years (if any)", null=True
),
),
migrations.AddField(
model_name="ride",
name="ride_sub_type",
field=models.CharField(
blank=True,
help_text="Sub-category of ride (e.g., 'Flying Coaster', 'Inverted Coaster', 'Log Flume')",
max_length=100,
),
),
migrations.AddField(
model_name="rideevent",
name="age_requirement",
field=models.PositiveIntegerField(
blank=True, help_text="Minimum age requirement in years (if any)", null=True
),
),
migrations.AddField(
model_name="rideevent",
name="ride_sub_type",
field=models.CharField(
blank=True,
help_text="Sub-category of ride (e.g., 'Flying Coaster', 'Inverted Coaster', 'Log Flume')",
max_length=100,
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_rideevent" ("age_requirement", "average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "closing_date_precision", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_date_precision", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "ride_sub_type", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."age_requirement", NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."closing_date_precision", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."ride_sub_type", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="cd829a8030511234c62ed355a58c753d15d09df9",
operation="INSERT",
pgid="pgtrigger_insert_insert_52074",
table="rides_ride",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ride",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_rideevent" ("age_requirement", "average_rating", "banner_image_id", "capacity_per_hour", "card_image_id", "category", "closing_date", "closing_date_precision", "created_at", "description", "designer_id", "id", "manufacturer_id", "max_height_in", "min_height_in", "name", "opening_date", "opening_date_precision", "opening_year", "park_area_id", "park_id", "park_url", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "post_closing_status", "ride_duration_seconds", "ride_model_id", "ride_sub_type", "search_text", "slug", "status", "status_since", "updated_at", "url") VALUES (NEW."age_requirement", NEW."average_rating", NEW."banner_image_id", NEW."capacity_per_hour", NEW."card_image_id", NEW."category", NEW."closing_date", NEW."closing_date_precision", NEW."created_at", NEW."description", NEW."designer_id", NEW."id", NEW."manufacturer_id", NEW."max_height_in", NEW."min_height_in", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."opening_year", NEW."park_area_id", NEW."park_id", NEW."park_url", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."post_closing_status", NEW."ride_duration_seconds", NEW."ride_model_id", NEW."ride_sub_type", NEW."search_text", NEW."slug", NEW."status", NEW."status_since", NEW."updated_at", NEW."url"); RETURN NULL;',
hash="32d2f4a4547c7fd91e136ea0ac378f1b6bb8ef30",
operation="UPDATE",
pgid="pgtrigger_update_update_4917a",
table="rides_ride",
when="AFTER",
),
),
),
]

View File

@@ -12,19 +12,23 @@ from .company import Company
from .credits import RideCredit from .credits import RideCredit
from .location import RideLocation from .location import RideLocation
from .media import RidePhoto from .media import RidePhoto
from .name_history import RideNameHistory
from .rankings import RankingSnapshot, RidePairComparison, RideRanking from .rankings import RankingSnapshot, RidePairComparison, RideRanking
from .reviews import RideReview from .reviews import RideReview
from .rides import Ride, RideModel, RollerCoasterStats from .rides import Ride, RideModel, RollerCoasterStats
from .stats import DarkRideStats, FlatRideStats, WaterRideStats from .stats import DarkRideStats, FlatRideStats, KiddieRideStats, TransportationStats, WaterRideStats
__all__ = [ __all__ = [
# Primary models # Primary models
"Ride", "Ride",
"RideModel", "RideModel",
"RideNameHistory",
"RollerCoasterStats", "RollerCoasterStats",
"WaterRideStats", "WaterRideStats",
"DarkRideStats", "DarkRideStats",
"FlatRideStats", "FlatRideStats",
"KiddieRideStats",
"TransportationStats",
"Company", "Company",
"RideLocation", "RideLocation",
"RideReview", "RideReview",
@@ -35,3 +39,4 @@ __all__ = [
"RidePairComparison", "RidePairComparison",
"RankingSnapshot", "RankingSnapshot",
] ]

View File

@@ -0,0 +1,73 @@
"""
Ride Name History model for tracking historical ride names.
This model stores the history of name changes for rides, enabling display of
former names on ride detail pages.
"""
import pghistory
from django.db import models
from apps.core.models import TrackedModel
@pghistory.track()
class RideNameHistory(TrackedModel):
"""
Tracks historical names of rides.
When a ride is renamed, this model stores the previous name along with
the year range it was used and an optional reason for the change.
"""
ride = models.ForeignKey(
"rides.Ride",
on_delete=models.CASCADE,
related_name="former_names",
help_text="The ride this name history entry belongs to",
)
former_name = models.CharField(
max_length=200,
help_text="The previous name of the ride",
)
from_year = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Year the ride started using this name",
)
to_year = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Year the ride stopped using this name",
)
reason = models.CharField(
max_length=500,
blank=True,
help_text="Reason for the name change (e.g., 'Retheme to Peanuts')",
)
class Meta(TrackedModel.Meta):
verbose_name = "Ride Name History"
verbose_name_plural = "Ride Name Histories"
ordering = ["-to_year", "-from_year"]
indexes = [
models.Index(fields=["ride", "-to_year"]),
]
def __str__(self):
year_range = ""
if self.from_year and self.to_year:
year_range = f" ({self.from_year}-{self.to_year})"
elif self.to_year:
year_range = f" (until {self.to_year})"
elif self.from_year:
year_range = f" (from {self.from_year})"
return f"{self.former_name}{year_range}"
def clean(self):
from django.core.exceptions import ValidationError
if self.from_year and self.to_year and self.from_year > self.to_year:
raise ValidationError(
{"from_year": "From year cannot be after to year."}
)

View File

@@ -508,7 +508,21 @@ class Ride(StateMachineMixin, TrackedModel):
help_text="Status to change to after closing date", help_text="Status to change to after closing date",
) )
opening_date = models.DateField(null=True, blank=True) opening_date = models.DateField(null=True, blank=True)
opening_date_precision = models.CharField(
max_length=10,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
blank=True,
help_text="Precision of the opening date",
)
closing_date = models.DateField(null=True, blank=True) closing_date = models.DateField(null=True, blank=True)
closing_date_precision = models.CharField(
max_length=10,
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
default="DAY",
blank=True,
help_text="Precision of the closing date",
)
status_since = models.DateField(null=True, blank=True) status_since = models.DateField(null=True, blank=True)
min_height_in = models.PositiveIntegerField(null=True, blank=True) min_height_in = models.PositiveIntegerField(null=True, blank=True)
max_height_in = models.PositiveIntegerField(null=True, blank=True) max_height_in = models.PositiveIntegerField(null=True, blank=True)
@@ -516,6 +530,18 @@ class Ride(StateMachineMixin, TrackedModel):
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True) ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True) average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
# Additional ride classification
ride_sub_type = models.CharField(
max_length=100,
blank=True,
help_text="Sub-category of ride (e.g., 'Flying Coaster', 'Inverted Coaster', 'Log Flume')",
)
age_requirement = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Minimum age requirement in years (if any)",
)
# Computed fields for hybrid filtering # Computed fields for hybrid filtering
opening_year = models.IntegerField(null=True, blank=True, db_index=True) opening_year = models.IntegerField(null=True, blank=True, db_index=True)
search_text = models.TextField(blank=True, db_index=True) search_text = models.TextField(blank=True, db_index=True)
@@ -680,6 +706,14 @@ class Ride(StateMachineMixin, TrackedModel):
self.save() self.save()
@property
def is_closing(self) -> bool:
"""Returns True if this ride has a closing date in the future (announced closure)."""
from django.utils import timezone
if self.closing_date:
return self.closing_date > timezone.now().date()
return False
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None:
# Handle slug generation and conflicts # Handle slug generation and conflicts
if not self.slug: if not self.slug:

View File

@@ -224,3 +224,152 @@ class FlatRideStats(TrackedModel):
def __str__(self) -> str: def __str__(self) -> str:
return f"Flat Ride Stats for {self.ride.name}" return f"Flat Ride Stats for {self.ride.name}"
# Transport Type Choices for Transportation Rides
TRANSPORT_TYPES = [
("TRAIN", "Train"),
("MONORAIL", "Monorail"),
("SKYLIFT", "Skylift / Chairlift"),
("FERRY", "Ferry / Boat"),
("PEOPLEMOVER", "PeopleMover"),
("CABLE_CAR", "Cable Car"),
("TRAM", "Tram"),
]
@pghistory.track()
class KiddieRideStats(TrackedModel):
"""
Statistics specific to kiddie rides (category=KR).
Tracks age-appropriate ride characteristics and theming.
"""
ride = models.OneToOneField(
"rides.Ride",
on_delete=models.CASCADE,
related_name="kiddie_stats",
help_text="Ride these kiddie ride statistics belong to",
)
min_age = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Minimum recommended age in years",
)
max_age = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum recommended age in years",
)
educational_theme = models.CharField(
max_length=200,
blank=True,
help_text="Educational theme if applicable (e.g., 'Dinosaurs', 'Space')",
)
character_theme = models.CharField(
max_length=200,
blank=True,
help_text="Character theme if applicable (e.g., 'Paw Patrol', 'Peppa Pig')",
)
guardian_required = models.BooleanField(
default=False,
help_text="Whether a guardian must be present during the ride",
)
adult_ride_along = models.BooleanField(
default=True,
help_text="Whether adults can ride along with children",
)
seats_per_vehicle = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Number of seats per ride vehicle",
)
class Meta(TrackedModel.Meta):
verbose_name = "Kiddie Ride Statistics"
verbose_name_plural = "Kiddie Ride Statistics"
ordering = ["ride"]
def __str__(self) -> str:
return f"Kiddie Ride Stats for {self.ride.name}"
@pghistory.track()
class TransportationStats(TrackedModel):
"""
Statistics specific to transportation rides (category=TR).
Tracks route, capacity, and vehicle information.
"""
ride = models.OneToOneField(
"rides.Ride",
on_delete=models.CASCADE,
related_name="transport_stats",
help_text="Ride these transportation statistics belong to",
)
transport_type = models.CharField(
max_length=20,
choices=TRANSPORT_TYPES,
default="TRAIN",
help_text="Type of transportation",
)
route_length_ft = models.DecimalField(
max_digits=8,
decimal_places=2,
null=True,
blank=True,
help_text="Total route length in feet",
)
stations_count = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Number of stations/stops on the route",
)
vehicle_capacity = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Passenger capacity per vehicle",
)
vehicles_count = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Total number of vehicles in operation",
)
round_trip_duration_minutes = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Duration of a complete round trip in minutes",
)
scenic_highlights = models.TextField(
blank=True,
help_text="Notable scenic views or attractions along the route",
)
is_one_way = models.BooleanField(
default=False,
help_text="Whether this is a one-way transportation (vs round-trip)",
)
class Meta(TrackedModel.Meta):
verbose_name = "Transportation Statistics"
verbose_name_plural = "Transportation Statistics"
ordering = ["ride"]
def __str__(self) -> str:
return f"Transportation Stats for {self.ride.name}"

View File

@@ -235,3 +235,46 @@ def update_ride_search_text_on_ride_model_change(sender, instance, **kwargs):
update_ride_search_text(ride) update_ride_search_text(ride)
except Exception as e: except Exception as e:
logger.exception(f"Failed to update ride search_text on ride model change: {e}") logger.exception(f"Failed to update ride search_text on ride model change: {e}")
# =============================================================================
# Automatic Name History Tracking
# =============================================================================
@receiver(pre_save, sender=Ride)
def track_ride_name_changes(sender, instance, **kwargs):
"""
Automatically create RideNameHistory when a ride's name changes.
This ensures versioning is automatic - when a ride is renamed,
the previous name is preserved in the name history.
"""
if not instance.pk:
return # Skip new rides
try:
old_instance = Ride.objects.get(pk=instance.pk)
if old_instance.name != instance.name:
from .models import RideNameHistory
current_year = timezone.now().year
# Create history entry for the old name
RideNameHistory.objects.create(
ride=instance,
former_name=old_instance.name,
to_year=current_year,
reason="Name changed",
)
logger.info(
f"Ride {instance.pk} name changed from '{old_instance.name}' "
f"to '{instance.name}' - history entry created"
)
except Ride.DoesNotExist:
pass # New ride, no history to track
except Exception as e:
logger.exception(f"Failed to track name change for ride {instance.pk}: {e}")

View File

@@ -0,0 +1,99 @@
# Agent Rules
**These rules are MANDATORY and MUST be followed without exception by all agents working on this codebase.**
---
## 1. Entity Versioning MUST Be Automatic
> [!CAUTION]
> **NON-NEGOTIABLE REQUIREMENT**
All versioned entities (Parks, Rides, Companies, etc.) MUST have their version history created **automatically via Django signals**—NEVER manually in views, serializers, or management commands.
### Why This Rule Exists
Manual version creation is fragile and error-prone. If any code path modifies a versioned entity without explicitly calling "create version," history is lost forever. Signal-based versioning guarantees that **every single modification** is captured, regardless of where the change originates.
### Required Implementation Pattern
```python
# apps/{entity}/signals.py - REQUIRED for all versioned entities
from django.db.models.signals import pre_save
from django.dispatch import receiver
from .models import Park, ParkVersion
from .serializers import ParkSerializer
@receiver(pre_save, sender=Park)
def auto_create_park_version(sender, instance, **kwargs):
"""
Automatically snapshot the entity BEFORE any modification.
This signal fires for ALL save operations, ensuring no history is ever lost.
"""
if not instance.pk:
return # Skip for initial creation
try:
old_instance = sender.objects.get(pk=instance.pk)
ParkVersion.objects.create(
park=instance,
data=ParkSerializer(old_instance).data,
changed_by=getattr(instance, '_changed_by', None),
change_summary=getattr(instance, '_change_summary', 'Auto-versioned')
)
except sender.DoesNotExist:
pass # Edge case: concurrent deletion
```
### Passing Context to Signals
When updating entities, attach metadata to the instance before saving:
```python
# In views/serializers - attach context, DON'T create versions manually
park._changed_by = request.user
park._change_summary = submission_note or "Updated via API"
park.save() # Signal handles versioning automatically
```
### What Is FORBIDDEN
The following patterns are **strictly prohibited** and will be flagged as non-compliant:
```python
# ❌ FORBIDDEN: Manual version creation in views
def update(self, request, slug=None):
park = self.get_object()
# ... update logic ...
ParkVersion.objects.create(park=park, ...) # VIOLATION!
park.save()
# ❌ FORBIDDEN: Manual version creation in serializers
def update(self, instance, validated_data):
ParkVersion.objects.create(park=instance, ...) # VIOLATION!
return super().update(instance, validated_data)
# ❌ FORBIDDEN: Manual version creation in management commands
def handle(self, *args, **options):
for park in Park.objects.all():
ParkVersion.objects.create(park=park, ...) # VIOLATION!
park.status = 'updated'
park.save()
```
### Compliance Checklist
For every versioned entity, verify:
- [ ] A `pre_save` signal receiver exists in `signals.py`
- [ ] The signal is connected in `apps.py` via `ready()` method
- [ ] No manual `{Entity}Version.objects.create()` calls exist in views
- [ ] No manual `{Entity}Version.objects.create()` calls exist in serializers
- [ ] No manual `{Entity}Version.objects.create()` calls exist in management commands
---
## Document Authority
This document has the same authority as all other `source_docs/` files. Per the `/comply` workflow, these specifications are **immutable law** and must be enforced immediately upon detection of any violation.