mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 00:55: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
|
# Company fields
|
||||||
operator_name = serializers.CharField(source="operator.name", read_only=True)
|
operator_name = serializers.CharField(source="operator.name", read_only=True)
|
||||||
|
operator_id = serializers.IntegerField(source="operator.id", read_only=True, allow_null=True)
|
||||||
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
|
property_owner_name = serializers.CharField(source="property_owner.name", read_only=True, allow_null=True)
|
||||||
|
|
||||||
# Image URLs for display
|
# Image URLs for display
|
||||||
banner_image_url = serializers.SerializerMethodField()
|
banner_image_url = serializers.SerializerMethodField()
|
||||||
card_image_url = serializers.SerializerMethodField()
|
card_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Computed property
|
||||||
|
is_closing = serializers.SerializerMethodField()
|
||||||
|
|
||||||
# Computed fields for filtering
|
# Computed fields for filtering
|
||||||
opening_year = serializers.IntegerField(read_only=True)
|
opening_year = serializers.IntegerField(read_only=True)
|
||||||
search_text = serializers.CharField(read_only=True)
|
search_text = serializers.CharField(read_only=True)
|
||||||
@@ -309,6 +313,11 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
|||||||
return obj.card_image.image.url
|
return obj.card_image.image.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField())
|
||||||
|
def get_is_closing(self, obj):
|
||||||
|
"""Check if park has an announced closing date in the future."""
|
||||||
|
return obj.is_closing
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Park
|
model = Park
|
||||||
fields = [
|
fields = [
|
||||||
@@ -321,7 +330,10 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
|||||||
"park_type",
|
"park_type",
|
||||||
# Dates and computed fields
|
# Dates and computed fields
|
||||||
"opening_date",
|
"opening_date",
|
||||||
|
"opening_date_precision",
|
||||||
"closing_date",
|
"closing_date",
|
||||||
|
"closing_date_precision",
|
||||||
|
"is_closing",
|
||||||
"opening_year",
|
"opening_year",
|
||||||
"operating_season",
|
"operating_season",
|
||||||
# Location fields
|
# Location fields
|
||||||
@@ -333,12 +345,17 @@ class HybridParkSerializer(serializers.ModelSerializer):
|
|||||||
"longitude",
|
"longitude",
|
||||||
# Company relationships
|
# Company relationships
|
||||||
"operator_name",
|
"operator_name",
|
||||||
|
"operator_id",
|
||||||
"property_owner_name",
|
"property_owner_name",
|
||||||
# Statistics
|
# Statistics
|
||||||
"size_acres",
|
"size_acres",
|
||||||
"average_rating",
|
"average_rating",
|
||||||
"ride_count",
|
"ride_count",
|
||||||
"coaster_count",
|
"coaster_count",
|
||||||
|
# Contact info
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
"timezone",
|
||||||
# Images
|
# Images
|
||||||
"banner_image_url",
|
"banner_image_url",
|
||||||
"card_image_url",
|
"card_image_url",
|
||||||
|
|||||||
@@ -491,6 +491,374 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
|||||||
return obj.card_image.image.url
|
return obj.card_image.image.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Computed property
|
||||||
|
is_closing = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField())
|
||||||
|
def get_is_closing(self, obj):
|
||||||
|
"""Check if ride has an announced closing date in the future."""
|
||||||
|
return obj.is_closing
|
||||||
|
|
||||||
|
# Water ride stats fields
|
||||||
|
water_wetness_level = serializers.SerializerMethodField()
|
||||||
|
water_splash_height_ft = serializers.SerializerMethodField()
|
||||||
|
water_has_splash_zone = serializers.SerializerMethodField()
|
||||||
|
water_boat_capacity = serializers.SerializerMethodField()
|
||||||
|
water_uses_flume = serializers.SerializerMethodField()
|
||||||
|
water_rapids_sections = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_water_wetness_level(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "water_stats") and obj.water_stats:
|
||||||
|
return obj.water_stats.wetness_level
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_water_splash_height_ft(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "water_stats") and obj.water_stats:
|
||||||
|
return float(obj.water_stats.splash_height_ft) if obj.water_stats.splash_height_ft else None
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_water_has_splash_zone(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "water_stats") and obj.water_stats:
|
||||||
|
return obj.water_stats.has_splash_zone
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_water_boat_capacity(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "water_stats") and obj.water_stats:
|
||||||
|
return obj.water_stats.boat_capacity
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_water_uses_flume(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "water_stats") and obj.water_stats:
|
||||||
|
return obj.water_stats.uses_flume
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_water_rapids_sections(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "water_stats") and obj.water_stats:
|
||||||
|
return obj.water_stats.rapids_sections
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Dark ride stats fields
|
||||||
|
dark_scene_count = serializers.SerializerMethodField()
|
||||||
|
dark_animatronic_count = serializers.SerializerMethodField()
|
||||||
|
dark_has_projection_technology = serializers.SerializerMethodField()
|
||||||
|
dark_is_interactive = serializers.SerializerMethodField()
|
||||||
|
dark_ride_system = serializers.SerializerMethodField()
|
||||||
|
dark_uses_practical_effects = serializers.SerializerMethodField()
|
||||||
|
dark_uses_motion_base = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_dark_scene_count(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.scene_count
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_dark_animatronic_count(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.animatronic_count
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_dark_has_projection_technology(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.has_projection_technology
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_dark_is_interactive(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.is_interactive
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_dark_ride_system(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.ride_system
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_dark_uses_practical_effects(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.uses_practical_effects
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_dark_uses_motion_base(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "dark_stats") and obj.dark_stats:
|
||||||
|
return obj.dark_stats.uses_motion_base
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Flat ride stats fields
|
||||||
|
flat_max_height_ft = serializers.SerializerMethodField()
|
||||||
|
flat_rotation_speed_rpm = serializers.SerializerMethodField()
|
||||||
|
flat_swing_angle_degrees = serializers.SerializerMethodField()
|
||||||
|
flat_motion_type = serializers.SerializerMethodField()
|
||||||
|
flat_arm_count = serializers.SerializerMethodField()
|
||||||
|
flat_seats_per_gondola = serializers.SerializerMethodField()
|
||||||
|
flat_max_g_force = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_flat_max_height_ft(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return float(obj.flat_stats.max_height_ft) if obj.flat_stats.max_height_ft else None
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_flat_rotation_speed_rpm(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return float(obj.flat_stats.rotation_speed_rpm) if obj.flat_stats.rotation_speed_rpm else None
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_flat_swing_angle_degrees(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return obj.flat_stats.swing_angle_degrees
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_flat_motion_type(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return obj.flat_stats.motion_type
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_flat_arm_count(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return obj.flat_stats.arm_count
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_flat_seats_per_gondola(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return obj.flat_stats.seats_per_gondola
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_flat_max_g_force(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "flat_stats") and obj.flat_stats:
|
||||||
|
return float(obj.flat_stats.max_g_force) if obj.flat_stats.max_g_force else None
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Kiddie ride stats fields
|
||||||
|
kiddie_min_age = serializers.SerializerMethodField()
|
||||||
|
kiddie_max_age = serializers.SerializerMethodField()
|
||||||
|
kiddie_educational_theme = serializers.SerializerMethodField()
|
||||||
|
kiddie_character_theme = serializers.SerializerMethodField()
|
||||||
|
kiddie_guardian_required = serializers.SerializerMethodField()
|
||||||
|
kiddie_adult_ride_along = serializers.SerializerMethodField()
|
||||||
|
kiddie_seats_per_vehicle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_kiddie_min_age(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.min_age
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_kiddie_max_age(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.max_age
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_kiddie_educational_theme(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.educational_theme or None
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_kiddie_character_theme(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.character_theme or None
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_kiddie_guardian_required(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.guardian_required
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_kiddie_adult_ride_along(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.adult_ride_along
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_kiddie_seats_per_vehicle(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "kiddie_stats") and obj.kiddie_stats:
|
||||||
|
return obj.kiddie_stats.seats_per_vehicle
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Transportation stats fields
|
||||||
|
transport_type = serializers.SerializerMethodField()
|
||||||
|
transport_route_length_ft = serializers.SerializerMethodField()
|
||||||
|
transport_stations_count = serializers.SerializerMethodField()
|
||||||
|
transport_vehicle_capacity = serializers.SerializerMethodField()
|
||||||
|
transport_vehicles_count = serializers.SerializerMethodField()
|
||||||
|
transport_round_trip_duration_minutes = serializers.SerializerMethodField()
|
||||||
|
transport_scenic_highlights = serializers.SerializerMethodField()
|
||||||
|
transport_is_one_way = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_transport_type(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.transport_type
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.FloatField(allow_null=True))
|
||||||
|
def get_transport_route_length_ft(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return float(obj.transport_stats.route_length_ft) if obj.transport_stats.route_length_ft else None
|
||||||
|
return None
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_transport_stations_count(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.stations_count
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_transport_vehicle_capacity(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.vehicle_capacity
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_transport_vehicles_count(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.vehicles_count
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||||
|
def get_transport_round_trip_duration_minutes(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.round_trip_duration_minutes
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||||
|
def get_transport_scenic_highlights(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.scenic_highlights or None
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.BooleanField(allow_null=True))
|
||||||
|
def get_transport_is_one_way(self, obj):
|
||||||
|
try:
|
||||||
|
if hasattr(obj, "transport_stats") and obj.transport_stats:
|
||||||
|
return obj.transport_stats.is_one_way
|
||||||
|
return None
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Ride
|
model = Ride
|
||||||
fields = [
|
fields = [
|
||||||
@@ -504,7 +872,10 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
|||||||
"post_closing_status",
|
"post_closing_status",
|
||||||
# Dates and computed fields
|
# Dates and computed fields
|
||||||
"opening_date",
|
"opening_date",
|
||||||
|
"opening_date_precision",
|
||||||
"closing_date",
|
"closing_date",
|
||||||
|
"closing_date_precision",
|
||||||
|
"is_closing",
|
||||||
"status_since",
|
"status_since",
|
||||||
"opening_year",
|
"opening_year",
|
||||||
# Park fields
|
# Park fields
|
||||||
@@ -533,6 +904,9 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
|||||||
"capacity_per_hour",
|
"capacity_per_hour",
|
||||||
"ride_duration_seconds",
|
"ride_duration_seconds",
|
||||||
"average_rating",
|
"average_rating",
|
||||||
|
# Additional classification
|
||||||
|
"ride_sub_type",
|
||||||
|
"age_requirement",
|
||||||
# Roller coaster stats
|
# Roller coaster stats
|
||||||
"coaster_height_ft",
|
"coaster_height_ft",
|
||||||
"coaster_length_ft",
|
"coaster_length_ft",
|
||||||
@@ -548,6 +922,46 @@ class HybridRideSerializer(serializers.ModelSerializer):
|
|||||||
"coaster_trains_count",
|
"coaster_trains_count",
|
||||||
"coaster_cars_per_train",
|
"coaster_cars_per_train",
|
||||||
"coaster_seats_per_car",
|
"coaster_seats_per_car",
|
||||||
|
# Water ride stats
|
||||||
|
"water_wetness_level",
|
||||||
|
"water_splash_height_ft",
|
||||||
|
"water_has_splash_zone",
|
||||||
|
"water_boat_capacity",
|
||||||
|
"water_uses_flume",
|
||||||
|
"water_rapids_sections",
|
||||||
|
# Dark ride stats
|
||||||
|
"dark_scene_count",
|
||||||
|
"dark_animatronic_count",
|
||||||
|
"dark_has_projection_technology",
|
||||||
|
"dark_is_interactive",
|
||||||
|
"dark_ride_system",
|
||||||
|
"dark_uses_practical_effects",
|
||||||
|
"dark_uses_motion_base",
|
||||||
|
# Flat ride stats
|
||||||
|
"flat_max_height_ft",
|
||||||
|
"flat_rotation_speed_rpm",
|
||||||
|
"flat_swing_angle_degrees",
|
||||||
|
"flat_motion_type",
|
||||||
|
"flat_arm_count",
|
||||||
|
"flat_seats_per_gondola",
|
||||||
|
"flat_max_g_force",
|
||||||
|
# Kiddie ride stats
|
||||||
|
"kiddie_min_age",
|
||||||
|
"kiddie_max_age",
|
||||||
|
"kiddie_educational_theme",
|
||||||
|
"kiddie_character_theme",
|
||||||
|
"kiddie_guardian_required",
|
||||||
|
"kiddie_adult_ride_along",
|
||||||
|
"kiddie_seats_per_vehicle",
|
||||||
|
# Transportation stats
|
||||||
|
"transport_type",
|
||||||
|
"transport_route_length_ft",
|
||||||
|
"transport_stations_count",
|
||||||
|
"transport_vehicle_capacity",
|
||||||
|
"transport_vehicles_count",
|
||||||
|
"transport_round_trip_duration_minutes",
|
||||||
|
"transport_scenic_highlights",
|
||||||
|
"transport_is_one_way",
|
||||||
# Images
|
# Images
|
||||||
"banner_image_url",
|
"banner_image_url",
|
||||||
"card_image_url",
|
"card_image_url",
|
||||||
|
|||||||
@@ -32,9 +32,18 @@ from .shared import ModelChoices
|
|||||||
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
"roles": ["OPERATOR", "PROPERTY_OWNER"],
|
||||||
"description": "Theme park operator based in Ohio",
|
"description": "Theme park operator based in Ohio",
|
||||||
"website": "https://cedarfair.com",
|
"website": "https://cedarfair.com",
|
||||||
"founded_date": "1983-01-01",
|
"person_type": "CORPORATION",
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"founded_year": 1983,
|
||||||
|
"founded_date": "1983-05-01",
|
||||||
|
"founded_date_precision": "MONTH",
|
||||||
|
"logo_url": "https://example.com/logo.png",
|
||||||
|
"banner_image_url": "https://example.com/banner.jpg",
|
||||||
|
"card_image_url": "https://example.com/card.jpg",
|
||||||
|
"average_rating": 4.5,
|
||||||
|
"review_count": 150,
|
||||||
|
"parks_count": 11,
|
||||||
"rides_count": 0,
|
"rides_count": 0,
|
||||||
"coasters_count": 0,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -42,15 +51,35 @@ from .shared import ModelChoices
|
|||||||
class CompanyDetailOutputSerializer(serializers.Serializer):
|
class CompanyDetailOutputSerializer(serializers.Serializer):
|
||||||
"""Output serializer for company details."""
|
"""Output serializer for company details."""
|
||||||
|
|
||||||
|
# Core fields
|
||||||
id = serializers.IntegerField()
|
id = serializers.IntegerField()
|
||||||
name = serializers.CharField()
|
name = serializers.CharField()
|
||||||
slug = serializers.CharField()
|
slug = serializers.CharField()
|
||||||
roles = serializers.ListField(child=serializers.CharField())
|
roles = serializers.ListField(child=serializers.CharField())
|
||||||
description = serializers.CharField()
|
description = serializers.CharField()
|
||||||
website = serializers.URLField()
|
website = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
# Entity type and status (ported from legacy)
|
||||||
|
person_type = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
status = serializers.CharField()
|
||||||
|
|
||||||
|
# Founding information
|
||||||
|
founded_year = serializers.IntegerField(allow_null=True)
|
||||||
founded_date = serializers.DateField(allow_null=True)
|
founded_date = serializers.DateField(allow_null=True)
|
||||||
|
founded_date_precision = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
# Image URLs
|
||||||
|
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
banner_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
card_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
# Rating and review aggregates
|
||||||
|
average_rating = serializers.DecimalField(max_digits=3, decimal_places=2, allow_null=True)
|
||||||
|
review_count = serializers.IntegerField()
|
||||||
|
|
||||||
|
# Counts
|
||||||
|
parks_count = serializers.IntegerField()
|
||||||
rides_count = serializers.IntegerField()
|
rides_count = serializers.IntegerField()
|
||||||
coasters_count = serializers.IntegerField()
|
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = serializers.DateTimeField()
|
created_at = serializers.DateTimeField()
|
||||||
@@ -67,7 +96,31 @@ class CompanyCreateInputSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
description = serializers.CharField(allow_blank=True, default="")
|
description = serializers.CharField(allow_blank=True, default="")
|
||||||
website = serializers.URLField(required=False, allow_blank=True)
|
website = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
# Entity type and status
|
||||||
|
person_type = serializers.ChoiceField(
|
||||||
|
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
)
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
|
||||||
|
default="ACTIVE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Founding information
|
||||||
|
founded_year = serializers.IntegerField(required=False, allow_null=True)
|
||||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||||
|
founded_date_precision = serializers.ChoiceField(
|
||||||
|
choices=["YEAR", "MONTH", "DAY"],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image URLs
|
||||||
|
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
banner_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
card_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
class CompanyUpdateInputSerializer(serializers.Serializer):
|
class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||||
@@ -80,7 +133,31 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
description = serializers.CharField(allow_blank=True, required=False)
|
description = serializers.CharField(allow_blank=True, required=False)
|
||||||
website = serializers.URLField(required=False, allow_blank=True)
|
website = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
# Entity type and status
|
||||||
|
person_type = serializers.ChoiceField(
|
||||||
|
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
)
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Founding information
|
||||||
|
founded_year = serializers.IntegerField(required=False, allow_null=True)
|
||||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||||
|
founded_date_precision = serializers.ChoiceField(
|
||||||
|
choices=["YEAR", "MONTH", "DAY"],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image URLs
|
||||||
|
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
banner_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
card_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
# === RIDE MODEL SERIALIZERS ===
|
# === RIDE MODEL SERIALIZERS ===
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
|||||||
banner_image = serializers.SerializerMethodField()
|
banner_image = serializers.SerializerMethodField()
|
||||||
card_image = serializers.SerializerMethodField()
|
card_image = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Former names (name history)
|
||||||
|
former_names = serializers.SerializerMethodField()
|
||||||
|
|
||||||
# URL
|
# URL
|
||||||
url = serializers.SerializerMethodField()
|
url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -406,6 +409,24 @@ class RideDetailOutputSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||||
|
def get_former_names(self, obj):
|
||||||
|
"""Get the former names (name history) for this ride."""
|
||||||
|
from apps.rides.models import RideNameHistory
|
||||||
|
|
||||||
|
former_names = RideNameHistory.objects.filter(ride=obj).order_by("-to_year", "-from_year")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": entry.id,
|
||||||
|
"former_name": entry.former_name,
|
||||||
|
"from_year": entry.from_year,
|
||||||
|
"to_year": entry.to_year,
|
||||||
|
"reason": entry.reason,
|
||||||
|
}
|
||||||
|
for entry in former_names
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class RideImageSettingsInputSerializer(serializers.Serializer):
|
class RideImageSettingsInputSerializer(serializers.Serializer):
|
||||||
"""Input serializer for setting ride banner and card images."""
|
"""Input serializer for setting ride banner and card images."""
|
||||||
@@ -841,3 +862,37 @@ class RideReviewUpdateInputSerializer(serializers.Serializer):
|
|||||||
if value and value > timezone.now().date():
|
if value and value > timezone.now().date():
|
||||||
raise serializers.ValidationError("Visit date cannot be in the future")
|
raise serializers.ValidationError("Visit date cannot be in the future")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# === RIDE NAME HISTORY SERIALIZERS ===
|
||||||
|
|
||||||
|
|
||||||
|
class RideNameHistoryOutputSerializer(serializers.Serializer):
|
||||||
|
"""Output serializer for ride name history (former names)."""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
former_name = serializers.CharField()
|
||||||
|
from_year = serializers.IntegerField(allow_null=True)
|
||||||
|
to_year = serializers.IntegerField(allow_null=True)
|
||||||
|
reason = serializers.CharField()
|
||||||
|
created_at = serializers.DateTimeField()
|
||||||
|
|
||||||
|
|
||||||
|
class RideNameHistoryCreateInputSerializer(serializers.Serializer):
|
||||||
|
"""Input serializer for creating ride name history entries."""
|
||||||
|
|
||||||
|
former_name = serializers.CharField(max_length=200)
|
||||||
|
from_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
|
||||||
|
to_year = serializers.IntegerField(required=False, allow_null=True, min_value=1800, max_value=2100)
|
||||||
|
reason = serializers.CharField(max_length=500, required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Validate year range."""
|
||||||
|
from_year = attrs.get("from_year")
|
||||||
|
to_year = attrs.get("to_year")
|
||||||
|
|
||||||
|
if from_year and to_year and from_year > to_year:
|
||||||
|
raise serializers.ValidationError("From year cannot be after to year")
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|||||||
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()
|
objects = CompanyManager()
|
||||||
|
|
||||||
|
# Core Fields
|
||||||
name = models.CharField(max_length=255, help_text="Company name")
|
name = models.CharField(max_length=255, help_text="Company name")
|
||||||
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
|
||||||
roles = ArrayField(
|
roles = ArrayField(
|
||||||
@@ -25,8 +26,72 @@ class Company(TrackedModel):
|
|||||||
description = models.TextField(blank=True, help_text="Detailed company description")
|
description = models.TextField(blank=True, help_text="Detailed company description")
|
||||||
website = models.URLField(blank=True, help_text="Company website URL")
|
website = models.URLField(blank=True, help_text="Company website URL")
|
||||||
|
|
||||||
# Operator-specific fields
|
# Person/Entity Type (ported from legacy thrillwiki-87)
|
||||||
|
PERSON_TYPES = [
|
||||||
|
("INDIVIDUAL", "Individual"),
|
||||||
|
("FIRM", "Firm"),
|
||||||
|
("ORGANIZATION", "Organization"),
|
||||||
|
("CORPORATION", "Corporation"),
|
||||||
|
("PARTNERSHIP", "Partnership"),
|
||||||
|
("GOVERNMENT", "Government Entity"),
|
||||||
|
]
|
||||||
|
person_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=PERSON_TYPES,
|
||||||
|
blank=True,
|
||||||
|
help_text="Type of entity (individual, firm, organization, etc.)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Company Status (ported from legacy)
|
||||||
|
COMPANY_STATUSES = [
|
||||||
|
("ACTIVE", "Active"),
|
||||||
|
("DEFUNCT", "Defunct"),
|
||||||
|
("MERGED", "Merged"),
|
||||||
|
("ACQUIRED", "Acquired"),
|
||||||
|
("RENAMED", "Renamed"),
|
||||||
|
("DORMANT", "Dormant"),
|
||||||
|
]
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=COMPANY_STATUSES,
|
||||||
|
default="ACTIVE",
|
||||||
|
help_text="Current operational status of the company",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Founding Information (enhanced from just founded_year)
|
||||||
founded_year = models.PositiveIntegerField(blank=True, null=True, help_text="Year the company was founded")
|
founded_year = models.PositiveIntegerField(blank=True, null=True, help_text="Year the company was founded")
|
||||||
|
founded_date = models.DateField(blank=True, null=True, help_text="Full founding date if known")
|
||||||
|
DATE_PRECISION_CHOICES = [
|
||||||
|
("YEAR", "Year only"),
|
||||||
|
("MONTH", "Month and year"),
|
||||||
|
("DAY", "Full date"),
|
||||||
|
]
|
||||||
|
founded_date_precision = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=DATE_PRECISION_CHOICES,
|
||||||
|
blank=True,
|
||||||
|
help_text="Precision of the founding date",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image URLs (ported from legacy)
|
||||||
|
logo_url = models.URLField(blank=True, help_text="Company logo image URL")
|
||||||
|
banner_image_url = models.URLField(blank=True, help_text="Banner image for company page header")
|
||||||
|
card_image_url = models.URLField(blank=True, help_text="Card/thumbnail image for listings")
|
||||||
|
|
||||||
|
# Rating & Review Aggregates (computed fields, updated by triggers/signals)
|
||||||
|
average_rating = models.DecimalField(
|
||||||
|
max_digits=3,
|
||||||
|
decimal_places=2,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Average rating from reviews (auto-calculated)",
|
||||||
|
)
|
||||||
|
review_count = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Total number of reviews (auto-calculated)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Counts (auto-calculated)
|
||||||
parks_count = models.IntegerField(default=0, help_text="Number of parks operated (auto-calculated)")
|
parks_count = models.IntegerField(default=0, help_text="Number of parks operated (auto-calculated)")
|
||||||
rides_count = models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)")
|
rides_count = models.IntegerField(default=0, help_text="Number of rides manufactured (auto-calculated)")
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,21 @@ class Park(StateMachineMixin, TrackedModel):
|
|||||||
|
|
||||||
# Details
|
# Details
|
||||||
opening_date = models.DateField(null=True, blank=True, help_text="Opening date")
|
opening_date = models.DateField(null=True, blank=True, help_text="Opening date")
|
||||||
|
opening_date_precision = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
|
||||||
|
default="DAY",
|
||||||
|
blank=True,
|
||||||
|
help_text="Precision of the opening date (YEAR for circa dates)",
|
||||||
|
)
|
||||||
closing_date = models.DateField(null=True, blank=True, help_text="Closing date")
|
closing_date = models.DateField(null=True, blank=True, help_text="Closing date")
|
||||||
|
closing_date_precision = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
|
||||||
|
default="DAY",
|
||||||
|
blank=True,
|
||||||
|
help_text="Precision of the closing date",
|
||||||
|
)
|
||||||
operating_season = models.CharField(max_length=255, blank=True, help_text="Operating season")
|
operating_season = models.CharField(max_length=255, blank=True, help_text="Operating season")
|
||||||
size_acres = models.DecimalField(
|
size_acres = models.DecimalField(
|
||||||
max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres"
|
max_digits=10, decimal_places=2, null=True, blank=True, help_text="Park size in acres"
|
||||||
@@ -310,6 +324,14 @@ class Park(StateMachineMixin, TrackedModel):
|
|||||||
return self.location.formatted_address
|
return self.location.formatted_address
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool:
|
||||||
|
"""Returns True if this park has a closing date in the future (announced closure)."""
|
||||||
|
from django.utils import timezone
|
||||||
|
if self.closing_date:
|
||||||
|
return self.closing_date > timezone.now().date()
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def coordinates(self) -> list[float] | None:
|
def coordinates(self) -> list[float] | None:
|
||||||
"""Returns coordinates as a list [latitude, longitude]"""
|
"""Returns coordinates as a list [latitude, longitude]"""
|
||||||
|
|||||||
@@ -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 .credits import RideCredit
|
||||||
from .location import RideLocation
|
from .location import RideLocation
|
||||||
from .media import RidePhoto
|
from .media import RidePhoto
|
||||||
|
from .name_history import RideNameHistory
|
||||||
from .rankings import RankingSnapshot, RidePairComparison, RideRanking
|
from .rankings import RankingSnapshot, RidePairComparison, RideRanking
|
||||||
from .reviews import RideReview
|
from .reviews import RideReview
|
||||||
from .rides import Ride, RideModel, RollerCoasterStats
|
from .rides import Ride, RideModel, RollerCoasterStats
|
||||||
from .stats import DarkRideStats, FlatRideStats, WaterRideStats
|
from .stats import DarkRideStats, FlatRideStats, KiddieRideStats, TransportationStats, WaterRideStats
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Primary models
|
# Primary models
|
||||||
"Ride",
|
"Ride",
|
||||||
"RideModel",
|
"RideModel",
|
||||||
|
"RideNameHistory",
|
||||||
"RollerCoasterStats",
|
"RollerCoasterStats",
|
||||||
"WaterRideStats",
|
"WaterRideStats",
|
||||||
"DarkRideStats",
|
"DarkRideStats",
|
||||||
"FlatRideStats",
|
"FlatRideStats",
|
||||||
|
"KiddieRideStats",
|
||||||
|
"TransportationStats",
|
||||||
"Company",
|
"Company",
|
||||||
"RideLocation",
|
"RideLocation",
|
||||||
"RideReview",
|
"RideReview",
|
||||||
@@ -35,3 +39,4 @@ __all__ = [
|
|||||||
"RidePairComparison",
|
"RidePairComparison",
|
||||||
"RankingSnapshot",
|
"RankingSnapshot",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
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",
|
help_text="Status to change to after closing date",
|
||||||
)
|
)
|
||||||
opening_date = models.DateField(null=True, blank=True)
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
|
opening_date_precision = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
|
||||||
|
default="DAY",
|
||||||
|
blank=True,
|
||||||
|
help_text="Precision of the opening date",
|
||||||
|
)
|
||||||
closing_date = models.DateField(null=True, blank=True)
|
closing_date = models.DateField(null=True, blank=True)
|
||||||
|
closing_date_precision = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[("YEAR", "Year"), ("MONTH", "Month"), ("DAY", "Day")],
|
||||||
|
default="DAY",
|
||||||
|
blank=True,
|
||||||
|
help_text="Precision of the closing date",
|
||||||
|
)
|
||||||
status_since = models.DateField(null=True, blank=True)
|
status_since = models.DateField(null=True, blank=True)
|
||||||
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||||
max_height_in = models.PositiveIntegerField(null=True, blank=True)
|
max_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||||
@@ -516,6 +530,18 @@ class Ride(StateMachineMixin, TrackedModel):
|
|||||||
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||||
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True, blank=True)
|
||||||
|
|
||||||
|
# Additional ride classification
|
||||||
|
ride_sub_type = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text="Sub-category of ride (e.g., 'Flying Coaster', 'Inverted Coaster', 'Log Flume')",
|
||||||
|
)
|
||||||
|
age_requirement = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Minimum age requirement in years (if any)",
|
||||||
|
)
|
||||||
|
|
||||||
# Computed fields for hybrid filtering
|
# Computed fields for hybrid filtering
|
||||||
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
|
opening_year = models.IntegerField(null=True, blank=True, db_index=True)
|
||||||
search_text = models.TextField(blank=True, db_index=True)
|
search_text = models.TextField(blank=True, db_index=True)
|
||||||
@@ -680,6 +706,14 @@ class Ride(StateMachineMixin, TrackedModel):
|
|||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool:
|
||||||
|
"""Returns True if this ride has a closing date in the future (announced closure)."""
|
||||||
|
from django.utils import timezone
|
||||||
|
if self.closing_date:
|
||||||
|
return self.closing_date > timezone.now().date()
|
||||||
|
return False
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
# Handle slug generation and conflicts
|
# Handle slug generation and conflicts
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
|
|||||||
@@ -224,3 +224,152 @@ class FlatRideStats(TrackedModel):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Flat Ride Stats for {self.ride.name}"
|
return f"Flat Ride Stats for {self.ride.name}"
|
||||||
|
|
||||||
|
|
||||||
|
# Transport Type Choices for Transportation Rides
|
||||||
|
TRANSPORT_TYPES = [
|
||||||
|
("TRAIN", "Train"),
|
||||||
|
("MONORAIL", "Monorail"),
|
||||||
|
("SKYLIFT", "Skylift / Chairlift"),
|
||||||
|
("FERRY", "Ferry / Boat"),
|
||||||
|
("PEOPLEMOVER", "PeopleMover"),
|
||||||
|
("CABLE_CAR", "Cable Car"),
|
||||||
|
("TRAM", "Tram"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
|
class KiddieRideStats(TrackedModel):
|
||||||
|
"""
|
||||||
|
Statistics specific to kiddie rides (category=KR).
|
||||||
|
|
||||||
|
Tracks age-appropriate ride characteristics and theming.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ride = models.OneToOneField(
|
||||||
|
"rides.Ride",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="kiddie_stats",
|
||||||
|
help_text="Ride these kiddie ride statistics belong to",
|
||||||
|
)
|
||||||
|
|
||||||
|
min_age = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Minimum recommended age in years",
|
||||||
|
)
|
||||||
|
|
||||||
|
max_age = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Maximum recommended age in years",
|
||||||
|
)
|
||||||
|
|
||||||
|
educational_theme = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Educational theme if applicable (e.g., 'Dinosaurs', 'Space')",
|
||||||
|
)
|
||||||
|
|
||||||
|
character_theme = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Character theme if applicable (e.g., 'Paw Patrol', 'Peppa Pig')",
|
||||||
|
)
|
||||||
|
|
||||||
|
guardian_required = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether a guardian must be present during the ride",
|
||||||
|
)
|
||||||
|
|
||||||
|
adult_ride_along = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether adults can ride along with children",
|
||||||
|
)
|
||||||
|
|
||||||
|
seats_per_vehicle = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Number of seats per ride vehicle",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(TrackedModel.Meta):
|
||||||
|
verbose_name = "Kiddie Ride Statistics"
|
||||||
|
verbose_name_plural = "Kiddie Ride Statistics"
|
||||||
|
ordering = ["ride"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Kiddie Ride Stats for {self.ride.name}"
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
|
class TransportationStats(TrackedModel):
|
||||||
|
"""
|
||||||
|
Statistics specific to transportation rides (category=TR).
|
||||||
|
|
||||||
|
Tracks route, capacity, and vehicle information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ride = models.OneToOneField(
|
||||||
|
"rides.Ride",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="transport_stats",
|
||||||
|
help_text="Ride these transportation statistics belong to",
|
||||||
|
)
|
||||||
|
|
||||||
|
transport_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=TRANSPORT_TYPES,
|
||||||
|
default="TRAIN",
|
||||||
|
help_text="Type of transportation",
|
||||||
|
)
|
||||||
|
|
||||||
|
route_length_ft = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Total route length in feet",
|
||||||
|
)
|
||||||
|
|
||||||
|
stations_count = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Number of stations/stops on the route",
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle_capacity = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Passenger capacity per vehicle",
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicles_count = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Total number of vehicles in operation",
|
||||||
|
)
|
||||||
|
|
||||||
|
round_trip_duration_minutes = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Duration of a complete round trip in minutes",
|
||||||
|
)
|
||||||
|
|
||||||
|
scenic_highlights = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Notable scenic views or attractions along the route",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_one_way = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether this is a one-way transportation (vs round-trip)",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(TrackedModel.Meta):
|
||||||
|
verbose_name = "Transportation Statistics"
|
||||||
|
verbose_name_plural = "Transportation Statistics"
|
||||||
|
ordering = ["ride"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Transportation Stats for {self.ride.name}"
|
||||||
|
|||||||
@@ -235,3 +235,46 @@ def update_ride_search_text_on_ride_model_change(sender, instance, **kwargs):
|
|||||||
update_ride_search_text(ride)
|
update_ride_search_text(ride)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Failed to update ride search_text on ride model change: {e}")
|
logger.exception(f"Failed to update ride search_text on ride model change: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Automatic Name History Tracking
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=Ride)
|
||||||
|
def track_ride_name_changes(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Automatically create RideNameHistory when a ride's name changes.
|
||||||
|
|
||||||
|
This ensures versioning is automatic - when a ride is renamed,
|
||||||
|
the previous name is preserved in the name history.
|
||||||
|
"""
|
||||||
|
if not instance.pk:
|
||||||
|
return # Skip new rides
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_instance = Ride.objects.get(pk=instance.pk)
|
||||||
|
|
||||||
|
if old_instance.name != instance.name:
|
||||||
|
from .models import RideNameHistory
|
||||||
|
|
||||||
|
current_year = timezone.now().year
|
||||||
|
|
||||||
|
# Create history entry for the old name
|
||||||
|
RideNameHistory.objects.create(
|
||||||
|
ride=instance,
|
||||||
|
former_name=old_instance.name,
|
||||||
|
to_year=current_year,
|
||||||
|
reason="Name changed",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Ride {instance.pk} name changed from '{old_instance.name}' "
|
||||||
|
f"to '{instance.name}' - history entry created"
|
||||||
|
)
|
||||||
|
except Ride.DoesNotExist:
|
||||||
|
pass # New ride, no history to track
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to track name change for ride {instance.pk}: {e}")
|
||||||
|
|
||||||
|
|||||||
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