mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-04 21:35:19 -05:00
lol
This commit is contained in:
73
.agent/MEMORY/migration_source.md
Normal file
73
.agent/MEMORY/migration_source.md
Normal 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
|
||||
83
.agent/MEMORY/source_mapping.md
Normal file
83
.agent/MEMORY/source_mapping.md
Normal 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` |
|
||||
329
.agent/rules/api-conventions.md
Normal file
329
.agent/rules/api-conventions.md
Normal 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 }
|
||||
})
|
||||
```
|
||||
306
.agent/rules/component-patterns.md
Normal file
306
.agent/rules/component-patterns.md
Normal 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>
|
||||
```
|
||||
191
.agent/rules/design-system.md
Normal file
191
.agent/rules/design-system.md
Normal 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.)
|
||||
254
.agent/rules/django-standards.md
Normal file
254
.agent/rules/django-standards.md
Normal 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
|
||||
204
.agent/rules/nuxt-standards.md
Normal file
204
.agent/rules/nuxt-standards.md
Normal 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
|
||||
85
.agent/workflows/comply.md
Normal file
85
.agent/workflows/comply.md
Normal 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 |
|
||||
```
|
||||
168
.agent/workflows/migrate-component.md
Normal file
168
.agent/workflows/migrate-component.md
Normal 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.
|
||||
223
.agent/workflows/migrate-hook.md
Normal file
223
.agent/workflows/migrate-hook.md
Normal 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 |
|
||||
183
.agent/workflows/migrate-page.md
Normal file
183
.agent/workflows/migrate-page.md
Normal 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 |
|
||||
201
.agent/workflows/migrate-type.md
Normal file
201
.agent/workflows/migrate-type.md
Normal 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
193
.agent/workflows/migrate.md
Normal 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
|
||||
472
.agent/workflows/moderation.md
Normal file
472
.agent/workflows/moderation.md
Normal 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
360
.agent/workflows/new-api.md
Normal 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
|
||||
```
|
||||
279
.agent/workflows/new-component.md
Normal file
279
.agent/workflows/new-component.md
Normal 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]
|
||||
```
|
||||
311
.agent/workflows/new-feature.md
Normal file
311
.agent/workflows/new-feature.md
Normal 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]
|
||||
```
|
||||
235
.agent/workflows/new-page.md
Normal file
235
.agent/workflows/new-page.md
Normal 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]
|
||||
```
|
||||
@@ -233,12 +233,16 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Company fields
|
||||
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)
|
||||
|
||||
# Image URLs for display
|
||||
banner_image_url = serializers.SerializerMethodField()
|
||||
card_image_url = serializers.SerializerMethodField()
|
||||
|
||||
# Computed property
|
||||
is_closing = serializers.SerializerMethodField()
|
||||
|
||||
# Computed fields for filtering
|
||||
opening_year = serializers.IntegerField(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 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:
|
||||
model = Park
|
||||
fields = [
|
||||
@@ -321,7 +330,10 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
"park_type",
|
||||
# Dates and computed fields
|
||||
"opening_date",
|
||||
"opening_date_precision",
|
||||
"closing_date",
|
||||
"closing_date_precision",
|
||||
"is_closing",
|
||||
"opening_year",
|
||||
"operating_season",
|
||||
# Location fields
|
||||
@@ -333,12 +345,17 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
||||
"longitude",
|
||||
# Company relationships
|
||||
"operator_name",
|
||||
"operator_id",
|
||||
"property_owner_name",
|
||||
# Statistics
|
||||
"size_acres",
|
||||
"average_rating",
|
||||
"ride_count",
|
||||
"coaster_count",
|
||||
# Contact info
|
||||
"phone",
|
||||
"email",
|
||||
"timezone",
|
||||
# Images
|
||||
"banner_image_url",
|
||||
"card_image_url",
|
||||
|
||||
@@ -491,6 +491,374 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
return obj.card_image.image.url
|
||||
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:
|
||||
model = Ride
|
||||
fields = [
|
||||
@@ -504,7 +872,10 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
"post_closing_status",
|
||||
# Dates and computed fields
|
||||
"opening_date",
|
||||
"opening_date_precision",
|
||||
"closing_date",
|
||||
"closing_date_precision",
|
||||
"is_closing",
|
||||
"status_since",
|
||||
"opening_year",
|
||||
# Park fields
|
||||
@@ -533,6 +904,9 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
"capacity_per_hour",
|
||||
"ride_duration_seconds",
|
||||
"average_rating",
|
||||
# Additional classification
|
||||
"ride_sub_type",
|
||||
"age_requirement",
|
||||
# Roller coaster stats
|
||||
"coaster_height_ft",
|
||||
"coaster_length_ft",
|
||||
@@ -548,6 +922,46 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
||||
"coaster_trains_count",
|
||||
"coaster_cars_per_train",
|
||||
"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
|
||||
"banner_image_url",
|
||||
"card_image_url",
|
||||
|
||||
@@ -32,9 +32,18 @@ from .shared import ModelChoices
|
||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||
"description": "Theme park operator based in Ohio",
|
||||
"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,
|
||||
"coasters_count": 0,
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -42,15 +51,35 @@ from .shared import ModelChoices
|
||||
class CompanyDetailOutputSerializer(serializers.Serializer):
|
||||
"""Output serializer for company details."""
|
||||
|
||||
# Core fields
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
slug = serializers.CharField()
|
||||
roles = serializers.ListField(child=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_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()
|
||||
coasters_count = serializers.IntegerField()
|
||||
|
||||
# Metadata
|
||||
created_at = serializers.DateTimeField()
|
||||
@@ -67,7 +96,31 @@ class CompanyCreateInputSerializer(serializers.Serializer):
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
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_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):
|
||||
@@ -80,7 +133,31 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||
)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
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_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 ===
|
||||
|
||||
@@ -208,6 +208,9 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
banner_image = serializers.SerializerMethodField()
|
||||
card_image = serializers.SerializerMethodField()
|
||||
|
||||
# Former names (name history)
|
||||
former_names = serializers.SerializerMethodField()
|
||||
|
||||
# URL
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
@@ -406,6 +409,24 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
||||
|
||||
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):
|
||||
"""Input serializer for setting ride banner and card images."""
|
||||
@@ -841,3 +862,37 @@ class RideReviewUpdateInputSerializer(serializers.Serializer):
|
||||
if value and value > timezone.now().date():
|
||||
raise serializers.ValidationError("Visit date cannot be in the future")
|
||||
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
|
||||
|
||||
|
||||
251
backend/apps/parks/migrations/0027_add_company_entity_fields.py
Normal file
251
backend/apps/parks/migrations/0027_add_company_entity_fields.py
Normal 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,7 @@ class Company(TrackedModel):
|
||||
|
||||
objects = CompanyManager()
|
||||
|
||||
# Core Fields
|
||||
name = models.CharField(max_length=255, help_text="Company name")
|
||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||
roles = ArrayField(
|
||||
@@ -25,8 +26,72 @@ class Company(TrackedModel):
|
||||
description = models.TextField(blank=True, help_text="Detailed company description")
|
||||
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_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)")
|
||||
rides_count = models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)")
|
||||
|
||||
|
||||
@@ -54,7 +54,21 @@ class Park(StateMachineMixin, TrackedModel):
|
||||
|
||||
# Details
|
||||
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_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")
|
||||
size_acres = models.DecimalField(
|
||||
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 ""
|
||||
|
||||
@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
|
||||
def coordinates(self) -> list[float] | None:
|
||||
"""Returns coordinates as a list [latitude, longitude]"""
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
155
backend/apps/rides/migrations/0031_add_ride_name_history.py
Normal file
155
backend/apps/rides/migrations/0031_add_ride_name_history.py
Normal 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -12,19 +12,23 @@ from .company import Company
|
||||
from .credits import RideCredit
|
||||
from .location import RideLocation
|
||||
from .media import RidePhoto
|
||||
from .name_history import RideNameHistory
|
||||
from .rankings import RankingSnapshot, RidePairComparison, RideRanking
|
||||
from .reviews import RideReview
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .stats import DarkRideStats, FlatRideStats, WaterRideStats
|
||||
from .stats import DarkRideStats, FlatRideStats, KiddieRideStats, TransportationStats, WaterRideStats
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Ride",
|
||||
"RideModel",
|
||||
"RideNameHistory",
|
||||
"RollerCoasterStats",
|
||||
"WaterRideStats",
|
||||
"DarkRideStats",
|
||||
"FlatRideStats",
|
||||
"KiddieRideStats",
|
||||
"TransportationStats",
|
||||
"Company",
|
||||
"RideLocation",
|
||||
"RideReview",
|
||||
@@ -35,3 +39,4 @@ __all__ = [
|
||||
"RidePairComparison",
|
||||
"RankingSnapshot",
|
||||
]
|
||||
|
||||
|
||||
73
backend/apps/rides/models/name_history.py
Normal file
73
backend/apps/rides/models/name_history.py
Normal 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."}
|
||||
)
|
||||
@@ -508,7 +508,21 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
help_text="Status to change to after closing date",
|
||||
)
|
||||
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_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)
|
||||
min_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)
|
||||
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
|
||||
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
|
||||
search_text = models.TextField(blank=True, db_index=True)
|
||||
@@ -680,6 +706,14 @@ class Ride(StateMachineMixin, TrackedModel):
|
||||
|
||||
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:
|
||||
# Handle slug generation and conflicts
|
||||
if not self.slug:
|
||||
|
||||
@@ -224,3 +224,152 @@ class FlatRideStats(TrackedModel):
|
||||
|
||||
def __str__(self) -> str:
|
||||
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}"
|
||||
|
||||
@@ -235,3 +235,46 @@ def update_ride_search_text_on_ride_model_change(sender, instance, **kwargs):
|
||||
update_ride_search_text(ride)
|
||||
except Exception as 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}")
|
||||
|
||||
|
||||
99
source_docs/AGENT_RULES.md
Normal file
99
source_docs/AGENT_RULES.md
Normal 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.
|
||||
Reference in New Issue
Block a user